Spaces:
Running
Running
Upload EEGCrystalMaker.py
Browse files- EEGCrystalMaker.py +802 -0
EEGCrystalMaker.py
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
EEG Crystal Maker
|
| 4 |
+
=================
|
| 5 |
+
|
| 6 |
+
Standalone GUI for growing neural crystal lattices from EEG data.
|
| 7 |
+
|
| 8 |
+
Features:
|
| 9 |
+
- Load any EDF file
|
| 10 |
+
- Set resolution (32x32 to 2048x2048)
|
| 11 |
+
- Watch crystallization in real-time
|
| 12 |
+
- Save crystal state + pin map
|
| 13 |
+
- Load and continue growing
|
| 14 |
+
|
| 15 |
+
The crystal lattice is a 2D Izhikevich neuron sheet with STDP plasticity.
|
| 16 |
+
EEG electrodes inject current at mapped positions (the "pins").
|
| 17 |
+
Over time, the coupling weights crystallize into a structure that
|
| 18 |
+
reflects the EEG's spatiotemporal patterns.
|
| 19 |
+
|
| 20 |
+
Output:
|
| 21 |
+
- .npz file containing:
|
| 22 |
+
- weights (4 directional coupling matrices)
|
| 23 |
+
- pin_coords (electrode positions on grid)
|
| 24 |
+
- pin_names (electrode labels)
|
| 25 |
+
- metadata (resolution, training steps, etc.)
|
| 26 |
+
|
| 27 |
+
Author: Built for Antti's consciousness crystallography research
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
import sys
|
| 31 |
+
import os
|
| 32 |
+
import re
|
| 33 |
+
import json
|
| 34 |
+
import numpy as np
|
| 35 |
+
from datetime import datetime
|
| 36 |
+
|
| 37 |
+
from PyQt6.QtWidgets import (
|
| 38 |
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
| 39 |
+
QPushButton, QLabel, QSpinBox, QDoubleSpinBox, QFileDialog,
|
| 40 |
+
QProgressBar, QGroupBox, QGridLayout, QComboBox, QCheckBox,
|
| 41 |
+
QSlider, QFrame, QMessageBox, QStatusBar
|
| 42 |
+
)
|
| 43 |
+
from PyQt6.QtCore import Qt, QTimer
|
| 44 |
+
from PyQt6.QtGui import QImage, QPixmap, QPainter, QColor, QFont
|
| 45 |
+
|
| 46 |
+
import cv2
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
import mne
|
| 50 |
+
MNE_AVAILABLE = True
|
| 51 |
+
except ImportError:
|
| 52 |
+
MNE_AVAILABLE = False
|
| 53 |
+
print("Warning: MNE not installed. EEG loading will not work.")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CrystalLattice:
|
| 57 |
+
"""The neural crystal - Izhikevich sheet with STDP."""
|
| 58 |
+
|
| 59 |
+
def __init__(self, grid_size=64):
|
| 60 |
+
self.grid_size = grid_size
|
| 61 |
+
self.init_arrays()
|
| 62 |
+
|
| 63 |
+
# Izhikevich parameters
|
| 64 |
+
self.a = 0.02
|
| 65 |
+
self.b = 0.2
|
| 66 |
+
self.c = -65.0
|
| 67 |
+
self.d = 8.0
|
| 68 |
+
self.dt = 0.5
|
| 69 |
+
|
| 70 |
+
# STDP parameters
|
| 71 |
+
self.learning_rate = 0.005
|
| 72 |
+
self.trace_decay = 0.95
|
| 73 |
+
self.weight_max = 2.0
|
| 74 |
+
self.weight_min = 0.01
|
| 75 |
+
|
| 76 |
+
# Coupling strength - how much neighbors influence each other
|
| 77 |
+
self.coupling_strength = 5.0 # Higher = more spread
|
| 78 |
+
|
| 79 |
+
# Statistics
|
| 80 |
+
self.total_spikes = 0
|
| 81 |
+
self.learning_steps = 0
|
| 82 |
+
|
| 83 |
+
def init_arrays(self):
|
| 84 |
+
"""Initialize all arrays to current grid_size."""
|
| 85 |
+
n = self.grid_size
|
| 86 |
+
|
| 87 |
+
# Neural state
|
| 88 |
+
self.v = np.ones((n, n), dtype=np.float32) * -65.0
|
| 89 |
+
self.u = self.v * 0.2
|
| 90 |
+
|
| 91 |
+
# Crystal weights (4 directions)
|
| 92 |
+
self.weights_up = np.ones((n, n), dtype=np.float32) * 0.5
|
| 93 |
+
self.weights_down = np.ones((n, n), dtype=np.float32) * 0.5
|
| 94 |
+
self.weights_left = np.ones((n, n), dtype=np.float32) * 0.5
|
| 95 |
+
self.weights_right = np.ones((n, n), dtype=np.float32) * 0.5
|
| 96 |
+
|
| 97 |
+
# Spike trace for STDP
|
| 98 |
+
self.spike_trace = np.zeros((n, n), dtype=np.float32)
|
| 99 |
+
|
| 100 |
+
def resize(self, new_size):
|
| 101 |
+
"""Resize the lattice (resets state)."""
|
| 102 |
+
self.grid_size = new_size
|
| 103 |
+
self.init_arrays()
|
| 104 |
+
self.total_spikes = 0
|
| 105 |
+
self.learning_steps = 0
|
| 106 |
+
|
| 107 |
+
def step(self, input_current, learning=True):
|
| 108 |
+
"""One simulation step with optional STDP learning."""
|
| 109 |
+
v = self.v
|
| 110 |
+
u = self.u
|
| 111 |
+
I = input_current
|
| 112 |
+
|
| 113 |
+
# Clamp input to prevent explosion
|
| 114 |
+
I = np.clip(I, -100, 100)
|
| 115 |
+
|
| 116 |
+
# Neighbor coupling
|
| 117 |
+
v_up = np.roll(v, -1, axis=0)
|
| 118 |
+
v_down = np.roll(v, 1, axis=0)
|
| 119 |
+
v_left = np.roll(v, -1, axis=1)
|
| 120 |
+
v_right = np.roll(v, 1, axis=1)
|
| 121 |
+
|
| 122 |
+
neighbor_influence = (
|
| 123 |
+
self.weights_up * v_up +
|
| 124 |
+
self.weights_down * v_down +
|
| 125 |
+
self.weights_left * v_left +
|
| 126 |
+
self.weights_right * v_right
|
| 127 |
+
)
|
| 128 |
+
total_weight = (self.weights_up + self.weights_down +
|
| 129 |
+
self.weights_left + self.weights_right)
|
| 130 |
+
neighbor_avg = neighbor_influence / (total_weight + 1e-6)
|
| 131 |
+
|
| 132 |
+
I_coupling = self.coupling_strength * (neighbor_avg - v)
|
| 133 |
+
I_coupling = np.clip(I_coupling, -50, 50) # Prevent coupling explosion
|
| 134 |
+
|
| 135 |
+
# Izhikevich dynamics
|
| 136 |
+
dv = (0.04 * v * v + 5.0 * v + 140.0 - u + I + I_coupling) * self.dt
|
| 137 |
+
du = self.a * (self.b * v - u) * self.dt
|
| 138 |
+
|
| 139 |
+
v = v + dv
|
| 140 |
+
u = u + du
|
| 141 |
+
|
| 142 |
+
# Clamp voltage to sane range (prevents NaN cascade)
|
| 143 |
+
v = np.clip(v, -100, 50)
|
| 144 |
+
u = np.clip(u, -50, 50)
|
| 145 |
+
|
| 146 |
+
# Handle any NaN that slipped through
|
| 147 |
+
v = np.nan_to_num(v, nan=self.c, posinf=30.0, neginf=-100.0)
|
| 148 |
+
u = np.nan_to_num(u, nan=self.c * self.b, posinf=20.0, neginf=-20.0)
|
| 149 |
+
|
| 150 |
+
# Spikes
|
| 151 |
+
spikes = v >= 30.0
|
| 152 |
+
v[spikes] = self.c
|
| 153 |
+
u[spikes] += self.d
|
| 154 |
+
|
| 155 |
+
self.v = v
|
| 156 |
+
self.u = u
|
| 157 |
+
self.total_spikes += np.sum(spikes)
|
| 158 |
+
|
| 159 |
+
# STDP
|
| 160 |
+
if learning and self.learning_rate > 0:
|
| 161 |
+
self.learning_steps += 1
|
| 162 |
+
|
| 163 |
+
self.spike_trace *= self.trace_decay
|
| 164 |
+
self.spike_trace[spikes] = 1.0
|
| 165 |
+
|
| 166 |
+
trace_up = np.roll(self.spike_trace, -1, axis=0)
|
| 167 |
+
trace_down = np.roll(self.spike_trace, 1, axis=0)
|
| 168 |
+
trace_left = np.roll(self.spike_trace, -1, axis=1)
|
| 169 |
+
trace_right = np.roll(self.spike_trace, 1, axis=1)
|
| 170 |
+
|
| 171 |
+
spike_float = spikes.astype(np.float32)
|
| 172 |
+
lr = self.learning_rate
|
| 173 |
+
|
| 174 |
+
# Potentiation
|
| 175 |
+
dw_up = lr * spike_float * trace_up
|
| 176 |
+
dw_down = lr * spike_float * trace_down
|
| 177 |
+
dw_left = lr * spike_float * trace_left
|
| 178 |
+
dw_right = lr * spike_float * trace_right
|
| 179 |
+
|
| 180 |
+
# Depression
|
| 181 |
+
spike_up = np.roll(spike_float, -1, axis=0)
|
| 182 |
+
spike_down = np.roll(spike_float, 1, axis=0)
|
| 183 |
+
spike_left = np.roll(spike_float, -1, axis=1)
|
| 184 |
+
spike_right = np.roll(spike_float, 1, axis=1)
|
| 185 |
+
|
| 186 |
+
dw_up -= 0.5 * lr * self.spike_trace * spike_up
|
| 187 |
+
dw_down -= 0.5 * lr * self.spike_trace * spike_down
|
| 188 |
+
dw_left -= 0.5 * lr * self.spike_trace * spike_left
|
| 189 |
+
dw_right -= 0.5 * lr * self.spike_trace * spike_right
|
| 190 |
+
|
| 191 |
+
self.weights_up = np.clip(self.weights_up + dw_up, self.weight_min, self.weight_max)
|
| 192 |
+
self.weights_down = np.clip(self.weights_down + dw_down, self.weight_min, self.weight_max)
|
| 193 |
+
self.weights_left = np.clip(self.weights_left + dw_left, self.weight_min, self.weight_max)
|
| 194 |
+
self.weights_right = np.clip(self.weights_right + dw_right, self.weight_min, self.weight_max)
|
| 195 |
+
|
| 196 |
+
return spikes
|
| 197 |
+
|
| 198 |
+
def get_energy(self):
|
| 199 |
+
"""Total weight energy."""
|
| 200 |
+
return float(np.sum(self.weights_up) + np.sum(self.weights_down) +
|
| 201 |
+
np.sum(self.weights_left) + np.sum(self.weights_right))
|
| 202 |
+
|
| 203 |
+
def get_entropy(self):
|
| 204 |
+
"""Weight distribution entropy."""
|
| 205 |
+
all_weights = np.concatenate([
|
| 206 |
+
self.weights_up.flatten(),
|
| 207 |
+
self.weights_down.flatten(),
|
| 208 |
+
self.weights_left.flatten(),
|
| 209 |
+
self.weights_right.flatten()
|
| 210 |
+
])
|
| 211 |
+
w_norm = all_weights / (np.sum(all_weights) + 1e-9)
|
| 212 |
+
return float(-np.sum(w_norm * np.log(w_norm + 1e-9)))
|
| 213 |
+
|
| 214 |
+
def render_activity(self, size=256):
|
| 215 |
+
"""Render activity as image."""
|
| 216 |
+
disp = np.clip(self.v, -90.0, 40.0)
|
| 217 |
+
disp = np.nan_to_num(disp, nan=-65.0, posinf=40.0, neginf=-90.0)
|
| 218 |
+
norm = ((disp + 90.0) / 130.0 * 255.0).astype(np.uint8)
|
| 219 |
+
heat = cv2.applyColorMap(norm, cv2.COLORMAP_INFERNO)
|
| 220 |
+
heat = cv2.resize(heat, (size, size), interpolation=cv2.INTER_NEAREST)
|
| 221 |
+
return cv2.cvtColor(heat, cv2.COLOR_BGR2RGB)
|
| 222 |
+
|
| 223 |
+
def render_crystal(self, size=256):
|
| 224 |
+
"""Render crystal structure as image."""
|
| 225 |
+
horizontal = (self.weights_left + self.weights_right) / 2
|
| 226 |
+
vertical = (self.weights_up + self.weights_down) / 2
|
| 227 |
+
|
| 228 |
+
h_norm = (horizontal - self.weight_min) / (self.weight_max - self.weight_min)
|
| 229 |
+
v_norm = (vertical - self.weight_min) / (self.weight_max - self.weight_min)
|
| 230 |
+
anisotropy = np.abs(h_norm - v_norm)
|
| 231 |
+
|
| 232 |
+
img = np.zeros((self.grid_size, self.grid_size, 3), dtype=np.uint8)
|
| 233 |
+
img[:, :, 0] = (h_norm * 255).astype(np.uint8)
|
| 234 |
+
img[:, :, 1] = ((1 - anisotropy) * 255).astype(np.uint8)
|
| 235 |
+
img[:, :, 2] = (v_norm * 255).astype(np.uint8)
|
| 236 |
+
|
| 237 |
+
return cv2.resize(img, (size, size), interpolation=cv2.INTER_NEAREST)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class EEGSource:
|
| 241 |
+
"""Handles EEG loading and electrode mapping."""
|
| 242 |
+
|
| 243 |
+
STANDARD_MAP = {
|
| 244 |
+
"FP1": (0.30, 0.10), "FP2": (0.70, 0.10),
|
| 245 |
+
"F7": (0.10, 0.30), "F3": (0.30, 0.30), "FZ": (0.50, 0.25),
|
| 246 |
+
"F4": (0.70, 0.30), "F8": (0.90, 0.30),
|
| 247 |
+
"T7": (0.10, 0.50), "T3": (0.10, 0.50), # T3 alias
|
| 248 |
+
"C3": (0.30, 0.50), "CZ": (0.50, 0.50),
|
| 249 |
+
"C4": (0.70, 0.50), "T8": (0.90, 0.50), "T4": (0.90, 0.50), # T4 alias
|
| 250 |
+
"P7": (0.10, 0.70), "T5": (0.10, 0.70), # T5 alias
|
| 251 |
+
"P3": (0.30, 0.70), "PZ": (0.50, 0.75),
|
| 252 |
+
"P4": (0.70, 0.70), "P8": (0.90, 0.70), "T6": (0.90, 0.70), # T6 alias
|
| 253 |
+
"O1": (0.35, 0.90), "OZ": (0.50, 0.90), "O2": (0.65, 0.90),
|
| 254 |
+
"A1": (0.05, 0.50), "A2": (0.95, 0.50), # Ear references
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
def __init__(self):
|
| 258 |
+
self.raw = None
|
| 259 |
+
self.data = None
|
| 260 |
+
self.sfreq = 256.0
|
| 261 |
+
self.ch_names = []
|
| 262 |
+
self.current_idx = 0
|
| 263 |
+
self.amplification = 1e9 # Default amplification (Medium)
|
| 264 |
+
|
| 265 |
+
# Pin mapping
|
| 266 |
+
self.pin_coords = [] # (row, col) for each channel
|
| 267 |
+
self.pin_names = [] # Channel names
|
| 268 |
+
self.pin_indices = [] # Channel indices in data
|
| 269 |
+
|
| 270 |
+
def load(self, filepath):
|
| 271 |
+
"""Load EDF file."""
|
| 272 |
+
if not MNE_AVAILABLE:
|
| 273 |
+
raise RuntimeError("MNE not installed")
|
| 274 |
+
|
| 275 |
+
raw = mne.io.read_raw_edf(filepath, preload=True, verbose=False)
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
raw.pick_types(eeg=True, meg=False, eog=False, ecg=False,
|
| 279 |
+
emg=False, misc=False, stim=False)
|
| 280 |
+
except:
|
| 281 |
+
pass
|
| 282 |
+
|
| 283 |
+
if raw.info["sfreq"] > 256:
|
| 284 |
+
raw.resample(256, npad="auto", verbose=False)
|
| 285 |
+
|
| 286 |
+
self.raw = raw
|
| 287 |
+
self.data = raw.get_data()
|
| 288 |
+
self.sfreq = float(raw.info["sfreq"])
|
| 289 |
+
self.ch_names = list(raw.ch_names)
|
| 290 |
+
self.current_idx = 0
|
| 291 |
+
|
| 292 |
+
return len(self.ch_names), self.data.shape[1]
|
| 293 |
+
|
| 294 |
+
def map_electrodes(self, grid_size):
|
| 295 |
+
"""Map electrodes to grid positions."""
|
| 296 |
+
self.pin_coords = []
|
| 297 |
+
self.pin_names = []
|
| 298 |
+
self.pin_indices = []
|
| 299 |
+
|
| 300 |
+
for idx, name in enumerate(self.ch_names):
|
| 301 |
+
clean = re.sub(r'[^A-Z0-9]', '', name.upper())
|
| 302 |
+
|
| 303 |
+
pos = None
|
| 304 |
+
# Try exact match first
|
| 305 |
+
for std_name, std_pos in self.STANDARD_MAP.items():
|
| 306 |
+
if std_name in clean or clean in std_name:
|
| 307 |
+
pos = std_pos
|
| 308 |
+
break
|
| 309 |
+
|
| 310 |
+
# Try prefix match
|
| 311 |
+
if pos is None:
|
| 312 |
+
for std_name, std_pos in self.STANDARD_MAP.items():
|
| 313 |
+
if len(clean) >= 2 and clean[:2] == std_name[:2]:
|
| 314 |
+
pos = std_pos
|
| 315 |
+
break
|
| 316 |
+
|
| 317 |
+
if pos:
|
| 318 |
+
grid_r = int(pos[1] * (grid_size - 1))
|
| 319 |
+
grid_c = int(pos[0] * (grid_size - 1))
|
| 320 |
+
self.pin_coords.append((grid_r, grid_c))
|
| 321 |
+
self.pin_names.append(name)
|
| 322 |
+
self.pin_indices.append(idx)
|
| 323 |
+
|
| 324 |
+
return len(self.pin_coords)
|
| 325 |
+
|
| 326 |
+
def get_input_current(self, grid_size):
|
| 327 |
+
"""Get input current for one timestep."""
|
| 328 |
+
if self.data is None:
|
| 329 |
+
return np.zeros((grid_size, grid_size), dtype=np.float32)
|
| 330 |
+
|
| 331 |
+
n_samples = self.data.shape[1]
|
| 332 |
+
sample_idx = self.current_idx % n_samples
|
| 333 |
+
self.current_idx += 1
|
| 334 |
+
|
| 335 |
+
I = np.zeros((grid_size, grid_size), dtype=np.float32)
|
| 336 |
+
|
| 337 |
+
# Small spread - electrodes are injection points
|
| 338 |
+
# The coupling between neurons spreads activity, not the electrode radius
|
| 339 |
+
spread_radius = max(2, grid_size // 128) # ~8 at 1024, ~2 at 256
|
| 340 |
+
spread_sigma = max(1.0, spread_radius / 2.0)
|
| 341 |
+
|
| 342 |
+
# Pre-compute Gaussian kernel once
|
| 343 |
+
kernel_size = spread_radius * 2 + 1
|
| 344 |
+
y, x = np.ogrid[-spread_radius:spread_radius+1, -spread_radius:spread_radius+1]
|
| 345 |
+
kernel = np.exp(-(x*x + y*y) / (2 * spread_sigma * spread_sigma)).astype(np.float32)
|
| 346 |
+
|
| 347 |
+
for i, ch_idx in enumerate(self.pin_indices):
|
| 348 |
+
if i < len(self.pin_coords):
|
| 349 |
+
r, c = self.pin_coords[i]
|
| 350 |
+
val = self.data[ch_idx, sample_idx]
|
| 351 |
+
|
| 352 |
+
# Scale EEG
|
| 353 |
+
scaled = float(val) * self.amplification
|
| 354 |
+
scaled = np.clip(scaled, -500, 500)
|
| 355 |
+
|
| 356 |
+
# Calculate bounds for kernel placement
|
| 357 |
+
r_start = max(0, r - spread_radius)
|
| 358 |
+
r_end = min(grid_size, r + spread_radius + 1)
|
| 359 |
+
c_start = max(0, c - spread_radius)
|
| 360 |
+
c_end = min(grid_size, c + spread_radius + 1)
|
| 361 |
+
|
| 362 |
+
# Corresponding kernel bounds
|
| 363 |
+
kr_start = r_start - (r - spread_radius)
|
| 364 |
+
kr_end = kernel_size - ((r + spread_radius + 1) - r_end)
|
| 365 |
+
kc_start = c_start - (c - spread_radius)
|
| 366 |
+
kc_end = kernel_size - ((c + spread_radius + 1) - c_end)
|
| 367 |
+
|
| 368 |
+
# Add weighted kernel to input
|
| 369 |
+
I[r_start:r_end, c_start:c_end] += scaled * kernel[kr_start:kr_end, kc_start:kc_end]
|
| 370 |
+
|
| 371 |
+
return I
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
class CrystalMakerWindow(QMainWindow):
|
| 375 |
+
"""Main GUI window."""
|
| 376 |
+
|
| 377 |
+
def __init__(self):
|
| 378 |
+
super().__init__()
|
| 379 |
+
self.setWindowTitle("EEG Crystal Maker")
|
| 380 |
+
self.setMinimumSize(1000, 700)
|
| 381 |
+
|
| 382 |
+
# Core objects
|
| 383 |
+
self.crystal = CrystalLattice(64)
|
| 384 |
+
self.eeg = EEGSource()
|
| 385 |
+
|
| 386 |
+
# State
|
| 387 |
+
self.is_running = False
|
| 388 |
+
self.eeg_loaded = False
|
| 389 |
+
self.edf_path = ""
|
| 390 |
+
|
| 391 |
+
# Timer for simulation
|
| 392 |
+
self.timer = QTimer()
|
| 393 |
+
self.timer.timeout.connect(self.simulation_step)
|
| 394 |
+
|
| 395 |
+
self.setup_ui()
|
| 396 |
+
self.update_display()
|
| 397 |
+
|
| 398 |
+
def setup_ui(self):
|
| 399 |
+
"""Build the UI."""
|
| 400 |
+
central = QWidget()
|
| 401 |
+
self.setCentralWidget(central)
|
| 402 |
+
layout = QHBoxLayout(central)
|
| 403 |
+
|
| 404 |
+
# Left panel - controls
|
| 405 |
+
left_panel = QVBoxLayout()
|
| 406 |
+
layout.addLayout(left_panel, stretch=1)
|
| 407 |
+
|
| 408 |
+
# EEG Loading
|
| 409 |
+
eeg_group = QGroupBox("EEG Source")
|
| 410 |
+
eeg_layout = QVBoxLayout(eeg_group)
|
| 411 |
+
|
| 412 |
+
self.edf_label = QLabel("No file loaded")
|
| 413 |
+
self.edf_label.setWordWrap(True)
|
| 414 |
+
eeg_layout.addWidget(self.edf_label)
|
| 415 |
+
|
| 416 |
+
load_btn = QPushButton("Load EDF File...")
|
| 417 |
+
load_btn.clicked.connect(self.load_edf)
|
| 418 |
+
eeg_layout.addWidget(load_btn)
|
| 419 |
+
|
| 420 |
+
self.eeg_info = QLabel("Channels: -\nSamples: -\nPins mapped: -")
|
| 421 |
+
eeg_layout.addWidget(self.eeg_info)
|
| 422 |
+
|
| 423 |
+
left_panel.addWidget(eeg_group)
|
| 424 |
+
|
| 425 |
+
# Crystal Settings
|
| 426 |
+
crystal_group = QGroupBox("Crystal Settings")
|
| 427 |
+
crystal_layout = QGridLayout(crystal_group)
|
| 428 |
+
|
| 429 |
+
crystal_layout.addWidget(QLabel("Resolution:"), 0, 0)
|
| 430 |
+
self.resolution_combo = QComboBox()
|
| 431 |
+
self.resolution_combo.addItems(["32", "64", "128", "256", "512", "1024"])
|
| 432 |
+
self.resolution_combo.setCurrentText("64")
|
| 433 |
+
self.resolution_combo.currentTextChanged.connect(self.on_resolution_changed)
|
| 434 |
+
crystal_layout.addWidget(self.resolution_combo, 0, 1)
|
| 435 |
+
|
| 436 |
+
crystal_layout.addWidget(QLabel("Learning Rate:"), 1, 0)
|
| 437 |
+
self.lr_spin = QDoubleSpinBox()
|
| 438 |
+
self.lr_spin.setRange(0.0001, 0.1)
|
| 439 |
+
self.lr_spin.setSingleStep(0.001)
|
| 440 |
+
self.lr_spin.setValue(0.005)
|
| 441 |
+
self.lr_spin.valueChanged.connect(self.on_lr_changed)
|
| 442 |
+
crystal_layout.addWidget(self.lr_spin, 1, 1)
|
| 443 |
+
|
| 444 |
+
crystal_layout.addWidget(QLabel("EEG Amplification:"), 2, 0)
|
| 445 |
+
self.amp_combo = QComboBox()
|
| 446 |
+
self.amp_combo.addItems(["1e8 (Low)", "1e9 (Medium)", "1e10 (High)", "1e11 (Very High)"])
|
| 447 |
+
self.amp_combo.setCurrentIndex(1) # Default to Medium
|
| 448 |
+
self.amp_combo.currentIndexChanged.connect(self.on_amp_changed)
|
| 449 |
+
crystal_layout.addWidget(self.amp_combo, 2, 1)
|
| 450 |
+
|
| 451 |
+
crystal_layout.addWidget(QLabel("Coupling Strength:"), 3, 0)
|
| 452 |
+
self.coupling_spin = QDoubleSpinBox()
|
| 453 |
+
self.coupling_spin.setRange(0.1, 20.0)
|
| 454 |
+
self.coupling_spin.setSingleStep(0.5)
|
| 455 |
+
self.coupling_spin.setValue(5.0)
|
| 456 |
+
self.coupling_spin.valueChanged.connect(self.on_coupling_changed)
|
| 457 |
+
crystal_layout.addWidget(self.coupling_spin, 3, 1)
|
| 458 |
+
|
| 459 |
+
crystal_layout.addWidget(QLabel("Target Steps:"), 4, 0)
|
| 460 |
+
self.target_steps_spin = QSpinBox()
|
| 461 |
+
self.target_steps_spin.setRange(100, 100000)
|
| 462 |
+
self.target_steps_spin.setSingleStep(100)
|
| 463 |
+
self.target_steps_spin.setValue(800)
|
| 464 |
+
crystal_layout.addWidget(self.target_steps_spin, 4, 1)
|
| 465 |
+
|
| 466 |
+
left_panel.addWidget(crystal_group)
|
| 467 |
+
|
| 468 |
+
# Simulation Control
|
| 469 |
+
control_group = QGroupBox("Simulation")
|
| 470 |
+
control_layout = QVBoxLayout(control_group)
|
| 471 |
+
|
| 472 |
+
btn_layout = QHBoxLayout()
|
| 473 |
+
self.start_btn = QPushButton("▶ Start")
|
| 474 |
+
self.start_btn.clicked.connect(self.toggle_simulation)
|
| 475 |
+
btn_layout.addWidget(self.start_btn)
|
| 476 |
+
|
| 477 |
+
self.reset_btn = QPushButton("↺ Reset")
|
| 478 |
+
self.reset_btn.clicked.connect(self.reset_crystal)
|
| 479 |
+
btn_layout.addWidget(self.reset_btn)
|
| 480 |
+
control_layout.addLayout(btn_layout)
|
| 481 |
+
|
| 482 |
+
# Speed slider
|
| 483 |
+
speed_layout = QHBoxLayout()
|
| 484 |
+
speed_layout.addWidget(QLabel("Speed:"))
|
| 485 |
+
self.speed_slider = QSlider(Qt.Orientation.Horizontal)
|
| 486 |
+
self.speed_slider.setRange(1, 100)
|
| 487 |
+
self.speed_slider.setValue(50)
|
| 488 |
+
self.speed_slider.valueChanged.connect(self.on_speed_changed)
|
| 489 |
+
speed_layout.addWidget(self.speed_slider)
|
| 490 |
+
control_layout.addLayout(speed_layout)
|
| 491 |
+
|
| 492 |
+
# Progress
|
| 493 |
+
self.progress_bar = QProgressBar()
|
| 494 |
+
self.progress_bar.setRange(0, 800)
|
| 495 |
+
control_layout.addWidget(self.progress_bar)
|
| 496 |
+
|
| 497 |
+
left_panel.addWidget(control_group)
|
| 498 |
+
|
| 499 |
+
# Statistics
|
| 500 |
+
stats_group = QGroupBox("Statistics")
|
| 501 |
+
stats_layout = QVBoxLayout(stats_group)
|
| 502 |
+
|
| 503 |
+
self.stats_label = QLabel("Steps: 0\nSpikes: 0\nEnergy: 0\nEntropy: 0")
|
| 504 |
+
self.stats_label.setFont(QFont("Monospace", 10))
|
| 505 |
+
stats_layout.addWidget(self.stats_label)
|
| 506 |
+
|
| 507 |
+
left_panel.addWidget(stats_group)
|
| 508 |
+
|
| 509 |
+
# Save/Load
|
| 510 |
+
file_group = QGroupBox("File Operations")
|
| 511 |
+
file_layout = QVBoxLayout(file_group)
|
| 512 |
+
|
| 513 |
+
save_btn = QPushButton("💾 Save Crystal...")
|
| 514 |
+
save_btn.clicked.connect(self.save_crystal)
|
| 515 |
+
file_layout.addWidget(save_btn)
|
| 516 |
+
|
| 517 |
+
load_crystal_btn = QPushButton("📂 Load Crystal...")
|
| 518 |
+
load_crystal_btn.clicked.connect(self.load_crystal)
|
| 519 |
+
file_layout.addWidget(load_crystal_btn)
|
| 520 |
+
|
| 521 |
+
left_panel.addWidget(file_group)
|
| 522 |
+
|
| 523 |
+
left_panel.addStretch()
|
| 524 |
+
|
| 525 |
+
# Right panel - visualization
|
| 526 |
+
right_panel = QVBoxLayout()
|
| 527 |
+
layout.addLayout(right_panel, stretch=2)
|
| 528 |
+
|
| 529 |
+
# Activity view
|
| 530 |
+
activity_group = QGroupBox("Neural Activity")
|
| 531 |
+
activity_layout = QVBoxLayout(activity_group)
|
| 532 |
+
self.activity_label = QLabel()
|
| 533 |
+
self.activity_label.setMinimumSize(400, 400)
|
| 534 |
+
self.activity_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
| 535 |
+
self.activity_label.setStyleSheet("background-color: #1a1a1a;")
|
| 536 |
+
activity_layout.addWidget(self.activity_label)
|
| 537 |
+
right_panel.addWidget(activity_group)
|
| 538 |
+
|
| 539 |
+
# Crystal view
|
| 540 |
+
crystal_view_group = QGroupBox("Crystal Structure")
|
| 541 |
+
crystal_view_layout = QVBoxLayout(crystal_view_group)
|
| 542 |
+
self.crystal_label = QLabel()
|
| 543 |
+
self.crystal_label.setMinimumSize(400, 400)
|
| 544 |
+
self.crystal_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
| 545 |
+
self.crystal_label.setStyleSheet("background-color: #1a1a1a;")
|
| 546 |
+
crystal_view_layout.addWidget(self.crystal_label)
|
| 547 |
+
right_panel.addWidget(crystal_view_group)
|
| 548 |
+
|
| 549 |
+
# Status bar
|
| 550 |
+
self.status_bar = QStatusBar()
|
| 551 |
+
self.setStatusBar(self.status_bar)
|
| 552 |
+
self.status_bar.showMessage("Ready - Load an EDF file to begin")
|
| 553 |
+
|
| 554 |
+
def load_edf(self):
|
| 555 |
+
"""Load EDF file dialog."""
|
| 556 |
+
filepath, _ = QFileDialog.getOpenFileName(
|
| 557 |
+
self, "Open EDF File", "", "EDF Files (*.edf);;All Files (*)"
|
| 558 |
+
)
|
| 559 |
+
if filepath:
|
| 560 |
+
try:
|
| 561 |
+
n_channels, n_samples = self.eeg.load(filepath)
|
| 562 |
+
n_pins = self.eeg.map_electrodes(self.crystal.grid_size)
|
| 563 |
+
|
| 564 |
+
self.edf_path = filepath
|
| 565 |
+
self.eeg_loaded = True
|
| 566 |
+
|
| 567 |
+
fname = os.path.basename(filepath)
|
| 568 |
+
self.edf_label.setText(f"Loaded: {fname}")
|
| 569 |
+
self.eeg_info.setText(
|
| 570 |
+
f"Channels: {n_channels}\n"
|
| 571 |
+
f"Samples: {n_samples}\n"
|
| 572 |
+
f"Pins mapped: {n_pins}"
|
| 573 |
+
)
|
| 574 |
+
self.status_bar.showMessage(f"Loaded {fname} - {n_pins} electrodes mapped")
|
| 575 |
+
|
| 576 |
+
except Exception as e:
|
| 577 |
+
QMessageBox.critical(self, "Error", f"Failed to load EDF:\n{str(e)}")
|
| 578 |
+
|
| 579 |
+
def on_resolution_changed(self, text):
|
| 580 |
+
"""Handle resolution change."""
|
| 581 |
+
new_size = int(text)
|
| 582 |
+
if new_size != self.crystal.grid_size:
|
| 583 |
+
self.crystal.resize(new_size)
|
| 584 |
+
if self.eeg_loaded:
|
| 585 |
+
n_pins = self.eeg.map_electrodes(new_size)
|
| 586 |
+
self.eeg_info.setText(
|
| 587 |
+
f"Channels: {len(self.eeg.ch_names)}\n"
|
| 588 |
+
f"Samples: {self.eeg.data.shape[1]}\n"
|
| 589 |
+
f"Pins mapped: {n_pins}"
|
| 590 |
+
)
|
| 591 |
+
self.update_display()
|
| 592 |
+
self.status_bar.showMessage(f"Resolution changed to {new_size}x{new_size}")
|
| 593 |
+
|
| 594 |
+
def on_lr_changed(self, value):
|
| 595 |
+
"""Handle learning rate change."""
|
| 596 |
+
self.crystal.learning_rate = value
|
| 597 |
+
|
| 598 |
+
def on_amp_changed(self, index):
|
| 599 |
+
"""Handle amplification change."""
|
| 600 |
+
amp_values = [1e8, 1e9, 1e10, 1e11]
|
| 601 |
+
self.eeg.amplification = amp_values[index]
|
| 602 |
+
self.status_bar.showMessage(f"Amplification set to {amp_values[index]:.0e}")
|
| 603 |
+
|
| 604 |
+
def on_coupling_changed(self, value):
|
| 605 |
+
"""Handle coupling strength change."""
|
| 606 |
+
self.crystal.coupling_strength = value
|
| 607 |
+
|
| 608 |
+
def on_speed_changed(self, value):
|
| 609 |
+
"""Handle speed slider change."""
|
| 610 |
+
if self.is_running:
|
| 611 |
+
# Map 1-100 to 100ms-1ms interval
|
| 612 |
+
interval = max(1, 101 - value)
|
| 613 |
+
self.timer.setInterval(interval)
|
| 614 |
+
|
| 615 |
+
def toggle_simulation(self):
|
| 616 |
+
"""Start/stop simulation."""
|
| 617 |
+
if not self.eeg_loaded:
|
| 618 |
+
QMessageBox.warning(self, "Warning", "Please load an EDF file first.")
|
| 619 |
+
return
|
| 620 |
+
|
| 621 |
+
if self.is_running:
|
| 622 |
+
self.timer.stop()
|
| 623 |
+
self.is_running = False
|
| 624 |
+
self.start_btn.setText("▶ Start")
|
| 625 |
+
self.status_bar.showMessage("Simulation paused")
|
| 626 |
+
else:
|
| 627 |
+
interval = max(1, 101 - self.speed_slider.value())
|
| 628 |
+
self.timer.start(interval)
|
| 629 |
+
self.is_running = True
|
| 630 |
+
self.start_btn.setText("⏸ Pause")
|
| 631 |
+
self.status_bar.showMessage("Simulation running...")
|
| 632 |
+
|
| 633 |
+
def simulation_step(self):
|
| 634 |
+
"""One step of simulation."""
|
| 635 |
+
I = self.eeg.get_input_current(self.crystal.grid_size)
|
| 636 |
+
self.crystal.step(I, learning=True)
|
| 637 |
+
|
| 638 |
+
# Update progress
|
| 639 |
+
target = self.target_steps_spin.value()
|
| 640 |
+
self.progress_bar.setMaximum(target)
|
| 641 |
+
self.progress_bar.setValue(min(self.crystal.learning_steps, target))
|
| 642 |
+
|
| 643 |
+
# Update display every few steps for performance
|
| 644 |
+
if self.crystal.learning_steps % 5 == 0:
|
| 645 |
+
self.update_display()
|
| 646 |
+
|
| 647 |
+
# Auto-stop at target
|
| 648 |
+
if self.crystal.learning_steps >= target:
|
| 649 |
+
self.toggle_simulation()
|
| 650 |
+
self.status_bar.showMessage(f"Completed {target} steps - Crystal ready to save!")
|
| 651 |
+
|
| 652 |
+
def reset_crystal(self):
|
| 653 |
+
"""Reset crystal to initial state."""
|
| 654 |
+
self.crystal.init_arrays()
|
| 655 |
+
self.crystal.total_spikes = 0
|
| 656 |
+
self.crystal.learning_steps = 0
|
| 657 |
+
if self.eeg_loaded:
|
| 658 |
+
self.eeg.current_idx = 0
|
| 659 |
+
self.update_display()
|
| 660 |
+
self.status_bar.showMessage("Crystal reset")
|
| 661 |
+
|
| 662 |
+
def update_display(self):
|
| 663 |
+
"""Update visualization."""
|
| 664 |
+
# Activity
|
| 665 |
+
activity_img = self.crystal.render_activity(400)
|
| 666 |
+
|
| 667 |
+
# Draw electrode pins on activity
|
| 668 |
+
if self.eeg_loaded:
|
| 669 |
+
scale = 400 / self.crystal.grid_size
|
| 670 |
+
for r, c in self.eeg.pin_coords:
|
| 671 |
+
x, y = int(c * scale), int(r * scale)
|
| 672 |
+
cv2.circle(activity_img, (x, y), 3, (0, 255, 0), -1)
|
| 673 |
+
|
| 674 |
+
h, w, ch = activity_img.shape
|
| 675 |
+
qimg = QImage(activity_img.data, w, h, w * ch, QImage.Format.Format_RGB888)
|
| 676 |
+
self.activity_label.setPixmap(QPixmap.fromImage(qimg))
|
| 677 |
+
|
| 678 |
+
# Crystal
|
| 679 |
+
crystal_img = self.crystal.render_crystal(400)
|
| 680 |
+
h, w, ch = crystal_img.shape
|
| 681 |
+
qimg = QImage(crystal_img.data, w, h, w * ch, QImage.Format.Format_RGB888)
|
| 682 |
+
self.crystal_label.setPixmap(QPixmap.fromImage(qimg))
|
| 683 |
+
|
| 684 |
+
# Stats
|
| 685 |
+
self.stats_label.setText(
|
| 686 |
+
f"Steps: {self.crystal.learning_steps}\n"
|
| 687 |
+
f"Spikes: {self.crystal.total_spikes:,}\n"
|
| 688 |
+
f"Energy: {self.crystal.get_energy():.1f}\n"
|
| 689 |
+
f"Entropy: {self.crystal.get_entropy():.2f}"
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
+
self.progress_bar.setValue(self.crystal.learning_steps)
|
| 693 |
+
|
| 694 |
+
def save_crystal(self):
|
| 695 |
+
"""Save crystal to file."""
|
| 696 |
+
if self.crystal.learning_steps == 0:
|
| 697 |
+
QMessageBox.warning(self, "Warning", "No crystal to save - run some training first.")
|
| 698 |
+
return
|
| 699 |
+
|
| 700 |
+
default_name = f"crystal_{self.crystal.grid_size}x{self.crystal.grid_size}_{self.crystal.learning_steps}steps.npz"
|
| 701 |
+
filepath, _ = QFileDialog.getSaveFileName(
|
| 702 |
+
self, "Save Crystal", default_name, "NumPy Archive (*.npz);;All Files (*)"
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
if filepath:
|
| 706 |
+
try:
|
| 707 |
+
# Prepare pin data
|
| 708 |
+
pin_coords = np.array(self.eeg.pin_coords) if self.eeg.pin_coords else np.array([])
|
| 709 |
+
pin_names = np.array(self.eeg.pin_names) if self.eeg.pin_names else np.array([])
|
| 710 |
+
|
| 711 |
+
np.savez(filepath,
|
| 712 |
+
# Weights
|
| 713 |
+
weights_up=self.crystal.weights_up,
|
| 714 |
+
weights_down=self.crystal.weights_down,
|
| 715 |
+
weights_left=self.crystal.weights_left,
|
| 716 |
+
weights_right=self.crystal.weights_right,
|
| 717 |
+
# Pin map
|
| 718 |
+
pin_coords=pin_coords,
|
| 719 |
+
pin_names=pin_names,
|
| 720 |
+
# Metadata
|
| 721 |
+
grid_size=self.crystal.grid_size,
|
| 722 |
+
learning_steps=self.crystal.learning_steps,
|
| 723 |
+
total_spikes=self.crystal.total_spikes,
|
| 724 |
+
learning_rate=self.crystal.learning_rate,
|
| 725 |
+
edf_source=os.path.basename(self.edf_path) if self.edf_path else "",
|
| 726 |
+
created=datetime.now().isoformat()
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
self.status_bar.showMessage(f"Saved crystal to {os.path.basename(filepath)}")
|
| 730 |
+
|
| 731 |
+
except Exception as e:
|
| 732 |
+
QMessageBox.critical(self, "Error", f"Failed to save:\n{str(e)}")
|
| 733 |
+
|
| 734 |
+
def load_crystal(self):
|
| 735 |
+
"""Load crystal from file."""
|
| 736 |
+
filepath, _ = QFileDialog.getOpenFileName(
|
| 737 |
+
self, "Load Crystal", "", "NumPy Archive (*.npz);;All Files (*)"
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
if filepath:
|
| 741 |
+
try:
|
| 742 |
+
data = np.load(filepath, allow_pickle=True)
|
| 743 |
+
|
| 744 |
+
# Get grid size and resize
|
| 745 |
+
grid_size = int(data['grid_size'])
|
| 746 |
+
self.crystal.resize(grid_size)
|
| 747 |
+
self.resolution_combo.setCurrentText(str(grid_size))
|
| 748 |
+
|
| 749 |
+
# Load weights
|
| 750 |
+
self.crystal.weights_up = data['weights_up']
|
| 751 |
+
self.crystal.weights_down = data['weights_down']
|
| 752 |
+
self.crystal.weights_left = data['weights_left']
|
| 753 |
+
self.crystal.weights_right = data['weights_right']
|
| 754 |
+
|
| 755 |
+
# Load stats
|
| 756 |
+
self.crystal.learning_steps = int(data['learning_steps'])
|
| 757 |
+
self.crystal.total_spikes = int(data['total_spikes'])
|
| 758 |
+
if 'learning_rate' in data:
|
| 759 |
+
self.crystal.learning_rate = float(data['learning_rate'])
|
| 760 |
+
self.lr_spin.setValue(self.crystal.learning_rate)
|
| 761 |
+
|
| 762 |
+
# Load pin map
|
| 763 |
+
if 'pin_coords' in data and len(data['pin_coords']) > 0:
|
| 764 |
+
self.eeg.pin_coords = [tuple(c) for c in data['pin_coords']]
|
| 765 |
+
self.eeg.pin_names = list(data['pin_names'])
|
| 766 |
+
|
| 767 |
+
self.update_display()
|
| 768 |
+
self.status_bar.showMessage(f"Loaded crystal from {os.path.basename(filepath)}")
|
| 769 |
+
|
| 770 |
+
except Exception as e:
|
| 771 |
+
QMessageBox.critical(self, "Error", f"Failed to load:\n{str(e)}")
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
def main():
|
| 775 |
+
app = QApplication(sys.argv)
|
| 776 |
+
app.setStyle("Fusion")
|
| 777 |
+
|
| 778 |
+
# Dark theme
|
| 779 |
+
palette = app.palette()
|
| 780 |
+
palette.setColor(palette.ColorRole.Window, QColor(53, 53, 53))
|
| 781 |
+
palette.setColor(palette.ColorRole.WindowText, QColor(255, 255, 255))
|
| 782 |
+
palette.setColor(palette.ColorRole.Base, QColor(25, 25, 25))
|
| 783 |
+
palette.setColor(palette.ColorRole.AlternateBase, QColor(53, 53, 53))
|
| 784 |
+
palette.setColor(palette.ColorRole.ToolTipBase, QColor(255, 255, 255))
|
| 785 |
+
palette.setColor(palette.ColorRole.ToolTipText, QColor(255, 255, 255))
|
| 786 |
+
palette.setColor(palette.ColorRole.Text, QColor(255, 255, 255))
|
| 787 |
+
palette.setColor(palette.ColorRole.Button, QColor(53, 53, 53))
|
| 788 |
+
palette.setColor(palette.ColorRole.ButtonText, QColor(255, 255, 255))
|
| 789 |
+
palette.setColor(palette.ColorRole.BrightText, QColor(255, 0, 0))
|
| 790 |
+
palette.setColor(palette.ColorRole.Link, QColor(42, 130, 218))
|
| 791 |
+
palette.setColor(palette.ColorRole.Highlight, QColor(42, 130, 218))
|
| 792 |
+
palette.setColor(palette.ColorRole.HighlightedText, QColor(0, 0, 0))
|
| 793 |
+
app.setPalette(palette)
|
| 794 |
+
|
| 795 |
+
window = CrystalMakerWindow()
|
| 796 |
+
window.show()
|
| 797 |
+
|
| 798 |
+
sys.exit(app.exec())
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
if __name__ == "__main__":
|
| 802 |
+
main()
|