diff --git a/__pycache__/live_wire.cpython-312.pyc b/__pycache__/live_wire.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8404b8c9798df68288011af419140c3a717972bb Binary files /dev/null and b/__pycache__/live_wire.cpython-312.pyc differ diff --git a/data/AgamodonSlice.png b/data/AgamodonSlice.png new file mode 100644 index 0000000000000000000000000000000000000000..b8d0db7cc7dc30c9c26d15ad9e2f43466e273097 Binary files /dev/null and b/data/AgamodonSlice.png differ diff --git a/data/AngustifronsSlice35.png b/data/AngustifronsSlice35.png new file mode 100644 index 0000000000000000000000000000000000000000..17cadf804cd3af6d626674f06419108cf792f7f1 Binary files /dev/null and b/data/AngustifronsSlice35.png differ diff --git a/data/BipesSlice4.png b/data/BipesSlice4.png new file mode 100644 index 0000000000000000000000000000000000000000..6d17dc3048990d94b3f195b693a1b112e7eb3a5e Binary files /dev/null and b/data/BipesSlice4.png differ diff --git a/data/BipesSlice4NoCropping.png b/data/BipesSlice4NoCropping.png new file mode 100644 index 0000000000000000000000000000000000000000..42c1bf536eecb3458f1221869415059a4a6e9bc3 Binary files /dev/null and b/data/BipesSlice4NoCropping.png differ diff --git a/modules/find_path.py b/modules/find_path.py index 426563ddd4843ab49ab9c08f8424cbd21508d864..4ae9c7fa5afc77a9a29cf214956f55fbfd225fc7 100644 --- a/modules/find_path.py +++ b/modules/find_path.py @@ -1,17 +1,30 @@ from skimage.graph import route_through_array -def find_path(cost_image, points): +def find_path(cost_image: "numpy.ndarray", points: list) -> list: + """ + Find the optimal path through a cost image between two points. + Parameters: + cost_image (numpy.ndarray): A 2D array representing the cost of traversing each pixel. + points (list): A list containing two tuples, each representing the + (row, column) coordinates of the seed and target points. + + Returns: + list: A list of (row, column) tuples representing the path from the seed to the target point. + + Raises: + ValueError: If the points list does not contain exactly two points. + """ if len(points) != 2: raise ValueError("Points should be a list of 2 points: seed and target.") seed_rc, target_rc = points path_rc, cost = route_through_array( - cost_image, - start=seed_rc, - end=target_rc, + cost_image, + start=seed_rc, + end=target_rc, fully_connected=True ) - return path_rc \ No newline at end of file + return path_rc diff --git a/modules/imageGraphicsView.py b/modules/imageGraphicsView.py index c2c984755352faf36f90645adf1a771e9d306d6c..1855d717866a601eafe886b0e311da772f9322da 100644 --- a/modules/imageGraphicsView.py +++ b/modules/imageGraphicsView.py @@ -1,8 +1,8 @@ +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 -import math +from PyQt5.QtCore import Qt, QRectF, QPoint import numpy as np from panZoomGraphicsView import PanZoomGraphicsView from labeledPointItem import LabeledPointItem @@ -10,6 +10,14 @@ from find_path import find_path class ImageGraphicsView(PanZoomGraphicsView): + """ + A custom QGraphicsView for displaying and interacting with an image. + + 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. + """ + def __init__(self, parent=None): super().__init__(parent) self.scene = QGraphicsScene(self) @@ -19,10 +27,10 @@ class ImageGraphicsView(PanZoomGraphicsView): self.image_item = QGraphicsPixmapItem() self.scene.addItem(self.image_item) - self.anchor_points = [] # List[(x, y)] - self.point_items = [] # LabeledPointItem - self.full_path_points = [] # QGraphicsEllipseItems for path - self._full_path_xy = [] # entire path coords (smoothed) + self.anchor_points = [] + self.point_items = [] + self.full_path_points = [] + self._full_path_xy = [] self.dot_radius = 4 self.path_radius = 1 @@ -49,16 +57,18 @@ class ImageGraphicsView(PanZoomGraphicsView): self._savgol_window_length = 7 def set_rainbow_enabled(self, enabled: bool): + """Enable rainbow coloring of the path.""" self._rainbow_enabled = enabled self._rebuild_full_path() def toggle_rainbow(self): + """Toggle rainbow coloring of the path.""" self._rainbow_enabled = not self._rainbow_enabled self._rebuild_full_path() def set_savgol_window_length(self, wlen: int): - if wlen < 3: - wlen = 3 + """Set the window length for Savitzky-Golay smoothing.""" + wlen = max(3, wlen) if wlen % 2 == 0: wlen += 1 self._savgol_window_length = wlen @@ -68,7 +78,8 @@ class ImageGraphicsView(PanZoomGraphicsView): # -------------------------------------------------------------------- # LOADING # -------------------------------------------------------------------- - def load_image(self, path): + def load_image(self, path: str): + """Load an image from a file path.""" pixmap = QPixmap(path) if not pixmap.isNull(): self.image_item.setPixmap(pixmap) @@ -90,7 +101,9 @@ class ImageGraphicsView(PanZoomGraphicsView): # -------------------------------------------------------------------- # ANCHOR POINTS # -------------------------------------------------------------------- - def _insert_anchor_point(self, idx, x, y, label="", removable=True, z_val=0, radius=4): + def _insert_anchor_point(self, idx, x: float, y: float, label="", removable=True, + z_val=0, radius=4): + """Insert an anchor point at a specific index.""" x_clamped = self._clamp(x, radius, self._img_w - radius) y_clamped = self._clamp(y, radius, self._img_h - radius) @@ -110,7 +123,7 @@ class ImageGraphicsView(PanZoomGraphicsView): self.scene.addItem(item) def _add_guide_point(self, x, y): - # Ensure we clamp properly + """Add a guide point to the path.""" x_clamped = self._clamp(x, self.dot_radius, self._img_w - self.dot_radius) y_clamped = self._clamp(y, self.dot_radius, self._img_h - self.dot_radius) @@ -125,7 +138,8 @@ class ImageGraphicsView(PanZoomGraphicsView): self._apply_all_guide_points_to_cost() self._rebuild_full_path() - def _insert_anchor_between_subpath(self, x_new, y_new): + def _insert_anchor_between_subpath(self, x_new: float, y_new: float ): + """Insert an anchor point between existing anchor points.""" # If somehow we have no path yet if not self._full_path_xy: self._insert_anchor_point(-1, x_new, y_new) @@ -147,9 +161,11 @@ class ImageGraphicsView(PanZoomGraphicsView): return def approx_equal(xa, ya, xb, yb, tol=1e-3): + """Check if two points are approximately equal.""" return (abs(xa - xb) < tol) and (abs(ya - yb) < tol) def is_anchor(coord): + """Check if a point is an anchor point.""" cx, cy = coord for (ax, ay) in self.anchor_points: if approx_equal(ax, ay, cx, cy): @@ -158,23 +174,23 @@ class ImageGraphicsView(PanZoomGraphicsView): # Walk left left_anchor_pt = None - iL = best_idx - while iL >= 0: - px, py = self._full_path_xy[iL] + i_l = best_idx + while i_l >= 0: + px, py = self._full_path_xy[i_l] if is_anchor((px, py)): left_anchor_pt = (px, py) break - iL -= 1 + i_l -= 1 # Walk right right_anchor_pt = None - iR = best_idx - while iR < len(self._full_path_xy): - px, py = self._full_path_xy[iR] + i_r = best_idx + while i_r < len(self._full_path_xy): + px, py = self._full_path_xy[i_r] if is_anchor((px, py)): right_anchor_pt = (px, py) break - iR += 1 + i_r += 1 # If we can't find distinct anchors on left & right, # just insert before E. @@ -221,7 +237,8 @@ class ImageGraphicsView(PanZoomGraphicsView): if self.point_items[i].is_removable(): self._lower_cost_in_circle(ax, ay, self.radius_cost_image) - def _lower_cost_in_circle(self, x_f, y_f, radius): + def _lower_cost_in_circle(self, x_f: float, y_f: float, radius: int): + """Lower the cost in a circle centered at (x_f, y_f).""" if self.cost_image is None: return h, w = self.cost_image.shape @@ -244,6 +261,7 @@ class ImageGraphicsView(PanZoomGraphicsView): # PATH BUILDING # -------------------------------------------------------------------- def _rebuild_full_path(self): + """Rebuild the full path based on the anchor points.""" for item in self.full_path_points: self.scene.removeItem(item) self.full_path_points.clear() @@ -295,7 +313,8 @@ class ImageGraphicsView(PanZoomGraphicsView): if p_item._text_item: p_item.setZValue(100) - def _compute_subpath_xy(self, xA, yA, xB, yB): + def _compute_subpath_xy(self, xA: float, yA: float, xB: float, yB: float): + """Compute a subpath between two points.""" if self.cost_image is None: return [] h, w = self.cost_image.shape @@ -313,7 +332,8 @@ class ImageGraphicsView(PanZoomGraphicsView): # Convert from (row, col) to (x, y) return [(c, r) for (r, c) in path_rc] - def _rainbow_color(self, fraction): + def _rainbow_color(self, fraction: float): + """Get a rainbow color.""" hue = int(300 * fraction) saturation = 255 value = 255 @@ -322,7 +342,8 @@ class ImageGraphicsView(PanZoomGraphicsView): # -------------------------------------------------------------------- # MOUSE EVENTS # -------------------------------------------------------------------- - def mousePressEvent(self, event): + 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 @@ -341,9 +362,10 @@ class ImageGraphicsView(PanZoomGraphicsView): elif event.button() == Qt.RightButton: self._remove_point_by_click(event.pos()) - super().mousePressEvent(event) + super().mouse_press_event(event) - def mouseMoveEvent(self, event): + def mouse_move_event(self, event): + """Handle mouse move events for dragging a point or dragging the view""" if self._dragging_idx is not None: scene_pos = self.mapToScene(event.pos()) x_new = scene_pos.x() - self._drag_offset[0] @@ -368,10 +390,11 @@ class ImageGraphicsView(PanZoomGraphicsView): if dist > self._drag_threshold: self._was_dragging = True - super().mouseMoveEvent(event) + super().mouse_move_event(event) - def mouseReleaseEvent(self, event): - super().mouseReleaseEvent(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 self.setCursor(Qt.ArrowCursor) @@ -394,7 +417,8 @@ class ImageGraphicsView(PanZoomGraphicsView): self._was_dragging = False - def _remove_point_by_click(self, view_pos): + 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 @@ -409,7 +433,8 @@ class ImageGraphicsView(PanZoomGraphicsView): self._apply_all_guide_points_to_cost() self._rebuild_full_path() - def _find_item_near(self, view_pos, 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() @@ -431,6 +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() @@ -442,6 +468,7 @@ class ImageGraphicsView(PanZoomGraphicsView): self._full_path_xy.clear() def clear_guide_points(self): + """Clear all guide points.""" i = 0 while i < len(self.anchor_points): if self.point_items[i].is_removable(): @@ -461,4 +488,5 @@ class ImageGraphicsView(PanZoomGraphicsView): self._rebuild_full_path() def get_full_path_xy(self): - return self._full_path_xy \ No newline at end of file + """Returns the entire path as a list of (x, y) coordinates.""" + return self._full_path_xy diff --git a/modules/labeledPointItem.py b/modules/labeledPointItem.py index ff9e263fd112579b1cb5e3467acb3b684effbcbf..f96bb8ff7875daead14c7ada43dc899e6ebad4b3 100644 --- a/modules/labeledPointItem.py +++ b/modules/labeledPointItem.py @@ -1,10 +1,20 @@ +import math from PyQt5.QtWidgets import QGraphicsEllipseItem, QGraphicsTextItem from PyQt5.QtGui import QPen, QBrush, QColor, QFont from PyQt5.QtCore import Qt -import math + class LabeledPointItem(QGraphicsEllipseItem): - def __init__(self, x, y, label="", radius=4, color=Qt.red, removable=True, z_value=0, parent=None): + """ + A QGraphicsEllipseItem subclass that represents a labeled point in a 2D space. + + This class creates a circular point. + The point can be customized with different colors, sizes, and labels, and can + be marked as removable. + """ + + def __init__(self, x: float, y: float, label: str ="", radius:int =4, + color=Qt.red, removable=True, z_value=0, parent=None): super().__init__(0, 0, 2*radius, 2*radius, parent) self._x = x self._y = y @@ -30,6 +40,7 @@ class LabeledPointItem(QGraphicsEllipseItem): self.set_pos(x, y) def _scale_text_to_fit(self): + """Scales the text to fit inside the circle.""" if not self._text_item: return self._text_item.setScale(1.0) @@ -43,6 +54,7 @@ class LabeledPointItem(QGraphicsEllipseItem): self._center_label() def _center_label(self): + """Centers the text inside the circle.""" if not self._text_item: return ellipse_w = 2 * self._r @@ -62,10 +74,14 @@ class LabeledPointItem(QGraphicsEllipseItem): self.setPos(x - self._r, y - self._r) def get_pos(self): + """Returns the (x, y) coordinates of the center of the circle.""" return (self._x, self._y) def distance_to(self, x_other, y_other): + """Returns the Euclidean distance from the center + of the circle to another circle.""" return math.sqrt((self._x - x_other)**2 + (self._y - y_other)**2) def is_removable(self): - return self._removable \ No newline at end of file + """Returns True if the point is removable, False otherwise.""" + return self._removable diff --git a/modules/load_image.py b/modules/load_image.py index 5bded2a55ebbc1a93d8f42d587920948d779965f..d2fa7eadf42d6098f0cc14bee50a4f015d9d0240 100644 --- a/modules/load_image.py +++ b/modules/load_image.py @@ -1,4 +1,13 @@ import cv2 -def load_image(path): +def load_image(path: str) -> "numpy.ndarray": + """ + Loads an image from the specified file path in grayscale mode. + + Args: + path (str): The file path to the image. + + Returns: + numpy.ndarray: The loaded grayscale image. + """ return cv2.imread(path, cv2.IMREAD_GRAYSCALE) \ No newline at end of file diff --git a/modules/panZoomGraphicsView.py b/modules/panZoomGraphicsView.py index 85d5e1ac297ad469c8ff6416b9b6fc869df8aa5d..dc86430cf0f11138094e72876f8ec4aed95eacc0 100644 --- a/modules/panZoomGraphicsView.py +++ b/modules/panZoomGraphicsView.py @@ -1,8 +1,10 @@ from PyQt5.QtWidgets import QGraphicsView, QSizePolicy from PyQt5.QtCore import Qt -# A pan & zoom QGraphicsView class PanZoomGraphicsView(QGraphicsView): + """ + A QGraphicsView subclass that supports panning and zooming with the mouse. + """ def __init__(self, parent=None): super().__init__(parent) self.setDragMode(QGraphicsView.NoDrag) # We'll handle panning manually @@ -10,10 +12,10 @@ class PanZoomGraphicsView(QGraphicsView): self._panning = False self._pan_start = None - # Let it expand in layouts + # Expands layout self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - def wheelEvent(self, event): + def wheel_event(self, event): """ Zoom in/out with mouse wheel. """ zoom_in_factor = 1.25 zoom_out_factor = 1 / zoom_in_factor @@ -23,25 +25,25 @@ class PanZoomGraphicsView(QGraphicsView): self.scale(zoom_out_factor, zoom_out_factor) event.accept() - def mousePressEvent(self, event): + def mouse_press_event(self, event): """ If left button: Start panning (unless overridden). """ if event.button() == Qt.LeftButton: self._panning = True self._pan_start = event.pos() self.setCursor(Qt.ClosedHandCursor) - super().mousePressEvent(event) + super().mouse_press_event(event) - def mouseMoveEvent(self, event): + def mouse_move_event(self, event): """ If panning, translate the scene. """ if self._panning and self._pan_start is not None: delta = event.pos() - self._pan_start self._pan_start = event.pos() self.translate(delta.x(), delta.y()) - super().mouseMoveEvent(event) + super().mouse_move_event(event) - def mouseReleaseEvent(self, event): + def mouse_release_event(self, event): """ End panning. """ if event.button() == Qt.LeftButton: self._panning = False self.setCursor(Qt.ArrowCursor) - super().mouseReleaseEvent(event) + super().mouse_release_event(event) diff --git a/modules/preprocess_image.py b/modules/preprocess_image.py index 403323faacb81a18905b08eb53a0a80bae6614e4..351988f1b00389c592d35e1913a0ca1221cb7224 100644 --- a/modules/preprocess_image.py +++ b/modules/preprocess_image.py @@ -1,11 +1,23 @@ from skimage.filters import gaussian from skimage import exposure -def preprocess_image(image, sigma=3, clip_limit=0.01): - # Apply histogram equalization - image_contrasted = exposure.equalize_adapthist(image, clip_limit=clip_limit) - # Apply smoothing +def preprocess_image(image: "np.ndarray", sigma: int = 3, clip_limit: float = 0.01) -> "np.ndarray": + """ + Preprocess the input image by applying histogram equalization and Gaussian smoothing. + + Args: + image: (ndarray): Input image to be processed. + sigma: (float, optional): Standard deviation for Gaussian kernel. Default is 3. + clip_limit: (float, optional): Clipping limit for contrast enhancement. Default is 0.01. + Returns: + ndarray: The preprocessed image. + """ + # Applies histogram equalization to enhance contrast + image_contrasted = exposure.equalize_adapthist( + image, clip_limit=clip_limit) + + # Applies smoothing smoothed_img = gaussian(image_contrasted, sigma=sigma) - return smoothed_img \ No newline at end of file + return smoothed_img