Spaces:
Running
Running
File size: 7,620 Bytes
3bb804c |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
"""
Antti's CrystalMaker Node - A 3D polyrhythmic field generator
Based on the PolyrhythmicSea class from crystal_kingdom.py
Place this file in the 'nodes' folder
"""
import numpy as np
from PyQt6 import QtGui
import cv2
import sys
import os
# --- This is the new, correct block ---
import __main__
BaseNode = __main__.BaseNode
PA_INSTANCE = getattr(__main__, "PA_INSTANCE", None)
# ------------------------------------
try:
# --- FIX: Change import to ndimage.convolve for periodic boundaries ---
from scipy.ndimage import convolve
SCIPY_AVAILABLE = True
except ImportError:
SCIPY_AVAILABLE = False
print("Warning: CrystalMakerNode requires 'scipy'.")
print("Please run: pip install scipy")
class CrystalMakerNode(BaseNode):
NODE_CATEGORY = "Source"
NODE_COLOR = QtGui.QColor(100, 200, 250) # Crystalline blue
def __init__(self, grid_size=32, num_fields=10):
super().__init__()
self.node_title = "Antti's CrystalMaker"
self.inputs = {
'tension': 'signal',
'damping': 'signal',
'nonlinearity_a': 'signal',
'nonlinearity_b': 'signal'
}
self.outputs = {
'field_slice': 'image', # 2D slice of the 3D field
'total_energy': 'signal'
}
self.N = int(grid_size)
self.num_fields = int(num_fields)
# --- Physics Parameters from crystal_kingdom.py ---
self.dt = 0.05
self.polyrhythm_coupling = 0.1
self.nonlinearity_A = 1.0
self.nonlinearity_B = 1.0
self.damping_factor = 0.005
self.tension = 5.0
self.base_frequencies_min = 0.5
self.base_frequencies_max = 2.5
self.diffusion_coeffs_min = 0.05
self.diffusion_coeffs_max = 0.1
self.total_energy = 0.0
# --- Internal 3D State ---
self._initialize_fields_and_params()
# 3D Laplacian Kernel
self.kern = np.zeros((3,3,3), np.float32)
self.kern[1,1,1] = -6
for dx,dy,dz in [(1,1,0),(1,1,2),(1,0,1),(1,2,1),(0,1,1),(2,1,1)]:
self.kern[dx,dy,dz] = 1
if not SCIPY_AVAILABLE:
self.node_title = "CrystalMaker (No SciPy!)"
def _initialize_fields_and_params(self):
"""Initializes or re-initializes fields."""
shape = (self.N, self.N, self.N)
self.phi_fields = [(np.random.rand(*shape).astype(np.float32) - 0.5) * 0.5
for _ in range(self.num_fields)]
self.phi_o_fields = [np.copy(phi) for phi in self.phi_fields]
self.base_frequencies = np.linspace(self.base_frequencies_min, self.base_frequencies_max, self.num_fields)
self.diffusion_coeffs = np.linspace(self.diffusion_coeffs_max, self.diffusion_coeffs_min, self.num_fields)
self.field_phases = np.random.uniform(0, 2 * np.pi, self.num_fields)
self.phi = np.zeros(shape, dtype=np.float32)
self.phi_o = np.zeros(shape, dtype=np.float32)
self._update_summed_fields()
def _update_summed_fields(self):
"""Update the main summed field from individual phi fields"""
self.phi = np.sum(self.phi_fields, axis=0) / max(1, len(self.phi_fields))
self.phi_o = np.sum(self.phi_o_fields, axis=0) / max(1, len(self.phi_fields))
def _potential_deriv(self, field_k):
"""Calculate the derivative of the potential function for a field"""
return -self.nonlinearity_A * field_k + self.nonlinearity_B * (field_k**3)
def _laplacian(self, f):
"""3D Laplacian using convolution with periodic boundary ('wrap')"""
if not SCIPY_AVAILABLE:
return np.zeros_like(f)
# --- FIX: Use mode='wrap' with scipy.ndimage.convolve ---
return convolve(f, self.kern, mode='wrap')
# --- END FIX ---
def step(self):
if not SCIPY_AVAILABLE:
return
# --- Update parameters from inputs ---
# Map signals [-1, 1] to a useful range
self.tension = (self.get_blended_input('tension', 'sum') or 0.0) * 10.0 + 10.0 # Range [0, 20]
self.damping_factor = (self.get_blended_input('damping', 'sum') or 0.0) * 0.02 + 0.02 # Range [0, 0.04]
self.nonlinearity_A = (self.get_blended_input('nonlinearity_a', 'sum') or 0.0) + 1.0 # Range [0, 2]
self.nonlinearity_B = (self.get_blended_input('nonlinearity_b', 'sum') or 0.0) + 1.0 # Range [0, 2]
# --- Run simulation step (from crystal_kingdom.py) ---
new_phi_list = []
self.field_phases += self.base_frequencies * self.dt
self.field_phases %= (2 * np.pi)
for k in range(self.num_fields):
phi_k = self.phi_fields[k]
phi_o_k = self.phi_o_fields[k]
vel_k = phi_k - phi_o_k
lap_k = self._laplacian(phi_k)
potential_deriv_k = self._potential_deriv(phi_k)
other_fields_sum = (np.sum(self.phi_fields, axis=0) - phi_k)
coupling_force = self.polyrhythm_coupling * other_fields_sum / max(1, self.num_fields - 1)
driving_force_k = 0.005 * np.sin(self.field_phases[k])
c2 = 1.0 / (1.0 + self.tension * phi_k**2 + 1e-6)
acc = (c2 * self.diffusion_coeffs[k] * lap_k -
potential_deriv_k +
coupling_force +
driving_force_k)
new_phi_k = phi_k + (1 - self.damping_factor * self.dt) * vel_k + self.dt**2 * acc
new_phi_list.append(new_phi_k)
# Update fields
# Note: phi_o_fields update logic (phi_o_k = phi_k) seems missing from the original source step,
# but the physics uses phi_o_k to compute vel_k, so we need to update it here.
self.phi_o_fields = self.phi_fields # Save current as previous for the next step
self.phi_fields = new_phi_list
self._update_summed_fields()
# Calculate total energy (simplified)
self.total_energy = np.mean(self.phi**2)
def get_output(self, port_name):
if port_name == 'field_slice':
# Output the middle slice
z_mid = self.N // 2
field_slice = self.phi[z_mid, :, :]
# Normalize field for output
vmax = np.abs(field_slice).max() + 1e-9
return (field_slice / (2 * vmax)) + 0.5 # map [-v, v] to [0, 1]
elif port_name == 'total_energy':
return self.total_energy
return None
def get_display_image(self):
# Get the middle slice for the node's display
z_mid = self.N // 2
field_slice = self.phi[z_mid, :, :]
# Normalize field for display
vmax = np.abs(field_slice).max() + 1e-9
img_norm = np.clip((field_slice / (2 * vmax)) + 0.5, 0.0, 1.0)
img_u8 = (img_norm * 255).astype(np.uint8)
# Apply a colormap
img_color = cv2.applyColorMap(img_u8, cv2.COLORMAP_JET)
img_color = np.ascontiguousarray(img_color)
h, w = img_color.shape[:2]
return QtGui.QImage(img_color.data, w, h, 3*w, QtGui.QImage.Format.Format_BGR888)
def get_config_options(self):
return [
("Grid Size (3D)", "N", self.N, None),
("Num Fields", "num_fields", self.num_fields, None),
]
|