diff --git a/GUI_draft_live.py b/GUI_draft_live.py index 064c80da0c6a4fac5094f54d3e54b1cb14b0d63d..edb8741ab4c0d21b9ce1b8dee262eb196c45d94a 100644 --- a/GUI_draft_live.py +++ b/GUI_draft_live.py @@ -9,12 +9,17 @@ from PyQt5.QtWidgets import ( QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsTextItem, - QSlider, QLabel + QSlider, QLabel, QCheckBox, QGridLayout, QSizePolicy ) -from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont +from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont, QImage from PyQt5.QtCore import Qt, QRectF, QSize -from live_wire import compute_cost_image, find_path +# Make sure the following imports exist in live_wire.py (or similar): +# from skimage import exposure +# from skimage.filters import gaussian +# def preprocess_image(image, sigma=3, clip_limit=0.01): ... + +from live_wire import compute_cost_image, find_path, preprocess_image # ------------------------------------------------------------------------ @@ -28,6 +33,9 @@ class PanZoomGraphicsView(QGraphicsView): 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. @@ -72,15 +80,9 @@ class PanZoomGraphicsView(QGraphicsView): # ------------------------------------------------------------------------ # A specialized PanZoomGraphicsView for the circle editor -# - Only pan if user did NOT click on the draggable circle -# - If the mouse is over the circle item, scrolling changes radius # ------------------------------------------------------------------------ class CircleEditorGraphicsView(PanZoomGraphicsView): def __init__(self, circle_editor_widget, parent=None): - """ - :param circle_editor_widget: Reference to the parent CircleEditorWidget - so we can communicate (e.g. update slider). - """ super().__init__(parent) self._circle_editor_widget = circle_editor_widget @@ -116,7 +118,7 @@ class CircleEditorGraphicsView(PanZoomGraphicsView): if isinstance(it, DraggableCircleItem): # Scroll up -> increase radius, scroll down -> decrease delta = event.angleDelta().y() - # each wheel "notch" is typically 120, so let's do small steps + # each wheel "notch" is typically 120 step = 1 if delta > 0 else -1 old_r = it.radius() @@ -232,7 +234,7 @@ class CircleEditorWidget(QWidget): Called by CircleEditorGraphicsView when the user scrolls on the circle item. We sync the slider to the new radius. """ - self._slider.blockSignals(True) # to avoid recursively calling set_radius + self._slider.blockSignals(True) self._slider.setValue(new_radius) self._slider.blockSignals(False) @@ -347,8 +349,12 @@ class ImageGraphicsView(PanZoomGraphicsView): self.cost_image_original = None self.cost_image = None - # Rainbow toggle - self._rainbow_enabled = True + # Rainbow toggle => start with OFF + self._rainbow_enabled = False + + # Smoothing parameters + self._savgol_window_length = 7 + self._savgol_polyorder = 1 def set_rainbow_enabled(self, enabled: bool): """Enable/disable rainbow mode, then rebuild the path.""" @@ -360,6 +366,29 @@ class ImageGraphicsView(PanZoomGraphicsView): 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: + p = wlen - 1 + self._savgol_polyorder = p + + self._rebuild_full_path() + # -------------------------------------------------------------------- # LOADING # -------------------------------------------------------------------- @@ -407,7 +436,7 @@ class ImageGraphicsView(PanZoomGraphicsView): def _add_guide_point(self, x, y): 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) + y_clamped = self._clamp(y, self.dot_radius, self._img_w - self.dot_radius) self._revert_cost_to_original() @@ -553,9 +582,14 @@ class ImageGraphicsView(PanZoomGraphicsView): if len(sub_xy) > 1: big_xy.extend(sub_xy[1:]) - 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=self._savgol_polyorder, + axis=0 + ) big_xy = smoothed.tolist() self._full_path_xy = big_xy[:] @@ -605,7 +639,7 @@ class ImageGraphicsView(PanZoomGraphicsView): return QColor.fromHsv(hue, saturation, value) # -------------------------------------------------------------------- - # MOUSE EVENTS (with pan & zoom from PanZoomGraphicsView) + # MOUSE EVENTS # -------------------------------------------------------------------- def mousePressEvent(self, event): if event.button() == Qt.LeftButton: @@ -750,6 +784,113 @@ class ImageGraphicsView(PanZoomGraphicsView): return self._full_path_xy +# ------------------------------------------------------------------------ +# Advanced Settings Widget +# ------------------------------------------------------------------------ +class AdvancedSettingsWidget(QWidget): + def __init__(self, main_window, parent=None): + super().__init__(parent) + self._main_window = main_window # to call e.g. main_window.open_circle_editor() + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # A small grid for the controls (buttons/sliders) + 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("Open Circle Editor") + 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 + lab_smoothing = QLabel("Line smoothing (SavGol window_length)") + controls_layout.addWidget(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.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.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.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() + + # 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 + 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 _on_toggle_rainbow(self): + self._main_window.toggle_rainbow() + + def _on_line_smoothing_slider(self, value): + self._main_window.image_view.set_savgol_window_length(value) + + def _on_contrast_slider(self, value): + clip_limit = value / 100.0 + 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) + """ + 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) + + 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 # ------------------------------------------------------------------------ @@ -759,19 +900,25 @@ class MainWindow(QMainWindow): self.setWindowTitle("Test GUI") self._last_loaded_pixmap = None - self._circle_radius_for_later_use = 0 + self._circle_calibrated_radius = 6 + self._last_loaded_file_path = None - # Original main widget + # For the contrast slider + self._current_clip_limit = 0.01 + + # Outer widget + layout self._main_widget = QWidget() - self._main_layout = QVBoxLayout(self._main_widget) + 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.addLayout(self._left_panel) + self.setCentralWidget(self._main_widget) # Image view self.image_view = ImageGraphicsView() - self._main_layout.addWidget(self.image_view) + self._left_panel.addWidget(self.image_view) # Button row btn_layout = QHBoxLayout() - self.btn_load_image = QPushButton("Load Image") self.btn_load_image.clicked.connect(self.load_image) btn_layout.addWidget(self.btn_load_image) @@ -784,36 +931,41 @@ class MainWindow(QMainWindow): self.btn_clear_points.clicked.connect(self.clear_points) btn_layout.addWidget(self.btn_clear_points) - self.btn_toggle_rainbow = QPushButton("Toggle Rainbow") - self.btn_toggle_rainbow.clicked.connect(self.toggle_rainbow) - btn_layout.addWidget(self.btn_toggle_rainbow) - - # New circle editor button - self.btn_open_editor = QPushButton("Open Circle Editor") - self.btn_open_editor.clicked.connect(self.open_circle_editor) - btn_layout.addWidget(self.btn_open_editor) + # "Advanced Settings" button + 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._main_layout.addLayout(btn_layout) - self.setCentralWidget(self._main_widget) + self._left_panel.addLayout(btn_layout) - self.resize(900, 600) + # Create advanced settings widget (hidden by default) + self._advanced_widget = AdvancedSettingsWidget(self) + self._advanced_widget.hide() + self._main_layout.addWidget(self._advanced_widget) - # We keep references for old/new + 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() + # 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.""" if not self._last_loaded_pixmap: print("No image loaded yet! Cannot open circle editor.") return - # Step 1: take the old widget out of QMainWindow ownership old_widget = self.takeCentralWidget() self._old_central_widget = old_widget - # Step 2: create the editor - init_radius = 20 + init_radius = self._circle_calibrated_radius editor = CircleEditorWidget( pixmap=self._last_loaded_pixmap, init_radius=init_radius, @@ -821,31 +973,36 @@ class MainWindow(QMainWindow): ) self._editor = editor - # Step 3: set the new editor as the central widget self.setCentralWidget(editor) def _on_circle_editor_done(self, final_radius): - self._circle_radius_for_later_use = final_radius + self._circle_calibrated_radius = final_radius print(f"Circle Editor done. Radius = {final_radius}") - # Take the editor out + 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) - # Put back the old widget if self._old_central_widget is not None: self.setCentralWidget(self._old_central_widget) self._old_central_widget = None - # We can delete the editor if we like if self._editor is not None: self._editor.deleteLater() self._editor = None - # -------------------------------------------------------------------- - # Existing Functions - # -------------------------------------------------------------------- def toggle_rainbow(self): self.image_view.toggle_rainbow() @@ -858,15 +1015,60 @@ 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() - # Store a pixmap to reuse 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): full_xy = self.image_view.get_full_path_xy() if not full_xy: