diff --git a/modules/advancedSettingsWidget.py b/modules/advancedSettingsWidget.py index c11186352d905f3ca41d6f3abacdae3b0415ddba..8983d37ec11bfbb78686e1ff9734f9c237d79c75 100644 --- a/modules/advancedSettingsWidget.py +++ b/modules/advancedSettingsWidget.py @@ -2,22 +2,27 @@ from PyQt5.QtWidgets import ( QPushButton, QVBoxLayout, QWidget, QSlider, QLabel, QGridLayout, QSizePolicy ) -from PyQt5.QtGui import QPixmap, QImage +from PyQt5.QtGui import QPixmap, QImage, QShowEvent from PyQt5.QtCore import Qt import numpy as np +from mainWindow import MainWindow +from typing import Optional class AdvancedSettingsWidget(QWidget): """ Shows toggle rainbow, circle editor, line smoothing slider, contrast slider, plus two image previews (contrasted-blurred and cost). - The images should maintain aspect ratio upon resize. + The images maintain aspect ratio upon resize. """ - def __init__(self, main_window, parent=None): + def __init__(self, main_window: MainWindow, parent: Optional[QWidget] = None): + """ + Constructor. + """ super().__init__(parent) self._main_window = main_window - self._last_cb_pix = None # store QPixmap for contrasted-blurred - self._last_cost_pix = None # store QPixmap for cost + self._last_cb_pix = None # store QPixmap for contrasted-blurred image + self._last_cost_pix = None # store QPixmap for cost image main_layout = QVBoxLayout() self.setLayout(main_layout) @@ -25,17 +30,17 @@ class AdvancedSettingsWidget(QWidget): # A small grid for controls controls_layout = QGridLayout() - # 1) Rainbow toggle + # Rainbow toggle self.btn_toggle_rainbow = QPushButton("Toggle Rainbow") self.btn_toggle_rainbow.clicked.connect(self._on_toggle_rainbow) controls_layout.addWidget(self.btn_toggle_rainbow, 0, 0) - # 2) Circle editor + # Disk size calibration (Circle editor) self.btn_circle_editor = QPushButton("Calibrate Kernel Size") self.btn_circle_editor.clicked.connect(self._main_window.open_circle_editor) controls_layout.addWidget(self.btn_circle_editor, 0, 1) - # 3) Line smoothing slider + label + # Line smoothing slider + label self._lab_smoothing = QLabel("Line smoothing (3)") controls_layout.addWidget(self._lab_smoothing, 1, 0) self.line_smoothing_slider = QSlider(Qt.Horizontal) @@ -44,7 +49,7 @@ class AdvancedSettingsWidget(QWidget): self.line_smoothing_slider.valueChanged.connect(self._on_line_smoothing_slider) controls_layout.addWidget(self.line_smoothing_slider, 1, 1) - # 4) Contrast slider + label + # Contrast slider + label self._lab_contrast = QLabel("Contrast (0.01)") controls_layout.addWidget(self._lab_contrast, 2, 0) self.contrast_slider = QSlider(Qt.Horizontal) @@ -56,14 +61,12 @@ class AdvancedSettingsWidget(QWidget): main_layout.addLayout(controls_layout) - # We'll set a minimum width so that the main window expands - # rather than overlapping the image self.setMinimumWidth(350) - # Now a vertical layout for the two images, each with a label above it + # A vertical layout for the two images, each with a label above it images_layout = QVBoxLayout() - # 1) Contrasted-blurred label + image + # Contrasted-blurred label + image self.label_cb_title = QLabel("Contrasted Blurred Image") self.label_cb_title.setAlignment(Qt.AlignCenter) images_layout.addWidget(self.label_cb_title) @@ -73,7 +76,7 @@ class AdvancedSettingsWidget(QWidget): self.label_contrasted_blurred.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) images_layout.addWidget(self.label_contrasted_blurred) - # 2) Cost image label + image + # Cost image label + image self.label_cost_title = QLabel("Current COST IMAGE") self.label_cost_title.setAlignment(Qt.AlignCenter) images_layout.addWidget(self.label_cost_title) @@ -85,21 +88,24 @@ class AdvancedSettingsWidget(QWidget): main_layout.addLayout(images_layout) - def showEvent(self, event): + def showEvent(self, event: QShowEvent): """ When shown, ask parent to resize to accommodate. """ super().showEvent(event) if self.parentWidget(): self.parentWidget().adjustSize() - def resizeEvent(self, event): + def resizeEvent(self, event: QShowEvent): """ Keep the images at correct aspect ratio by re-scaling - our stored pixmaps to the new label sizes. + stored pixmaps to the new label sizes. """ super().resizeEvent(event) self._update_labels() def _update_labels(self): + """ + Re-scale stored pixmaps to the new label sizes. + """ if self._last_cb_pix is not None: scaled_cb = self._last_cb_pix.scaled( self.label_contrasted_blurred.size(), @@ -117,21 +123,29 @@ class AdvancedSettingsWidget(QWidget): self.label_cost_image.setPixmap(scaled_cost) def _on_toggle_rainbow(self): + """ + Called when the rainbow toggle button is clicked. + """ self._main_window.toggle_rainbow() - def _on_line_smoothing_slider(self, value): + def _on_line_smoothing_slider(self, value: int): + """ + Called when the line smoothing slider is moved. + """ self._lab_smoothing.setText(f"Line smoothing ({value})") self._main_window.image_view.set_savgol_window_length(value) - def _on_contrast_slider(self, value): + def _on_contrast_slider(self, value: int): + """ + Called when the contrast slider is moved. + """ clip_limit = value / 100.0 self._lab_contrast.setText(f"Contrast ({clip_limit:.2f})") self._main_window.update_contrast(clip_limit) - def update_displays(self, contrasted_img_np, cost_img_np): + def update_displays(self, contrasted_img_np: np.ndarray, cost_img_np: np.ndarray): """ - Called by main_window to refresh the two images in the advanced panel. - We'll store them as QPixmaps, then do the re-scale in _update_labels(). + Update the contrasted-blurred and cost images. """ cb_pix = self._np_array_to_qpixmap(contrasted_img_np) cost_pix = self._np_array_to_qpixmap(cost_img_np, normalize=True) @@ -140,7 +154,10 @@ class AdvancedSettingsWidget(QWidget): self._last_cost_pix = cost_pix self._update_labels() - def _np_array_to_qpixmap(self, arr, normalize=False): + def _np_array_to_qpixmap(self, arr: np.ndarray, normalize: bool = False) -> QPixmap: + """ + Convert a numpy array to a QPixmap. + """ if arr is None: return None arr_ = arr.copy() diff --git a/modules/circleEditorGraphicsView.py b/modules/circleEditorGraphicsView.py index d3a35173d58d3a4b0b09b558e0c187c12750e057..5134f532e5f25206c8302ade24c95d1f3acd3a2c 100644 --- a/modules/circleEditorGraphicsView.py +++ b/modules/circleEditorGraphicsView.py @@ -1,15 +1,24 @@ -from PyQt5.QtWidgets import QGraphicsView +from PyQt5.QtWidgets import QGraphicsView, QWidget from panZoomGraphicsView import PanZoomGraphicsView from PyQt5.QtCore import Qt +from PyQt5.QtGui import QMouseEvent, QWheelEvent from draggableCircleItem import DraggableCircleItem +from circleEditorWidget import CircleEditorWidget +from typing import Optional -# A specialized PanZoomGraphicsView for the circle editor +# A specialized PanZoomGraphicsView for the circle editor (disk size calibration) class CircleEditorGraphicsView(PanZoomGraphicsView): - def __init__(self, circle_editor_widget, parent=None): + def __init__(self, circle_editor_widget: CircleEditorWidget, parent: Optional[QWidget] = None): + """ + Constructor. + """ super().__init__(parent) self._circle_editor_widget = circle_editor_widget - def mousePressEvent(self, event): + def mousePressEvent(self, event: QMouseEvent): + """ + If the user clicks on the circle, we let the circle item handle the event. + """ if event.button() == Qt.LeftButton: # Check if user clicked on the circle item clicked_item = self.itemAt(event.pos()) @@ -24,10 +33,9 @@ class CircleEditorGraphicsView(PanZoomGraphicsView): return QGraphicsView.mousePressEvent(self, event) super().mousePressEvent(event) - def wheelEvent(self, event): + def wheelEvent(self, event: QWheelEvent): """ - If the mouse is hovering over the circle, we adjust the circle's radius - instead of zooming the image. + If the user scrolls the mouse wheel over the circle, we change the circle """ pos_in_widget = event.pos() item_under = self.itemAt(pos_in_widget) @@ -46,4 +54,4 @@ class CircleEditorGraphicsView(PanZoomGraphicsView): event.accept() return - super().wheelEvent(event) + super().wheelEvent(event) \ No newline at end of file diff --git a/modules/circleEditorWidget.py b/modules/circleEditorWidget.py index aef3101258b59c15ecb1a9475204800be90ad02a..b8230e8ef1e5350888cf1124ebeb864ccfafeb6f 100644 --- a/modules/circleEditorWidget.py +++ b/modules/circleEditorWidget.py @@ -1,14 +1,21 @@ from PyQt5.QtWidgets import ( - QGraphicsScene, QGraphicsPixmapItem, QPushButton, + QGraphicsScene, QGraphicsPixmapItem, QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QSlider, QLabel ) -from PyQt5.QtGui import QFont +from PyQt5.QtGui import QFont, QPixmap from PyQt5.QtCore import Qt, QRectF, QSize from circleEditorGraphicsView import CircleEditorGraphicsView from draggableCircleItem import DraggableCircleItem +from typing import Optional, Callable class CircleEditorWidget(QWidget): - def __init__(self, pixmap, init_radius=20, done_callback=None, parent=None): + """ + A widget for the user to calibrate the disk size (kernel size) for the ridge detection. + """ + def __init__(self, pixmap: QPixmap, init_radius: int = 20, done_callback: Optional[Callable[[], None]] = None, parent: Optional[QWidget] = None): + """ + Constructor. + """ super().__init__(parent) self._pixmap = pixmap self._done_callback = done_callback @@ -17,9 +24,7 @@ class CircleEditorWidget(QWidget): layout = QVBoxLayout(self) self.setLayout(layout) - # - # 1) ADD A CENTERED LABEL ABOVE THE IMAGE, WITH BIGGER FONT - # + # Add centered label above image label_instructions = QLabel("Scale the dot to be of the size of your ridge") label_instructions.setAlignment(Qt.AlignCenter) big_font = QFont("Arial", 20) @@ -27,15 +32,12 @@ class CircleEditorWidget(QWidget): label_instructions.setFont(big_font) layout.addWidget(label_instructions) - # - # 2) THE SPECIALIZED GRAPHICS VIEW THAT SHOWS THE IMAGE - # + # Show the image self._graphics_view = CircleEditorGraphicsView(circle_editor_widget=self) self._scene = QGraphicsScene(self) self._graphics_view.setScene(self._scene) layout.addWidget(self._graphics_view) - # Show the image self._image_item = QGraphicsPixmapItem(self._pixmap) self._scene.addItem(self._image_item) @@ -49,9 +51,7 @@ class CircleEditorWidget(QWidget): self._graphics_view.setSceneRect(QRectF(self._pixmap.rect())) self._graphics_view.fitInView(self._image_item, Qt.KeepAspectRatio) - # - # 3) CONTROLS BELOW - # + ### Controls below bottom_layout = QHBoxLayout() layout.addLayout(bottom_layout) @@ -64,7 +64,7 @@ class CircleEditorWidget(QWidget): self._slider.setValue(self._init_radius) bottom_layout.addWidget(self._slider) - # done button + # Done button self._btn_done = QPushButton("Done") bottom_layout.addWidget(self._btn_done) @@ -72,16 +72,25 @@ class CircleEditorWidget(QWidget): self._slider.valueChanged.connect(self._on_slider_changed) self._btn_done.clicked.connect(self._on_done_clicked) - def _on_slider_changed(self, value): + def _on_slider_changed(self, value: int): + """ + Handle slider value changes. + """ self._circle_item.set_radius(value) self._lbl_size.setText(f"size ({value})") def _on_done_clicked(self): + """ + Handle the user clicking the "Done" button. + """ final_radius = self._circle_item.radius() if self._done_callback is not None: self._done_callback(final_radius) - def update_slider_value(self, new_radius): + def update_slider_value(self, new_radius: int): + """ + Update the slider value. + """ self._slider.blockSignals(True) self._slider.setValue(new_radius) self._slider.blockSignals(False) diff --git a/modules/circle_edge_kernel.py b/modules/circle_edge_kernel.py index 4270fba0203817b67eaf879f011078a981e94ca6..f8587459ca36433198857e31488018368068747f 100644 --- a/modules/circle_edge_kernel.py +++ b/modules/circle_edge_kernel.py @@ -1,21 +1,17 @@ import numpy as np +from typing import Optional -def circle_edge_kernel(k_size=5, radius=None): +def circle_edge_kernel(k_size: int = 5, radius: Optional[int] = None) -> np.ndarray: """ Create a k_size x k_size array whose values increase linearly from 0 at the center to 1 at the circle boundary (radius). - Parameters - ---------- - k_size : int - The size (width and height) of the kernel array. - radius : float, optional - The circle's radius. By default, set to (k_size-1)/2. + Args: + k_size: The size (width and height) of the kernel array. + radius: The circle's radius. By default, set to (k_size-1)/2. - Returns - ------- - kernel : 2D numpy array of shape (k_size, k_size) - The circle-edge-weighted kernel. + Returns: + kernel: The circle-edge-weighted kernel. """ if radius is None: # By default, let the radius be half the kernel size diff --git a/modules/compute_disk_size.py b/modules/compute_disk_size.py index c4cb24d66a0d4c84587d84eff3bc342606d5e74f..84f9b6d6d2938c13ddd3d728fc3cf53af48bc99c 100644 --- a/modules/compute_disk_size.py +++ b/modules/compute_disk_size.py @@ -1,4 +1,14 @@ import numpy as np -def compute_disk_size(user_radius, upscale_factor=1.2): +def compute_disk_size(user_radius: int, upscale_factor: float = 1.2) -> int: + """ + Compute the size of the disk to be used in the cost image computation. + + Args: + user_radius: The radius in pixels. + upscale_factor: The factor by which the disk size will be upscaled. + + Returns: + The size of the disk. + """ return int(np.ceil(upscale_factor * 2 * user_radius + 1) // 2 * 2 + 1) \ No newline at end of file diff --git a/modules/downscale.py b/modules/downscale.py index 2387e26bfd74f79861cfbcc3c58336f22554be65..ca95b390b444b9ffe271b3422f93045ec3ea7fd7 100644 --- a/modules/downscale.py +++ b/modules/downscale.py @@ -1,10 +1,19 @@ import cv2 +import numpy as np +from typing import Tuple # Currently not implemented -def downscale(img, points, scale_percent): +def downscale(img: np.ndarray, points: Tuple[Tuple[int, int], Tuple[int, int]], scale_percent: int) -> Tuple[np.ndarray, Tuple[Tuple[int, int], Tuple[int, int]]]: """ - Downsample `img` to `scale_percent` size and scale the given points accordingly. - Returns (downsampled_img, (scaled_seed, scaled_target)). + Downscale an image and its corresponding points. + + Args: + img: The image. + points: The points to downscale. + scale_percent: The percentage to downscale to. E.g. scale_percent = 60 results in a new image 60% of the original image's size. + + Returns: + The downsampled image and the downsampled points. """ if scale_percent == 100: return img, (tuple(points[0]), tuple(points[1])) diff --git a/modules/draggableCircleItem.py b/modules/draggableCircleItem.py index 8d0e6fa15fe823b36a54bd89a96b1ca4fe0b49b2..884dc41b0fa58eccff8cde148f13e42dab892b1e 100644 --- a/modules/draggableCircleItem.py +++ b/modules/draggableCircleItem.py @@ -1,9 +1,16 @@ -from PyQt5.QtWidgets import QGraphicsEllipseItem -from PyQt5.QtGui import QPen, QBrush +from PyQt5.QtWidgets import QGraphicsEllipseItem, QGraphicsItem +from PyQt5.QtGui import QPen, QBrush, QColor from PyQt5.QtCore import Qt +from typing import Optional class DraggableCircleItem(QGraphicsEllipseItem): - def __init__(self, x, y, radius=20, color=Qt.red, parent=None): + """ + A QGraphicsEllipseItem that can be dragged around. + """ + def __init__(self, x: float, y: float, radius: float = 20, color: QColor = Qt.red, parent: Optional[QGraphicsItem] = None): + """ + Constructor. + """ super().__init__(0, 0, 2*radius, 2*radius, parent) self._r = radius @@ -20,7 +27,10 @@ class DraggableCircleItem(QGraphicsEllipseItem): # Position so that (x, y) is the center self.setPos(x - radius, y - radius) - def set_radius(self, r): + def set_radius(self, r: float): + """ + Set the radius of the circle + """ old_center = self.sceneBoundingRect().center() self._r = r self.setRect(0, 0, 2*r, 2*r) @@ -30,4 +40,7 @@ class DraggableCircleItem(QGraphicsEllipseItem): self.moveBy(diff_x, diff_y) def radius(self): + """ + Get the radius of the circle + """ return self._r \ No newline at end of file diff --git a/modules/imageGraphicsView.py b/modules/imageGraphicsView.py index 387f74191613a4fff6aed65374157dab17d6599c..1855d717866a601eafe886b0e311da772f9322da 100644 --- a/modules/imageGraphicsView.py +++ b/modules/imageGraphicsView.py @@ -2,7 +2,7 @@ import math from scipy.signal import savgol_filter from PyQt5.QtWidgets import QGraphicsScene, QGraphicsPixmapItem from PyQt5.QtGui import QPixmap, QColor -from PyQt5.QtCore import Qt, QRectF +from PyQt5.QtCore import Qt, QRectF, QPoint import numpy as np from panZoomGraphicsView import PanZoomGraphicsView from labeledPointItem import LabeledPointItem @@ -16,30 +16,6 @@ class ImageGraphicsView(PanZoomGraphicsView): This class extends PanZoomGraphicsView to provide additional functionality for loading images, adding labeled anchor points, and computing paths between points based on a cost image. - - Attributes: - scene (QGraphicsScene): The graphics scene for displaying items. - image_item (QGraphicsPixmapItem): The item for displaying the loaded image. - anchor_points (list): List of tuples representing anchor points (x, y). - point_items (list): List of LabeledPointItem objects for anchor points. - full_path_points (list): List of QGraphicsEllipseItems representing the path. - _full_path_xy (list): List of coordinates for the entire path. - dot_radius (int): Radius of the anchor points. - path_radius (int): Radius of the path points. - radius_cost_image (int): Radius for lowering cost in the cost image. - _img_w (int): Width of the loaded image. - _img_h (int): Height of the loaded image. - _mouse_pressed (bool): Indicates if the mouse is pressed. - _press_view_pos (QPoint): Position of the mouse press event. - _drag_threshold (int): Threshold for detecting drag events. - _was_dragging (bool): Indicates if a drag event occurred. - _dragging_idx (int): Index of the point being dragged. - _drag_offset (tuple): Offset for dragging points. - _drag_counter (int): Counter for drag events. - cost_image_original (np.ndarray): Original cost image. - cost_image (np.ndarray): Current cost image. - _rainbow_enabled (bool): Indicates if rainbow coloring is enabled. - _savgol_window_length (int): Window length for Savitzky-Golay smoothing. """ def __init__(self, parent=None): @@ -51,10 +27,10 @@ class ImageGraphicsView(PanZoomGraphicsView): self.image_item = QGraphicsPixmapItem() self.scene.addItem(self.image_item) - self.anchor_points = [] - self.point_items = [] - self.full_path_points = [] - self._full_path_xy = [] + self.anchor_points = [] + self.point_items = [] + self.full_path_points = [] + self._full_path_xy = [] self.dot_radius = 4 self.path_radius = 1 @@ -367,6 +343,7 @@ class ImageGraphicsView(PanZoomGraphicsView): # MOUSE EVENTS # -------------------------------------------------------------------- def mouse_press_event(self, event): + """Handle mouse press events for dragging a point or adding a point.""" if event.button() == Qt.LeftButton: self._mouse_pressed = True self._was_dragging = False @@ -416,6 +393,7 @@ class ImageGraphicsView(PanZoomGraphicsView): super().mouse_move_event(event) def mouse_release_event(self, event): + """Handle mouse release events for dragging a point or adding a point.""" super().mouse_release_event(event) if event.button() == Qt.LeftButton and self._mouse_pressed: self._mouse_pressed = False @@ -439,7 +417,8 @@ class ImageGraphicsView(PanZoomGraphicsView): self._was_dragging = False - def _remove_point_by_click(self, view_pos: "QPoint"): + def _remove_point_by_click(self, view_pos: QPoint): + """Remove a point by clicking on it.""" idx = self._find_item_near(view_pos, threshold=10) if idx is None: return @@ -454,7 +433,8 @@ class ImageGraphicsView(PanZoomGraphicsView): self._apply_all_guide_points_to_cost() self._rebuild_full_path() - def _find_item_near(self, view_pos: "QPoint", threshold=10): + def _find_item_near(self, view_pos: QPoint, threshold=10): + """Find the index of an item near a given position.""" scene_pos = self.mapToScene(view_pos) x_click, y_click = scene_pos.x(), scene_pos.y() @@ -476,7 +456,7 @@ class ImageGraphicsView(PanZoomGraphicsView): return max(mn, min(val, mx)) def _clear_all_points(self): - + """Clear all anchor points and guide points.""" for it in self.point_items: self.scene.removeItem(it) self.point_items.clear() diff --git a/modules/labeledPointItem.py b/modules/labeledPointItem.py index 861a95fb402306c1696c85be33903595c1235b9b..f96bb8ff7875daead14c7ada43dc899e6ebad4b3 100644 --- a/modules/labeledPointItem.py +++ b/modules/labeledPointItem.py @@ -11,16 +11,6 @@ class LabeledPointItem(QGraphicsEllipseItem): This class creates a circular point. The point can be customized with different colors, sizes, and labels, and can be marked as removable. - - Attributes: - x (float): The x-coordinate of the point. - y (float): The y-coordinate of the point. - label (str): The label text for the point. - radius (int): The radius of the point. - color (QColor): The color of the point. - removable (bool): Indicates if the point can be removed. - z_value (float): The z-value of the point for stacking order. - parent (QGraphicsItem): The parent QGraphicsItem, if any. """ def __init__(self, x: float, y: float, label: str ="", radius:int =4, diff --git a/modules/panZoomGraphicsView.py b/modules/panZoomGraphicsView.py index ed9947fb58a554164c297602241df7824049fe68..dc86430cf0f11138094e72876f8ec4aed95eacc0 100644 --- a/modules/panZoomGraphicsView.py +++ b/modules/panZoomGraphicsView.py @@ -4,16 +4,6 @@ from PyQt5.QtCore import Qt class PanZoomGraphicsView(QGraphicsView): """ A QGraphicsView subclass that supports panning and zooming with the mouse. - - Attributes: - _panning (bool): Indicates whether panning is currently active. - _pan_start (QPoint): The starting point of the panning action. - - Methods: - wheel_event(event): Zooms in or out based on the mouse wheel movement. - mouse_press_event(event): Starts panning if the left mouse button is pressed. - mouse_move_event(event): Translates the scene if panning is active. - mouse_release_event(event): Ends panning when the left mouse button is released. """ def __init__(self, parent=None): super().__init__(parent)