diff --git a/GUI_draft_live.py b/GUI_draft_live.py index 83011b489fa48e972168fed6ae0cb0b6cd934f8f..90089d8827ccae9665dc4797e7fba4e5bba094d0 100644 --- a/GUI_draft_live.py +++ b/GUI_draft_live.py @@ -1,5 +1,6 @@ import sys import math +import csv # <-- Added import numpy as np # For smoothing the path @@ -8,14 +9,242 @@ from scipy.signal import savgol_filter from PyQt5.QtWidgets import ( QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton, - QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsTextItem + QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsTextItem, + QSlider, QLabel, QCheckBox, QGridLayout, QSizePolicy ) -from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont -from PyQt5.QtCore import Qt, QRectF +from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont, QImage +from PyQt5.QtCore import Qt, QRectF, QSize + +# live_wire.py must contain something like: +# from skimage import exposure +# from skimage.filters import gaussian +# def preprocess_image(image, sigma=3, clip_limit=0.01): ... +# def compute_cost_image(path, user_radius, sigma=3, clip_limit=0.01): ... +# def find_path(cost_image, points): ... +# ... +from live_wire import compute_cost_image, find_path, preprocess_image + + +# ------------------------------------------------------------------------ +# A pan & zoom QGraphicsView +# ------------------------------------------------------------------------ +class PanZoomGraphicsView(QGraphicsView): + def __init__(self, parent=None): + super().__init__(parent) + self.setDragMode(QGraphicsView.NoDrag) # We'll handle panning manually + self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + self._panning = False + self._pan_start = None + + # Let it expand in layouts + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + def wheelEvent(self, event): + """ Zoom in/out with mouse wheel. """ + zoom_in_factor = 1.25 + zoom_out_factor = 1 / zoom_in_factor + if event.angleDelta().y() > 0: + self.scale(zoom_in_factor, zoom_in_factor) + else: + self.scale(zoom_out_factor, zoom_out_factor) + event.accept() + + def mousePressEvent(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) + + def mouseMoveEvent(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) + + def mouseReleaseEvent(self, event): + """ End panning. """ + if event.button() == Qt.LeftButton: + self._panning = False + self.setCursor(Qt.ArrowCursor) + super().mouseReleaseEvent(event) + -from live_wire import compute_cost_image, find_path +# ------------------------------------------------------------------------ +# A specialized PanZoomGraphicsView for the circle editor +# ------------------------------------------------------------------------ +class CircleEditorGraphicsView(PanZoomGraphicsView): + def __init__(self, circle_editor_widget, parent=None): + super().__init__(parent) + self._circle_editor_widget = circle_editor_widget + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + # Check if user clicked on the circle item + clicked_item = self.itemAt(event.pos()) + if clicked_item is not None: + # climb up parent chain + it = clicked_item + while it is not None and not hasattr(it, "boundingRect"): + it = it.parentItem() + + if isinstance(it, DraggableCircleItem): + # Let normal item-dragging occur, no pan + return QGraphicsView.mousePressEvent(self, event) + super().mousePressEvent(event) + + def wheelEvent(self, event): + """ + If the mouse is hovering over the circle, we adjust the circle's radius + instead of zooming the image. + """ + pos_in_widget = event.pos() + item_under = self.itemAt(pos_in_widget) + if item_under is not None: + it = item_under + while it is not None and not hasattr(it, "boundingRect"): + it = it.parentItem() + + if isinstance(it, DraggableCircleItem): + delta = event.angleDelta().y() + step = 1 if delta > 0 else -1 + old_r = it.radius() + new_r = max(1, old_r + step) + it.set_radius(new_r) + self._circle_editor_widget.update_slider_value(new_r) + event.accept() + return + super().wheelEvent(event) + +# ------------------------------------------------------------------------ +# Draggable circle item (centered at (x, y) with radius) +# ------------------------------------------------------------------------ +class DraggableCircleItem(QGraphicsEllipseItem): + def __init__(self, x, y, radius=20, color=Qt.red, parent=None): + super().__init__(0, 0, 2*radius, 2*radius, parent) + self._r = radius + + pen = QPen(color) + brush = QBrush(color) + self.setPen(pen) + self.setBrush(brush) + + # Enable item-based dragging + self.setFlags(QGraphicsEllipseItem.ItemIsMovable | + QGraphicsEllipseItem.ItemIsSelectable | + QGraphicsEllipseItem.ItemSendsScenePositionChanges) + + # Position so that (x, y) is the center + self.setPos(x - radius, y - radius) + + def set_radius(self, r): + old_center = self.sceneBoundingRect().center() + self._r = r + self.setRect(0, 0, 2*r, 2*r) + new_center = self.sceneBoundingRect().center() + diff_x = old_center.x() - new_center.x() + diff_y = old_center.y() - new_center.y() + self.moveBy(diff_x, diff_y) + + def radius(self): + return self._r + + +# ------------------------------------------------------------------------ +# Circle editor widget with slider + done +# ------------------------------------------------------------------------ +class CircleEditorWidget(QWidget): + def __init__(self, pixmap, init_radius=20, done_callback=None, parent=None): + super().__init__(parent) + self._pixmap = pixmap + self._done_callback = done_callback + self._init_radius = init_radius + + layout = QVBoxLayout(self) + self.setLayout(layout) + + # + # 1) ADD A CENTERED LABEL ABOVE THE IMAGE, WITH BIGGER FONT + # + label_instructions = QLabel("Scale the dot to be of the size of your ridge") + label_instructions.setAlignment(Qt.AlignCenter) + big_font = QFont("Arial", 20) + big_font.setBold(True) + label_instructions.setFont(big_font) + layout.addWidget(label_instructions) + + # + # 2) THE SPECIALIZED GRAPHICS VIEW THAT SHOWS 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) + + # Put circle in center + cx = self._pixmap.width() / 2 + cy = self._pixmap.height() / 2 + self._circle_item = DraggableCircleItem(cx, cy, radius=self._init_radius, color=Qt.red) + self._scene.addItem(self._circle_item) + + # Fit in view + self._graphics_view.setSceneRect(QRectF(self._pixmap.rect())) + self._graphics_view.fitInView(self._image_item, Qt.KeepAspectRatio) + + # + # 3) CONTROLS BELOW + # + bottom_layout = QHBoxLayout() + layout.addLayout(bottom_layout) + + # label + slider + self._lbl_size = QLabel(f"size ({self._init_radius})") + bottom_layout.addWidget(self._lbl_size) + + self._slider = QSlider(Qt.Horizontal) + self._slider.setRange(1, 200) + self._slider.setValue(self._init_radius) + bottom_layout.addWidget(self._slider) + + # done button + self._btn_done = QPushButton("Done") + bottom_layout.addWidget(self._btn_done) + + # Connect signals + self._slider.valueChanged.connect(self._on_slider_changed) + self._btn_done.clicked.connect(self._on_done_clicked) + + def _on_slider_changed(self, value): + self._circle_item.set_radius(value) + self._lbl_size.setText(f"size ({value})") + + def _on_done_clicked(self): + 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): + self._slider.blockSignals(True) + self._slider.setValue(new_radius) + self._slider.blockSignals(False) + self._lbl_size.setText(f"size ({new_radius})") + + def sizeHint(self): + return QSize(800, 600) + + +# ------------------------------------------------------------------------ +# Labeled point item +# ------------------------------------------------------------------------ class LabeledPointItem(QGraphicsEllipseItem): def __init__(self, x, y, label="", radius=4, color=Qt.red, removable=True, z_value=0, parent=None): super().__init__(0, 0, 2*radius, 2*radius, parent) @@ -84,25 +313,23 @@ class LabeledPointItem(QGraphicsEllipseItem): return self._removable -class ImageGraphicsView(QGraphicsView): +# ------------------------------------------------------------------------ +# The original ImageGraphicsView with pan & zoom +# ------------------------------------------------------------------------ +class ImageGraphicsView(PanZoomGraphicsView): def __init__(self, parent=None): super().__init__(parent) self.scene = QGraphicsScene(self) self.setScene(self.scene) - # Zoom around mouse pointer - self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) - # Image display self.image_item = QGraphicsPixmapItem() self.scene.addItem(self.image_item) self.anchor_points = [] # List[(x, y)] - self.point_items = [] # LabeledPointItem objects - self.full_path_points = [] # QGraphicsEllipseItems for the path - - # We'll store the entire path coords (smoothed) for reference - self._full_path_xy = [] + self.point_items = [] # LabeledPointItem + self.full_path_points = [] # QGraphicsEllipseItems for path + self._full_path_xy = [] # entire path coords (smoothed) self.dot_radius = 4 self.path_radius = 1 @@ -110,10 +337,6 @@ class ImageGraphicsView(QGraphicsView): self._img_w = 0 self._img_h = 0 - # Pan/Drag - self.setDragMode(QGraphicsView.ScrollHandDrag) - self.viewport().setCursor(Qt.ArrowCursor) - self._mouse_pressed = False self._press_view_pos = None self._drag_threshold = 5 @@ -126,6 +349,29 @@ class ImageGraphicsView(QGraphicsView): self.cost_image_original = None self.cost_image = None + # Rainbow toggle => start with OFF + self._rainbow_enabled = False + + # Smoothing parameters + self._savgol_window_length = 7 + + def set_rainbow_enabled(self, enabled: bool): + self._rainbow_enabled = enabled + self._rebuild_full_path() + + def toggle_rainbow(self): + self._rainbow_enabled = not self._rainbow_enabled + self._rebuild_full_path() + + def set_savgol_window_length(self, wlen: int): + if wlen < 3: + wlen = 3 + if wlen % 2 == 0: + wlen += 1 + self._savgol_window_length = wlen + + self._rebuild_full_path() + # -------------------------------------------------------------------- # LOADING # -------------------------------------------------------------------- @@ -152,18 +398,17 @@ class ImageGraphicsView(QGraphicsView): # ANCHOR POINTS # -------------------------------------------------------------------- def _insert_anchor_point(self, idx, x, y, label="", removable=True, z_val=0, radius=4): - """Insert anchor at index=idx (or -1 => before E). Clamps x,y to image bounds.""" x_clamped = self._clamp(x, radius, self._img_w - radius) y_clamped = self._clamp(y, radius, self._img_h - radius) if idx < 0: + # Insert before E if there's at least 2 anchors if len(self.anchor_points) >= 2: idx = len(self.anchor_points) - 1 else: idx = len(self.anchor_points) self.anchor_points.insert(idx, (x_clamped, y_clamped)) - color = Qt.green if label in ("S", "E") else Qt.red item = LabeledPointItem(x_clamped, y_clamped, label=label, radius=radius, color=color, @@ -172,33 +417,28 @@ class ImageGraphicsView(QGraphicsView): self.scene.addItem(item) def _add_guide_point(self, x, y): - """User clicked => find the correct sub-path, insert the point in that sub-path.""" + # Ensure we clamp properly 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) self._revert_cost_to_original() if not self._full_path_xy: - # If there's no existing path built, we just insert normally self._insert_anchor_point(-1, x_clamped, y_clamped, label="", removable=True, z_val=1, radius=self.dot_radius) else: - # Insert the new anchor in between the correct anchors, - # by finding nearest coordinate in _full_path_xy, then - # walking left+right until we find bounding anchors. self._insert_anchor_between_subpath(x_clamped, y_clamped) self._apply_all_guide_points_to_cost() self._rebuild_full_path() def _insert_anchor_between_subpath(self, x_new, y_new): - """Find the subpath bounding (x_new,y_new) and insert the new anchor accordingly.""" - # If no path, fallback + # If somehow we have no path yet if not self._full_path_xy: self._insert_anchor_point(-1, x_new, y_new) return - # 1) Find nearest coordinate in the path + # Find nearest point in the current full path best_idx = None best_d2 = float('inf') for i, (px, py) in enumerate(self._full_path_xy): @@ -210,26 +450,21 @@ class ImageGraphicsView(QGraphicsView): best_idx = i if best_idx is None: - # fallback self._insert_anchor_point(-1, x_new, y_new) return - # 2) Identify bounding anchors by walking left / right def approx_equal(xa, ya, xb, yb, tol=1e-3): return (abs(xa - xb) < tol) and (abs(ya - yb) < tol) def is_anchor(coord): cx, cy = coord - # check if (cx,cy) is approx any anchor in self.anchor_points for (ax, ay) in self.anchor_points: if approx_equal(ax, ay, cx, cy): return True return False + # Walk left left_anchor_pt = None - right_anchor_pt = None - - # walk left iL = best_idx while iL >= 0: px, py = self._full_path_xy[iL] @@ -238,7 +473,8 @@ class ImageGraphicsView(QGraphicsView): break iL -= 1 - # walk right + # Walk right + right_anchor_pt = None iR = best_idx while iR < len(self._full_path_xy): px, py = self._full_path_xy[iR] @@ -247,12 +483,16 @@ class ImageGraphicsView(QGraphicsView): break iR += 1 + # If we can't find distinct anchors on left & right, + # just insert before E. if not left_anchor_pt or not right_anchor_pt: - # If we can't find bounding anchors => fallback + self._insert_anchor_point(-1, x_new, y_new) + return + if left_anchor_pt == right_anchor_pt: self._insert_anchor_point(-1, x_new, y_new) return - # 3) Find which anchor_points indices correspond to left_anchor_pt, right_anchor_pt + # Convert anchor coords -> anchor_points indices left_idx = None right_idx = None for i, (ax, ay) in enumerate(self.anchor_points): @@ -262,23 +502,14 @@ class ImageGraphicsView(QGraphicsView): right_idx = i if left_idx is None or right_idx is None: - # fallback self._insert_anchor_point(-1, x_new, y_new) return - # We want the new anchor to be inserted right after left_idx, - # so that the subpath between left_idx and right_idx - # is effectively subdivided. - # This ensures anchor_points = [..., left_anchor, new_point, ..., right_anchor, ...] - insert_idx = right_idx - # But if left_idx < right_idx => we do insert_idx=left_idx+1 - # in case we want them consecutive. + # Insert between them if left_idx < right_idx: insert_idx = left_idx + 1 else: - # means the path might be reversed, or there's some tricky indexing - # We'll just do min or max - insert_idx = max(right_idx, left_idx) + insert_idx = right_idx + 1 self._insert_anchor_point(insert_idx, x_new, y_new, label="", removable=True, z_val=1, radius=self.dot_radius) @@ -320,8 +551,6 @@ class ImageGraphicsView(QGraphicsView): # PATH BUILDING # -------------------------------------------------------------------- def _rebuild_full_path(self): - """Compute subpaths between anchors, smooth them, store all coords, display all of them.""" - # Clear old path visuals for item in self.full_path_points: self.scene.removeItem(item) self.full_path_points.clear() @@ -338,33 +567,42 @@ class ImageGraphicsView(QGraphicsView): if i == 0: big_xy.extend(sub_xy) else: - # avoid repeating the shared anchor if len(sub_xy) > 1: big_xy.extend(sub_xy[1:]) - # Smooth if we have enough points - if len(big_xy) >= 7: + if len(big_xy) >= self._savgol_window_length: arr_xy = np.array(big_xy) - smoothed = savgol_filter(arr_xy, window_length=7, polyorder=1, axis=0) + smoothed = savgol_filter( + arr_xy, + window_length=self._savgol_window_length, + polyorder=2, + axis=0 + ) big_xy = smoothed.tolist() - # We now store the entire path in _full_path_xy self._full_path_xy = big_xy[:] - # Display ALL points - for (px, py) in big_xy: - path_item = LabeledPointItem(px, py, label="", radius=self.path_radius, - color=Qt.magenta, removable=False, z_value=0) + n_points = len(big_xy) + for i, (px, py) in enumerate(big_xy): + fraction = i / (n_points - 1) if n_points > 1 else 0 + color = Qt.red + if self._rainbow_enabled: + color = self._rainbow_color(fraction) + + path_item = LabeledPointItem(px, py, label="", + radius=self.path_radius, + color=color, + removable=False, + z_value=0) self.full_path_points.append(path_item) self.scene.addItem(path_item) - # Keep S/E on top + # Keep anchor labels on top for p_item in self.point_items: if p_item._text_item: p_item.setZValue(100) def _compute_subpath_xy(self, xA, yA, xB, yB): - """Return the raw path from (xA,yA)->(xB,yB).""" if self.cost_image is None: return [] h, w = self.cost_image.shape @@ -379,8 +617,15 @@ class ImageGraphicsView(QGraphicsView): except ValueError as e: print("Error in find_path:", e) return [] + # Convert from (row, col) to (x, y) return [(c, r) for (r, c) in path_rc] + def _rainbow_color(self, fraction): + hue = int(300 * fraction) + saturation = 255 + value = 255 + return QColor.fromHsv(hue, saturation, value) + # -------------------------------------------------------------------- # MOUSE EVENTS # -------------------------------------------------------------------- @@ -390,81 +635,65 @@ class ImageGraphicsView(QGraphicsView): self._was_dragging = False self._press_view_pos = event.pos() - # See if user is clicking near an existing anchor => drag it idx = self._find_item_near(event.pos(), threshold=10) if idx is not None: self._dragging_idx = idx self._drag_counter = 0 - scene_pos = self.mapToScene(event.pos()) px, py = self.point_items[idx].get_pos() self._drag_offset = (scene_pos.x() - px, scene_pos.y() - py) - self.setDragMode(QGraphicsView.NoDrag) - self.viewport().setCursor(Qt.ClosedHandCursor) + self.setCursor(Qt.ClosedHandCursor) return - else: - # no anchor => we'll add a new point - self.setDragMode(QGraphicsView.ScrollHandDrag) - self.viewport().setCursor(Qt.ClosedHandCursor) elif event.button() == Qt.RightButton: - # Right-click => remove anchor if removable self._remove_point_by_click(event.pos()) super().mousePressEvent(event) def mouseMoveEvent(self, event): if self._dragging_idx is not None: - # Dragging anchor scene_pos = self.mapToScene(event.pos()) x_new = scene_pos.x() - self._drag_offset[0] y_new = scene_pos.y() - self._drag_offset[1] - # clamp so user can't drag outside r = self.point_items[self._dragging_idx]._r x_clamped = self._clamp(x_new, r, self._img_w - r) y_clamped = self._clamp(y_new, r, self._img_h - r) self.point_items[self._dragging_idx].set_pos(x_clamped, y_clamped) self._drag_counter += 1 + # Update path every 4 moves if self._drag_counter >= 4: - # partial path update self._drag_counter = 0 self._revert_cost_to_original() self._apply_all_guide_points_to_cost() self.anchor_points[self._dragging_idx] = (x_clamped, y_clamped) self._rebuild_full_path() - else: if self._mouse_pressed and (event.buttons() & Qt.LeftButton): dist = (event.pos() - self._press_view_pos).manhattanLength() if dist > self._drag_threshold: self._was_dragging = True - super().mouseMoveEvent(event) + super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) if event.button() == Qt.LeftButton and self._mouse_pressed: self._mouse_pressed = False - self.viewport().setCursor(Qt.ArrowCursor) + self.setCursor(Qt.ArrowCursor) if self._dragging_idx is not None: - # finished dragging => final update idx = self._dragging_idx self._dragging_idx = None self._drag_offset = (0, 0) - self.setDragMode(QGraphicsView.ScrollHandDrag) - newX, newY = self.point_items[idx].get_pos() self.anchor_points[idx] = (newX, newY) - self._revert_cost_to_original() self._apply_all_guide_points_to_cost() self._rebuild_full_path() - else: - # If user wasn't dragging => add new guide point + # No drag => add point if not self._was_dragging: scene_pos = self.mapToScene(event.pos()) x, y = scene_pos.x(), scene_pos.y() @@ -476,7 +705,6 @@ class ImageGraphicsView(QGraphicsView): idx = self._find_item_near(view_pos, threshold=10) if idx is None: return - # skip if S/E if not self.point_items[idx].is_removable(): return @@ -503,18 +731,6 @@ class ImageGraphicsView(QGraphicsView): return closest_idx return None - # -------------------------------------------------------------------- - # ZOOM - # -------------------------------------------------------------------- - def wheelEvent(self, event): - zoom_in_factor = 1.25 - zoom_out_factor = 1 / zoom_in_factor - if event.angleDelta().y() > 0: - self.scale(zoom_in_factor, zoom_in_factor) - else: - self.scale(zoom_out_factor, zoom_out_factor) - event.accept() - # -------------------------------------------------------------------- # UTILS # -------------------------------------------------------------------- @@ -533,7 +749,6 @@ class ImageGraphicsView(QGraphicsView): self._full_path_xy.clear() def clear_guide_points(self): - """Remove all removable anchors, keep S/E. Rebuild path.""" i = 0 while i < len(self.anchor_points): if self.point_items[i].is_removable(): @@ -553,42 +768,287 @@ class ImageGraphicsView(QGraphicsView): self._rebuild_full_path() def get_full_path_xy(self): - """Return the entire path (x,y) array after smoothing.""" return self._full_path_xy +# ------------------------------------------------------------------------ +# Advanced Settings Widget +# ------------------------------------------------------------------------ +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. + """ + def __init__(self, main_window, parent=None): + 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 + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # A small grid for controls + controls_layout = QGridLayout() + + # 1) 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 + 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 + self._lab_smoothing = QLabel("Line smoothing (3)") + controls_layout.addWidget(self._lab_smoothing, 1, 0) + self.line_smoothing_slider = QSlider(Qt.Horizontal) + self.line_smoothing_slider.setRange(3, 51) + self.line_smoothing_slider.setValue(3) + self.line_smoothing_slider.valueChanged.connect(self._on_line_smoothing_slider) + controls_layout.addWidget(self.line_smoothing_slider, 1, 1) + + # 4) Contrast slider + label + self._lab_contrast = QLabel("Contrast (0.01)") + controls_layout.addWidget(self._lab_contrast, 2, 0) + self.contrast_slider = QSlider(Qt.Horizontal) + self.contrast_slider.setRange(1, 20) + self.contrast_slider.setValue(1) # i.e. 0.01 + self.contrast_slider.setSingleStep(1) + self.contrast_slider.valueChanged.connect(self._on_contrast_slider) + controls_layout.addWidget(self.contrast_slider, 2, 1) + + 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 + images_layout = QVBoxLayout() + + # 1) 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) + + self.label_contrasted_blurred = QLabel() + self.label_contrasted_blurred.setAlignment(Qt.AlignCenter) + self.label_contrasted_blurred.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + images_layout.addWidget(self.label_contrasted_blurred) + + # 2) 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) + + self.label_cost_image = QLabel() + self.label_cost_image.setAlignment(Qt.AlignCenter) + self.label_cost_image.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + images_layout.addWidget(self.label_cost_image) + + main_layout.addLayout(images_layout) + + def showEvent(self, event): + """ When shown, ask parent to resize to accommodate. """ + super().showEvent(event) + if self.parentWidget(): + self.parentWidget().adjustSize() + + def resizeEvent(self, event): + """ + Keep the images at correct aspect ratio by re-scaling + our stored pixmaps to the new label sizes. + """ + super().resizeEvent(event) + self._update_labels() + + def _update_labels(self): + if self._last_cb_pix is not None: + scaled_cb = self._last_cb_pix.scaled( + self.label_contrasted_blurred.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation + ) + self.label_contrasted_blurred.setPixmap(scaled_cb) + + if self._last_cost_pix is not None: + scaled_cost = self._last_cost_pix.scaled( + self.label_cost_image.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation + ) + self.label_cost_image.setPixmap(scaled_cost) + + def _on_toggle_rainbow(self): + self._main_window.toggle_rainbow() + + def _on_line_smoothing_slider(self, value): + self._lab_smoothing.setText(f"Line smoothing ({value})") + self._main_window.image_view.set_savgol_window_length(value) + + def _on_contrast_slider(self, value): + 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): + """ + 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(). + """ + cb_pix = self._np_array_to_qpixmap(contrasted_img_np) + cost_pix = self._np_array_to_qpixmap(cost_img_np, normalize=True) + + self._last_cb_pix = cb_pix + self._last_cost_pix = cost_pix + self._update_labels() + + def _np_array_to_qpixmap(self, arr, normalize=False): + if arr is None: + return None + arr_ = arr.copy() + if normalize: + mn, mx = arr_.min(), arr_.max() + if abs(mx - mn) < 1e-12: + arr_[:] = 0 + else: + arr_ = (arr_ - mn) / (mx - mn) + arr_ = np.clip(arr_, 0, 1) + arr_255 = (arr_ * 255).astype(np.uint8) + + h, w = arr_255.shape + qimage = QImage(arr_255.data, w, h, w, QImage.Format_Grayscale8) + return QPixmap.fromImage(qimage) + + +# ------------------------------------------------------------------------ +# Main Window +# ------------------------------------------------------------------------ class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Test GUI") - main_widget = QWidget() - main_layout = QVBoxLayout(main_widget) + self._last_loaded_pixmap = None + self._circle_calibrated_radius = 6 + self._last_loaded_file_path = None + + # For the contrast slider + self._current_clip_limit = 0.01 + + # Outer widget + layout + self._main_widget = QWidget() + self._main_layout = QHBoxLayout(self._main_widget) + + # The "left" part: container for the image area + its controls + self._left_panel = QVBoxLayout() + + # We'll make a container widget for the left panel, so we can set stretches: + self._left_container = QWidget() + self._left_container.setLayout(self._left_panel) + + # Now we add them to the main layout with 70%:30% ratio + self._main_layout.addWidget(self._left_container, 7) # 70% + + # We haven't added the advanced widget yet, but we'll do so with ratio=3 => 30% + self._advanced_widget = AdvancedSettingsWidget(self) + # Hide it initially + self._advanced_widget.hide() + self._main_layout.addWidget(self._advanced_widget, 3) + self.setCentralWidget(self._main_widget) + + # The image view self.image_view = ImageGraphicsView() - main_layout.addWidget(self.image_view) + self._left_panel.addWidget(self.image_view) - # Buttons layout + # Button row btn_layout = QHBoxLayout() - - # Load Image self.btn_load_image = QPushButton("Load Image") self.btn_load_image.clicked.connect(self.load_image) btn_layout.addWidget(self.btn_load_image) - # Export Path self.btn_export_path = QPushButton("Export Path") self.btn_export_path.clicked.connect(self.export_path) btn_layout.addWidget(self.btn_export_path) - # Clear Points self.btn_clear_points = QPushButton("Clear Points") self.btn_clear_points.clicked.connect(self.clear_points) btn_layout.addWidget(self.btn_clear_points) - main_layout.addLayout(btn_layout) - self.setCentralWidget(main_widget) - self.resize(900, 600) + # "Advanced Settings" toggle + self.btn_advanced = QPushButton("Advanced Settings") + self.btn_advanced.setCheckable(True) + self.btn_advanced.clicked.connect(self._toggle_advanced_settings) + btn_layout.addWidget(self.btn_advanced) + + self._left_panel.addLayout(btn_layout) + + self.resize(1000, 600) + self._old_central_widget = None + self._editor = None + + def _toggle_advanced_settings(self, checked): + if checked: + self._advanced_widget.show() + else: + self._advanced_widget.hide() + # Force re-layout + self.adjustSize() + + def open_circle_editor(self): + """ Replace central widget with circle editor. """ + if not self._last_loaded_pixmap: + print("No image loaded yet! Cannot open circle editor.") + return + + old_widget = self.takeCentralWidget() + self._old_central_widget = old_widget + + init_radius = self._circle_calibrated_radius + editor = CircleEditorWidget( + pixmap=self._last_loaded_pixmap, + init_radius=init_radius, + done_callback=self._on_circle_editor_done + ) + self._editor = editor + self.setCentralWidget(editor) + + def _on_circle_editor_done(self, final_radius): + self._circle_calibrated_radius = final_radius + print(f"Circle Editor done. Radius = {final_radius}") + + if self._last_loaded_file_path: + cost_img = compute_cost_image( + self._last_loaded_file_path, + self._circle_calibrated_radius, + clip_limit=self._current_clip_limit + ) + self.image_view.cost_image_original = cost_img + self.image_view.cost_image = cost_img.copy() + self.image_view._apply_all_guide_points_to_cost() + self.image_view._rebuild_full_path() + self._update_advanced_images() + + editor_widget = self.takeCentralWidget() + if editor_widget is not None: + editor_widget.setParent(None) + + if self._old_central_widget is not None: + self.setCentralWidget(self._old_central_widget) + self._old_central_widget = None + + if self._editor is not None: + self._editor.deleteLater() + self._editor = None + + def toggle_rainbow(self): + self.image_view.toggle_rainbow() def load_image(self): options = QFileDialog.Options() @@ -599,27 +1059,109 @@ class MainWindow(QMainWindow): ) if file_path: self.image_view.load_image(file_path) - cost_img = compute_cost_image(file_path) + + cost_img = compute_cost_image( + file_path, + self._circle_calibrated_radius, + clip_limit=self._current_clip_limit + ) + self.image_view.cost_image_original = cost_img + self.image_view.cost_image = cost_img.copy() + + pm = QPixmap(file_path) + if not pm.isNull(): + self._last_loaded_pixmap = pm + + self._last_loaded_file_path = file_path + self._update_advanced_images() + + def update_contrast(self, clip_limit): + self._current_clip_limit = clip_limit + if self._last_loaded_file_path: + cost_img = compute_cost_image( + self._last_loaded_file_path, + self._circle_calibrated_radius, + clip_limit=clip_limit + ) self.image_view.cost_image_original = cost_img self.image_view.cost_image = cost_img.copy() + self.image_view._apply_all_guide_points_to_cost() + self.image_view._rebuild_full_path() + + self._update_advanced_images() + + def _update_advanced_images(self): + if not self._last_loaded_pixmap: + return + pm_np = self._qpixmap_to_gray_float(self._last_loaded_pixmap) + contrasted_blurred = preprocess_image( + pm_np, + sigma=3, + clip_limit=self._current_clip_limit + ) + cost_img_np = self.image_view.cost_image + self._advanced_widget.update_displays(contrasted_blurred, cost_img_np) + + def _qpixmap_to_gray_float(self, qpix): + img = qpix.toImage() + img = img.convertToFormat(QImage.Format_ARGB32) + ptr = img.bits() + ptr.setsize(img.byteCount()) + arr = np.frombuffer(ptr, np.uint8).reshape((img.height(), img.width(), 4)) + rgb = arr[..., :3].astype(np.float32) + gray = rgb.mean(axis=2) / 255.0 + return gray def export_path(self): - """Export the full path (x,y) as a .npy file.""" + """ + Exports the path as a CSV in the format: x, y, TYPE, + ensuring that each anchor influences exactly one path point. + """ full_xy = self.image_view.get_full_path_xy() if not full_xy: print("No path to export.") return + # We'll consider each anchor point as "USER-PLACED". + # But unlike a distance-threshold approach, we assign each anchor + # to exactly one closest path point. + anchor_points = self.image_view.anchor_points + + # For each anchor, find the index of the closest path point + user_placed_indices = set() + for ax, ay in anchor_points: + min_dist = float('inf') + closest_idx = None + for i, (px, py) in enumerate(full_xy): + dist = math.hypot(px - ax, py - ay) + if dist < min_dist: + min_dist = dist + closest_idx = i + if closest_idx is not None: + user_placed_indices.add(closest_idx) + + # Ask user for the CSV filename options = QFileDialog.Options() file_path, _ = QFileDialog.getSaveFileName( self, "Export Path", "", - "NumPy Files (*.npy);;All Files (*)", + "CSV Files (*.csv);;All Files (*)", options=options ) - if file_path: - arr = np.array(full_xy) - np.save(file_path, arr) - print(f"Exported path with {len(arr)} points to {file_path}") + if not file_path: + return + + import csv + with open(file_path, 'w', newline='') as csvfile: + writer = csv.writer(csvfile) + writer.writerow(["x", "y", "TYPE"]) + + for i, (x, y) in enumerate(full_xy): + ptype = "USER-PLACED" if i in user_placed_indices else "PATH" + writer.writerow([x, y, ptype]) + + print(f"Exported path with {len(full_xy)} points to {file_path}") + + def clear_points(self): self.image_view.clear_guide_points() diff --git a/data/agamodonPath.npy b/data/agamodonPath.npy new file mode 100644 index 0000000000000000000000000000000000000000..5081e86649a488510afbf0606cf8d9465c813a70 Binary files /dev/null and b/data/agamodonPath.npy differ diff --git a/data/agamodonPoints.npy b/data/agamodonPoints.npy new file mode 100644 index 0000000000000000000000000000000000000000..75ec4b2945d7e7a49b6b72de2fddeca8bcee6615 Binary files /dev/null and b/data/agamodonPoints.npy differ diff --git a/data/agamodon_slice.png b/data/agamodon_slice.png new file mode 100644 index 0000000000000000000000000000000000000000..aa58983b107d16300d3425d4150d64bb568de087 Binary files /dev/null and b/data/agamodon_slice.png differ diff --git a/data/angustifronsPoints.npy b/data/angustifronsPoints.npy new file mode 100644 index 0000000000000000000000000000000000000000..188f483b5c7a7433d144acd9e2787678e9fceccd Binary files /dev/null and b/data/angustifronsPoints.npy differ diff --git a/data/angustifrons_slice.png b/data/angustifrons_slice.png new file mode 100644 index 0000000000000000000000000000000000000000..21323872af3c72094cf891a9ecec4d74bf82fdff Binary files /dev/null and b/data/angustifrons_slice.png differ diff --git a/data/baikaPoints.npy b/data/baikaPoints.npy new file mode 100644 index 0000000000000000000000000000000000000000..e5c79954c80b6cd87d793e8c03830da3619c268c Binary files /dev/null and b/data/baikaPoints.npy differ diff --git a/data/bipesPoints.npy b/data/bipesPoints.npy new file mode 100644 index 0000000000000000000000000000000000000000..166d2be24b69db24a4e5488ddea7261c5165b2b5 Binary files /dev/null and b/data/bipesPoints.npy differ diff --git a/data/bipes_slice.png b/data/bipes_slice.png new file mode 100644 index 0000000000000000000000000000000000000000..c99746284766bd5f1694e6ac12c0adef08b8f87c Binary files /dev/null and b/data/bipes_slice.png differ diff --git a/data/exportedPath.npy b/data/exportedPath.npy new file mode 100644 index 0000000000000000000000000000000000000000..a2d546e367aa3e18efefe88ecbdca9a09c7205cd Binary files /dev/null and b/data/exportedPath.npy differ diff --git a/data/test_image.jpg b/data/test_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..632e369fa39c002c0eb0f96dac18b22be08cf418 Binary files /dev/null and b/data/test_image.jpg differ diff --git a/live_wire.py b/live_wire.py index e42f7d5412c94fbf86cc2408a66da67ac62b92a9..71fcc28db2f933eb64aaff8428d92f1cdca60b3a 100644 --- a/live_wire.py +++ b/live_wire.py @@ -1,4 +1,3 @@ -import time import cv2 import numpy as np import matplotlib.pyplot as plt @@ -8,62 +7,40 @@ from skimage.feature import canny from skimage.graph import route_through_array from scipy.signal import convolve2d -''' -### Canny Edge cost image -def compute_cost_image(path, sigma=3): - - ### Load image - image = cv2.imread(path, cv2.IMREAD_GRAYSCALE) - - # Apply histogram equalization - image_contrasted = exposure.equalize_adapthist(image, clip_limit=0.01) +### Disk live wire cost image - # Apply smoothing - smoothed_img = gaussian(image_contrasted, sigma=sigma) +def compute_disk_size(user_radius, upscale_factor=1.2): + return int(np.ceil(upscale_factor * 2 * user_radius + 1) // 2 * 2 + 1) - # Apply Canny edge detection - canny_img = canny(smoothed_img) - # Create cost image - cost_img = 1.0 / (canny_img + 1e-5) # Invert edges: higher cost where edges are stronger +def load_image(path): + return cv2.imread(path, cv2.IMREAD_GRAYSCALE) - return cost_img +def preprocess_image(image, sigma=3, clip_limit=0.01): + # Apply histogram equalization + image_contrasted = exposure.equalize_adapthist(image, clip_limit=clip_limit) -def find_path(cost_image, points): + # Apply smoothing + smoothed_img = gaussian(image_contrasted, sigma=sigma) - if len(points) != 2: - raise ValueError("Points should be a list of 2 points: seed and target.") - - seed_rc, target_rc = points + return smoothed_img - path_rc, cost = route_through_array( - cost_image, - start=seed_rc, - end=target_rc, - fully_connected=True - ) - return path_rc -''' +def compute_cost_image(path, user_radius, sigma=3, clip_limit=0.01): -### Disk live wire cost image -def compute_cost_image(path, sigma=3, disk_size=15): + disk_size = compute_disk_size(user_radius) ### Load image - image = cv2.imread(path, cv2.IMREAD_GRAYSCALE) - - # Apply histogram equalization - image_contrasted = exposure.equalize_adapthist(image, clip_limit=0.01) + image = load_image(path) # Apply smoothing - smoothed_img = gaussian(image_contrasted, sigma=sigma) + smoothed_img = preprocess_image(image, sigma=sigma, clip_limit=clip_limit) # Apply Canny edge detection canny_img = canny(smoothed_img) # Do disk thing binary_img = canny_img - k_size = 17 kernel = circle_edge_kernel(k_size=disk_size) convolved = convolve2d(binary_img, kernel, mode='same', boundary='fill') @@ -128,12 +105,7 @@ def circle_edge_kernel(k_size=5, radius=None): return kernel - - - - - -# Other functions +# Other functions (to be implemented?) def downscale(img, points, scale_percent): """ Downsample `img` to `scale_percent` size and scale the given points accordingly. @@ -160,39 +132,4 @@ def downscale(img, points, scale_percent): scaled_seed_xy = (int(seed_xy[0] * scale_x), int(seed_xy[1] * scale_y)) scaled_target_xy = (int(target_xy[0] * scale_x), int(target_xy[1] * scale_y)) - return downsampled_img, (scaled_seed_xy, scaled_target_xy) - -def compute_cost(image, sigma=3.0, epsilon=1e-5): - """ - Smooth the image, run Canny edge detection, then invert the edge map into a cost image. - """ - - # Apply histogram equalization - image_contrasted = exposure.equalize_adapthist(image, clip_limit=0.01) - - # Apply smoothing - smoothed_img = gaussian(image_contrasted, sigma=sigma) - - # Apply Canny edge detection - canny_img = canny(smoothed_img) - - # Create cost image - cost_img = 1.0 / (canny_img + epsilon) # Invert edges: higher cost where edges are stronger - - return cost_img, canny_img - -def backtrack_pixels_on_image(img_color, path_coords, bgr_color=(0, 0, 255)): - """ - Color the path on the (already converted BGR) image in the specified color. - `path_coords` should be a list of (row, col) or (y, x). - """ - for (row, col) in path_coords: - img_color[row, col] = bgr_color - return img_color - -def export_path(path_coords, path_name): - """ - Export the path to a np array. - """ - np.save(path_name, path_coords) - return None + return downsampled_img, (scaled_seed_xy, scaled_target_xy) \ No newline at end of file