66import matplotlib .colors as mcolors
77import sys
88
9+ class RegionSelectViewBox (pg .ViewBox ):
10+ """Custom ViewBox that handles region selection with mouse events."""
11+
12+ def __init__ (self , parent_viewer ):
13+ super ().__init__ ()
14+ self .parent_viewer = parent_viewer
15+ self .region_start = None
16+ self .region_current = None
17+ self .dragging = False
18+
19+ def mousePressEvent (self , ev ):
20+ if (self .parent_viewer .region_selection_active and
21+ ev .button () == QtCore .Qt .MouseButton .LeftButton and
22+ ev .modifiers () & QtCore .Qt .KeyboardModifier .ControlModifier ):
23+ # Start region selection
24+ self .region_start = self .mapSceneToView (ev .scenePos ())
25+ self .dragging = True
26+ ev .accept ()
27+ else :
28+ super ().mousePressEvent (ev )
29+
30+ def mouseMoveEvent (self , ev ):
31+ if self .dragging and self .parent_viewer .region_selection_active :
32+ # Update region selection
33+ self .region_current = self .mapSceneToView (ev .scenePos ())
34+ self .parent_viewer .update_region_selection (self .region_start , self .region_current )
35+ ev .accept ()
36+ else :
37+ super ().mouseMoveEvent (ev )
38+
39+ def mouseReleaseEvent (self , ev ):
40+ if (self .dragging and self .parent_viewer .region_selection_active and
41+ ev .button () == QtCore .Qt .MouseButton .LeftButton ):
42+ # Finish region selection
43+ self .region_current = self .mapSceneToView (ev .scenePos ())
44+ self .parent_viewer .finish_region_selection (self .region_start , self .region_current )
45+ self .dragging = False
46+ ev .accept ()
47+ else :
48+ super ().mouseReleaseEvent (ev )
49+
950class ResultViewer (QtWidgets .QMainWindow ):
1051 def __init__ (self , video , displacements , points , fps = 30 , magnification = 1 , point_size = 10 , colormap = "cool" ):
1152 """
@@ -67,6 +108,14 @@ def __init__(self, video, displacements, points, fps=30, magnification=1, point_
67108 self .disp_max = np .max (np .abs (displacements ))
68109 self .colormap = colormap
69110
111+ # Region selection variables
112+ self .region_selection_active = False
113+ self .region_start_point = None
114+ self .region_end_point = None
115+ self .region_rect = None
116+ self .region_overlay = None
117+ self .selected_region = None # (x, y, width, height) in image coordinates
118+
70119 self .timer = QtCore .QTimer ()
71120 self .timer .timeout .connect (self .next_frame )
72121
@@ -133,7 +182,11 @@ def init_ui(self):
133182 self .view = pg .GraphicsLayoutWidget ()
134183 self .img_item = pg .ImageItem ()
135184 self .scatter = pg .ScatterPlotItem (size = self .points_size , brush = 'r' , pxMode = True )
136- self .viewbox = self .view .addViewBox ()
185+
186+ # Create custom viewbox for region selection
187+ self .viewbox = RegionSelectViewBox (self )
188+ self .view .addItem (self .viewbox )
189+
137190 self .viewbox .addItem (self .img_item )
138191 self .viewbox .addItem (self .scatter )
139192 self .viewbox .setAspectLocked (True )
@@ -248,6 +301,30 @@ def init_ui(self):
248301 self .export_resolution_combo .setCurrentText ("4x pixel scale" )
249302 export_layout .addWidget (self .export_resolution_combo )
250303
304+ # Region selection controls
305+ export_layout .addWidget (QtWidgets .QLabel ("Region Selection:" ))
306+
307+ region_layout = QtWidgets .QHBoxLayout ()
308+
309+ # Region selection button
310+ self .region_select_button = QtWidgets .QPushButton ("Select Region" )
311+ self .region_select_button .setCheckable (True )
312+ self .region_select_button .clicked .connect (self .toggle_region_selection )
313+ region_layout .addWidget (self .region_select_button )
314+
315+ # Clear region button
316+ self .clear_region_button = QtWidgets .QPushButton ("Clear" )
317+ self .clear_region_button .clicked .connect (self .clear_region_selection )
318+ self .clear_region_button .setEnabled (False )
319+ region_layout .addWidget (self .clear_region_button )
320+
321+ export_layout .addLayout (region_layout )
322+
323+ # Region info label
324+ self .region_info_label = QtWidgets .QLabel ("Full frame will be exported" )
325+ self .region_info_label .setStyleSheet ("font-size: 10px; color: #aaa;" )
326+ export_layout .addWidget (self .region_info_label )
327+
251328 # Frame range controls (only for non-mode shape videos)
252329 if not self .is_mode_shape :
253330 self .frame_range_label = QtWidgets .QLabel ("Frame Range:" )
@@ -505,6 +582,117 @@ def set_full_range(self):
505582 self .start_frame_spin .setValue (0 )
506583 self .stop_frame_spin .setValue (self .video .shape [0 ] - 1 )
507584
585+ def toggle_region_selection (self ):
586+ """Toggle region selection mode."""
587+ self .region_selection_active = self .region_select_button .isChecked ()
588+
589+ if self .region_selection_active :
590+ self .region_select_button .setText ("Cancel Selection" )
591+ self .region_select_button .setStyleSheet ("background-color: #d73a00;" )
592+ # Clear any existing region
593+ self .clear_region_graphics ()
594+ else :
595+ self .region_select_button .setText ("Select Region" )
596+ self .region_select_button .setStyleSheet ("" )
597+ # Clear any temporary selection graphics
598+ self .clear_region_graphics ()
599+
600+ def clear_region_selection (self ):
601+ """Clear the current region selection."""
602+ self .selected_region = None
603+ self .clear_region_graphics ()
604+ self .clear_region_button .setEnabled (False )
605+ self .region_info_label .setText ("Full frame will be exported" )
606+
607+ # Reset the selection button if it was active
608+ if self .region_selection_active :
609+ self .region_select_button .setChecked (False )
610+ self .toggle_region_selection ()
611+
612+ def clear_region_graphics (self ):
613+ """Remove region selection graphics from the view."""
614+ if self .region_rect is not None :
615+ self .viewbox .removeItem (self .region_rect )
616+ self .region_rect = None
617+ if self .region_overlay is not None :
618+ self .viewbox .removeItem (self .region_overlay )
619+ self .region_overlay = None
620+
621+ def update_region_selection (self , start_point , current_point ):
622+ """Update the region selection rectangle during dragging."""
623+ if start_point is None or current_point is None :
624+ return
625+
626+ # Clear previous rectangle
627+ if self .region_rect is not None :
628+ self .viewbox .removeItem (self .region_rect )
629+
630+ # Create new rectangle
631+ x1 , y1 = start_point .x (), start_point .y ()
632+ x2 , y2 = current_point .x (), current_point .y ()
633+
634+ # Ensure proper ordering
635+ x_min , x_max = min (x1 , x2 ), max (x1 , x2 )
636+ y_min , y_max = min (y1 , y2 ), max (y1 , y2 )
637+
638+ # Create rectangle item
639+ self .region_rect = pg .RectROI ([x_min , y_min ], [x_max - x_min , y_max - y_min ],
640+ pen = pg .mkPen (color = 'red' , width = 2 ),
641+ movable = False , removable = False )
642+ self .viewbox .addItem (self .region_rect )
643+
644+ def finish_region_selection (self , start_point , end_point ):
645+ """Finish region selection and apply overlay."""
646+ if start_point is None or end_point is None :
647+ return
648+
649+ # Calculate region bounds
650+ x1 , y1 = start_point .x (), start_point .y ()
651+ x2 , y2 = end_point .x (), end_point .y ()
652+
653+ # Ensure proper ordering and clip to image bounds
654+ video_height , video_width = self .video [0 ].shape
655+ x_min = max (0 , min (x1 , x2 ))
656+ x_max = min (video_width , max (x1 , x2 ))
657+ y_min = max (0 , min (y1 , y2 ))
658+ y_max = min (video_height , max (y1 , y2 ))
659+
660+ # Store the selected region
661+ self .selected_region = (int (x_min ), int (y_min ), int (x_max - x_min ), int (y_max - y_min ))
662+
663+ # Update UI
664+ self .region_select_button .setChecked (False )
665+ self .toggle_region_selection ()
666+ self .clear_region_button .setEnabled (True )
667+ self .region_info_label .setText (f"Region: { self .selected_region [2 ]} x{ self .selected_region [3 ]} pixels" )
668+
669+ # Create overlay effect
670+ self .create_region_overlay ()
671+
672+ def create_region_overlay (self ):
673+ """Create a semi-transparent overlay outside the selected region."""
674+ if self .selected_region is None :
675+ return
676+
677+ # Clear existing overlay
678+ if self .region_overlay is not None :
679+ self .viewbox .removeItem (self .region_overlay )
680+
681+ # Create overlay using ImageItem with alpha channel
682+ video_height , video_width = self .video [0 ].shape
683+ overlay = np .zeros ((video_height , video_width , 4 ), dtype = np .uint8 )
684+
685+ # Set alpha to 128 (semi-transparent) for the entire overlay
686+ overlay [:, :, 3 ] = 128
687+
688+ # Make the selected region fully transparent
689+ x , y , w , h = self .selected_region
690+ overlay [y :y + h , x :x + w , 3 ] = 0
691+
692+ # Create ImageItem for overlay
693+ self .region_overlay = pg .ImageItem (overlay .transpose ((1 , 0 , 2 )))
694+ self .viewbox .addItem (self .region_overlay )
695+
508696 def next_frame (self ):
509697 if self .is_mode_shape :
510698 self .current_frame = (self .current_frame + 1 ) % int (self .fps * self .time_per_period )
@@ -581,6 +769,11 @@ def update_frame(self):
581769 self .viewbox .removeItem (shaft )
582770 self .arrow_shafts .clear ()
583771
772+ # Ensure region overlay is on top if it exists
773+ if self .region_overlay is not None :
774+ self .viewbox .removeItem (self .region_overlay )
775+ self .viewbox .addItem (self .region_overlay )
776+
584777 def export_video (self ):
585778 """Export the current visualization as a video file with pixel-perfect rendering."""
586779 try :
@@ -607,8 +800,15 @@ def export_video(self):
607800
608801 # Calculate export dimensions based on video dimensions and pixel scaling
609802 video_height , video_width = self .video [0 ].shape
610- export_width = video_width * pixel_scale
611- export_height = video_height * pixel_scale
803+
804+ # Handle region selection
805+ if self .selected_region is not None :
806+ region_x , region_y , region_width , region_height = self .selected_region
807+ export_width = region_width * pixel_scale
808+ export_height = region_height * pixel_scale
809+ else :
810+ export_width = video_width * pixel_scale
811+ export_height = video_height * pixel_scale
612812
613813 # Use MP4 with high quality settings
614814 default_ext = "mp4"
@@ -691,12 +891,23 @@ def export_video(self):
691891 else :
692892 frame_rgb = base_frame
693893
894+ # Apply region cropping if selected
895+ if self .selected_region is not None :
896+ region_x , region_y , region_width , region_height = self .selected_region
897+ frame_rgb = frame_rgb [region_y :region_y + region_height , region_x :region_x + region_width ]
898+
694899 # Scale up the frame without interpolation (nearest neighbor)
695900 export_frame = np .repeat (np .repeat (frame_rgb , pixel_scale , axis = 0 ), pixel_scale , axis = 1 )
696901
697902 # Calculate displaced points
698903 displaced_pts = self .grid + displ
699904
905+ # Calculate region offset for coordinate transformation
906+ region_offset_x = 0
907+ region_offset_y = 0
908+ if self .selected_region is not None :
909+ region_offset_x , region_offset_y = self .selected_region [0 ], self .selected_region [1 ]
910+
700911 # Draw displacement visualization on the scaled frame
701912 if show_arrows :
702913 # Draw arrows showing displacement
@@ -705,27 +916,37 @@ def export_video(self):
705916 cmap = plt .colormaps [self .colormap ]
706917
707918 for i , (pt0 , pt1 , mag ) in enumerate (zip (self .grid , displaced_pts , magnitudes )):
708- # Scale coordinates to export resolution
709- start_pt = (int (pt0 [0 ] * pixel_scale ), int (pt0 [1 ] * pixel_scale ))
710- end_pt = (int (pt1 [0 ] * pixel_scale ), int (pt1 [1 ] * pixel_scale ))
711-
712- # Get color for this magnitude
713- color = cmap (norm (mag ))
714- color_bgr = tuple (int (255 * c ) for c in color [2 ::- 1 ]) # Convert RGB to BGR
715-
716- # Draw arrow line
717- cv2 .line (export_frame , start_pt , end_pt , color_bgr ,
718- max (1 , point_size * pixel_scale // 10 ))
919+ # Apply region offset and scale coordinates to export resolution
920+ start_pt = (int ((pt0 [0 ] - region_offset_x ) * pixel_scale ),
921+ int ((pt0 [1 ] - region_offset_y ) * pixel_scale ))
922+ end_pt = (int ((pt1 [0 ] - region_offset_x ) * pixel_scale ),
923+ int ((pt1 [1 ] - region_offset_y ) * pixel_scale ))
719924
720- # Draw arrow head
721- cv2 .circle (export_frame , end_pt , max (1 , point_size * pixel_scale // 5 ),
722- color_bgr , - 1 )
925+ # Check if points are within the export frame bounds
926+ if (0 <= start_pt [0 ] < export_width and 0 <= start_pt [1 ] < export_height and
927+ 0 <= end_pt [0 ] < export_width and 0 <= end_pt [1 ] < export_height ):
928+
929+ # Get color for this magnitude
930+ color = cmap (norm (mag ))
931+ color_bgr = tuple (int (255 * c ) for c in color [2 ::- 1 ]) # Convert RGB to BGR
932+
933+ # Draw arrow line
934+ cv2 .line (export_frame , start_pt , end_pt , color_bgr ,
935+ max (1 , point_size * pixel_scale // 10 ))
936+
937+ # Draw arrow head
938+ cv2 .circle (export_frame , end_pt , max (1 , point_size * pixel_scale // 5 ),
939+ color_bgr , - 1 )
723940 else :
724941 # Draw points at displaced positions
725942 for pt in displaced_pts :
726- center = (int (pt [0 ] * pixel_scale ), int (pt [1 ] * pixel_scale ))
727- cv2 .circle (export_frame , center , max (1 , point_size * pixel_scale // 5 ),
728- (0 , 0 , 255 ), - 1 ) # Red circles
943+ center = (int ((pt [0 ] - region_offset_x ) * pixel_scale ),
944+ int ((pt [1 ] - region_offset_y ) * pixel_scale ))
945+
946+ # Check if point is within the export frame bounds
947+ if (0 <= center [0 ] < export_width and 0 <= center [1 ] < export_height ):
948+ cv2 .circle (export_frame , center , max (1 , point_size * pixel_scale // 5 ),
949+ (0 , 0 , 255 ), - 1 ) # Red circles
729950
730951 # Ensure the frame is in the correct format and size
731952 export_frame = np .clip (export_frame , 0 , 255 ).astype (np .uint8 )
@@ -746,11 +967,16 @@ def export_video(self):
746967 else :
747968 frame_info = f"Frames: { start_frame } to { stop_frame } ({ total_frames } total)"
748969
970+ # Add region info if applicable
971+ region_info = ""
972+ if self .selected_region is not None :
973+ region_info = f"Region: { self .selected_region [2 ]} x{ self .selected_region [3 ]} pixels\n "
974+
749975 QtWidgets .QMessageBox .information (self , "Export Complete" ,
750976 f"Video exported successfully to:\n { file_path } \n "
751977 f"Resolution: { export_width } x{ export_height } "
752978 f"(pixel scale: { pixel_scale } x)\n "
753- f"{ frame_info } " )
979+ f"{ region_info } { frame_info } " )
754980
755981 except Exception as e :
756982 import traceback
0 commit comments