Skip to content

Commit 5940c33

Browse files
committed
select region to export to mp4
1 parent 25bc0df commit 5940c33

File tree

1 file changed

+247
-21
lines changed

1 file changed

+247
-21
lines changed

pyidi/GUIs/result_viewer.py

Lines changed: 247 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,47 @@
66
import matplotlib.colors as mcolors
77
import 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+
950
class 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

Comments
 (0)