diff --git a/GUI_draft_live.py b/GUI_draft_live.py index edb8741ab4c0d21b9ce1b8dee262eb196c45d94a..0e6eddead0163d2840ded7435da5682b06417ee3 100644 --- a/GUI_draft_live.py +++ b/GUI_draft_live.py @@ -14,11 +14,11 @@ from PyQt5.QtWidgets import ( from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont, QImage from PyQt5.QtCore import Qt, QRectF, QSize -# Make sure the following imports exist in live_wire.py (or similar): +# 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): ... from live_wire import compute_cost_image, find_path, preprocess_image @@ -37,9 +37,7 @@ class PanZoomGraphicsView(QGraphicsView): self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) def wheelEvent(self, event): - """ - Zoom in/out with mouse wheel. - """ + """ Zoom in/out with mouse wheel. """ zoom_in_factor = 1.25 zoom_out_factor = 1 / zoom_in_factor if event.angleDelta().y() > 0: @@ -49,9 +47,7 @@ class PanZoomGraphicsView(QGraphicsView): event.accept() def mousePressEvent(self, event): - """ - If left button: Start panning (unless overridden in a subclass). - """ + """ If left button: Start panning (unless overridden). """ if event.button() == Qt.LeftButton: self._panning = True self._pan_start = event.pos() @@ -59,9 +55,7 @@ class PanZoomGraphicsView(QGraphicsView): super().mousePressEvent(event) def mouseMoveEvent(self, event): - """ - If panning, translate the scene. - """ + """ 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() @@ -69,9 +63,7 @@ class PanZoomGraphicsView(QGraphicsView): super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): - """ - End panning. - """ + """ End panning. """ if event.button() == Qt.LeftButton: self._panning = False self.setCursor(Qt.ArrowCursor) @@ -88,50 +80,41 @@ class CircleEditorGraphicsView(PanZoomGraphicsView): def mousePressEvent(self, event): if event.button() == Qt.LeftButton: - # Check if the user clicked on the circle item + # Check if user clicked on the circle item clicked_item = self.itemAt(event.pos()) if clicked_item is not None: - # Walk up parent chain to see if it is our DraggableCircleItem + # 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, don't initiate panning + # Let normal item-dragging occur, no pan return QGraphicsView.mousePressEvent(self, event) - # Otherwise proceed with normal panning logic super().mousePressEvent(event) def wheelEvent(self, event): """ - Overridden so that if the mouse is hovering over the circle, - we adjust the circle's radius instead of zooming the image. + 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: - # climb up the chain to find if it's our DraggableCircleItem it = item_under while it is not None and not hasattr(it, "boundingRect"): it = it.parentItem() if isinstance(it, DraggableCircleItem): - # Scroll up -> increase radius, scroll down -> decrease delta = event.angleDelta().y() - # each wheel "notch" is typically 120 step = 1 if delta > 0 else -1 - old_r = it.radius() new_r = max(1, old_r + step) it.set_radius(new_r) - - # Also update the slider in the parent CircleEditorWidget self._circle_editor_widget.update_slider_value(new_r) - event.accept() return - # else do normal pan/zoom super().wheelEvent(event) @@ -157,7 +140,6 @@ class DraggableCircleItem(QGraphicsEllipseItem): self.setPos(x - radius, y - radius) def set_radius(self, r): - # Keep the same center, just change radius old_center = self.sceneBoundingRect().center() self._r = r self.setRect(0, 0, 2*r, 2*r) @@ -183,7 +165,7 @@ class CircleEditorWidget(QWidget): layout = QVBoxLayout(self) self.setLayout(layout) - # Use specialized CircleEditorGraphicsView + # Specialized CircleEditorGraphicsView self._graphics_view = CircleEditorGraphicsView(circle_editor_widget=self) self._scene = QGraphicsScene(self) self._graphics_view.setScene(self._scene) @@ -202,12 +184,13 @@ class CircleEditorWidget(QWidget): self._graphics_view.setSceneRect(QRectF(self._pixmap.rect())) self._graphics_view.fitInView(self._image_item, Qt.KeepAspectRatio) - # Bottom controls (slider + done) + # Bottom controls bottom_layout = QHBoxLayout() layout.addLayout(bottom_layout) - lbl = QLabel("size:") - bottom_layout.addWidget(lbl) + # We'll store the label in an attribute so we can update with the slider value + 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) @@ -223,6 +206,7 @@ class CircleEditorWidget(QWidget): 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() @@ -230,13 +214,10 @@ class CircleEditorWidget(QWidget): self._done_callback(final_radius) def update_slider_value(self, new_radius): - """ - Called by CircleEditorGraphicsView when the user scrolls on the circle item. - We sync the slider to the 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) @@ -357,30 +338,20 @@ class ImageGraphicsView(PanZoomGraphicsView): self._savgol_polyorder = 1 def set_rainbow_enabled(self, enabled: bool): - """Enable/disable rainbow mode, then rebuild the path.""" self._rainbow_enabled = enabled self._rebuild_full_path() def toggle_rainbow(self): - """Flip the rainbow mode and rebuild path.""" self._rainbow_enabled = not self._rainbow_enabled self._rebuild_full_path() def set_savgol_window_length(self, wlen: int): - """ - Set the window length for the Savitzky-Golay filter and update polyorder - based on window_length_polyorder_ratio. - """ - # SavGol requires window_length to be odd and >= 3 if wlen < 3: wlen = 3 if wlen % 2 == 0: wlen += 1 - self._savgol_window_length = wlen - # polyorder is nearest integer to (window_length / 7) - # but must be >= 1 and < window_length p = round(wlen / 7.0) p = max(1, p) if p >= wlen: @@ -415,18 +386,17 @@ class ImageGraphicsView(PanZoomGraphicsView): # 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, @@ -435,8 +405,9 @@ class ImageGraphicsView(PanZoomGraphicsView): self.scene.addItem(item) def _add_guide_point(self, x, y): + # 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_w - self.dot_radius) + y_clamped = self._clamp(y, self.dot_radius, self._img_h - self.dot_radius) self._revert_cost_to_original() @@ -450,10 +421,12 @@ class ImageGraphicsView(PanZoomGraphicsView): self._rebuild_full_path() def _insert_anchor_between_subpath(self, x_new, y_new): + # If somehow we have no path yet if not self._full_path_xy: self._insert_anchor_point(-1, x_new, y_new) return + # 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): @@ -498,14 +471,16 @@ class ImageGraphicsView(PanZoomGraphicsView): 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: 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 + # Convert anchor coords -> anchor_points indices left_idx = None right_idx = None for i, (ax, ay) in enumerate(self.anchor_points): @@ -518,6 +493,7 @@ class ImageGraphicsView(PanZoomGraphicsView): self._insert_anchor_point(-1, x_new, y_new) return + # Insert between them if left_idx < right_idx: insert_idx = left_idx + 1 else: @@ -597,10 +573,9 @@ class ImageGraphicsView(PanZoomGraphicsView): 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) - else: - color = Qt.red path_item = LabeledPointItem(px, py, label="", radius=self.path_radius, @@ -630,6 +605,7 @@ class ImageGraphicsView(PanZoomGraphicsView): 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): @@ -674,6 +650,7 @@ class ImageGraphicsView(PanZoomGraphicsView): 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: self._drag_counter = 0 self._revert_cost_to_original() @@ -698,10 +675,8 @@ class ImageGraphicsView(PanZoomGraphicsView): idx = self._dragging_idx self._dragging_idx = None self._drag_offset = (0, 0) - 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() @@ -788,14 +763,24 @@ class ImageGraphicsView(PanZoomGraphicsView): # 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. + + Now displays the images stacked vertically with labels above them. + """ def __init__(self, main_window, parent=None): super().__init__(parent) - self._main_window = main_window # to call e.g. main_window.open_circle_editor() + 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 the controls (buttons/sliders) + # A small grid for controls controls_layout = QGridLayout() # 1) Rainbow toggle @@ -809,69 +794,108 @@ class AdvancedSettingsWidget(QWidget): controls_layout.addWidget(self.btn_circle_editor, 0, 1) # 3) Line smoothing slider + label - lab_smoothing = QLabel("Line smoothing (SavGol window_length)") - controls_layout.addWidget(lab_smoothing, 1, 0) + self._lab_smoothing = QLabel("Line smoothing (7)") + controls_layout.addWidget(self._lab_smoothing, 1, 0) self.line_smoothing_slider = QSlider(Qt.Horizontal) - self.line_smoothing_slider.setRange(3, 51) # allow from 3 to 51 - self.line_smoothing_slider.setValue(7) # default + self.line_smoothing_slider.setRange(3, 51) + self.line_smoothing_slider.setValue(7) self.line_smoothing_slider.valueChanged.connect(self._on_line_smoothing_slider) controls_layout.addWidget(self.line_smoothing_slider, 1, 1) # 4) Contrast slider + label - lab_contrast = QLabel("Contrast (clip_limit)") - controls_layout.addWidget(lab_contrast, 2, 0) + 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(0, 100) # 0..100 => 0..1 with step of 0.01 - self.contrast_slider.setValue(1) # default is 0.01 + self.contrast_slider.setRange(0, 100) + self.contrast_slider.setValue(1) # i.e. 0.01 self.contrast_slider.valueChanged.connect(self._on_contrast_slider) controls_layout.addWidget(self.contrast_slider, 2, 1) main_layout.addLayout(controls_layout) - # Now a horizontal layout for the two images - images_layout = QHBoxLayout() + # 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) - # Contrasted blurred self.label_contrasted_blurred = QLabel() - self.label_contrasted_blurred.setText("CONTRASTED BLURRED IMG") self.label_contrasted_blurred.setAlignment(Qt.AlignCenter) self.label_contrasted_blurred.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.label_contrasted_blurred.setScaledContents(True) images_layout.addWidget(self.label_contrasted_blurred) - # Cost image + # 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.setText("Current COST IMAGE") self.label_cost_image.setAlignment(Qt.AlignCenter) self.label_cost_image.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.label_cost_image.setScaledContents(True) 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. - contrasted_img_np = the grayscaled blurred+contrasted image as float or 0-1 array - cost_img_np = the current cost image (numpy array) + 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) - if cb_pix is not None: - self.label_contrasted_blurred.setPixmap(cb_pix) - if cost_pix is not None: - self.label_cost_image.setPixmap(cost_pix) + 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: @@ -908,8 +932,8 @@ class MainWindow(QMainWindow): # Outer widget + layout self._main_widget = QWidget() - self._main_layout = QHBoxLayout(self._main_widget) # horizontal so we can place advanced on the right - self._left_panel = QVBoxLayout() # for the image & row of buttons + self._main_layout = QHBoxLayout(self._main_widget) + self._left_panel = QVBoxLayout() self._main_layout.addLayout(self._left_panel) self.setCentralWidget(self._main_widget) @@ -931,7 +955,7 @@ class MainWindow(QMainWindow): self.btn_clear_points.clicked.connect(self.clear_points) btn_layout.addWidget(self.btn_clear_points) - # "Advanced Settings" button + # "Advanced Settings" toggle self.btn_advanced = QPushButton("Advanced Settings") self.btn_advanced.setCheckable(True) self.btn_advanced.clicked.connect(self._toggle_advanced_settings) @@ -939,7 +963,7 @@ class MainWindow(QMainWindow): self._left_panel.addLayout(btn_layout) - # Create advanced settings widget (hidden by default) + # Advanced settings widget self._advanced_widget = AdvancedSettingsWidget(self) self._advanced_widget.hide() self._main_layout.addWidget(self._advanced_widget) @@ -953,11 +977,10 @@ class MainWindow(QMainWindow): self._advanced_widget.show() else: self._advanced_widget.hide() - # Ask Qt to re-layout the window so it can expand/shrink as needed: self.adjustSize() def open_circle_editor(self): - """Removes the current central widget, replaces with circle editor.""" + """ Replace central widget with circle editor. """ if not self._last_loaded_pixmap: print("No image loaded yet! Cannot open circle editor.") return @@ -972,7 +995,6 @@ class MainWindow(QMainWindow): done_callback=self._on_circle_editor_done ) self._editor = editor - self.setCentralWidget(editor) def _on_circle_editor_done(self, final_radius): @@ -1049,7 +1071,6 @@ class MainWindow(QMainWindow): 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,