1- # -*- coding: utf-8 -*-
2-
3- """3D Feature view."""
4-
5- # -----------------------------------------------------------------------------
6- # Imports
7- # -----------------------------------------------------------------------------
8-
91import logging
10-
112import numpy as np
12- from phylib .utils import Bunch , emit
3+ from phylib .utils import Bunch , emit , connect , unconnect
134from phy .utils .color import selected_cluster_color
145from phy .plot .visuals import ScatterVisual , TextVisual , LineVisual
156from phy .plot .transform import range_transform , NDC
@@ -26,7 +17,8 @@ def _get_point_color(clu_idx=None):
2617 if clu_idx is not None :
2718 color = selected_cluster_color (clu_idx , .5 )
2819 else :
29- color = (.5 ,) * 4
20+ # Lighter gray for background points so they are visible on black background
21+ color = (0.5 , 0.5 , 0.5 , 0.5 )
3022 assert len (color ) == 4
3123 return color
3224
@@ -417,7 +409,17 @@ def get_clusters_data(self, fixed_channels=None, load_all=None):
417409 return []
418410
419411 # Choose the channels based on the first selected cluster.
420- channel_ids = list (bunchs [0 ].get ('channel_ids' , [])) if bunchs else []
412+ if bunchs and len (bunchs ) > 0 :
413+ first_bunch = bunchs [0 ]
414+ # Prioritize 'channel_ids' from the bunch itself
415+ if 'channel_ids' in first_bunch and len (first_bunch .channel_ids ) > 0 :
416+ channel_ids = list (first_bunch .channel_ids )
417+ else :
418+ # Fallback to empty if not present (shouldn't happen with valid features)
419+ channel_ids = []
420+ else :
421+ channel_ids = []
422+
421423 logger .debug (f"Extracted channel_ids from first bunch: { channel_ids [:5 ] if len (channel_ids ) > 5 else channel_ids } " )
422424
423425 # Always update channel_ids if not in fixed_channels mode
@@ -629,6 +631,39 @@ def on_request_split(self, sender=None):
629631
630632 return np .array (spike_ids_to_split , dtype = np .int64 )
631633
634+ def attach (self , gui ):
635+ """Attach the view to the GUI."""
636+ super (Feature3DView , self ).attach (gui )
637+ # Manually connect the split event to the controller
638+ # This ensures that when 'K' is pressed, this view's on_request_split is called
639+ connect (self .on_request_split )
640+
641+ # Add actions - the shortcuts are automatically handled by the Actions system
642+ self .actions .add (self .zoom_in , name = 'Zoom in' )
643+ self .actions .add (self .zoom_out , name = 'Zoom out' )
644+ self .actions .add (self .reset_view , name = 'Reset view' )
645+ self .actions .separator ()
646+ # Register the toggle action so the default shortcut is picked up.
647+ self .actions .add (
648+ self .toggle_automatic_channel_selection ,
649+ checkable = True ,
650+ checked = not self .fixed_channels ,
651+ )
652+
653+ # Projection toggle (Orthographic/Perspective)
654+ self .actions .add (
655+ self .toggle_projection_mode ,
656+ name = 'Orthographic projection' ,
657+ checkable = True ,
658+ checked = (self .projection_mode == 'orthographic' )
659+ )
660+
661+ # Create axis actions at startup
662+ self ._create_axis_actions ()
663+
664+ # Force an initial plot to ensure the view is not blank on startup.
665+ self .plot ()
666+
632667 def plot (self , ** kwargs ):
633668 """Update the view with the selected clusters."""
634669 logger .debug ("Feature3DView.plot() called" )
@@ -655,13 +690,18 @@ def plot(self, **kwargs):
655690 primary_channel = self .channel_ids [0 ]
656691 primary_channel_label = self .channel_labels .get (primary_channel , str (primary_channel ))
657692 primary_channel_text = f" (ch{ primary_channel_label } )"
693+
694+ logger .debug (f"Updating axes for primary channel: { primary_channel } (label: { primary_channel_label } )" )
658695
659696 # Preserve the chosen PC (PC1/PC2/PC3) for each axis when relabeling Primary
660697 for axis_name in ('x' , 'y' , 'z' ):
661698 current_label = getattr (self , f'{ axis_name } _axis' )
699+ # Update if it's explicitly a Primary axis OR if it's just initialized generic PC
662700 if '(Primary' in current_label :
663701 pc = current_label .split (' ' )[0 ] # e.g., 'PC1'
664- setattr (self , f'{ axis_name } _axis' , f"{ pc } (Primary{ primary_channel_text } )" )
702+ new_label = f"{ pc } (Primary{ primary_channel_text } )"
703+ setattr (self , f'{ axis_name } _axis' , new_label )
704+ logger .debug (f"Updated { axis_name } _axis to { new_label } " )
665705
666706 if not bunchs :
667707 logger .debug ("No cluster data, clearing view" )
@@ -690,30 +730,49 @@ def plot(self, **kwargs):
690730
691731 # Get and plot background data (gray points)
692732 if self .channel_ids :
693- logger .debug ("Getting background data" )
694- background_data = self .features (None , channel_ids = self .channel_ids )
695- # Handle both list and single-bunch returns
696- background = background_data [0 ] if isinstance (background_data , (list , tuple )) and background_data else background_data
697- if background :
698- background .cluster_id = None
699- x_bg = self ._get_axis_data (background , self .x_axis )
700- y_bg = self ._get_axis_data (background , self .y_axis )
701- z_bg = self ._get_axis_data (background , self .z_axis )
702- points_3d = np .column_stack ([x_bg , y_bg , z_bg ])
703-
704- # Store cluster data
705- cluster_info = {
706- 'points_3d' : points_3d ,
707- 'cluster_id' : None ,
708- 'clu_idx' : None ,
709- 'color' : _get_point_color (None ),
710- 'spike_ids' : background .get ('spike_ids' ),
711- 'bunch' : background
712- }
713- self ._cluster_data .append (cluster_info )
733+ logger .debug (f"Attempting to get background data for channels: { self .channel_ids } " )
734+ try :
735+ # Request background features for the current channels
736+ # Note: We use None for cluster_id to get background
737+ background_data = self .features (None , channel_ids = self .channel_ids , load_all = False )
738+
739+ # Handle both list and single-bunch returns
740+ background = background_data [0 ] if isinstance (background_data , (list , tuple )) and background_data else background_data
714741
715- all_points_3d .append (points_3d )
716- all_cluster_ids .extend ([None ] * len (points_3d ))
742+ if background :
743+ spike_ids = background .get ('spike_ids' )
744+ logger .debug (f"Background data received: { len (spike_ids ) if spike_ids is not None else 0 } spikes" )
745+
746+ background .cluster_id = None
747+ x_bg = self ._get_axis_data (background , self .x_axis )
748+ y_bg = self ._get_axis_data (background , self .y_axis )
749+ z_bg = self ._get_axis_data (background , self .z_axis )
750+
751+ # Just plot whatever we got, even if some are zeros
752+ if len (x_bg ) > 0 :
753+ points_3d = np .column_stack ([x_bg , y_bg , z_bg ])
754+
755+ # Store cluster data
756+ cluster_info = {
757+ 'points_3d' : points_3d ,
758+ 'cluster_id' : None ,
759+ 'clu_idx' : None ,
760+ 'color' : _get_point_color (None ),
761+ 'spike_ids' : spike_ids ,
762+ 'bunch' : background
763+ }
764+ self ._cluster_data .append (cluster_info )
765+
766+ all_points_3d .append (points_3d )
767+ all_cluster_ids .extend ([None ] * len (points_3d ))
768+ else :
769+ logger .debug ("Background data has 0 length after axis extraction" )
770+ else :
771+ logger .debug ("No background data returned from features()" )
772+ except Exception as e :
773+ logger .error (f"Error retrieving background data: { e } " )
774+ else :
775+ logger .debug ("Skipping background: No channel_ids set" )
717776
718777 # Plot each cluster
719778 for clu_idx , bunch in enumerate (bunchs ):
@@ -999,36 +1058,6 @@ def zoom_out(self):
9991058 self .scale_3d /= 1.1
10001059 self ._update_projections ()
10011060
1002- def attach (self , gui ):
1003- """Attach the view to the GUI."""
1004- super (Feature3DView , self ).attach (gui )
1005-
1006- # Add actions - the shortcuts are automatically handled by the Actions system
1007- self .actions .add (self .zoom_in , name = 'Zoom in' )
1008- self .actions .add (self .zoom_out , name = 'Zoom out' )
1009- self .actions .add (self .reset_view , name = 'Reset view' )
1010- self .actions .separator ()
1011- # Register the toggle action so the default shortcut is picked up.
1012- self .actions .add (
1013- self .toggle_automatic_channel_selection ,
1014- checkable = True ,
1015- checked = not self .fixed_channels ,
1016- )
1017-
1018- # Projection toggle (Orthographic/Perspective)
1019- self .actions .add (
1020- self .toggle_projection_mode ,
1021- name = 'Orthographic projection' ,
1022- checkable = True ,
1023- checked = (self .projection_mode == 'orthographic' )
1024- )
1025-
1026- # Create axis actions at startup
1027- self ._create_axis_actions ()
1028-
1029- # Force an initial plot to ensure the view is not blank on startup.
1030- self .plot ()
1031-
10321061 def toggle_projection_mode (self , checked ):
10331062 """Toggle between orthographic and perspective projection."""
10341063 self .projection_mode = 'orthographic' if checked else 'perspective'
@@ -1172,4 +1201,3 @@ def status(self):
11721201 f'Primary: ch{ primary_channel_label } ({ channel_mode } ) | '
11731202 f'Rotation: X={ self .rotation_x :.2f} , Y={ self .rotation_y :.2f} , Z={ self .rotation_z :.2f} | '
11741203 f'Scale: { self .scale_3d :.2f} ' )
1175-
0 commit comments