CoolFace
Apppublic

Aluode/PerceptionLabPortable

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
perceptionlab12.py1633 linesDownload Raw Back to app
1#!/usr/bin/env python3
2"""
3Antti's Perception Laboratory - Host v9.2 (Config Load Fix)
4-----------------------------------------------------------------
5[V9.2 FIX] Fixed config loading to call set_config_options() for proper node initialization
6[V9.1 FIX] Fixed get_blended_input to preserve complex arrays instead of destroying them
7[V9 FEATURE] Added a global 'Speed' setting (slider) to control the simulation timer interval.
8[V8 FEATURE] ADDED NODE EXPORT OPTION during save for easy sharing of workflow + node files.
9[V7 FEATURE] Added support for custom node state (W-Matrices, etc.) 
10               saved to a sidecar folder during graph save/load.
11[V6.1 FEATURE] Added 'learned_weight' support to EdgeItem. 
12               The physics engine now multiplies signal transfer by this weight.
13[V5 CORE] Robust NaN/Inf handling.
14"""
15
16import sys
17import numpy as np
18from PyQt6 import QtWidgets, QtGui, QtCore
19import cv2
20import pyqtgraph as pg
21from collections import deque
22import os
23import inspect
24import importlib.util
25import json
26import shutil # <--- ADDED for file copy
27
28pg.setConfigOptions(imageAxisOrder='row-major')
29
30# --- HELPER FUNCTION WITH NaN PROTECTION ---
31def numpy_to_qimage(array):
32    """
33    Converts a numpy array (H, W, C) or (H, W) to a QImage
34    for high-quality scaling. Now with NaN/Inf protection.
35    """
36    if array is None:
37        return QtGui.QImage()
38    
39    # [v5 FIX] Handle NaN/Inf in display arrays
40    if not np.all(np.isfinite(array)):
41        array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=0.0)
42    
43    # Ensure array is in 0-1 float range first
44    if array.dtype != np.float32:
45        array = array / 255.0 if array.max() > 1.0 else array.astype(np.float32)
46    elif array.max() > 1.0 and array.min() >= 0.0:
47        # If it's float but > 1.0 (e.g. 0-255 float), normalize
48        array = array / 255.0
49    
50    array = np.clip(array, 0, 1) * 255
51    array = array.astype(np.uint8)
52    
53    # Make array contiguous in memory
54    array = np.ascontiguousarray(array)
55    
56    if array.ndim == 2: # Grayscale
57        h, w = array.shape
58        qimage = QtGui.QImage(array.data, w, h, w, QtGui.QImage.Format.Format_Grayscale8)
59    elif array.ndim == 3 and array.shape[2] == 3: # RGB
60        h, w, c = array.shape
61        # Create QImage from 24-bit RGB data
62        qimage = QtGui.QImage(array.data, w, h, 3 * w, QtGui.QImage.Format.Format_RGB888)
63    elif array.ndim == 3 and array.shape[2] == 4: # RGBA
64        h, w, c = array.shape
65        # Create QImage from 32-bit RGBA data
66        qimage = QtGui.QImage(array.data, w, h, 4 * w, QtGui.QImage.Format.Format_RGBA8888)
67    else:
68        return QtGui.QImage() # Unsupported dimensions
69        
70    # QImage is a view on the numpy array. We must make a copy
71    # or the array will be garbage-collected and QImage will crash.
72    return qimage.copy()
73# --- END HELPER FUNCTION ---
74
75
76# --- Global PyAudio Instance ---
77try:
78    import pyaudio
79    PA_INSTANCE = pyaudio.PyAudio()
80except ImportError:
81    # print("Warning: pyaudio not installed. Audio nodes will be non-functional.")
82    pyaudio = None
83    PA_INSTANCE = None
84
85# ==================== BASE NODE SYSTEM ====================
86
87class BaseNode:
88    """Base class for all perception nodes"""
89    NODE_CATEGORY = "Base"
90    NODE_COLOR = QtGui.QColor(80, 80, 80)
91    
92    def __init__(self):
93        self.inputs = {}
94        self.outputs = {}
95        self.input_data = {}
96        self.node_title = "Base Node"
97        # Store the module file path for saving/exporting
98        # This will be set by the loading system when the node is instantiated
99        self.node_file_path = None 
100        
101    def pre_step(self):
102        self.input_data = {name: [] for name in self.inputs}
103        
104    def set_input(self, port_name, value, port_type='signal', coupling=1.0):
105        """[v5 FIX] Added NaN protection in signal input processing"""
106        if port_name not in self.input_data:
107            return
108        if port_type == 'signal':
109            if isinstance(value, (np.ndarray, list)):
110                value = value[0] if len(value) > 0 else 0.0
111            
112            # [v5 FIX] Protect against NaN/Inf in signals
113            try:
114                fval = float(value)
115                if not np.isfinite(fval):
116                    fval = 0.0
117            except (ValueError, TypeError):
118                fval = 0.0
119                
120            self.input_data[port_name].append(fval * coupling)
121        else:
122            if value is not None:
123                self.input_data[port_name].append(value)
124                
125    def get_blended_input(self, port_name, blend_mode='sum'):
126        """[v5 FIX] Added NaN protection in blending operations
127           [v9.1 FIX] Preserve complex arrays instead of destroying them"""
128        values = self.input_data.get(port_name, [])
129        if not values:
130            return None
131            
132        if blend_mode == 'sum' and isinstance(values[0], (int, float)):
133            result = np.sum(values)
134            # [v5 FIX] Safety check
135            if not np.isfinite(result):
136                return 0.0
137            return result
138        elif blend_mode == 'mean' and isinstance(values[0], np.ndarray):
139            if len(values) > 0:
140                # [v9.1 FIX] Preserve complex arrays - don't cast to float!
141                valid_arrays = []
142                for v in values:
143                    if v is not None and v.size > 0:
144                        if np.iscomplexobj(v):
145                            valid_arrays.append(v.astype(np.complex64))
146                        else:
147                            valid_arrays.append(v.astype(np.float32))
148                if not valid_arrays:
149                    return None
150                result = np.mean(valid_arrays, axis=0)
151                # [v5 FIX] Clean up result (only for real arrays)
152                if not np.iscomplexobj(result) and not np.all(np.isfinite(result)):
153                    result = np.nan_to_num(result, nan=0.0, posinf=1.0, neginf=0.0)
154                return result
155            return None
156        return values[0]
157        
158    def step(self):
159        pass
160        
161    def get_output(self, port_name):
162        return None
163        
164    def get_display_image(self):
165        # Base implementation returns None.
166        # Subclasses can return a np.ndarray (float 0-1) or a QImage
167        return None
168        
169    def close(self):
170        pass
171
172    def get_config_options(self):
173        return []
174    
175    # --- V7 CUSTOM STATE METHODS ---
176    # Nodes that need to save large binary states (like W matrices) will implement these.
177    def save_custom_state(self, folder_path, node_id):
178        """Called by the host to save custom state (e.g. .npy files)"""
179        return None # Return filename if successful
180
181    def load_custom_state(self, filepath):
182        """Called by the host to load custom state from a file"""
183        pass
184
185# ==================== NODE LOADING SYSTEM ====================
186
187def load_nodes_from_folder(folder_path):
188    if not os.path.exists(folder_path):
189        print(f"Nodes folder not found, creating: {folder_path}")
190        try:
191            os.makedirs(folder_path)
192        except Exception as e:
193            print(f"Could not create nodes folder: {e}")
194            return {}
195            
196    found_nodes = {}
197    
198    # Inject BaseNode and PA_INSTANCE into the __main__ module for compatibility
199    main_module = sys.modules['__main__']
200    setattr(main_module, 'BaseNode', BaseNode)
201    setattr(main_module, 'PA_INSTANCE', PA_INSTANCE)
202    setattr(main_module, 'np', np) # Inject numpy for convenience in nodes
203    setattr(main_module, 'cv2', cv2) # Inject cv2 for convenience in nodes
204    setattr(main_module, 'os', os) # Inject os for convenience in nodes
205    
206    for filename in os.listdir(folder_path):
207        if filename.endswith(".py") and filename != "__init__.py":
208            module_name = filename[:-3]
209            file_path = os.path.join(folder_path, filename)
210            
211            try:
212                spec = importlib.util.spec_from_file_location(module_name, file_path)
213                module = importlib.util.module_from_spec(spec)
214                
215                # V6 Compatibility: Ensure module can resolve __main__ imports
216                module.__dict__['__main__'] = main_module
217                sys.path.append(os.path.dirname(os.path.abspath(__file__)))
218                spec.loader.exec_module(module)
219                
220                module_nodes = []
221                for name, cls in inspect.getmembers(module, inspect.isclass):
222                    if issubclass(cls, BaseNode) and cls is not BaseNode:
223                        node_key_name = cls.__name__
224                        found_nodes[node_key_name] = {
225                            "class": cls,
226                            "module_name": module_name,
227                            "category": cls.NODE_CATEGORY,
228                            "file_path": file_path 
229                        }
230                        # Set the file path on the class itself for access during instantiation
231                        # This is the crucial part that ensures the path is known later.
232                        setattr(cls, '_NODE_SOURCE_FILE', file_path) 
233                        
234                        module_nodes.append(name)
235                        
236                if module_nodes:
237                    print(f"  > Loaded: {', '.join(module_nodes)} from {filename}")
238
239            except Exception as e:
240                print(f"Error loading {filename}: {e}")
241                
242    return found_nodes
243
244print("Loading external nodes from './nodes' folder...")
245NODE_TYPES = load_nodes_from_folder('nodes')
246
247PORT_COLORS = {
248    'signal': QtGui.QColor(200, 200, 200),
249    'image': QtGui.QColor(100, 150, 255),
250    'spectrum': QtGui.QColor(255, 150, 100),
251    'complex_spectrum': QtGui.QColor(255, 100, 255),
252}
253
254# ==================== GRAPHICS ITEMS ====================
255
256PORT_RADIUS = 7
257NODE_W, NODE_H = 200, 180  # Increased default height for better text
258
259class PortItem(QtWidgets.QGraphicsEllipseItem):
260    def __init__(self, parent, name, port_type, is_output=False):
261        super().__init__(-PORT_RADIUS, -PORT_RADIUS, PORT_RADIUS*2, PORT_RADIUS*2, parent)
262        self.name = name
263        self.port_type = port_type
264        self.is_output = is_output
265        self.base_color = PORT_COLORS.get(port_type, QtGui.QColor(255, 0, 0))
266        self.setBrush(QtGui.QBrush(self.base_color))
267        self.setZValue(3)
268        self.setAcceptHoverEvents(True)
269        
270    def hoverEnterEvent(self, ev):
271        self.setBrush(QtGui.QBrush(QtGui.QColor(255, 200, 60)))
272    def hoverLeaveEvent(self, ev):
273        self.setBrush(QtGui.QBrush(self.base_color))
274
275class EdgeItem(QtWidgets.QGraphicsPathItem):
276    def __init__(self, src_port, tgt_port=None):
277        super().__init__()
278        self.src = src_port
279        self.tgt = tgt_port
280        self.port_type = src_port.port_type
281        self.setZValue(1)
282        self.effect_val = 0.0
283        
284        # [V6.1 FEATURE] Learned weight property
285        # The SystemOptimizer will write to this. 
286        # Default is 1.0 (full connectivity).
287        self.learned_weight = 1.0 
288        
289        pen = QtGui.QPen(PORT_COLORS.get(self.port_type, QtGui.QColor(200,200,200)))
290        pen.setWidthF(2.0)
291        pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
292        self.setPen(pen)
293        
294    def update_path(self):
295        sp = self.src.scenePos()
296        tp = self.tgt.scenePos() if self.tgt else sp
297        path = QtGui.QPainterPath()
298        path.moveTo(sp)
299        dx = (tp.x() - sp.x()) * 0.5
300        c1 = QtCore.QPointF(sp.x() + dx, sp.y())
301        c2 = QtCore.QPointF(tp.x() - dx, tp.y())
302        path.cubicTo(c1, c2, tp)
303        self.setPath(path)
304        self.update_style()
305        
306    def update_style(self):
307        """[v5 FIX] Bulletproof NaN handling prevents UI crash from bad math"""
308        # Protect against NaN/Inf that would crash int() conversion
309        if not np.isfinite(self.effect_val):
310            self.effect_val = 0.0
311            
312        val = np.clip(self.effect_val, 0.0, 1.0)
313        alpha = int(80 + val * 175)  # Safe now - no NaN possible
314        w = 2.0 + val * 4.0
315        
316        # [V6.1] Visual feedback for pruning
317        # If learned_weight is low, make the line transparent
318        # We combine signal activity (effect_val) with structural weight (learned_weight)
319        weight_alpha = int(255 * self.learned_weight)
320        final_alpha = min(alpha, weight_alpha)
321        
322        color = PORT_COLORS.get(self.port_type, QtGui.QColor(200,200,200)).lighter(130)
323        color.setAlpha(final_alpha)
324        pen = QtGui.QPen(color)
325        pen.setWidthF(w)
326        self.setPen(pen)
327
328class NodeItem(QtWidgets.QGraphicsItem):
329    def __init__(self, sim_node, w=NODE_W, h=NODE_H):
330        super().__init__()
331        self.setFlags(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
332                      QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
333                      QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
334        self.sim = sim_node
335        self.in_ports = {}
336        self.out_ports = {}
337        self.min_w = NODE_W
338        self.min_h = NODE_H
339        self.rect = QtCore.QRectF(0, 0, w, h)
340        self.display_pix = None
341        
342        # --- [V5.1 ENHANCEMENT] Resize and Button setup ---
343        self.resize_handle_size = 15
344        self.resize_handle = QtCore.QRectF(
345            self.rect.width() - self.resize_handle_size,
346            self.rect.height() - self.resize_handle_size,
347            self.resize_handle_size,
348            self.resize_handle_size
349        )
350        self.is_resizing = False
351        self.setAcceptHoverEvents(True)
352        
353        self.random_btn_rect = None
354        self.zoom_in_rect = None 
355        self.zoom_out_rect = None 
356        
357        if hasattr(self.sim, 'randomize'):
358            self.random_btn_rect = QtCore.QRectF(self.rect.width() - 18, 4, 14, 14)
359        if hasattr(self.sim, 'zoom_factor'):
360            self.zoom_in_rect = QtCore.QRectF(self.rect.width() - 38, 4, 14, 14) 
361            self.zoom_out_rect = QtCore.QRectF(self.rect.width() - 18, 4, 14, 14) 
362        # --- END V5.1 ENHANCEMENT ---
363        
364        self.init_ports()
365        self.setZValue(2)
366        self.update_port_positions()
367        
368    def init_ports(self):
369        y_in = 50
370        for name, ptype in self.sim.inputs.items():
371            self.in_ports[name] = PortItem(self, name, ptype, False)
372            self.in_ports[name].setPos(0, y_in)
373            y_in += 25
374            
375        y_out = 50
376        for name, ptype in self.sim.outputs.items():
377            self.out_ports[name] = PortItem(self, name, ptype, True)
378            self.out_ports[name].setPos(self.rect.width(), y_out)
379            y_out += 25
380
381    def update_port_positions(self):
382        for name, port in self.out_ports.items():
383            port.setPos(self.rect.width(), port.y())
384            
385        # --- [V5.1 ENHANCEMENT] Update button/handle positions ---
386        if self.random_btn_rect:
387            self.random_btn_rect.moveTopRight(self.rect.topRight() + QtCore.QPointF(-4, 4))
388        if self.zoom_in_rect and self.zoom_out_rect:
389            self.zoom_in_rect.moveTopRight(self.rect.topRight() + QtCore.QPointF(-24, 4))
390            self.zoom_out_rect.moveTopRight(self.rect.topRight() + QtCore.QPointF(-4, 4))
391            
392        self.resize_handle.moveBottomRight(self.rect.bottomRight())
393        # --- END V5.1 ENHANCEMENT ---
394        
395        if self.scene():
396            for edge in self.scene().edges:
397                if (edge.src.parentItem() == self) or (edge.tgt.parentItem() == self):
398                    edge.update_path()
399
400    def hoverMoveEvent(self, ev):
401        if self.resize_handle.contains(ev.pos()):
402            self.setCursor(QtCore.Qt.CursorShape.SizeFDiagCursor)
403        else:
404            self.setCursor(QtCore.Qt.CursorShape.ArrowCursor)
405        super().hoverMoveEvent(ev)
406
407    def hoverLeaveEvent(self, ev):
408        self.setCursor(QtCore.Qt.CursorShape.ArrowCursor)
409        super().hoverLeaveEvent(ev)
410
411    def mousePressEvent(self, ev):
412        # --- [V5.1 ENHANCEMENT] Handle resizing via handle ---
413        if self.resize_handle.contains(ev.pos()) and ev.button() == QtCore.Qt.MouseButton.LeftButton:
414            self.is_resizing = True
415            self.resize_start_pos = ev.pos()
416            self.resize_start_rect = QtCore.QRectF(self.rect)
417            ev.accept()
418            return
419
420        # --- [V5.1 ENHANCEMENT] Handle R/Zoom buttons ---
421        if self.random_btn_rect and self.random_btn_rect.contains(ev.pos()):
422            if hasattr(self.sim, 'randomize'):
423                self.sim.randomize()
424                self.update_display()
425            ev.accept()
426            return
427        
428        if self.zoom_in_rect and self.zoom_in_rect.contains(ev.pos()):
429            if hasattr(self.sim, 'zoom_factor'):
430                # Zoom In (Smaller factor)
431                self.sim.zoom_factor = max(0.1, self.sim.zoom_factor / 1.2) 
432                self.update_display()
433            ev.accept()
434            return
435        if self.zoom_out_rect and self.zoom_out_rect.contains(ev.pos()):
436            if hasattr(self.sim, 'zoom_factor'):
437                # Zoom Out (Larger factor)
438                self.sim.zoom_factor = min(5.0, self.sim.zoom_factor * 1.2) 
439                self.update_display()
440            ev.accept()
441            return
442        
443        super().mousePressEvent(ev)
444        
445    def mouseMoveEvent(self, ev):
446        # --- [V5.1 ENHANCEMENT] Handle resizing via handle ---
447        if self.is_resizing:
448            delta = ev.pos() - self.resize_start_pos
449            new_w = max(self.min_w, self.resize_start_rect.width() + delta.x())
450            new_h = max(self.min_h, self.resize_start_rect.height() + delta.y())
451            
452            self.prepareGeometryChange()
453            self.rect.setWidth(new_w)
454            self.rect.setHeight(new_h)
455            self.update_port_positions()
456            
457            # Allow nodes to track their own size
458            if hasattr(self.sim, 'w') and hasattr(self.sim, 'h'):
459                 self.sim.w = int(new_w)
460                 self.sim.h = int(new_h)
461            
462            ev.accept()
463            return
464
465        super().mouseMoveEvent(ev)
466
467    def mouseReleaseEvent(self, ev):
468        if self.is_resizing:
469            self.is_resizing = False
470            self.setCursor(QtCore.Qt.CursorShape.ArrowCursor)
471            ev.accept()
472            return
473        super().mouseReleaseEvent(ev)
474        
475    def boundingRect(self):
476        return self.rect.adjusted(-8, -8, 8, 8)
477        
478    def paint(self, painter, option, widget):
479        painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
480        painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing) # Better text
481        
482        base_color = self.sim.NODE_COLOR
483        if self.isSelected():
484            base_color = base_color.lighter(150)
485        
486        painter.setBrush(QtGui.QBrush(base_color))
487        painter.setPen(QtGui.QPen(QtGui.QColor(60, 60, 60), 2))
488        painter.drawRoundedRect(self.rect, 10, 10)
489        
490        # --- [V5.1 ENHANCEMENT] Better title/category rendering ---
491        title_rect = QtCore.QRectF(8, 6, self.rect.width() - 24, 22)
492        painter.setPen(QtGui.QColor(255, 255, 255))
493        font = QtGui.QFont("Segoe UI", 11, QtGui.QFont.Weight.Bold)
494        font.setHintingPreference(QtGui.QFont.HintingPreference.PreferFullHinting)
495        painter.setFont(font)
496        painter.drawText(title_rect, QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, 
497                        self.sim.node_title)
498        
499        category_rect = QtCore.QRectF(8, 26, self.rect.width() - 16, 16)
500        painter.setPen(QtGui.QColor(200, 200, 200))
501        category_font = QtGui.QFont("Segoe UI", 8)
502        category_font.setHintingPreference(QtGui.QFont.HintingPreference.PreferFullHinting)
503        painter.setFont(category_font)
504        painter.drawText(category_rect, QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
505                        self.sim.NODE_CATEGORY)
506        
507        # Better port labels
508        port_font = QtGui.QFont("Segoe UI", 8)
509        port_font.setHintingPreference(QtGui.QFont.HintingPreference.PreferFullHinting)
510        painter.setFont(port_font)
511        painter.setPen(QtGui.QColor(220, 220, 220))
512        
513        for name, port in self.in_ports.items():
514            painter.drawText(port.pos() + QtCore.QPointF(12, 4), name)
515        
516        for name, port in self.out_ports.items():
517            w_text = painter.fontMetrics().boundingRect(name).width()
518            painter.drawText(port.pos() + QtCore.QPointF(-w_text - 12, 4), name)
519        # --- END V5.1 ENHANCEMENT ---
520        
521        if self.display_pix:
522            img_h = self.rect.height() - 60
523            img_w = self.rect.width() - 16
524            
525            if img_h >= 10 and img_w >= 10:
526                target = QtCore.QRectF(8, 48, img_w, img_h)
527                scaled = self.display_pix.scaled(
528                    int(img_w), int(img_h), 
529                    QtCore.Qt.AspectRatioMode.KeepAspectRatio,
530                    QtCore.Qt.TransformationMode.SmoothTransformation
531                )
532                x = 8 + (img_w - scaled.width()) / 2
533                y = 48 + (img_h - scaled.height()) / 2
534                painter.drawPixmap(
535                    QtCore.QRectF(x, y, scaled.width(), scaled.height()),
536                    scaled, 
537                    QtCore.QRectF(scaled.rect())
538                )
539        
540        # --- [V5.1 ENHANCEMENT] Draw R/Zoom buttons and resize handle ---
541        if self.random_btn_rect:
542            painter.setBrush(QtGui.QColor(255, 200, 60))
543            painter.setPen(QtGui.QColor(40, 40, 40))
544            painter.drawEllipse(self.random_btn_rect)
545            painter.setPen(QtGui.QColor(40, 40, 40))
546            painter.setFont(QtGui.QFont("Arial", 8, QtGui.QFont.Weight.Bold))
547            painter.drawText(self.random_btn_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "R")
548            
549        if self.zoom_in_rect and self.zoom_in_rect.contains(ev.pos()):
550            painter.setBrush(QtGui.QColor(60, 180, 255))
551            painter.setPen(QtGui.QColor(40, 40, 40))
552            painter.drawEllipse(self.zoom_in_rect)
553            painter.setFont(QtGui.QFont("Arial", 8, QtGui.QFont.Weight.Bold))
554            painter.drawText(self.zoom_in_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "-") # Zoom in
555            
556            painter.drawEllipse(self.zoom_out_rect)
557            painter.drawText(self.zoom_out_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "+") # Zoom out
558            
559        # Draw resize handle
560        p = self.rect.bottomRight()
561        painter.setPen(QtGui.QPen(QtGui.QColor(255, 255, 255, 80), 1))
562        painter.drawLine(int(p.x() - 12), int(p.y() - 4), int(p.x() - 4), int(p.y() - 12))
563        painter.drawLine(int(p.x() - 8), int(p.y() - 4), int(p.x() - 4), int(p.y() - 8))
564        # --- END V5.1 ENHANCEMENT ---
565
566    def update_display(self):
567        """[v5 FIX] Now uses numpy_to_qimage which has NaN protection"""
568        data = self.sim.get_display_image()
569        
570        if data is None:
571            self.display_pix = None
572        elif isinstance(data, QtGui.QImage):
573            self.display_pix = QtGui.QPixmap.fromImage(data)
574        elif isinstance(data, np.ndarray):
575            # Use the protected helper
576            self.display_pix = QtGui.QPixmap.fromImage(numpy_to_qimage(data))
577        else:
578            self.display_pix = None
579            
580        self.update()
581
582# ==================== CONFIG DIALOG ====================
583
584class NodeConfigDialog(QtWidgets.QDialog):
585    def __init__(self, node_item, parent=None):
586        super().__init__(parent)
587        self.node_item = node_item
588        self.sim_node = node_item.sim
589        self.setWindowTitle(f"Configure: {self.sim_node.node_title}")
590        self.setMinimumWidth(400)
591        
592        self.widgets_map = {}
593        
594        layout = QtWidgets.QVBoxLayout(self)
595        
596        scroll = QtWidgets.QScrollArea()
597        scroll.setWidgetResizable(True)
598        container = QtWidgets.QWidget()
599        form = QtWidgets.QFormLayout(container)
600        
601        for item in self.sim_node.get_config_options():
602            label_text, attr_name, current_value, widget_type = item
603            
604            if widget_type == 'file_open':
605                # --- [V5.1 ENHANCEMENT] File browser logic ---
606                h_layout = QtWidgets.QHBoxLayout()
607                line = QtWidgets.QLineEdit(str(current_value))
608                browse_btn = QtWidgets.QPushButton("Browse...")
609                
610                # Connect the browse button to a helper function
611                browse_btn.clicked.connect(lambda _, k=attr_name, w=line: self.open_file_dialog(k, w))
612                
613                h_layout.addWidget(line)
614                h_layout.addWidget(browse_btn)
615                
616                widget = QtWidgets.QWidget()
617                widget.setLayout(h_layout)
618                form.addRow(label_text + ":", widget)
619                self.widgets_map[attr_name] = ('text', line, None) 
620                # --- END V5.1 ENHANCEMENT ---
621                
622            elif isinstance(widget_type, list):
623                combo = QtWidgets.QComboBox()
624                
625                # V6 Compatibility: Handle tuple/list options (name, value) or simple values
626                for option in widget_type:
627                    if isinstance(option, (list, tuple)) and len(option) == 2:
628                        name, value = option
629                        combo.addItem(str(name), userData=value)
630                    else:
631                        name = str(option)
632                        value = option
633                        combo.addItem(name, userData=value)
634                
635                try:
636                    # Try to find index by current value data
637                    current_data_index = combo.findData(current_value)
638                    if current_data_index != -1:
639                        combo.setCurrentIndex(current_data_index)
640                    else:
641                         # If value wasn't explicitly defined in the list, add it if it's an ID/number
642                         combo.addItem(f"Current ID ({current_value})", userData=current_value)
643                         combo.setCurrentIndex(combo.count() - 1)
644                        
645                except ValueError:
646                    pass
647                        
648                form.addRow(label_text + ":", combo)
649                self.widgets_map[attr_name] = ('combo', combo, widget_type)
650                
651            elif widget_type == 'int':
652                spin = QtWidgets.QSpinBox()
653                spin.setRange(-999999, 999999)
654                spin.setValue(int(current_value))
655                form.addRow(label_text + ":", spin)
656                self.widgets_map[attr_name] = ('int', spin, None)
657                
658            elif widget_type == 'float':
659                spin = QtWidgets.QDoubleSpinBox()
660                spin.setRange(-999999.0, 999999.0)
661                spin.setDecimals(4)
662                spin.setValue(float(current_value))
663                form.addRow(label_text + ":", spin)
664                self.widgets_map[attr_name] = ('float', spin, None)
665                
666            elif widget_type == 'bool':
667                check = QtWidgets.QCheckBox()
668                check.setChecked(bool(current_value))
669                form.addRow(label_text + ":", check)
670                self.widgets_map[attr_name] = ('bool', check, None)
671                
672            elif widget_type == 'text_multi':
673                text_edit = QtWidgets.QTextEdit()
674                text_edit.setPlainText(str(current_value))
675                text_edit.setMaximumHeight(100)
676                form.addRow(label_text + ":", text_edit)
677                self.widgets_map[attr_name] = ('text_multi', text_edit, None)
678            
679            elif widget_type == 'text':
680                line = QtWidgets.QLineEdit(str(current_value))
681                form.addRow(label_text + ":", line)
682                self.widgets_map[attr_name] = ('text', line, None)
683
684            else:
685                line = QtWidgets.QLineEdit(str(current_value))
686                form.addRow(label_text + ":", line)
687                self.widgets_map[attr_name] = ('text', line, None)
688        
689        scroll.setWidget(container)
690        layout.addWidget(scroll)
691        
692        btn_box = QtWidgets.QDialogButtonBox(
693            QtWidgets.QDialogButtonBox.StandardButton.Ok | 
694            QtWidgets.QDialogButtonBox.StandardButton.Cancel
695        )
696        btn_box.accepted.connect(self.accept)
697        btn_box.rejected.connect(self.reject)
698        layout.addWidget(btn_box)
699        
700    # --- [V5.1 ENHANCEMENT] Helper method for file dialog ---
701    def open_file_dialog(self, key, line_edit_widget):
702        fileName, _ = QtWidgets.QFileDialog.getOpenFileName(
703            self, "Select File", "", "All Files (*)")
704        
705        if fileName:
706            line_edit_widget.setText(fileName)
707            # Immediately update node's config and trigger potential reload
708            setattr(self.sim_node, key, fileName)
709            if hasattr(self.sim_node, '_load_image'):
710                 self.sim_node._load_image()
711            if hasattr(self.sim_node, 'setup_source'):
712                 self.sim_node.setup_source()
713            if hasattr(self.sim_node, 'update'):
714                 self.sim_node.update()
715    # --- END V5.1 ENHANCEMENT ---
716    
717    def get_config_dict(self):
718        config = {}
719        for attr_name, (w_type, widget, options) in self.widgets_map.items():
720            if w_type == 'combo':
721                # Grab the data stored in the combo box item
722                config[attr_name] = widget.currentData()
723            elif w_type == 'int':
724                config[attr_name] = widget.value()
725            elif w_type == 'float':
726                config[attr_name] = widget.value()
727            elif w_type == 'bool':
728                config[attr_name] = widget.isChecked()
729            elif w_type == 'text' or w_type == 'text_multi':
730                text = widget.text() if w_type == 'text' else widget.toPlainText()
731                try:
732                    # Attempt to convert to int/float
733                    fval = float(text)
734                    if fval.is_integer():
735                        config[attr_name] = int(fval)
736                    else:
737                        config[attr_name] = fval
738                except ValueError:
739                    config[attr_name] = text
740        return config
741
742# ==================== MAIN SCENE ====================
743
744class PerceptionScene(QtWidgets.QGraphicsScene):
745    def __init__(self):
746        super().__init__()
747        self.setBackgroundBrush(QtGui.QColor(30, 30, 30))
748        self.nodes = []
749        self.edges = []
750        
751        self.temp_edge = None
752        self.connection_start_port = None
753        
754    def add_node(self, node_class, x=0, y=0, w=NODE_W, h=NODE_H):
755        sim_node = node_class()
756        
757        # --- FIX: Set the file path on the instance ---
758        if hasattr(node_class, '_NODE_SOURCE_FILE'):
759            sim_node.node_file_path = node_class._NODE_SOURCE_FILE
760        # ---------------------------------------------
761
762        node_item = NodeItem(sim_node, w, h)
763        node_item.setPos(x, y)
764        self.addItem(node_item)
765        self.nodes.append(node_item)
766        node_item.update_display() 
767        return node_item
768        
769    def remove_node(self, node_item):
770        if node_item not in self.nodes:
771            return
772            
773        for edge in list(self.edges):
774            if edge.src.parentItem() == node_item or edge.tgt.parentItem() == node_item:
775                self.removeItem(edge)
776                self.edges.remove(edge)
777                
778        node_item.sim.close()
779        self.removeItem(node_item)
780        self.nodes.remove(node_item)
781        
782    def remove_edge(self, edge):
783        """Helper for deleting edges"""
784        if edge in self.edges:
785            self.removeItem(edge)
786            self.edges.remove(edge)
787    
788    def delete_selected_edges(self):
789        selected_edges = [i for i in self.selectedItems() if isinstance(i, EdgeItem)]
790        for edge in selected_edges:
791            self.remove_edge(edge)
792    
793    def mousePressEvent(self, ev):
794        """
795        [v6 FIX]: The button/handle interaction logic is entirely handled by
796        NodeItem.mousePressEvent, preventing the crash when 'item' is None.
797        """
798        if ev.button() != QtCore.Qt.MouseButton.LeftButton:
799            super().mousePressEvent(ev)
800            return
801            
802        item = self.itemAt(ev.scenePos(), QtGui.QTransform())
803        
804        if isinstance(item, PortItem):
805            if self.connection_start_port is None:
806                # First click - start connection from output port
807                if item.is_output:
808                    self.connection_start_port = item
809                    self.temp_edge = EdgeItem(item)
810                    self.addItem(self.temp_edge)
811                    # Create animated preview
812                    self.temp_edge.setPen(QtGui.QPen(
813                        QtGui.QColor(255, 200, 60), 3.0, QtCore.Qt.PenStyle.DashLine
814                    ))
815                    ev.accept()
816                    return
817            else:
818                # Second click - complete connection to input port
819                if not item.is_output and item.parentItem() != self.connection_start_port.parentItem():
820                    # Valid connection: output -> input, different nodes
821                    if self.connection_start_port.port_type == item.port_type:
822                        # Create permanent edge
823                        edge = EdgeItem(self.connection_start_port, item)
824                        self.addItem(edge)
825                        self.edges.append(edge)
826                        edge.update_path()
827                    
828                # Clean up temp edge regardless
829                self.cancel_connection()
830                ev.accept()
831                return
832        
833        else:
834            if self.connection_start_port is not None:
835                self.cancel_connection()
836            super().mousePressEvent(ev)
837    
838    def mouseMoveEvent(self, ev):
839        """[v5 ENHANCED] Animate preview wire during connection"""
840        if self.temp_edge and self.connection_start_port:
841            class TempTarget:
842                def __init__(self, pos): self._pos = pos
843                def scenePos(self): return self._pos
844            
845            self.temp_edge.tgt = TempTarget(ev.scenePos())
846            self.temp_edge.update_path()
847            ev.accept()
848            return
849            
850        super().mouseMoveEvent(ev)
851    
852    def mouseReleaseEvent(self, ev):
853        super().mouseReleaseEvent(ev)
854    
855    def cancel_connection(self):
856        """[v5] Clean up connection state"""
857        if self.temp_edge:
858            self.removeItem(self.temp_edge)
859            self.temp_edge = None
860        self.connection_start_port = None
861
862# ==================== MAIN WINDOW ====================
863
864# Base interval in milliseconds for the fastest setting (100%)
865BASE_TIMER_INTERVAL_MS = 30 
866
867class PerceptionLab(QtWidgets.QWidget):
868    def __init__(self):
869        super().__init__()
870        # Updated window title for V9
871        # Updated for V8/V9.1
872        self.setWindowTitle("Antti's Perception Laboratory v9.1 - Complex Array Fix ๐Ÿ”ฎ") 
873        self.resize(1400, 800)
874        
875        self.scene = PerceptionScene()
876        self.view = QtWidgets.QGraphicsView(self.scene)
877        self.view.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
878        self.view.setDragMode(QtWidgets.QGraphicsView.DragMode.RubberBandDrag)
879        
880        self.NODE_CLASS_MAP = {name: info for name, info in NODE_TYPES.items()}
881        
882        self.is_running = False
883        self.coupling_strength = 0.7
884        self.simulation_speed_factor = 1.0 # New property: 1.0 = normal (30ms), 0.1 = slow (300ms)
885        
886        main_layout = QtWidgets.QVBoxLayout(self)
887        main_layout.setContentsMargins(0, 0, 0, 0)
888        main_layout.setSpacing(0)
889        
890        # Top toolbar
891        toolbar = QtWidgets.QWidget()
892        toolbar.setStyleSheet("background: #2a2a2a; padding: 6px;")
893        toolbar_layout = QtWidgets.QHBoxLayout(toolbar)
894        toolbar_layout.setContentsMargins(8, 4, 8, 4)
895        
896        # Add Node button with menu
897        self.btn_add = QtWidgets.QPushButton("โž• Add Node")
898        self.btn_add.setStyleSheet("padding: 6px 12px; font-weight: bold; background: #3a5a8a; color: white; border-radius: 5px;")
899        self.btn_add.clicked.connect(self.show_add_node_menu)
900        toolbar_layout.addWidget(self.btn_add)
901        
902        toolbar_layout.addSpacing(10)
903        
904        # Run/Stop button
905        self.btn_run = QtWidgets.QPushButton("โ–ถ Start")
906        self.btn_run.setStyleSheet("padding: 6px 12px; font-weight: bold; background: #2a5a2a; color: white; border-radius: 5px;")
907        self.btn_run.clicked.connect(self.toggle_run)
908        toolbar_layout.addWidget(self.btn_run)
909        
910        toolbar_layout.addSpacing(20)
911        
912        # --- NEW: Speed Slider ---
913        toolbar_layout.addWidget(QtWidgets.QLabel("Speed:"))
914        self.speed_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
915        self.speed_slider.setRange(1, 100) # 1% (Slowest) to 100% (Fastest)
916        self.speed_slider.setValue(100)
917        self.speed_slider.setMaximumWidth(150)
918        self.speed_slider.valueChanged.connect(self.update_speed)
919        toolbar_layout.addWidget(self.speed_slider)
920        
921        self.speed_label = QtWidgets.QLabel("100%")
922        self.speed_label.setMinimumWidth(40)
923        toolbar_layout.addWidget(self.speed_label)
924
925        toolbar_layout.addSpacing(20)
926        # --- END NEW: Speed Slider ---
927        
928        # Coupling slider
929        toolbar_layout.addWidget(QtWidgets.QLabel("Coupling:"))
930        self.coupling_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
931        self.coupling_slider.setRange(0, 100)
932        self.coupling_slider.setValue(70)
933        self.coupling_slider.setMaximumWidth(150)
934        self.coupling_slider.valueChanged.connect(self.update_coupling)
935        toolbar_layout.addWidget(self.coupling_slider)
936        
937        self.coupling_label = QtWidgets.QLabel("0.70")
938        self.coupling_label.setMinimumWidth(40)
939        toolbar_layout.addWidget(self.coupling_label)
940        
941        toolbar_layout.addStretch()
942        
943        # File operations
944        btn_clear = QtWidgets.QPushButton("๐Ÿ—‘ Clear All")
945        btn_clear.setStyleSheet("padding: 6px 12px; background: #dc2626; color: white; border-radius: 5px;")
946        btn_clear.clicked.connect(self.clear_graph)
947        toolbar_layout.addWidget(btn_clear)
948        
949        btn_save = QtWidgets.QPushButton("๐Ÿ’พ Save")
950        btn_save.setStyleSheet("padding: 6px 12px; background: #2563eb; color: white; border-radius: 5px;")
951        self.btn_save = btn_save # Store reference for use in status update
952        self.btn_save.clicked.connect(self.save_graph)
953        toolbar_layout.addWidget(self.btn_save)
954        
955        btn_load = QtWidgets.QPushButton("๐Ÿ“ Load")
956        btn_load.setStyleSheet("padding: 6px 12px; background: #7c3aed; color: white; border-radius: 5px;")
957        btn_load.clicked.connect(self.load_graph)
958        toolbar_layout.addWidget(btn_load)
959        
960        main_layout.addWidget(toolbar)
961        main_layout.addWidget(self.view)
962        
963        # Status bar
964        status_bar = QtWidgets.QWidget()
965        status_bar.setStyleSheet("background: #2a2a2a; padding: 4px;")
966        status_layout = QtWidgets.QHBoxLayout(status_bar)
967        status_layout.setContentsMargins(8, 2, 8, 2)
968        
969        self.status = QtWidgets.QLabel("Ready - Add nodes to begin")
970        self.status.setStyleSheet("color: #aaa; font-size: 10px;")
971        status_layout.addWidget(self.status)
972        status_layout.addStretch()
973        
974        # Updated version label for V9
975        version_label = QtWidgets.QLabel("v9.1 - Complex Array Fix ๐Ÿ”ฎ")
976        version_label.setStyleSheet("color: #6495ed; font-size: 10px; font-weight: bold;")
977        status_layout.addWidget(version_label)
978        
979        main_layout.addWidget(status_bar)
980        
981        # Context menu
982        self.view.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
983        self.view.customContextMenuRequested.connect(self.show_context_menu)
984        
985        # Simulation timer
986        self.timer = QtCore.QTimer()
987        self.timer.timeout.connect(self.simulation_step)
988        
989    def show_add_node_menu(self):
990        menu = QtWidgets.QMenu(self)
991        
992        categories = {}
993        for name, info in self.NODE_CLASS_MAP.items():
994            cat = info.get('category', 'Uncategorized')
995            if cat not in categories:
996                categories[cat] = []
997            categories[cat].append((name, info))
998        
999        for cat in sorted(categories.keys()):
1000            submenu = menu.addMenu(cat)
1001            for name, info in sorted(categories[cat], key=lambda x: x[0]):
1002                action = submenu.addAction(name)
1003                action.triggered.connect(lambda checked, n=name: self.add_node(n))
1004        
1005        menu.exec(QtGui.QCursor.pos())
1006    
1007    def add_node(self, class_name):
1008        if class_name not in self.NODE_CLASS_MAP:
1009            return
1010            
1011        node_class = self.NODE_CLASS_MAP[class_name]['class']
1012        center = self.view.mapToScene(self.view.viewport().rect().center())
1013        
1014        # Check if node has w/h attributes to set initial size from config
1015        temp_node_instance = node_class()
1016        w = getattr(temp_node_instance, 'w', NODE_W) if hasattr(temp_node_instance, 'w') else NODE_W
1017        h = getattr(temp_node_instance, 'h', NODE_H) if hasattr(temp_node_instance, 'h') else NODE_H
1018        del temp_node_instance
1019        
1020        node = self.scene.add_node(node_class, center.x() - w/2, center.y() - h/2, w=w, h=h)
1021        
1022        if hasattr(node.sim, 'open_stream'):
1023            node.sim.open_stream()
1024        if hasattr(node.sim, 'setup_source'):
1025            node.sim.setup_source()
1026            
1027        self.status.setText(f"โœ“ Added {class_name}")
1028    
1029    def show_context_menu(self, pos):
1030        scene_pos = self.view.mapToScene(pos)
1031        item = self.scene.itemAt(scene_pos, QtGui.QTransform())
1032        
1033        selected_nodes = [i for i in self.scene.selectedItems() if isinstance(i, NodeItem)]
1034        clicked_node_item = None
1035
1036        if isinstance(item, NodeItem):
1037            clicked_node_item = item
1038        elif isinstance(item, PortItem) and hasattr(item.parentItem(), 'sim'):
1039            clicked_node_item = item.parentItem()
1040            
1041        if clicked_node_item and clicked_node_item not in selected_nodes:
1042             self.scene.clearSelection()
1043             clicked_node_item.setSelected(True)
1044             selected_nodes = [clicked_node_item]
1045        
1046        menu = QtWidgets.QMenu(self)
1047        
1048        if selected_nodes:
1049            # Delete Action
1050            delete_action = menu.addAction(f"๐Ÿ—‘๏ธ Delete Selected Node{'s' if len(selected_nodes) > 1 else ''} ({len(selected_nodes)})")
1051            delete_action.triggered.connect(lambda: self.delete_selected_nodes())
1052            
1053            if len(selected_nodes) == 1:
1054                menu.addSeparator()
1055                # Configure Action
1056                config_act = menu.addAction("โš™๏ธ Configure")
1057                config_act.triggered.connect(lambda: self.configure_node(selected_nodes[0]))
1058                
1059        else:
1060            # Add Node menu
1061            # Re-implementing the add node menu logic to be a submenu instead of recursive action
1062            categories = {}
1063            for name, info in self.NODE_CLASS_MAP.items():
1064                cat = info.get('category', 'Uncategorized')
1065                if cat not in categories:
1066                    categories[cat] = []
1067                categories[cat].append((name, info['class']))
1068            
1069            for cat in sorted(categories.keys()):
1070                submenu = menu.addMenu(cat)
1071                for name, cls in sorted(categories[cat], key=lambda x: x[0]):
1072                    action = submenu.addAction(name)
1073                    action.triggered.connect(lambda checked, nc=cls, sp=scene_pos: self.add_node_at_pos(nc, sp))
1074            
1075            
1076        global_pos = self.view.mapToGlobal(pos)
1077        menu.exec(global_pos)
1078
1079    def add_node_at_pos(self, node_class, scene_pos):
1080        # Check if node has w/h attributes to set initial size from config
1081        temp_node_instance = node_class()
1082        w = getattr(temp_node_instance, 'w', NODE_W) if hasattr(temp_node_instance, 'w') else NODE_W
1083        h = getattr(temp_node_instance, 'h', NODE_H) if hasattr(temp_node_instance, 'h') else NODE_H
1084        del temp_node_instance
1085        
1086        node = self.scene.add_node(node_class, scene_pos.x() - w/2, scene_pos.y() - h/2, w=w, h=h)
1087        
1088        if hasattr(node.sim, 'open_stream'):
1089            node.sim.open_stream()
1090        if hasattr(node.sim, 'setup_source'):
1091            node.sim.setup_source()
1092            
1093        self.status.setText(f"โœ“ Added {node.sim.node_title}")
1094        
1095    def delete_selected_nodes(self):
1096        selected_nodes = [i for i in self.scene.selectedItems() if isinstance(i, NodeItem)]
1097        for node in selected_nodes:
1098            self.scene.remove_node(node)
1099        self.status.setText("โœ“ Deleted selected nodes")
1100        
1101    def keyPressEvent(self, event):
1102        if event.key() == QtCore.Qt.Key.Key_Delete or event.key() == QtCore.Qt.Key.Key_Backspace:
1103            self.delete_selected_nodes()
1104            # Also delete selected edges
1105            self.scene.delete_selected_edges()
1106            return
1107        super().keyPressEvent(event)
1108        
1109    def configure_node(self, node_item):
1110        dialog = NodeConfigDialog(node_item, self)
1111        if dialog.exec():
1112            config = dialog.get_config_dict()
1113            for key, val in config.items():
1114                setattr(node_item.sim, key, val)
1115            
1116            # Re-run setup/open if configuration changes require it
1117            if hasattr(node_item.sim, 'open_stream'):
1118                node_item.sim.open_stream()
1119            if hasattr(node_item.sim, 'setup_source'):
1120                node_item.sim.setup_source()
1121            
1122            node_item.update_display()
1123            node_item.update_port_positions()
1124            self.status.setText(f"โœ“ Configured {node_item.sim.node_title}")
1125    
1126    def delete_edge(self, edge):
1127        self.scene.removeItem(edge)
1128        self.scene.edges.remove(edge)
1129        self.status.setText("โœ“ Connection deleted")
1130    
1131    def clear_edges(self):
1132        for edge in list(self.scene.edges):
1133            self.scene.removeItem(edge)
1134        self.scene.edges.clear()
1135
1136    # --- NEW: Speed control logic ---
1137    def update_speed(self, value):
1138        # 100% (value=100) -> 1.0 factor -> 30ms interval
1139        # 1% (value=1) -> 0.01 factor -> 3000ms interval
1140        self.simulation_speed_factor = value / 100.0
1141        self.speed_label.setText(f"{value}%")
1142        
1143        if self.is_running:
1144            # Recalculate and restart the timer with the new interval
1145            self.restart_timer()
1146            
1147    def restart_timer(self):
1148        if self.is_running:
1149            self.timer.stop()
1150            # The interval is BASE_TIMER_INTERVAL_MS / self.simulation_speed_factor
1151            # We enforce a minimum interval of 1ms
1152            interval = max(1, int(BASE_TIMER_INTERVAL_MS / self.simulation_speed_factor))
1153            self.timer.start(interval)
1154            self.status.setText(f"โ–ถ Running... | Interval: {interval}ms")
1155            self.last_time = QtCore.QTime.currentTime()
1156            self.frame_count = 0
1157    # --- END NEW: Speed control logic ---
1158    
1159    def update_coupling(self, value):
1160        self.coupling_strength = value / 100.0
1161        self.coupling_label.setText(f"{self.coupling_strength:.2f}")
1162    
1163    def toggle_run(self):
1164        self.is_running = not self.is_running
1165        if self.is_running:
1166            self.restart_timer()
1167            self.btn_run.setText("โธ Stop")
1168            self.btn_run.setStyleSheet("padding: 6px 12px; font-weight: bold; background: #5a2a2a; color: white; border-radius: 5px;")
1169            self.status.setText("โ–ถ Simulation running...")
1170            self.last_time = QtCore.QTime.currentTime()
1171            self.frame_count = 0
1172        else:
1173            self.timer.stop()
1174            self.btn_run.setText("โ–ถ Start")
1175            self.btn_run.setStyleSheet("padding: 6px 12px; font-weight: bold; background: #2a5a2a; color: white; border-radius: 5px;")
1176            self.status.setText("โธ Simulation paused")
1177    
1178    def simulation_step(self):
1179        """[v5 FIX] Enhanced error handling and NaN protection throughout pipeline"""
1180        # Step 1: Prepare all nodes
1181        for node_item in self.scene.nodes:
1182            node_item.sim.pre_step()
1183        
1184        # Step 2: Transfer data through edges
1185        for edge in self.scene.edges:
1186            src_node = edge.src.parentItem().sim
1187            tgt_node = edge.tgt.parentItem().sim
1188            
1189            try:
1190                output_val = src_node.get_output(edge.src.name)
1191                
1192                # [v5 FIX] Robust effect calculation with NaN protection
1193                edge.effect_val = 0.0
1194                
1195                # [V6.1 FIX] Use the Learned Weight from the optimizer
1196                # Defaults to 1.0 if no optimizer is running
1197                learned_weight = getattr(edge, 'learned_weight', 1.0)
1198                
1199                if output_val is not None:
1200                    tgt_node.set_input(

Showing the first 1,200 of 1633 lines. Download the file for the rest.

Aluode/PerceptionLabPortable ยท CoolFace