Skip to content
Snippets Groups Projects
Commit 56fda2e4 authored by Christian's avatar Christian
Browse files

added advanced settings

parent 76afb30a
No related branches found
No related tags found
No related merge requests found
...@@ -9,12 +9,17 @@ from PyQt5.QtWidgets import ( ...@@ -9,12 +9,17 @@ from PyQt5.QtWidgets import (
QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QApplication, QMainWindow, QGraphicsView, QGraphicsScene,
QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton, QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton,
QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsTextItem, 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 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): ...@@ -28,6 +33,9 @@ class PanZoomGraphicsView(QGraphicsView):
self._panning = False self._panning = False
self._pan_start = None self._pan_start = None
# Let it expand in layouts
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def wheelEvent(self, event): def wheelEvent(self, event):
""" """
Zoom in/out with mouse wheel. Zoom in/out with mouse wheel.
...@@ -72,15 +80,9 @@ class PanZoomGraphicsView(QGraphicsView): ...@@ -72,15 +80,9 @@ class PanZoomGraphicsView(QGraphicsView):
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
# A specialized PanZoomGraphicsView for the circle editor # 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): class CircleEditorGraphicsView(PanZoomGraphicsView):
def __init__(self, circle_editor_widget, parent=None): 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) super().__init__(parent)
self._circle_editor_widget = circle_editor_widget self._circle_editor_widget = circle_editor_widget
...@@ -116,7 +118,7 @@ class CircleEditorGraphicsView(PanZoomGraphicsView): ...@@ -116,7 +118,7 @@ class CircleEditorGraphicsView(PanZoomGraphicsView):
if isinstance(it, DraggableCircleItem): if isinstance(it, DraggableCircleItem):
# Scroll up -> increase radius, scroll down -> decrease # Scroll up -> increase radius, scroll down -> decrease
delta = event.angleDelta().y() 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 step = 1 if delta > 0 else -1
old_r = it.radius() old_r = it.radius()
...@@ -232,7 +234,7 @@ class CircleEditorWidget(QWidget): ...@@ -232,7 +234,7 @@ class CircleEditorWidget(QWidget):
Called by CircleEditorGraphicsView when the user scrolls on the circle item. Called by CircleEditorGraphicsView when the user scrolls on the circle item.
We sync the slider to the new radius. 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.setValue(new_radius)
self._slider.blockSignals(False) self._slider.blockSignals(False)
...@@ -347,8 +349,12 @@ class ImageGraphicsView(PanZoomGraphicsView): ...@@ -347,8 +349,12 @@ class ImageGraphicsView(PanZoomGraphicsView):
self.cost_image_original = None self.cost_image_original = None
self.cost_image = None self.cost_image = None
# Rainbow toggle # Rainbow toggle => start with OFF
self._rainbow_enabled = True self._rainbow_enabled = False
# Smoothing parameters
self._savgol_window_length = 7
self._savgol_polyorder = 1
def set_rainbow_enabled(self, enabled: bool): def set_rainbow_enabled(self, enabled: bool):
"""Enable/disable rainbow mode, then rebuild the path.""" """Enable/disable rainbow mode, then rebuild the path."""
...@@ -360,6 +366,29 @@ class ImageGraphicsView(PanZoomGraphicsView): ...@@ -360,6 +366,29 @@ class ImageGraphicsView(PanZoomGraphicsView):
self._rainbow_enabled = not self._rainbow_enabled self._rainbow_enabled = not self._rainbow_enabled
self._rebuild_full_path() 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 # LOADING
# -------------------------------------------------------------------- # --------------------------------------------------------------------
...@@ -407,7 +436,7 @@ class ImageGraphicsView(PanZoomGraphicsView): ...@@ -407,7 +436,7 @@ class ImageGraphicsView(PanZoomGraphicsView):
def _add_guide_point(self, x, y): def _add_guide_point(self, x, y):
x_clamped = self._clamp(x, self.dot_radius, self._img_w - self.dot_radius) 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() self._revert_cost_to_original()
...@@ -553,9 +582,14 @@ class ImageGraphicsView(PanZoomGraphicsView): ...@@ -553,9 +582,14 @@ class ImageGraphicsView(PanZoomGraphicsView):
if len(sub_xy) > 1: if len(sub_xy) > 1:
big_xy.extend(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) 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() big_xy = smoothed.tolist()
self._full_path_xy = big_xy[:] self._full_path_xy = big_xy[:]
...@@ -605,7 +639,7 @@ class ImageGraphicsView(PanZoomGraphicsView): ...@@ -605,7 +639,7 @@ class ImageGraphicsView(PanZoomGraphicsView):
return QColor.fromHsv(hue, saturation, value) return QColor.fromHsv(hue, saturation, value)
# -------------------------------------------------------------------- # --------------------------------------------------------------------
# MOUSE EVENTS (with pan & zoom from PanZoomGraphicsView) # MOUSE EVENTS
# -------------------------------------------------------------------- # --------------------------------------------------------------------
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.LeftButton: if event.button() == Qt.LeftButton:
...@@ -750,6 +784,113 @@ class ImageGraphicsView(PanZoomGraphicsView): ...@@ -750,6 +784,113 @@ class ImageGraphicsView(PanZoomGraphicsView):
return self._full_path_xy 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 # Main Window
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
...@@ -759,19 +900,25 @@ class MainWindow(QMainWindow): ...@@ -759,19 +900,25 @@ class MainWindow(QMainWindow):
self.setWindowTitle("Test GUI") self.setWindowTitle("Test GUI")
self._last_loaded_pixmap = None 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_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 # Image view
self.image_view = ImageGraphicsView() self.image_view = ImageGraphicsView()
self._main_layout.addWidget(self.image_view) self._left_panel.addWidget(self.image_view)
# Button row # Button row
btn_layout = QHBoxLayout() btn_layout = QHBoxLayout()
self.btn_load_image = QPushButton("Load Image") self.btn_load_image = QPushButton("Load Image")
self.btn_load_image.clicked.connect(self.load_image) self.btn_load_image.clicked.connect(self.load_image)
btn_layout.addWidget(self.btn_load_image) btn_layout.addWidget(self.btn_load_image)
...@@ -784,36 +931,41 @@ class MainWindow(QMainWindow): ...@@ -784,36 +931,41 @@ class MainWindow(QMainWindow):
self.btn_clear_points.clicked.connect(self.clear_points) self.btn_clear_points.clicked.connect(self.clear_points)
btn_layout.addWidget(self.btn_clear_points) btn_layout.addWidget(self.btn_clear_points)
self.btn_toggle_rainbow = QPushButton("Toggle Rainbow") # "Advanced Settings" button
self.btn_toggle_rainbow.clicked.connect(self.toggle_rainbow) self.btn_advanced = QPushButton("Advanced Settings")
btn_layout.addWidget(self.btn_toggle_rainbow) self.btn_advanced.setCheckable(True)
self.btn_advanced.clicked.connect(self._toggle_advanced_settings)
btn_layout.addWidget(self.btn_advanced)
# New circle editor button self._left_panel.addLayout(btn_layout)
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)
self._main_layout.addLayout(btn_layout) # Create advanced settings widget (hidden by default)
self.setCentralWidget(self._main_widget) self._advanced_widget = AdvancedSettingsWidget(self)
self._advanced_widget.hide()
self.resize(900, 600) self._main_layout.addWidget(self._advanced_widget)
# We keep references for old/new self.resize(1000, 600)
self._old_central_widget = None self._old_central_widget = None
self._editor = 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): def open_circle_editor(self):
"""Removes the current central widget, replaces with circle editor.""" """Removes the current central widget, replaces with circle editor."""
if not self._last_loaded_pixmap: if not self._last_loaded_pixmap:
print("No image loaded yet! Cannot open circle editor.") print("No image loaded yet! Cannot open circle editor.")
return return
# Step 1: take the old widget out of QMainWindow ownership
old_widget = self.takeCentralWidget() old_widget = self.takeCentralWidget()
self._old_central_widget = old_widget self._old_central_widget = old_widget
# Step 2: create the editor init_radius = self._circle_calibrated_radius
init_radius = 20
editor = CircleEditorWidget( editor = CircleEditorWidget(
pixmap=self._last_loaded_pixmap, pixmap=self._last_loaded_pixmap,
init_radius=init_radius, init_radius=init_radius,
...@@ -821,31 +973,36 @@ class MainWindow(QMainWindow): ...@@ -821,31 +973,36 @@ class MainWindow(QMainWindow):
) )
self._editor = editor self._editor = editor
# Step 3: set the new editor as the central widget
self.setCentralWidget(editor) self.setCentralWidget(editor)
def _on_circle_editor_done(self, final_radius): 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}") 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() editor_widget = self.takeCentralWidget()
if editor_widget is not None: if editor_widget is not None:
editor_widget.setParent(None) editor_widget.setParent(None)
# Put back the old widget
if self._old_central_widget is not None: if self._old_central_widget is not None:
self.setCentralWidget(self._old_central_widget) self.setCentralWidget(self._old_central_widget)
self._old_central_widget = None self._old_central_widget = None
# We can delete the editor if we like
if self._editor is not None: if self._editor is not None:
self._editor.deleteLater() self._editor.deleteLater()
self._editor = None self._editor = None
# --------------------------------------------------------------------
# Existing Functions
# --------------------------------------------------------------------
def toggle_rainbow(self): def toggle_rainbow(self):
self.image_view.toggle_rainbow() self.image_view.toggle_rainbow()
...@@ -858,15 +1015,60 @@ class MainWindow(QMainWindow): ...@@ -858,15 +1015,60 @@ class MainWindow(QMainWindow):
) )
if file_path: if file_path:
self.image_view.load_image(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_original = cost_img
self.image_view.cost_image = cost_img.copy() self.image_view.cost_image = cost_img.copy()
# Store a pixmap to reuse
pm = QPixmap(file_path) pm = QPixmap(file_path)
if not pm.isNull(): if not pm.isNull():
self._last_loaded_pixmap = pm 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): def export_path(self):
full_xy = self.image_view.get_full_path_xy() full_xy = self.image_view.get_full_path_xy()
if not full_xy: if not full_xy:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment