""" Camera GUI using PyQt5 and OpenCV Displays two video streams: deformation arrows and raw image """ import os os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" import sys import time import cv2 import numpy as np import pyqtgraph as pg from orisys import Sensor from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QCheckBox, QLineEdit, QFileDialog) from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QImage, QPixmap, QIcon def draw_arrows_fade(img, flow, depth, depth_scale=0.5, grid_spacing=20, arrow_scale=1.0, threshold=5.0): """ Draw optical flow arrows on image with transparency based on magnitude. Args: img: Input image (BGR or grayscale) flow: Optical flow field with shape (flow_height, flow_width, 2) depth: Depth information depth_scale: Scale factor for depth visualization grid_spacing: Spacing between arrows in pixels (default: 20) arrow_scale: Scale factor for arrow length (default: 1.0) threshold: Minimum flow magnitude to display arrow (default: 5.0) Returns: Image with arrows drawn, blended with original image """ depth_img = cv2.applyColorMap(((depth+1)*depth_scale* 255.0).astype('uint8'), cv2.COLORMAP_HOT) # Make a copy of the image img_copy = depth_img.copy() # Convert grayscale to BGR if needed if len(img_copy.shape) == 2 or img_copy.shape[2] == 1: img_copy = cv2.cvtColor(img_copy, cv2.COLOR_GRAY2BGR) # Get image and flow dimensions img_height, img_width = img_copy.shape[:2] flow_height, flow_width = flow.shape[:2] # Calculate scaling factors between image and flow dimensions scale_x = img_width / flow_width scale_y = img_height / flow_height # Create grid positions in flow coordinates grid_y, grid_x = np.mgrid[grid_spacing//2:flow_height:grid_spacing, grid_spacing//2:flow_width:grid_spacing] # Flatten grid positions grid_y_flat = grid_y.flatten() grid_x_flat = grid_x.flatten() # Get flow vectors at grid positions flow_vectors = flow[grid_y_flat, grid_x_flat, :] # Shape: (n_points, 2) # Calculate flow magnitudes magnitudes = np.linalg.norm(flow_vectors, axis=1) # Filter arrows by threshold: only show arrows above threshold valid_mask = magnitudes >= threshold # Normalize magnitudes to 0-1 range for alpha blending # Zero magnitude = transparent, higher magnitude = more opaque white max_magnitude = magnitudes.max() if magnitudes.max() > 0 else 1.0 alpha_values = magnitudes / max_magnitude # Scale grid positions to image coordinates img_grid_x = (grid_x_flat * scale_x).astype(np.int32) img_grid_y = (grid_y_flat * scale_y).astype(np.int32) # Scale flow vectors to image coordinates scaled_flow_x = flow_vectors[:, 0] * scale_x / arrow_scale scaled_flow_y = flow_vectors[:, 1] * scale_y / arrow_scale # Calculate arrow endpoints end_x = (img_grid_x + scaled_flow_x).astype(np.int32) end_y = (img_grid_y + scaled_flow_y).astype(np.int32) # Create overlay image with white background for arrows overlay = np.zeros_like(img_copy) # Create weight map for blending based on arrow presence and magnitude weight_map = np.zeros((img_height, img_width), dtype=np.float32) # Draw arrows on overlay with white color and build weight map for i in range(len(img_grid_x)): # Skip if magnitude is below threshold if not valid_mask[i]: continue # Check bounds if (0 <= img_grid_x[i] < img_width and 0 <= img_grid_y[i] < img_height and 0 <= end_x[i] < img_width and 0 <= end_y[i] < img_height): # Get alpha value (0 = transparent, 1 = opaque white) alpha = alpha_values[i] # Skip if magnitude is zero (transparent) if alpha <= 0: continue # Use constant thickness for all arrows (magnitude affects opacity, not thickness) thickness = 2 # White color for arrows color = (255, 255, 255) # Draw arrow on overlay cv2.arrowedLine(overlay, (img_grid_x[i], img_grid_y[i]), (end_x[i], end_y[i]), color, thickness=thickness, tipLength=0.3) # Create a line mask for this arrow in the weight map line_mask = np.zeros((img_height, img_width), dtype=np.float32) cv2.line(line_mask, (img_grid_x[i], img_grid_y[i]), (end_x[i], end_y[i]), 1.0, thickness=thickness) # Add arrow tip approximation cv2.circle(line_mask, (end_x[i], end_y[i]), thickness, 1.0, -1) # Weight by alpha (magnitude) - use maximum for overlapping arrows weight_map = np.maximum(weight_map, line_mask * alpha) # Expand weight map to 3 channels weight_map_3ch = np.stack([weight_map] * 3, axis=2) # Blend: result = original * (1 - weight) + overlay * weight result = (img_copy.astype(np.float32) * (1 - weight_map_3ch) + overlay.astype(np.float32) * weight_map_3ch) return result.astype(np.uint8) class CameraGUI(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Orisys Sensor Demo") # Set minimum window size and aspect ratio self.setMinimumSize(1200, 800) self.setGeometry(100, 100, 1366, 768) # Initial size # Set window icon (logo) if os.path.exists("orisys_logo_notext.png"): self.setWindowIcon(QIcon("orisys_logo_notext.png")) # Sensor variables self.sensor = None self.camera_running = False self.timer = QTimer() self.timer.timeout.connect(self.update_frame) # Recording variables self.recording = False self.video_writer_arrows = None self.video_writer_raw = None # Default to Windows Pictures folder pictures_folder = os.path.join(os.path.expanduser("~"), "Pictures") self.record_directory = os.path.join(pictures_folder, "OrisysRecordings") self.record_filename = "recording" # Plotting variables self.npoints = 100 # Chart data length self.start_time = None # Will be set when sensor starts self.data = None # Will be initialized in init_plots self.max_magnitude = 0.0 # Track maximum magnitude for scaling self.dynamic_scaling = True # Enable dynamic scaling by default self.max_scale = 500000.0 # Default max scale value # Create GUI self.init_ui() def init_ui(self): # Central widget central_widget = QWidget() self.setCentralWidget(central_widget) # Main layout: horizontal split between left (controls/images) and right (plots) main_layout = QHBoxLayout(central_widget) main_layout.setSpacing(15) # Left side: Controls and images left_panel = QWidget() left_panel_layout = QVBoxLayout(left_panel) left_panel_layout.setSpacing(10) # Title (left aligned) title_label = QLabel("叠动视触觉传感器演示") title_label.setStyleSheet(""" font-size: 24px; font-weight: bold; padding: 10px; color: #2c3e50; font-family: 'Segoe UI', Arial, sans-serif; background-color: #ecf0f1; border-radius: 8px; """) title_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) left_panel_layout.addWidget(title_label) # Button layout (top left) button_layout = QHBoxLayout() button_layout.setSpacing(10) # Start button self.start_button = QPushButton("Start") self.start_button.setStyleSheet(""" QPushButton { background-color: #27ae60; color: white; font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 12px 35px; border-radius: 6px; border: none; } QPushButton:hover { background-color: #229954; } QPushButton:pressed { background-color: #1e8449; } QPushButton:disabled { background-color: #95a5a6; color: #ecf0f1; } """) self.start_button.clicked.connect(self.start_camera) button_layout.addWidget(self.start_button) # Stop button self.stop_button = QPushButton("Stop") self.stop_button.setStyleSheet(""" QPushButton { background-color: #e74c3c; color: white; font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 12px 35px; border-radius: 6px; border: none; } QPushButton:hover { background-color: #c0392b; } QPushButton:pressed { background-color: #a93226; } QPushButton:disabled { background-color: #95a5a6; color: #ecf0f1; } """) self.stop_button.setEnabled(False) self.stop_button.clicked.connect(self.stop_camera) button_layout.addWidget(self.stop_button) # Reset button self.reset_button = QPushButton("Reset") self.reset_button.setStyleSheet(""" QPushButton { background-color: #f39c12; color: white; font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 12px 35px; border-radius: 6px; border: none; } QPushButton:hover { background-color: #e67e22; } QPushButton:pressed { background-color: #d35400; } QPushButton:disabled { background-color: #95a5a6; color: #ecf0f1; } """) self.reset_button.setEnabled(False) # Disabled until camera starts self.reset_button.clicked.connect(self.reset_sensor) button_layout.addWidget(self.reset_button) # Exit button exit_button = QPushButton("Exit") exit_button.setStyleSheet(""" QPushButton { background-color: #7f8c8d; color: white; font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 12px 35px; border-radius: 6px; border: none; } QPushButton:hover { background-color: #5d6d7e; } QPushButton:pressed { background-color: #566573; } """) exit_button.clicked.connect(self.close) button_layout.addWidget(exit_button) button_layout.addStretch() # Push buttons to the left left_panel_layout.addLayout(button_layout) # Recording controls recording_layout = QVBoxLayout() recording_label = QLabel("录制设置") recording_label.setStyleSheet(""" font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #2c3e50; padding: 5px; """) recording_layout.addWidget(recording_label) # Directory input dir_layout = QHBoxLayout() dir_label = QLabel("目录:") dir_label.setStyleSheet("font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif;") dir_layout.addWidget(dir_label) self.record_dir_textbox = QLineEdit() # Set default to Windows Pictures folder pictures_folder = os.path.join(os.path.expanduser("~"), "Pictures") default_dir = os.path.join(pictures_folder, "OrisysRecordings") self.record_dir_textbox.setText(default_dir) self.record_dir_textbox.setStyleSheet(""" font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif; padding: 4px; border: 1px solid #bdc3c7; border-radius: 4px; """) self.record_dir_textbox.textChanged.connect(self.on_record_dir_changed) dir_layout.addWidget(self.record_dir_textbox) # Browse button browse_button = QPushButton("浏览") browse_button.setStyleSheet(""" QPushButton { background-color: #95a5a6; color: white; font-size: 11px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 6px 15px; border-radius: 4px; border: none; } QPushButton:hover { background-color: #7f8c8d; } QPushButton:pressed { background-color: #5d6d7e; } """) browse_button.clicked.connect(self.browse_directory) dir_layout.addWidget(browse_button) recording_layout.addLayout(dir_layout) # Filename input filename_layout = QHBoxLayout() filename_label = QLabel("文件名:") filename_label.setStyleSheet("font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif;") filename_layout.addWidget(filename_label) self.record_filename_textbox = QLineEdit() self.record_filename_textbox.setText("recording") self.record_filename_textbox.setStyleSheet(""" font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif; padding: 4px; border: 1px solid #bdc3c7; border-radius: 4px; """) self.record_filename_textbox.textChanged.connect(self.on_record_filename_changed) filename_layout.addWidget(self.record_filename_textbox) recording_layout.addLayout(filename_layout) # Record button self.record_button = QPushButton("开始录制") self.record_button.setStyleSheet(""" QPushButton { background-color: #3498db; color: white; font-size: 13px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 10px 25px; border-radius: 6px; border: none; } QPushButton:hover { background-color: #2980b9; } QPushButton:pressed { background-color: #21618c; } QPushButton:disabled { background-color: #95a5a6; color: #ecf0f1; } """) self.record_button.setEnabled(False) # Disabled until camera starts self.record_button.clicked.connect(self.toggle_recording) recording_layout.addWidget(self.record_button) recording_widget = QWidget() recording_widget.setLayout(recording_layout) recording_widget.setStyleSheet(""" QWidget { border: 1px solid #bdc3c7; border-radius: 5px; background-color: #f8f9fa; padding: 8px; } """) left_panel_layout.addWidget(recording_widget) # Left side: Image displays (side by side) images_layout = QHBoxLayout() # Arrows (deformation) display arrows_label = QLabel("变形") arrows_label.setStyleSheet(""" font-size: 15px; font-weight: bold; padding: 8px; color: #2c3e50; font-family: 'Segoe UI', Arial, sans-serif; background-color: #ecf0f1; border-radius: 5px; """) arrows_label.setAlignment(Qt.AlignCenter) self.arrows_image_label = QLabel() self.arrows_image_label.setMinimumSize(400, 400) # Fixed initial size self.arrows_image_label.setMaximumSize(400, 400) # Fixed size self.arrows_image_label.setStyleSheet("background-color: black; border: 2px solid #4a90e2; border-radius: 5px;") self.arrows_image_label.setAlignment(Qt.AlignCenter) self.arrows_image_label.setScaledContents(True) # Allow scaling within fixed size arrows_container = QWidget() arrows_container_layout = QVBoxLayout(arrows_container) arrows_container_layout.addWidget(arrows_label) arrows_container_layout.addWidget(self.arrows_image_label) images_layout.addWidget(arrows_container) # Raw image display raw_label = QLabel("原始图像") raw_label.setStyleSheet(""" font-size: 15px; font-weight: bold; padding: 8px; color: #2c3e50; font-family: 'Segoe UI', Arial, sans-serif; background-color: #ecf0f1; border-radius: 5px; """) raw_label.setAlignment(Qt.AlignCenter) self.raw_image_label = QLabel() self.raw_image_label.setMinimumSize(400, 400) # Fixed initial size self.raw_image_label.setMaximumSize(400, 400) # Fixed size self.raw_image_label.setStyleSheet("background-color: black; border: 2px solid #4a90e2; border-radius: 5px;") self.raw_image_label.setAlignment(Qt.AlignCenter) self.raw_image_label.setScaledContents(True) # Allow scaling within fixed size raw_container = QWidget() raw_container_layout = QVBoxLayout(raw_container) raw_container_layout.addWidget(raw_label) raw_container_layout.addWidget(self.raw_image_label) images_layout.addWidget(raw_container) # FPS/Status label (left side, not spanning to plots) self.status_label = QLabel("Status: Stopped") self.status_label.setStyleSheet(""" font-size: 13px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #e74c3c; padding: 8px; background-color: #fadbd8; border-radius: 5px; """) self.status_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) left_panel_layout.addWidget(self.status_label) # Images layout images_widget = QWidget() images_widget.setLayout(images_layout) left_panel_layout.addWidget(images_widget) left_panel_layout.addStretch() # Push everything to top # Add left panel to main layout main_layout.addWidget(left_panel) # Right side: Plots with controls (all in one bordered container) plots_container = QWidget() plots_container.setStyleSheet(""" QWidget { border: 2px solid #4a90e2; border-radius: 8px; background-color: #ffffff; padding: 10px; } """) plots_container_layout = QVBoxLayout(plots_container) plots_container_layout.setContentsMargins(10, 10, 10, 10) plots_container_layout.setSpacing(10) # Create controls container (no border, just for layout) controls_container = QWidget() controls_layout = QVBoxLayout(controls_container) controls_layout.setContentsMargins(0, 0, 0, 0) # Title label controls_title = QLabel("力信息") controls_title.setStyleSheet(""" font-size: 18px; font-weight: bold; padding: 8px; color: #2c3e50; font-family: 'Segoe UI', Arial, sans-serif; """) controls_title.setAlignment(Qt.AlignCenter) controls_layout.addWidget(controls_title) # Scaling controls layout scaling_layout = QHBoxLayout() # Dynamic scaling checkbox self.dynamic_scaling_checkbox = QCheckBox("动态缩放") self.dynamic_scaling_checkbox.setChecked(True) self.dynamic_scaling_checkbox.setStyleSheet(""" font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif; color: #34495e; """) self.dynamic_scaling_checkbox.toggled.connect(self.on_dynamic_scaling_changed) scaling_layout.addWidget(self.dynamic_scaling_checkbox) # Max scale label and textbox max_scale_label = QLabel("最大刻度:") max_scale_label.setStyleSheet(""" font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif; color: #34495e; """) scaling_layout.addWidget(max_scale_label) self.max_scale_textbox = QLineEdit() self.max_scale_textbox.setText("500000.0") self.max_scale_textbox.setEnabled(False) # Disabled when dynamic scaling is on self.max_scale_textbox.setMaximumWidth(100) self.max_scale_textbox.setStyleSheet(""" font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif; padding: 4px; border: 1px solid #bdc3c7; border-radius: 4px; """) self.max_scale_textbox.textChanged.connect(self.on_max_scale_changed) scaling_layout.addWidget(self.max_scale_textbox) scaling_layout.addStretch() # Push controls to the left controls_layout.addLayout(scaling_layout) plots_container_layout.addWidget(controls_container) # Initialize plots self.init_plots() plots_container_layout.addWidget(self.plot_widget) # Add plots container to main layout (right side) main_layout.addWidget(plots_container) def on_dynamic_scaling_changed(self, checked): """Handle dynamic scaling checkbox state change""" self.dynamic_scaling = checked self.max_scale_textbox.setEnabled(not self.dynamic_scaling) def on_max_scale_changed(self, text): """Handle max scale textbox change""" try: self.max_scale = float(text) if self.max_scale <= 0: self.max_scale = 1.0 except ValueError: # Invalid input, keep previous value pass def on_record_dir_changed(self, text): """Handle record directory change""" if text: self.record_directory = text else: # Default to Windows Pictures folder if empty pictures_folder = os.path.join(os.path.expanduser("~"), "Pictures") self.record_directory = os.path.join(pictures_folder, "OrisysRecordings") def on_record_filename_changed(self, text): """Handle record filename change""" self.record_filename = text if text else "recording" def browse_directory(self): """Open directory browser dialog""" directory = QFileDialog.getExistingDirectory( self, "选择保存目录", self.record_directory if os.path.exists(self.record_directory) else "." ) if directory: self.record_dir_textbox.setText(directory) self.record_directory = directory def toggle_recording(self): """Toggle recording on/off""" if not self.recording: # Start recording if not os.path.exists(self.record_directory): os.makedirs(self.record_directory) # Get frame size from sensor images (original size, not scaled) if self.sensor and hasattr(self.sensor, 'img'): h_raw, w_raw = self.sensor.img.shape[:2] # Arrows image has same dimensions as raw (after cropping) h_arr = h_raw - 30 if h_raw > 30 else h_raw w_arr = w_raw - 30 if w_raw > 30 else w_raw # Create video writers fourcc = cv2.VideoWriter_fourcc(*'mp4v') fps = 30.0 arrows_path = os.path.join(self.record_directory, f"{self.record_filename}_arrows.mp4") raw_path = os.path.join(self.record_directory, f"{self.record_filename}_raw.mp4") self.video_writer_arrows = cv2.VideoWriter( arrows_path, fourcc, fps, (w_arr, h_arr) ) self.video_writer_raw = cv2.VideoWriter( raw_path, fourcc, fps, (w_arr, h_arr) ) if self.video_writer_arrows.isOpened() and self.video_writer_raw.isOpened(): self.recording = True self.record_button.setText("停止录制") self.record_button.setStyleSheet(""" QPushButton { background-color: #e74c3c; color: white; font-size: 13px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 10px 25px; border-radius: 6px; border: none; } QPushButton:hover { background-color: #c0392b; } QPushButton:pressed { background-color: #a93226; } """) else: # Stop recording self.recording = False if self.video_writer_arrows: self.video_writer_arrows.release() self.video_writer_arrows = None if self.video_writer_raw: self.video_writer_raw.release() self.video_writer_raw = None self.record_button.setText("开始录制") self.record_button.setStyleSheet(""" QPushButton { background-color: #3498db; color: white; font-size: 13px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; padding: 10px 25px; border-radius: 6px; border: none; } QPushButton:hover { background-color: #2980b9; } QPushButton:pressed { background-color: #21618c; } """) def init_plots(self): """Initialize pyqtgraph plots for force visualization""" # Configure pyqtgraph with modern color theme pg.setConfigOption('background', '#f8f9fa') # Light gray background pg.setConfigOption('foreground', '#2c3e50') # Dark blue-gray text # Create GraphicsLayoutWidget (will scale with window) self.plot_widget = pg.GraphicsLayoutWidget() self.plot_widget.setMinimumSize(400, 600) # Minimum size, will scale self.plot_widget.setBackground('#f8f9fa') # Light gray background # Modern color palette colors = { 'normal': '#3498db', # Blue 'shear_x': '#e74c3c', # Red 'shear_y': '#2ecc71', # Green 'magnitude': '#9b59b6' # Purple } # Create plots with modern styling p1 = self.plot_widget.addPlot(title="法相力") p1.setLabel('bottom', '数据点', units='', color='#34495e') p1.setLabel('left', '力', color='#34495e') p1.showGrid(x=True, y=True, alpha=0.3) p1.getAxis('bottom').setPen(pg.mkPen(color='#7f8c8d', width=1)) p1.getAxis('left').setPen(pg.mkPen(color='#7f8c8d', width=1)) p1.getAxis('bottom').setTextPen(pg.mkPen(color='#34495e')) p1.getAxis('left').setTextPen(pg.mkPen(color='#34495e')) self.plot_widget.nextRow() p2 = self.plot_widget.addPlot(title="切向力 X") p2.setLabel('bottom', '数据点', units='', color='#34495e') p2.setLabel('left', '力', color='#34495e') p2.showGrid(x=True, y=True, alpha=0.3) p2.getAxis('bottom').setPen(pg.mkPen(color='#7f8c8d', width=1)) p2.getAxis('left').setPen(pg.mkPen(color='#7f8c8d', width=1)) p2.getAxis('bottom').setTextPen(pg.mkPen(color='#34495e')) p2.getAxis('left').setTextPen(pg.mkPen(color='#34495e')) self.plot_widget.nextRow() p3 = self.plot_widget.addPlot(title="切向力 Y") p3.setLabel('bottom', '数据点', units='', color='#34495e') p3.setLabel('left', '力', color='#34495e') p3.showGrid(x=True, y=True, alpha=0.3) p3.getAxis('bottom').setPen(pg.mkPen(color='#7f8c8d', width=1)) p3.getAxis('left').setPen(pg.mkPen(color='#7f8c8d', width=1)) p3.getAxis('bottom').setTextPen(pg.mkPen(color='#34495e')) p3.getAxis('left').setTextPen(pg.mkPen(color='#34495e')) self.plot_widget.nextRow() p4 = self.plot_widget.addPlot(title="合力大小") p4.setLabel('bottom', '数据点', units='', color='#34495e') p4.setLabel('left', '力', color='#34495e') p4.showGrid(x=True, y=True, alpha=0.3) p4.getAxis('bottom').setPen(pg.mkPen(color='#7f8c8d', width=1)) p4.getAxis('left').setPen(pg.mkPen(color='#7f8c8d', width=1)) p4.getAxis('bottom').setTextPen(pg.mkPen(color='#34495e')) p4.getAxis('left').setTextPen(pg.mkPen(color='#34495e')) # Create plot curves with modern colors and styling self.plot_af = p1.plot(pen=pg.mkPen(color=colors['normal'], width=2)) self.plot_tfx = p2.plot(pen=pg.mkPen(color=colors['shear_x'], width=2)) self.plot_tfy = p3.plot(pen=pg.mkPen(color=colors['shear_y'], width=2)) self.plot_magnitude = p4.plot(pen=pg.mkPen(color=colors['magnitude'], width=2)) # Store plot references self.p1 = p1 self.p2 = p2 self.p3 = p3 self.p4 = p4 # Initialize data array: [npoints, 2, 4] where: # - First dimension: data points # - Second dimension: [time, force_value] # - Third dimension: [normal, shear_x, shear_y, magnitude] # Use relative time (0 to npoints) so x-axis always shows 0 to npoints self.data = np.zeros([self.npoints, 2, 4]) self.data_count = 0 # Track how many data points we've collected for i in range(4): self.data[:, 0, i] = np.arange(self.npoints) # Start with 0 to npoints-1 def update_plots(self, fn, fx, fy, current_time): """Update the plots with new force data""" if self.data is None: return # Calculate magnitude: sqrt(fn^2 + fx^2 + fy^2) magnitude = np.sqrt(fn**2 + fx**2 + fy**2) # Update maximum magnitude for scaling self.max_magnitude = max(self.max_magnitude, magnitude) # Update data count self.data_count += 1 if self.data_count <= self.npoints: # Before we have npoints, just append to the array idx = self.data_count - 1 # 0-indexed self.data[idx, 0, :] = idx # X-axis: 0, 1, 2, ..., data_count-1 self.data[idx, 1, 0] = fn self.data[idx, 1, 1] = fx self.data[idx, 1, 2] = fy self.data[idx, 1, 3] = magnitude # Show only the data we have so far show_indices = slice(0, self.data_count) # X-axis range: 0 to data_count-1 x_min, x_max = 0, max(0, self.data_count - 1) else: # After we have npoints, shift force data left (rolling window) # Shift force values left for i in range(4): self.data[:-1, 1, i] = self.data[1:, 1, i] # shift force data left only # Set new data at the end self.data[-1, 1, 0] = fn self.data[-1, 1, 1] = fx self.data[-1, 1, 2] = fy self.data[-1, 1, 3] = magnitude # Update x-axis values to show sliding window of actual data point indices # X-axis shows the actual data point numbers (data_count - npoints + 1 to data_count) start_idx = self.data_count - self.npoints for i in range(4): self.data[:, 0, i] = np.arange(start_idx, start_idx + self.npoints) # Show all npoints show_indices = slice(None) # X-axis range: start_idx to start_idx + npoints - 1 x_min, x_max = start_idx, start_idx + self.npoints - 1 # Set x-axis range to show the sliding window self.p1.setXRange(x_min, x_max) self.p2.setXRange(x_min, x_max) self.p3.setXRange(x_min, x_max) self.p4.setXRange(x_min, x_max) # Calculate scaling based on dynamic scaling setting if self.dynamic_scaling: # Dynamic scaling: use current maximum force sum magnitude from visible data * 1.05 # Calculate max from current visible data (magnitude is in index 3) if len(self.data[show_indices, 1, 3]) > 0: current_max_magnitude = max(self.data[show_indices, 1, 3]) if current_max_magnitude > 0: scale_max = current_max_magnitude * 1.05 else: scale_max = 1.0 else: scale_max = 1.0 else: # Fixed scaling: use value from textbox scale_max = self.max_scale # Update plot data with scaled Y ranges self.plot_af.setData(self.data[show_indices, 0, 0], self.data[show_indices, 1, 0]) y_min_0, y_max_0 = min(self.data[show_indices, 1, 0]), max(self.data[show_indices, 1, 0]) if y_max_0 > y_min_0: # Normal force: start from 0 (no negative part) self.p1.setYRange(0, scale_max) else: self.p1.setYRange(0, max(1.0, y_max_0 + 1)) self.plot_tfx.setData(self.data[show_indices, 0, 1], self.data[show_indices, 1, 1]) y_min_1, y_max_1 = min(self.data[show_indices, 1, 1]), max(self.data[show_indices, 1, 1]) if y_max_1 > y_min_1: self.p2.setYRange(-scale_max, scale_max) else: self.p2.setYRange(y_min_1 - 1, y_max_1 + 1) self.plot_tfy.setData(self.data[show_indices, 0, 2], self.data[show_indices, 1, 2]) y_min_2, y_max_2 = min(self.data[show_indices, 1, 2]), max(self.data[show_indices, 1, 2]) if y_max_2 > y_min_2: self.p3.setYRange(-scale_max, scale_max) else: self.p3.setYRange(y_min_2 - 1, y_max_2 + 1) # Update magnitude plot self.plot_magnitude.setData(self.data[show_indices, 0, 3], self.data[show_indices, 1, 3]) y_min_mag, y_max_mag = min(self.data[show_indices, 1, 3]), max(self.data[show_indices, 1, 3]) if y_max_mag > y_min_mag: self.p4.setYRange(0, scale_max) # Magnitude is always positive else: self.p4.setYRange(-1, 1) def start_camera(self): if not self.camera_running: try: # Open sensor (0 for camera, or use video file path) # sensor1 = Sensor("./s1.mp4",isstitch=True,cuda = True,verbose = True) self.sensor = Sensor(0, isstitch=True, cuda=True, verbose=True) # Initialize start time for plots self.start_time = time.time() self.data_count = 0 # Reset data count when starting self.max_magnitude = 0.0 # Reset max magnitude # Reset plot data self.data = np.zeros([self.npoints, 2, 4]) for i in range(4): self.data[:, 0, i] = np.arange(self.npoints) self.camera_running = True self.timer.start(30) # ~30 FPS self.status_label.setText("Status: Running") self.status_label.setStyleSheet(""" font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #27ae60; padding: 10px; background-color: #d5f4e6; border-radius: 5px; """) self.start_button.setEnabled(False) self.stop_button.setEnabled(True) self.reset_button.setEnabled(True) # Enable reset when camera starts self.record_button.setEnabled(True) # Enable recording when camera starts except Exception as e: self.status_label.setText(f"Status: Sensor Error - {str(e)}") self.status_label.setStyleSheet(""" font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #f39c12; padding: 10px; background-color: #fef5e7; border-radius: 5px; """) self.sensor = None def stop_camera(self): if self.camera_running: self.camera_running = False self.timer.stop() if self.sensor is not None: try: self.sensor.disconnect() except: pass self.sensor = None self.start_time = None self.data_count = 0 # Reset data count self.max_magnitude = 0.0 # Reset max magnitude self.status_label.setText("Status: Stopped") self.status_label.setStyleSheet(""" font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #e74c3c; padding: 10px; background-color: #fadbd8; border-radius: 5px; """) self.start_button.setEnabled(True) self.stop_button.setEnabled(False) self.reset_button.setEnabled(False) # Disable reset when camera stops self.record_button.setEnabled(False) # Disable recording when camera stops # Stop recording if active if self.recording: self.toggle_recording() # Clear images self.arrows_image_label.clear() self.arrows_image_label.setStyleSheet("background-color: black; border: 2px solid #333;") self.raw_image_label.clear() self.raw_image_label.setStyleSheet("background-color: black; border: 2px solid #333;") def reset_sensor(self): """Reset the sensor""" if self.sensor is not None and self.camera_running: try: self.sensor.reset() # Reset plot data self.data_count = 0 self.max_magnitude = 0.0 self.data = np.zeros([self.npoints, 2, 4]) for i in range(4): self.data[:, 0, i] = np.arange(self.npoints) self.status_label.setText("Status: Sensor Reset") self.status_label.setStyleSheet(""" font-size: 13px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #f39c12; padding: 8px; background-color: #fef5e7; border-radius: 5px; """) except Exception as e: self.status_label.setText(f"Status: Reset Error - {str(e)}") self.status_label.setStyleSheet(""" font-size: 13px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #e74c3c; padding: 8px; background-color: #fadbd8; border-radius: 5px; """) def update_frame(self): if self.camera_running and self.sensor is not None: try: # Extract and stitch compound eye image self.sensor.get_img() # Compute deformation and decomposition self.sensor.compute_deformation() # Extract information: # Vector fields: VRAW, VNORMAL, VSHEAR: raw, normal, tangential # Frame rate, timestamp: FPS, TIMESTAMP # Total deformation: FNORMAL, FSHEARX, FSHEARY: normal, tangential (X), tangential (Y) fps, fn, fx, fy, flow, depth = self.sensor.read_info( self.sensor.info.FPS, self.sensor.info.FNORMAL, self.sensor.info.FSHEARX, self.sensor.info.FSHEARY, self.sensor.info.VRAW, self.sensor.info.DEPTH ) # Generate arrows visualization arrows = draw_arrows_fade(self.sensor.img, flow, depth) # Crop edges (15 pixels from each side) - only if image is large enough h, w = arrows.shape[:2] if h > 30 and w > 30: arrows_cropped = arrows[15:-15, 15:-15, :] else: arrows_cropped = arrows # Crop raw image edges if large enough h_raw, w_raw = self.sensor.img.shape[:2] if h_raw > 30 and w_raw > 30: raw_cropped = self.sensor.img[15:-15, 15:-15] else: raw_cropped = self.sensor.img # Convert arrows image BGR to RGB arrows_rgb = cv2.cvtColor(arrows_cropped, cv2.COLOR_BGR2RGB) h_arr, w_arr, ch_arr = arrows_rgb.shape bytes_per_line_arr = ch_arr * w_arr qt_image_arr = QImage(arrows_rgb.data, w_arr, h_arr, bytes_per_line_arr, QImage.Format_RGB888) # Scale to fixed 400x400 size (maintains aspect ratio) scaled_pixmap_arr = QPixmap.fromImage(qt_image_arr).scaled( 400, 400, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.arrows_image_label.setPixmap(scaled_pixmap_arr) # Record arrows frame if recording if self.recording and self.video_writer_arrows: # Convert back to BGR for video writer arrows_bgr = cv2.cvtColor(arrows_cropped, cv2.COLOR_RGB2BGR) self.video_writer_arrows.write(arrows_bgr) # Convert raw image BGR to RGB raw_rgb = cv2.cvtColor(raw_cropped, cv2.COLOR_BGR2RGB) h_raw_img, w_raw_img, ch_raw = raw_rgb.shape bytes_per_line_raw = ch_raw * w_raw_img qt_image_raw = QImage(raw_rgb.data, w_raw_img, h_raw_img, bytes_per_line_raw, QImage.Format_RGB888) # Scale to fixed 400x400 size (maintains aspect ratio) scaled_pixmap_raw = QPixmap.fromImage(qt_image_raw).scaled( 400, 400, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.raw_image_label.setPixmap(scaled_pixmap_raw) # Record raw frame if recording if self.recording and self.video_writer_raw: # Convert back to BGR for video writer raw_bgr = cv2.cvtColor(raw_cropped, cv2.COLOR_RGB2BGR) self.video_writer_raw.write(raw_bgr) # Update status with FPS info self.status_label.setText(f"Status: Running - FPS: {fps:.1f}, FNormal: {fn:.2f}, FShearX: {fx:.2f}, FShearY: {fy:.2f}") self.status_label.setStyleSheet(""" font-size: 13px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #27ae60; padding: 8px; background-color: #d5f4e6; border-radius: 5px; """) # Update plots with force data if self.start_time is not None: current_time = time.time() - self.start_time self.update_plots(fn, fx, fy, current_time) except Exception as e: # Sensor read failed self.stop_camera() self.status_label.setText(f"Status: Sensor Error - {str(e)}") self.status_label.setStyleSheet(""" font-size: 14px; font-weight: bold; font-family: 'Segoe UI', Arial, sans-serif; color: #f39c12; padding: 10px; background-color: #fef5e7; border-radius: 5px; """) def closeEvent(self, event): self.stop_camera() event.accept() def main(): app = QApplication(sys.argv) window = CameraGUI() window.show() sys.exit(app.exec_()) if __name__ == '__main__': main()