Aluode/PerceptionLabPortable
0
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(
