diff --git a/frheed/gui.py b/frheed/gui.py index 64fbe0c..0b41c75 100644 --- a/frheed/gui.py +++ b/frheed/gui.py @@ -25,7 +25,7 @@ def __init__(self): # Initialize window super().__init__(parent=None) - # Store reference so the window doesn't get garbage-collected + # Store reference so the window doesn't get garbage collected windows.append(self) # Create the main widget diff --git a/frheed/image_processing.py b/frheed/image_processing.py index e913786..476ba37 100644 --- a/frheed/image_processing.py +++ b/frheed/image_processing.py @@ -107,17 +107,45 @@ def column_to_image(column: Union[np.ndarray, list]) -> np.ndarray: # Convert to 2D array return column[::-1, np.newaxis] -def extend_image(image: np.ndarray, new_col: np.ndarray) -> np.ndarray: +def extend_image(img: np.ndarray, new_col: np.ndarray, pad: bool=True) -> np.ndarray: """ Append a new column onto the right side of an image. """ - # Make sure the new column is the same height as the image - # If it isn't, pad the edges with np.nan - h, w = image.shape[:2] - if new_col.size != h: - print(f"Image height {h} does not match column height {new_col.size}") - return column_to_image(new_col) - - return np.append(image, column_to_image(new_col), axis=1) - + # Convert new column to 2D array so it can be appended to image + col = column_to_image(new_col) + + # Get image and column dimensions + im_h, im_w = img.shape[:2] + col_h, col_w = new_col.size, 1 + + # If column and image are same height, append column to image + if im_h == col_h: + return np.append(img, col, axis=1) + + # If padding and column is taller than image, pad image w/ zeros + elif pad and col_h > im_h: + # Create "blank" image + new_img = np.zeros((col_h, im_w)) + + # Paste the old image vertically centered + dy = int((col_h - im_h) / 2) + new_img[dy:dy+im_h, :] = img + + return np.append(new_img, col, axis=1) + + # If padding and column is shorter than image, pad column w/ zeros + elif pad and col_h < im_h: + # Create "blank" column + _col = np.zeros((im_h, 1)) + + # Paste the column vertically centered + dy = int((im_h - col_h) / 2) + _col[dy:dy+col_h, :] = col + + return np.append(img, _col, axis=1) + + # If not padding and sizes don't match, create a new image + elif not pad and col_h != im_h: + return col + def get_valid_colormaps() -> List[str]: return mpl.pyplot.colormaps() diff --git a/frheed/settings.py b/frheed/settings.py index 0d9dcb8..e4a682d 100644 --- a/frheed/settings.py +++ b/frheed/settings.py @@ -3,3 +3,5 @@ # PyQt window styling APP_STYLE = "Fusion" # Options are 'windowsvista', 'Windows', or 'Fusion' +# Default colormap for widgets +DEFAULT_CMAP = "Spectral" \ No newline at end of file diff --git a/frheed/widgets/camera_widget.py b/frheed/widgets/camera_widget.py index 17dd36e..60e2873 100644 --- a/frheed/widgets/camera_widget.py +++ b/frheed/widgets/camera_widget.py @@ -47,28 +47,54 @@ ) -from frheed.widgets.common_widgets import SliderLabel, DoubleSlider, HLine +from frheed.widgets.common_widgets import ( + SliderLabel, + DoubleSlider, + HLine, + VLine, + + ) from frheed.widgets.canvas_widget import CanvasWidget +from frheed.widgets.cmap_widgets import ColormapMenu from frheed.cameras import CameraError from frheed.cameras.flir import FlirCamera from frheed.cameras.usb import UsbCamera from frheed.image_processing import ( - apply_cmap, to_grayscale, ndarray_to_qpixmap, extend_image, column_to_image, + apply_cmap, to_grayscale, + ndarray_to_qpixmap, + extend_image, + column_to_image, get_valid_colormaps, + ) from frheed.constants import DATA_DIR -from frheed.utils import load_settings, save_settings +from frheed.utils import load_settings, save_settings, get_logger +from frheed.settings import DEFAULT_CMAP +# Local settings MIN_ZOOM = 0.20 MAX_ZOOM = 2.0 MIN_W = 480 MIN_H = 348 MAX_W = 2560 MAX_H = 2560 -DEFAULT_CMAP = "Spectral" DEFAULT_INTERPOLATION = cv2.INTER_CUBIC +# Logger +logger = get_logger() + +# TODO list +TODO = """ +Create icons for each of the toolbar buttons +Add tooltip text to each item +Add vertical splitters between logical groups +Ability to switch user & set experiment +Save settings of last user & sample +Add image annotation +Ability to change colormaps +""".strip("\n").split("\n") + class VideoWidget(QWidget): """ Holds the camera frame and toolbar buttons """ @@ -78,6 +104,7 @@ class VideoWidget(QWidget): _min_w = 480 _min_h = 348 _max_w = MAX_W + _output_folder = DATA_DIR def __init__(self, camera: Union[FlirCamera, UsbCamera], parent = None): super().__init__(parent) @@ -134,14 +161,13 @@ def __init__(self, camera: Union[FlirCamera, UsbCamera], parent = None): self.zoom_label = SliderLabel(self.slider, name="Zoom", precision=2) # Create button for opening settings - self.settings_button = QPushButton() - self.settings_button.setText("Edit Settings") + self.settings_button = QPushButton("Edit Settings") self.settings_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) # Create button for opening output folder - self.folder_button = QPushButton() - # TODO: Finish functionality of this button + self.folder_button = QPushButton("Open Output Folder") + self.folder_button.setToolTip("Open the capture save folder") # Create settings widget self.make_camera_settings_widget() @@ -160,6 +186,14 @@ def __init__(self, camera: Union[FlirCamera, UsbCamera], parent = None): # Create status bar self.status_bar = CameraStatusBar(self) + # Add colormap list to Canvas widget menu + self.menu = self.display.canvas.menu + self.menu.addSeparator() + self.cmap_menu = ColormapMenu() + self.menu.addMenu(self.cmap_menu) + self.cmap_menu.select_cmap(DEFAULT_CMAP) + self.status_bar.update_colormap(DEFAULT_CMAP) + # Add widgets self.layout.addLayout(self.toolbar_layout, 0, 0, 1, 1) self.toolbar_layout.addWidget(self.capture_button, 0, 0, 1, 1) @@ -167,6 +201,7 @@ def __init__(self, camera: Union[FlirCamera, UsbCamera], parent = None): self.toolbar_layout.addWidget(self.zoom_label, 0, 2, 1, 1) self.toolbar_layout.addWidget(self.slider, 0, 3, 1, 1) self.toolbar_layout.addWidget(self.settings_button, 0, 4, 1, 1) + self.toolbar_layout.addWidget(self.folder_button, 0, 5, 1, 1) self.layout.addWidget(self.scroll, 1, 0, 1, 1) self.layout.addWidget(self.status_bar, 2, 0, 1, 1) @@ -176,6 +211,7 @@ def __init__(self, camera: Union[FlirCamera, UsbCamera], parent = None): self.settings_button.clicked.connect(self.edit_settings) self.slider.valueChanged.connect(self.display.force_resize) self.frame_changed.connect(self.status_bar.frame_changed) + self.folder_button.clicked.connect(self.open_output_folder) # Attributes to be assigned later self.frame: Union[np.ndarray, None] = None @@ -202,6 +238,7 @@ def __init__(self, camera: Union[FlirCamera, UsbCamera], parent = None): # Connect other signals self.frame_ready.connect(self.analysis_worker.analyze_frame) + self.cmap_menu.cmap_selected.connect(self.status_bar.update_colormap) # Variables to be used in properties self._workers = (self.camera_worker, self.analysis_worker) @@ -285,6 +322,14 @@ def show_frame(self, frame: np.ndarray) -> None: # Show the QPixmap self.display.label.setPixmap(qpix) + # Update the tooltip + tooltip = "\n".join([ + f"Width: {self.frame.shape[1]} px", + f"Height: {self.frame.shape[0]} px", + f"Colormap: {self.colormap}" + ]) + self.display.setToolTip(tooltip) + # Emit frame_changed signal self.frame_changed.emit() @@ -312,6 +357,11 @@ def edit_settings(self) -> None: def set_colormap(self, colormap: str) -> None: self.colormap = colormap + @pyqtSlot() + def open_output_folder(self) -> None: + """ Open the output folder where videos and images are saved. """ + os.startfile(self.output_folder) + @property def camera(self) -> Union[FlirCamera, UsbCamera]: return self._camera @@ -333,15 +383,39 @@ def workers(self) -> tuple: def app(self) -> QApplication: return QApplication.instance() + @property + def valid_colormaps(self) -> list: + """ Available colormaps to choose from. """ + return get_valid_colormaps() + + @property + def valid_cmaps(self) -> list: + """ Alias for valid_colormaps """ + return self.valid_colormaps + @property def colormap(self) -> str: - return self._colormap + try: + return self.cmap_menu.cmap + except Exception as ex: + # print(ex) + # logger.exception(ex) + # logger.info(f"Using default colormap {DEFAULT_CMAP}") + return DEFAULT_CMAP @colormap.setter def colormap(self, colormap: str) -> None: - if colormap in get_valid_colormaps(): - self._colormap = colormap - # TODO: Update label that shows current colormap + self.cmap_menu.select_cmap(colormap) + + @property + def output_folder(self) -> str: + return self._output_folder + + @output_folder.setter + def output_folder(self, folder: str) -> None: + if isinstance(folder, str): + os.mkdirs(folder, exist_ok=True) + self._output_folder = folder def make_camera_settings_widget(self) -> None: self.settings_widget = CameraSettingsWidget(self) @@ -921,14 +995,22 @@ def __init__(self, parent: VideoWidget = None): self.incomplete_frames_label = QLabel() self.incomplete_frames_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + # Add widget for displaying current colormap + self.colormap_label = QLabel() + self.colormap_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + # Add widget for displaying errors self.error_label = QLabel() self.error_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # Add widgets self.insertWidget(0, self.fps_label, 0) - self.insertWidget(1, self.incomplete_frames_label, 1) - self.insertWidget(2, self.error_label, 0) + self.insertWidget(1, VLine(), 0) + self.insertWidget(2, self.incomplete_frames_label, 0) + self.insertWidget(3, VLine(), 0) + self.insertWidget(4, self.colormap_label, 1) + self.insertWidget(5, VLine(), 0) + self.insertWidget(6, self.error_label, 0) # Display status @@ -965,6 +1047,10 @@ def frame_changed(self) -> None: f"Incomplete images: {self.incomplete_image_count}" ) self.error_label.setText(self.error_status) + + @pyqtSlot(str) + def update_colormap(self, cmap: str) -> None: + self.colormap_label.setText(f"Colormap: {cmap} ") class Worker(QObject): @@ -1127,8 +1213,8 @@ def reset_timer(self) -> None: def test(): from frheed.utils import test_widget - camera = FlirCamera(lock=False) - # camera = UsbCamera(lock=False) + # camera = FlirCamera(lock=False) + camera = UsbCamera(lock=False) widget, app = test_widget(VideoWidget, camera=camera, block=True) return widget, app diff --git a/frheed/widgets/cmap_widgets.py b/frheed/widgets/cmap_widgets.py new file mode 100644 index 0000000..62bad6f --- /dev/null +++ b/frheed/widgets/cmap_widgets.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +""" +Widgets for displaying colormaps. +""" + +from PyQt5.QtWidgets import ( + QWidget, + QGridLayout, + QAction, + QMenu, + QProxyStyle, + QStyle, + QActionGroup, + + ) +from PyQt5.QtGui import ( + QIcon, + + ) +from PyQt5.QtCore import pyqtSignal, pyqtSlot + +import numpy as np + +from frheed.image_processing import ( + get_valid_colormaps, + apply_cmap, + ndarray_to_qpixmap, + + ) +from frheed.utils import get_logger +from frheed.settings import DEFAULT_CMAP + + +# Local settings +CMAP_W = 256 +CMAP_H = 256 +MENU_TEXT = "Select color&map" + +# Logger +logger = get_logger("frheed") + +# Todo list +TODO = """ +Make clicking one of the actions actually change the colormap + +""".strip("\n").split("\n") + + +class ColormapSelection(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._parent = parent + + # Create layout + self.layout = QGridLayout() + self.layout.setContentsMargins(4, 4, 4, 4) + self.layout.setSpacing(4) + self.setLayout(self.layout) + + # + + +class ColormapMenu(QMenu): + cmap_selected = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + + # Set menu text + self.setTitle(MENU_TEXT) + + # Create exclusive action group + # TODO: Figure out how to implement this + # group = QActionGroup(self) + + # Add all colormap menu actions + cmaps = get_valid_colormaps() + [self.addAction(ColormapAction(c, self)) for c in cmaps] + + # Update style to resize icon + # TODO: Figure out how to indicate item is checked w/ custom style + # self.setStyle(ColormapProxyStyle()) + + # Make the menu scrollable + self.setStyleSheet(self.styleSheet() + + """ + QMenu { + menu-scrollable: 1; + } + """) + + # Set default colormap + self.select_cmap(DEFAULT_CMAP) + + # Connect signals + self.cmap_selected.connect(self.select_cmap) + + @property + def cmap(self) -> str: + """ Return the hovered colormap (if any) or the checkd colormap. """ + return self.hovered_cmap or self.selected_cmap + + @property + def hovered_cmap(self) -> str: + if self.activeAction() is not None: + return self.activeAction().text() + else: + return None + + @property + def selected_cmap(self) -> str: + action = next((a for a in self.checked_actions), None) + if action is not None: + return action.text() + else: + raise TypeError + + @property + def checked_actions(self) -> list: + return [a for a in self.actions() if a.isChecked()] + + def get_action(self, cmap: str) -> QAction: + return next((a for a in self.actions() if a.text() == cmap), None) + + @pyqtSlot(str) + def select_cmap(self, cmap: str) -> None: + action = self.get_action(cmap) + if action is not None: + # Uncheck any selected colormaps (should only be one) + [a.setChecked(False) for a in self.checked_actions] + + # Check the colormap + action.setChecked(True) + + else: + raise AttributeError(f"No action found for {cmap}") + + +class ColormapAction(QAction): + def __init__(self, colormap: str, parent=None): + super().__init__(colormap, parent) + self._parent = parent + self.setCheckable(True) + self.set_colormap(colormap) + + @property + def colormap(self) -> str: + return self.text() + + @colormap.setter + def colormap(self, colormap: str) -> None: + self.set_colormap(colormap) + + def set_colormap(self, colormap: str) -> None: + """ Set the current colormap. """ + # Make sure colormap is valid + if colormap not in get_valid_colormaps(): + ex = ValueError(f"Colormap {colormap} not found") + logger.exception(ex) + raise ex + + # Set text + self.setText(colormap) + + # Get colormap icon + # TODO : Finish + test + icon = ColormapIcon(colormap) + self.setIcon(icon) + + # Connect signal + if hasattr(self._parent, "cmap_selected"): + # Disconnect any existing signals + try: + self.triggered.disconnect() + except TypeError: + pass + + # Connect new signals + self.triggered.connect( + lambda: self._parent.cmap_selected.emit(colormap) + ) + + +class ColormapIcon(QIcon): + def __init__(self, colormap: str): + self.cmap_array = get_cmap_array(colormap, CMAP_H) + pixmap = ndarray_to_qpixmap(self.cmap_array) + super().__init__(pixmap) + + +class ColormapProxyStyle(QProxyStyle): + """ Make the QAction icon larger. """ + def pixelMetric(self, metric, option=None, widget=None): + if metric == QStyle.PM_SmallIconSize: + return 30 + else: + return QProxyStyle.pixelMetric(self, metric, option, widget) + + +def get_cmap_array(cmap: str, h: int) -> np.ndarray: + """ Get a colormap sample with a particular size. """ + # Create array + arr = np.ones((h, CMAP_W)) + + # Set values of array + for i in range(0, CMAP_W): + arr[:, i] = i + + # Apply colormap + cmapped = apply_cmap(arr, cmap) + return cmapped + + +if __name__ == "__main__": + class tests: + def __init__(self): + funcs = [a for a in dir(self) if a.startswith("test_")] + for func in funcs: + getattr(self, func)() + + @staticmethod + def test_ColormapSelection(): + from frheed.utils import test_widget + wid, app = test_widget(ColormapSelection, block=True) + + @staticmethod + def test_ColormapMenu(): + menu = ColormapMenu() + assert isinstance(menu, QMenu), "ColormapMenu failed" + + @staticmethod + def test_get_cmap_array(): + w, h = CMAP_W, CMAP_H + s = get_cmap_array("Spectral", CMAP_H) + assert (h, w, 3) == s.shape, f"shape = {s.shape}" + + tests()