""" EEG Flow Fourier Node A carefully designed node for exploring how EEG signals create structure in flow fields and what eigenmodes emerge. The pipeline: EEG → Vector Field → Particle Trajectories → Density → FFT → Eigenmodes Key insight: Different mappings from EEG to vector field produce radically different eigenmode structures. """ import numpy as np import cv2 from scipy import ndimage import __main__ BaseNode = __main__.BaseNode QtGui = __main__.QtGui class EEGFlowFourierNode(BaseNode): """ EEG → Flow Field → FFT eigenmode explorer This node lets you experiment with different ways of mapping brain signals to spatial dynamics, then see what Fourier structure emerges. """ NODE_CATEGORY = "IHT_Core" NODE_COLOR = QtGui.QColor(60, 180, 200) def __init__(self, size=256): super().__init__() self.node_title = "EEG Flow Fourier" self.inputs = { # EEG band inputs 'delta': 'signal', # 1-4 Hz 'theta': 'signal', # 4-8 Hz 'alpha': 'signal', # 8-13 Hz 'beta': 'signal', # 13-30 Hz 'gamma': 'signal', # 30-45 Hz 'raw': 'signal', # raw EEG signal # Control inputs 'field_mode': 'signal', # 0-5: how EEG maps to vector field 'init_mode': 'signal', # 0-7: particle initialization 'particle_count': 'signal', # number of particles (scaled) 'speed': 'signal', # particle speed multiplier 'decay': 'signal', # trail decay rate 'reset': 'signal', # >0.5 resets particles # Advanced 'field_scale': 'signal', # spatial frequency of field 'momentum': 'signal', # particle momentum (smoothing) 'inject_x': 'signal', # manual field injection 'inject_y': 'signal', } self.outputs = { # Visual outputs 'flow_image': 'image', # the flow field trails 'fft_magnitude': 'image', # FFT magnitude (log scaled) 'fft_phase': 'image', # FFT phase 'eigenmode_image': 'image', # colorized eigenmode view # Data outputs 'complex_spectrum': 'complex_spectrum', # for holographic nodes 'dominant_frequency': 'signal', # strongest spatial freq 'spectral_entropy': 'signal', # complexity measure 'flow_coherence': 'signal', # how organized is flow 'eigenmode_centroid': 'signal', # where is spectral mass } self.size = int(size) self.half = self.size // 2 # Particle system self.particles = None self.velocities = None self.particle_count = 500 # Buffers self.trail_buffer = np.zeros((self.size, self.size), dtype=np.float32) self.field_x = np.zeros((self.size, self.size), dtype=np.float32) self.field_y = np.zeros((self.size, self.size), dtype=np.float32) # FFT results self.fft_result = None self.magnitude = None self.phase = None # Metrics self.dominant_freq = 0.0 self.spectral_entropy = 0.0 self.flow_coherence = 0.0 self.eigenmode_centroid = 0.0 # Coordinate grids (precomputed) y, x = np.mgrid[0:self.size, 0:self.size] self.x_grid = x.astype(np.float32) self.y_grid = y.astype(np.float32) self.cx, self.cy = self.size / 2, self.size / 2 self.r_grid = np.sqrt((x - self.cx)**2 + (y - self.cy)**2) self.theta_grid = np.arctan2(y - self.cy, x - self.cx) # Frequency grid for FFT analysis fx = np.fft.fftfreq(self.size) fy = np.fft.fftfreq(self.size) self.freq_x, self.freq_y = np.meshgrid(fx, fy) self.freq_r = np.sqrt(self.freq_x**2 + self.freq_y**2) # State tracking self.last_init_mode = -1 self.last_reset = 0.0 self.frame_count = 0 # Initialize self._init_particles(0) def _init_particles(self, mode): """Initialize particles with various patterns""" n = self.particle_count if mode == 0: # Random uniform self.particles = np.random.rand(n, 2) * self.size elif mode == 1: # Horizontal line t = np.linspace(0.05, 0.95, n) self.particles = np.stack([ t * self.size, np.ones(n) * self.cy ], axis=1) elif mode == 2: # Vertical line t = np.linspace(0.05, 0.95, n) self.particles = np.stack([ np.ones(n) * self.cx, t * self.size ], axis=1) elif mode == 3: # Circle angles = np.linspace(0, 2*np.pi, n, endpoint=False) r = self.size * 0.4 self.particles = np.stack([ self.cx + np.cos(angles) * r, self.cy + np.sin(angles) * r ], axis=1) elif mode == 4: # Grid side = int(np.sqrt(n)) xs = np.linspace(0.1, 0.9, side) * self.size ys = np.linspace(0.1, 0.9, side) * self.size xx, yy = np.meshgrid(xs, ys) self.particles = np.stack([xx.flatten(), yy.flatten()], axis=1)[:n] elif mode == 5: # Center point angles = np.random.rand(n) * 2 * np.pi radii = np.random.rand(n) * 5 # tight cluster self.particles = np.stack([ self.cx + np.cos(angles) * radii, self.cy + np.sin(angles) * radii ], axis=1) elif mode == 6: # Diagonal t = np.linspace(0.05, 0.95, n) self.particles = np.stack([ t * self.size, t * self.size ], axis=1) elif mode == 7: # Cross half = n // 2 t1 = np.linspace(0.05, 0.95, half) t2 = np.linspace(0.05, 0.95, n - half) p1 = np.stack([t1 * self.size, np.ones(half) * self.cy], axis=1) p2 = np.stack([np.ones(n-half) * self.cx, t2 * self.size], axis=1) self.particles = np.vstack([p1, p2]) elif mode == 8: # Spiral t = np.linspace(0, 6*np.pi, n) r = np.linspace(5, self.size * 0.45, n) self.particles = np.stack([ self.cx + np.cos(t) * r, self.cy + np.sin(t) * r ], axis=1) else: # Sparse random (good for lightning) n = min(n, 50) self.particles = np.random.rand(n, 2) * self.size self.velocities = np.zeros((len(self.particles), 2), dtype=np.float32) self.trail_buffer *= 0 # Clear trails on reinit def _build_field_mode0(self, bands): """Mode 0: Radial - bands control ring frequencies""" delta, theta, alpha, beta, gamma = bands field = np.zeros((self.size, self.size), dtype=np.float32) # Each band creates concentric ripples at different scales field += delta * np.sin(self.r_grid * 0.02) * 2 field += theta * np.sin(self.r_grid * 0.05) * 2 field += alpha * np.sin(self.r_grid * 0.10) * 2 field += beta * np.sin(self.r_grid * 0.20) * 2 field += gamma * np.sin(self.r_grid * 0.40) * 2 # Convert to vector field (perpendicular to radius = circular flow) self.field_x = -np.sin(self.theta_grid) * field self.field_y = np.cos(self.theta_grid) * field def _build_field_mode1(self, bands): """Mode 1: Cartesian - bands control x/y wave frequencies""" delta, theta, alpha, beta, gamma = bands # X component from odd bands self.field_x = ( delta * np.sin(self.y_grid * 0.03) + alpha * np.sin(self.y_grid * 0.08) + gamma * np.sin(self.y_grid * 0.20) ) # Y component from even bands self.field_y = ( theta * np.sin(self.x_grid * 0.05) + beta * np.sin(self.x_grid * 0.15) ) def _build_field_mode2(self, bands): """Mode 2: Interference - bands are point sources""" delta, theta, alpha, beta, gamma = bands # Five sources at different positions sources = [ (self.cx, self.cy * 0.3, delta), # top (self.cx * 0.3, self.cy, theta), # left (self.cx * 1.7, self.cy, alpha), # right (self.cx, self.cy * 1.7, beta), # bottom (self.cx, self.cy, gamma), # center ] potential = np.zeros((self.size, self.size), dtype=np.float32) for sx, sy, amp in sources: r = np.sqrt((self.x_grid - sx)**2 + (self.y_grid - sy)**2) + 1 potential += amp * np.sin(r * 0.1) / (1 + r * 0.01) # Gradient of potential = force field self.field_y, self.field_x = np.gradient(potential) def _build_field_mode3(self, bands): """Mode 3: Vortex - bands control rotation strength at different radii""" delta, theta, alpha, beta, gamma = bands # Rotation strength varies with radius rotation = np.zeros((self.size, self.size), dtype=np.float32) # Inner to outer rings controlled by bands rotation += delta * np.exp(-self.r_grid**2 / (self.size * 0.1)**2) rotation += theta * np.exp(-(self.r_grid - self.size*0.15)**2 / (self.size * 0.1)**2) rotation += alpha * np.exp(-(self.r_grid - self.size*0.25)**2 / (self.size * 0.1)**2) rotation += beta * np.exp(-(self.r_grid - self.size*0.35)**2 / (self.size * 0.1)**2) rotation += gamma * np.exp(-(self.r_grid - self.size*0.45)**2 / (self.size * 0.1)**2) # Perpendicular to radius (tangential flow) self.field_x = -np.sin(self.theta_grid) * rotation self.field_y = np.cos(self.theta_grid) * rotation def _build_field_mode4(self, bands): """Mode 4: Diagonal waves - creates X patterns in FFT""" delta, theta, alpha, beta, gamma = bands diag1 = self.x_grid + self.y_grid # diagonal diag2 = self.x_grid - self.y_grid # anti-diagonal wave1 = ( delta * np.sin(diag1 * 0.02) + alpha * np.sin(diag1 * 0.06) + gamma * np.sin(diag1 * 0.15) ) wave2 = ( theta * np.sin(diag2 * 0.03) + beta * np.sin(diag2 * 0.10) ) # Field follows diagonal gradients self.field_x = wave1 + wave2 self.field_y = wave1 - wave2 def _build_field_mode5(self, bands): """Mode 5: Fractal/turbulent - bands at octave frequencies""" delta, theta, alpha, beta, gamma = bands self.field_x = np.zeros((self.size, self.size), dtype=np.float32) self.field_y = np.zeros((self.size, self.size), dtype=np.float32) # Octave frequencies (doubling) freqs = [0.01, 0.02, 0.04, 0.08, 0.16] amps = [delta, theta, alpha, beta, gamma] for freq, amp in zip(freqs, amps): phase_x = np.random.rand() * 2 * np.pi phase_y = np.random.rand() * 2 * np.pi self.field_x += amp * np.sin(self.x_grid * freq * 2 * np.pi + phase_x) * np.cos(self.y_grid * freq * np.pi) self.field_y += amp * np.cos(self.x_grid * freq * np.pi) * np.sin(self.y_grid * freq * 2 * np.pi + phase_y) def step(self): self.frame_count += 1 # Get EEG bands delta = self.get_blended_input('delta', 'sum') or 0.0 theta = self.get_blended_input('theta', 'sum') or 0.0 alpha = self.get_blended_input('alpha', 'sum') or 0.0 beta = self.get_blended_input('beta', 'sum') or 0.0 gamma = self.get_blended_input('gamma', 'sum') or 0.0 raw = self.get_blended_input('raw', 'sum') or 0.0 # Normalize bands bands = np.array([delta, theta, alpha, beta, gamma]) band_sum = np.sum(np.abs(bands)) + 1e-6 bands_norm = bands / band_sum # relative power # Get control inputs field_mode = self.get_blended_input('field_mode', 'sum') or 0.0 field_mode = int(np.clip((field_mode + 1) * 3, 0, 5)) # 0-5 init_mode = self.get_blended_input('init_mode', 'sum') or 0.0 init_mode = int(np.clip((init_mode + 1) * 4, 0, 9)) # 0-9 particle_count_in = self.get_blended_input('particle_count', 'sum') or 0.0 self.particle_count = int(np.clip(200 + particle_count_in * 400, 50, 2000)) speed = self.get_blended_input('speed', 'sum') or 0.0 speed = 1.0 + speed * 2.0 decay = self.get_blended_input('decay', 'sum') or 0.0 decay = np.clip(0.92 + decay * 0.07, 0.85, 0.995) reset = self.get_blended_input('reset', 'sum') or 0.0 field_scale = self.get_blended_input('field_scale', 'sum') or 0.0 field_scale = 1.0 + field_scale momentum = self.get_blended_input('momentum', 'sum') or 0.0 momentum = np.clip(0.3 + momentum * 0.5, 0.0, 0.9) inject_x = self.get_blended_input('inject_x', 'sum') or 0.0 inject_y = self.get_blended_input('inject_y', 'sum') or 0.0 # Check for reinit need_reinit = False if reset > 0.5 and self.last_reset <= 0.5: need_reinit = True if init_mode != self.last_init_mode: need_reinit = True if self.particles is None or len(self.particles) != self.particle_count: need_reinit = True if need_reinit: self._init_particles(init_mode) self.last_init_mode = init_mode self.last_reset = reset # Build vector field based on mode if field_mode == 0: self._build_field_mode0(bands) elif field_mode == 1: self._build_field_mode1(bands) elif field_mode == 2: self._build_field_mode2(bands) elif field_mode == 3: self._build_field_mode3(bands) elif field_mode == 4: self._build_field_mode4(bands) else: self._build_field_mode5(bands) # Apply field scale self.field_x *= field_scale self.field_y *= field_scale # Add injection self.field_x += inject_x self.field_y += inject_y # Add raw EEG as global perturbation self.field_x += raw * 0.5 self.field_y += raw * 0.5 # Move particles velocities_list = [] for i in range(len(self.particles)): px = int(np.clip(self.particles[i, 0], 0, self.size - 1)) py = int(np.clip(self.particles[i, 1], 0, self.size - 1)) # Get field at particle position vx = self.field_x[py, px] * speed vy = self.field_y[py, px] * speed # Apply momentum vx = self.velocities[i, 0] * momentum + vx * (1 - momentum) vy = self.velocities[i, 1] * momentum + vy * (1 - momentum) # Limit speed spd = np.sqrt(vx*vx + vy*vy) if spd > 10: vx *= 10 / spd vy *= 10 / spd self.velocities[i] = [vx, vy] velocities_list.append([vx, vy]) # Update position self.particles[i, 0] += vx self.particles[i, 1] += vy # Wrap at boundaries (periodic) self.particles[i, 0] = self.particles[i, 0] % self.size self.particles[i, 1] = self.particles[i, 1] % self.size # Draw to trail buffer px = int(self.particles[i, 0]) py = int(self.particles[i, 1]) if 0 <= px < self.size and 0 <= py < self.size: self.trail_buffer[py, px] = 1.0 # Decay trail self.trail_buffer *= decay # Compute FFT of trail buffer self.fft_result = np.fft.fft2(self.trail_buffer) self.fft_result = np.fft.fftshift(self.fft_result) self.magnitude = np.abs(self.fft_result) self.phase = np.angle(self.fft_result) # Compute metrics self._compute_metrics(velocities_list) def _compute_metrics(self, velocities_list): """Compute spectral and flow metrics""" # Dominant frequency (peak in magnitude, excluding DC) mag_copy = self.magnitude.copy() mag_copy[self.half-2:self.half+3, self.half-2:self.half+3] = 0 # zero DC region peak_idx = np.unravel_index(np.argmax(mag_copy), mag_copy.shape) self.dominant_freq = self.freq_r[peak_idx] # Spectral entropy mag_norm = self.magnitude / (np.sum(self.magnitude) + 1e-10) mag_flat = mag_norm.flatten() mag_flat = mag_flat[mag_flat > 1e-10] self.spectral_entropy = -np.sum(mag_flat * np.log(mag_flat)) self.spectral_entropy = self.spectral_entropy / np.log(len(mag_flat)) # normalize to 0-1 # Eigenmode centroid (average frequency weighted by magnitude) total_mag = np.sum(self.magnitude) + 1e-10 self.eigenmode_centroid = np.sum(self.freq_r * self.magnitude) / total_mag # Flow coherence if len(velocities_list) > 1: vels = np.array(velocities_list) mean_vel = np.mean(vels, axis=0) mean_speed = np.linalg.norm(mean_vel) avg_speed = np.mean(np.linalg.norm(vels, axis=1)) + 1e-6 self.flow_coherence = mean_speed / avg_speed else: self.flow_coherence = 0.0 def get_output(self, port_name): if port_name == 'flow_image': # Colorize trail buffer img = np.stack([ self.trail_buffer * 0.3, self.trail_buffer * 0.8, self.trail_buffer * 1.0 ], axis=-1) return np.clip(img, 0, 1).astype(np.float32) elif port_name == 'fft_magnitude': if self.magnitude is None: return np.zeros((self.size, self.size, 3), dtype=np.float32) # Log scale for visibility mag_log = np.log(self.magnitude + 1) mag_norm = mag_log / (np.max(mag_log) + 1e-6) # Colormap colored = cv2.applyColorMap((mag_norm * 255).astype(np.uint8), cv2.COLORMAP_VIRIDIS) return colored.astype(np.float32) / 255.0 elif port_name == 'fft_phase': if self.phase is None: return np.zeros((self.size, self.size, 3), dtype=np.float32) # Phase to 0-1 phase_norm = (self.phase + np.pi) / (2 * np.pi) colored = cv2.applyColorMap((phase_norm * 255).astype(np.uint8), cv2.COLORMAP_HSV) return colored.astype(np.float32) / 255.0 elif port_name == 'eigenmode_image': if self.magnitude is None or self.phase is None: return np.zeros((self.size, self.size, 3), dtype=np.float32) # Magnitude as brightness, phase as hue mag_log = np.log(self.magnitude + 1) mag_norm = mag_log / (np.max(mag_log) + 1e-6) phase_norm = (self.phase + np.pi) / (2 * np.pi) # HSV: phase=hue, 1=sat, magnitude=value hsv = np.stack([ (phase_norm * 180).astype(np.uint8), np.ones_like(mag_norm, dtype=np.uint8) * 255, (mag_norm * 255).astype(np.uint8) ], axis=-1) rgb = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) return rgb.astype(np.float32) / 255.0 elif port_name == 'complex_spectrum': return self.fft_result elif port_name == 'dominant_frequency': return float(self.dominant_freq) elif port_name == 'spectral_entropy': return float(self.spectral_entropy) elif port_name == 'flow_coherence': return float(self.flow_coherence) elif port_name == 'eigenmode_centroid': return float(self.eigenmode_centroid) return None def draw_custom(self, painter): """Show current state""" painter.setPen(QtGui.QColor(200, 255, 255)) painter.setFont(QtGui.QFont("Consolas", 8)) info = f"P:{len(self.particles) if self.particles is not None else 0}" info += f" Coh:{self.flow_coherence:.2f}" info += f" Ent:{self.spectral_entropy:.2f}" painter.drawText(5, self.height - 25, info) class EEGFlowFourierCompactNode(BaseNode): """ Simplified version - fewer inputs, good defaults Just wire EEG and explore """ NODE_CATEGORY = "IHT_Core" NODE_COLOR = QtGui.QColor(80, 160, 220) def __init__(self, size=256): super().__init__() self.node_title = "EEG→Flow→FFT" self.inputs = { 'delta': 'signal', 'theta': 'signal', 'alpha': 'signal', 'beta': 'signal', 'gamma': 'signal', 'mode': 'signal', # 0-5 field modes 'init': 'signal', # 0-9 init patterns 'reset': 'signal', } self.outputs = { 'flow': 'image', 'fft': 'image', 'spectrum': 'complex_spectrum', 'entropy': 'signal', 'coherence': 'signal', } self.size = int(size) self.half = self.size // 2 # Particle system - moderate count for good patterns self.particle_count = 400 self.particles = None self.velocities = None # Buffers self.trail = np.zeros((self.size, self.size), dtype=np.float32) # Precomputed grids y, x = np.mgrid[0:self.size, 0:self.size] self.x = x.astype(np.float32) self.y = y.astype(np.float32) self.cx, self.cy = self.size/2, self.size/2 self.r = np.sqrt((x - self.cx)**2 + (y - self.cy)**2) self.theta = np.arctan2(y - self.cy, x - self.cx) # FFT frequency grid fx = np.fft.fftfreq(self.size) fy = np.fft.fftfreq(self.size) self.freq_x, self.freq_y = np.meshgrid(fx, fy) self.freq_r = np.sqrt(self.freq_x**2 + self.freq_y**2) # Outputs self.fft_result = None self.entropy = 0.0 self.coherence = 0.0 # State self.last_init = -1 self.last_reset = 0.0 self._init_particles(0) def _init_particles(self, mode): n = self.particle_count mode = int(mode) % 10 if mode == 0: self.particles = np.random.rand(n, 2) * self.size elif mode == 1: t = np.linspace(0.05, 0.95, n) self.particles = np.stack([t * self.size, np.ones(n) * self.cy], axis=1) elif mode == 2: t = np.linspace(0.05, 0.95, n) self.particles = np.stack([np.ones(n) * self.cx, t * self.size], axis=1) elif mode == 3: a = np.linspace(0, 2*np.pi, n, endpoint=False) r = self.size * 0.4 self.particles = np.stack([self.cx + np.cos(a)*r, self.cy + np.sin(a)*r], axis=1) elif mode == 4: side = int(np.sqrt(n)) xs = np.linspace(0.1, 0.9, side) * self.size ys = np.linspace(0.1, 0.9, side) * self.size xx, yy = np.meshgrid(xs, ys) self.particles = np.stack([xx.flatten(), yy.flatten()], axis=1)[:n] elif mode == 5: a = np.random.rand(n) * 2 * np.pi r = np.random.rand(n) * 5 self.particles = np.stack([self.cx + np.cos(a)*r, self.cy + np.sin(a)*r], axis=1) elif mode == 6: t = np.linspace(0.05, 0.95, n) self.particles = np.stack([t * self.size, t * self.size], axis=1) elif mode == 7: half = n // 2 t1 = np.linspace(0.05, 0.95, half) t2 = np.linspace(0.05, 0.95, n - half) p1 = np.stack([t1 * self.size, np.ones(half) * self.cy], axis=1) p2 = np.stack([np.ones(n-half) * self.cx, t2 * self.size], axis=1) self.particles = np.vstack([p1, p2]) elif mode == 8: t = np.linspace(0, 6*np.pi, n) r = np.linspace(5, self.size * 0.45, n) self.particles = np.stack([self.cx + np.cos(t)*r, self.cy + np.sin(t)*r], axis=1) else: self.particles = np.random.rand(min(n, 30), 2) * self.size self.velocities = np.zeros((len(self.particles), 2), dtype=np.float32) self.trail *= 0 def step(self): # Get bands d = self.get_blended_input('delta', 'sum') or 0.0 t = self.get_blended_input('theta', 'sum') or 0.0 a = self.get_blended_input('alpha', 'sum') or 0.0 b = self.get_blended_input('beta', 'sum') or 0.0 g = self.get_blended_input('gamma', 'sum') or 0.0 mode = self.get_blended_input('mode', 'sum') or 0.0 mode = int(np.clip((mode + 1) * 3, 0, 5)) init = self.get_blended_input('init', 'sum') or 0.0 init = int(np.clip((init + 1) * 5, 0, 9)) reset = self.get_blended_input('reset', 'sum') or 0.0 # Reinit check if (reset > 0.5 and self.last_reset <= 0.5) or init != self.last_init: self._init_particles(init) self.last_init = init self.last_reset = reset # Build field based on mode (simplified versions) if mode == 0: # Radial field = d * np.sin(self.r * 0.02) + t * np.sin(self.r * 0.05) + a * np.sin(self.r * 0.1) + b * np.sin(self.r * 0.2) + g * np.sin(self.r * 0.4) fx = -np.sin(self.theta) * field fy = np.cos(self.theta) * field elif mode == 1: # Cartesian fx = d * np.sin(self.y * 0.03) + a * np.sin(self.y * 0.08) + g * np.sin(self.y * 0.2) fy = t * np.sin(self.x * 0.05) + b * np.sin(self.x * 0.15) elif mode == 2: # Vortex rot = d * np.exp(-self.r**2/(self.size*0.2)**2) + a * np.exp(-(self.r-self.size*0.3)**2/(self.size*0.15)**2) fx = -np.sin(self.theta) * rot fy = np.cos(self.theta) * rot elif mode == 3: # Diagonal diag1, diag2 = self.x + self.y, self.x - self.y w1 = d * np.sin(diag1 * 0.02) + a * np.sin(diag1 * 0.06) w2 = t * np.sin(diag2 * 0.03) + b * np.sin(diag2 * 0.1) fx, fy = w1 + w2, w1 - w2 else: # Turbulent fx = d * np.sin(self.x * 0.02) * np.cos(self.y * 0.01) + g * np.sin(self.x * 0.16) fy = t * np.cos(self.x * 0.01) * np.sin(self.y * 0.04) + b * np.sin(self.y * 0.08) # Move particles vels = [] for i in range(len(self.particles)): px = int(np.clip(self.particles[i, 0], 0, self.size-1)) py = int(np.clip(self.particles[i, 1], 0, self.size-1)) vx = self.velocities[i, 0] * 0.3 + fx[py, px] * 0.7 vy = self.velocities[i, 1] * 0.3 + fy[py, px] * 0.7 spd = np.sqrt(vx*vx + vy*vy) if spd > 8: vx, vy = vx * 8/spd, vy * 8/spd self.velocities[i] = [vx, vy] vels.append([vx, vy]) self.particles[i] += [vx, vy] self.particles[i] = self.particles[i] % self.size px = int(self.particles[i, 0]) py = int(self.particles[i, 1]) if 0 <= px < self.size and 0 <= py < self.size: self.trail[py, px] = 1.0 self.trail *= 0.93 # FFT self.fft_result = np.fft.fftshift(np.fft.fft2(self.trail)) mag = np.abs(self.fft_result) # Entropy mag_norm = mag / (np.sum(mag) + 1e-10) mag_flat = mag_norm.flatten() mag_flat = mag_flat[mag_flat > 1e-10] self.entropy = -np.sum(mag_flat * np.log(mag_flat)) / np.log(len(mag_flat)) # Coherence if len(vels) > 1: v = np.array(vels) self.coherence = np.linalg.norm(np.mean(v, axis=0)) / (np.mean(np.linalg.norm(v, axis=1)) + 1e-6) def get_output(self, port_name): if port_name == 'flow': return np.stack([self.trail*0.3, self.trail*0.8, self.trail], axis=-1).astype(np.float32) elif port_name == 'fft': if self.fft_result is None: return np.zeros((self.size, self.size, 3), dtype=np.float32) mag = np.log(np.abs(self.fft_result) + 1) mag = mag / (np.max(mag) + 1e-6) return cv2.applyColorMap((mag * 255).astype(np.uint8), cv2.COLORMAP_VIRIDIS).astype(np.float32) / 255.0 elif port_name == 'spectrum': return self.fft_result elif port_name == 'entropy': return float(self.entropy) elif port_name == 'coherence': return float(self.coherence) return None