From bc48d5567b348901a51bb8f117ae93bde300cdc7 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 8 Jul 2025 06:13:12 +0200 Subject: [PATCH 01/63] created selection dir for further dev --- pyidi/__init__.py | 2 +- pyidi/gui.py | 2 +- pyidi/methods/idi_method.py | 2 +- pyidi/pyidi.py | 2 +- pyidi/{ => selection}/selection.py | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename pyidi/{ => selection}/selection.py (100%) diff --git a/pyidi/__init__.py b/pyidi/__init__.py index 8b954ee..9008264 100644 --- a/pyidi/__init__.py +++ b/pyidi/__init__.py @@ -3,7 +3,7 @@ from .pyidi_legacy import pyIDI from . import tools from . import postprocessing -from .selection import SubsetSelection +from .selection.selection import SubsetSelection from .load_analysis import load_analysis from .video_reader import VideoReader from .methods import * diff --git a/pyidi/gui.py b/pyidi/gui.py index 6313eb6..0ef332b 100644 --- a/pyidi/gui.py +++ b/pyidi/gui.py @@ -7,7 +7,7 @@ warnings.simplefilter("default") from . import tools -from . import selection +from .selection import selection from .methods import SimplifiedOpticalFlow from .methods import LucasKanade diff --git a/pyidi/methods/idi_method.py b/pyidi/methods/idi_method.py index 609451b..3d451f7 100644 --- a/pyidi/methods/idi_method.py +++ b/pyidi/methods/idi_method.py @@ -8,7 +8,7 @@ import inspect import matplotlib.pyplot as plt -from ..selection import SubsetSelection +from ..selection.selection import SubsetSelection from ..video_reader import VideoReader from ..tools import setup_logger diff --git a/pyidi/pyidi.py b/pyidi/pyidi.py index 87eb105..49d9a35 100644 --- a/pyidi/pyidi.py +++ b/pyidi/pyidi.py @@ -11,7 +11,7 @@ from .methods import IDIMethod, SimplifiedOpticalFlow, LucasKanade, DirectionalLucasKanade #, LucasKanadeSc, LucasKanadeSc2, GradientBasedOpticalFlow from .video_reader import VideoReader from . import tools -from . import selection +from .selection import selection available_method_shortcuts = [ ('sof', SimplifiedOpticalFlow), diff --git a/pyidi/selection.py b/pyidi/selection/selection.py similarity index 100% rename from pyidi/selection.py rename to pyidi/selection/selection.py From d2eb0344f5cb388e44d014b677e2e12dfa09b687 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 8 Jul 2025 14:18:01 +0200 Subject: [PATCH 02/63] started new point selection implementation --- pyidi/selection/main_window.py | 279 +++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 pyidi/selection/main_window.py diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py new file mode 100644 index 0000000..63d9dc7 --- /dev/null +++ b/pyidi/selection/main_window.py @@ -0,0 +1,279 @@ +from PyQt6 import QtWidgets, QtCore +from pyqtgraph import GraphicsLayoutWidget, ImageItem, ScatterPlotItem +import pyqtgraph as pg +import numpy as np +import sys + + +class SelectionGUI(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("ROI Selection Tool") + self.resize(1200, 800) + + self.selected_points = [] + self.selection_rects = [] + self.drawing_polygons = [[]] # list of paths, each path = list of (x, y) + + + self.scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 100, 100, 200), size=8) + + # Central widget with tab layout + self.tabs = QtWidgets.QTabWidget() + self.setCentralWidget(self.tabs) + + # --- Manual Tab --- + self.manual_tab = QtWidgets.QWidget() + self.manual_layout = QtWidgets.QHBoxLayout(self.manual_tab) + + # Image viewer + self.pg_widget = GraphicsLayoutWidget() + self.view = self.pg_widget.addViewBox(lockAspect=True) + self.image_item = ImageItem() + self.polygon_line = pg.PlotDataItem(pen=pg.mkPen('y', width=2)) + self.polygon_points_scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 0, 200), size=6) + self.view.addItem(self.image_item) + self.view.addItem(self.scatter) # Add scatter for showing points + self.view.addItem(self.polygon_line) + self.view.addItem(self.polygon_points_scatter) + self.manual_layout.addWidget(self.pg_widget, stretch=1) + + # Method buttons on the right + self.method_widget = QtWidgets.QWidget() + self.method_layout = QtWidgets.QVBoxLayout(self.method_widget) + + self.button_group = QtWidgets.QButtonGroup(self.method_widget) + self.button_group.setExclusive(True) + + self.method_buttons = {} + method_names = [ + "ROI grid", + "Deselect polygon", + "Only polygon", + "Manual", + "Along the line" + ] + for i, name in enumerate(method_names): + button = QtWidgets.QPushButton(name) + button.setCheckable(True) + if i == 0: + button.setChecked(True) # Default selection + self.button_group.addButton(button, i) + self.method_layout.addWidget(button) + self.method_buttons[name] = button + + # Subset size input + self.method_layout.addSpacing(20) + self.method_layout.addWidget(QtWidgets.QLabel("Subset size:")) + + self.subset_size_spinbox = QtWidgets.QSpinBox() + self.subset_size_spinbox.setRange(1, 1000) + self.subset_size_spinbox.setValue(11) + self.subset_size_spinbox.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + self.subset_size_spinbox.setSingleStep(2) + self.subset_size_spinbox.setMinimum(1) + self.subset_size_spinbox.setMaximum(999) + self.subset_size_spinbox.setWrapping(False) + self.subset_size_spinbox.valueChanged.connect(self.update_selected_points) + self.method_layout.addWidget(self.subset_size_spinbox) + + # Clear button + self.method_layout.addSpacing(20) + self.clear_button = QtWidgets.QPushButton("Clear selections") + self.clear_button.clicked.connect(self.clear_selection) + self.method_layout.addWidget(self.clear_button) + + # Start new line (only visible in "Along the line" mode) + self.start_new_line_button = QtWidgets.QPushButton("Start new line") + self.start_new_line_button.clicked.connect(self.start_new_line) + self.start_new_line_button.setVisible(False) # Hidden by default + self.method_layout.addWidget(self.start_new_line_button) + + self.method_layout.addStretch(1) + self.manual_layout.addWidget(self.method_widget) + + self.tabs.addTab(self.manual_tab, "Manual") + + # --- Automatic Tab --- + self.automatic_tab = QtWidgets.QWidget() + self.automatic_layout = QtWidgets.QVBoxLayout(self.automatic_tab) + self.tabs.addTab(self.automatic_tab, "Automatic") + + # Style + self.setStyleSheet(""" + QTabWidget::pane { border: 0; } + QTabBar::tab { + background: #333; + color: white; + padding: 10px; + border-radius: 4px; + margin: 2px; + } + QTabBar::tab:selected { + background: #0078d7; + } + QPushButton { + background-color: #444; + color: white; + padding: 6px 12px; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:checked { + background-color: #0078d7; + border: 1px solid #005bb5; + } + """) + + # Connect selection change handler + self.button_group.idClicked.connect(self.method_selected) + + # Connect mouse click + self.pg_widget.scene().sigMouseClicked.connect(self.on_mouse_click) + + def method_selected(self, id: int): + method_name = list(self.method_buttons.keys())[id] + print(f"Selected method: {method_name}") + self.start_new_line_button.setVisible(method_name == "Along the line") + + def on_mouse_click(self, event): + if self.method_buttons["Manual"].isChecked(): + self.handle_manual_selection(event) + elif self.method_buttons["Along the line"].isChecked(): + self.handle_polygon_drawing(event) + + def handle_manual_selection(self, event): + """Handle manual selection of points.""" + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + x_int, y_int = round(x-0.5)+0.5, round(y-0.5)+0.5 + self.selected_points.append((x_int, y_int)) + self.update_selected_points() + + def handle_polygon_drawing(self, event): + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 + + self.drawing_polygons[-1].append((x_int, y_int)) + + self.update_polygon_display() + + self.selected_points = self.points_from_all_polygons() + self.update_selected_points() + + def update_polygon_display(self): + # Flatten all points for scatter + all_pts = [pt for path in self.drawing_polygons for pt in path] + self.polygon_points_scatter.setData(pos=all_pts) + + # Combine segments into one continuous line for display + xs = [] + ys = [] + for path in self.drawing_polygons: + if len(path) >= 2: + xs.extend([p[0] for p in path] + [np.nan]) + ys.extend([p[1] for p in path] + [np.nan]) + elif len(path) == 1: + xs.extend([path[0][0], path[0][0], np.nan]) + ys.extend([path[0][1], path[0][1], np.nan]) + + self.polygon_line.setData(xs, ys) + + def points_from_all_polygons(self): + subset_size = self.subset_size_spinbox.value() + all_points = [] + for path in self.drawing_polygons: + if len(path) >= 2: + all_points.extend(self.points_along_polygon(path, subset_size)) + return all_points + + def update_selected_points(self): + # Clear previous rectangles + for rect in self.selection_rects: + self.view.removeItem(rect) + self.selection_rects.clear() + + # Update scatter points + if self.selected_points: + spots = [{'pos': pt} for pt in self.selected_points] + self.scatter.setData(spots) + + subset_size = self.subset_size_spinbox.value() + half_size = subset_size / 2 + + for x, y in self.selected_points: + rect = pg.QtWidgets.QGraphicsRectItem( + x - half_size, y - half_size, subset_size, subset_size + ) + rect.setPen(pg.mkPen((100, 255, 100, 200), width=2)) + self.view.addItem(rect) + self.selection_rects.append(rect) + else: + self.scatter.clear() + + def start_new_line(self): + print("Starting a new line...") + self.drawing_polygons.append([]) + self.update_polygon_display() + self.selected_points = self.points_from_all_polygons() + self.update_selected_points() + + + def clear_selection(self): + print("Clearing selections...") + self.selected_points = [] + self.drawing_polygons = [[]] + self.polygon_line.clear() + self.polygon_points_scatter.clear() + self.update_selected_points() + + def set_image(self, img: np.ndarray): + """Display image in the manual tab.""" + self.image_item.setImage(img) + + def points_along_polygon(self, polygon, subset_size): + if len(polygon) < 2: + return [] + + # List of points along the path + result_points = [] + + for i in range(len(polygon) - 1): + p1 = np.array(polygon[i]) + p2 = np.array(polygon[i + 1]) + segment = p2 - p1 + length = np.linalg.norm(segment) + + if length == 0: + continue + + direction = segment / length + n_points = int(length // subset_size) + + for j in range(n_points + 1): + pt = p1 + j * subset_size * direction + result_points.append((round(pt[0] - 0.5) + 0.5, round(pt[1] - 0.5) + 0.5)) + + return result_points + + + +def main(): + app = QtWidgets.QApplication(sys.argv) + gui = SelectionGUI() + + # Example grayscale image + example_image = np.random.rand(512, 512) * 255 + gui.set_image(example_image.astype(np.uint8)) + + gui.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() From 6957f508fc8440c612770d132e7c08ac13258105 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 05:24:19 +0200 Subject: [PATCH 03/63] moved along_the_line to new file --- pyidi/selection/along_line.py | 26 ++++++++++++++++++++++++++ pyidi/selection/main_window.py | 30 ++---------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) create mode 100644 pyidi/selection/along_line.py diff --git a/pyidi/selection/along_line.py b/pyidi/selection/along_line.py new file mode 100644 index 0000000..6853375 --- /dev/null +++ b/pyidi/selection/along_line.py @@ -0,0 +1,26 @@ +import numpy as np + +def points_along_polygon(polygon, subset_size): + if len(polygon) < 2: + return [] + + # List of points along the path + result_points = [] + + for i in range(len(polygon) - 1): + p1 = np.array(polygon[i]) + p2 = np.array(polygon[i + 1]) + segment = p2 - p1 + length = np.linalg.norm(segment) + + if length == 0: + continue + + direction = segment / length + n_points = int(length // subset_size) + + for j in range(n_points + 1): + pt = p1 + j * subset_size * direction + result_points.append((round(pt[0] - 0.5) + 0.5, round(pt[1] - 0.5) + 0.5)) + + return result_points \ No newline at end of file diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 63d9dc7..5f21e29 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -4,6 +4,7 @@ import numpy as np import sys +from along_line import points_along_polygon class SelectionGUI(QtWidgets.QMainWindow): def __init__(self): @@ -189,7 +190,7 @@ def points_from_all_polygons(self): all_points = [] for path in self.drawing_polygons: if len(path) >= 2: - all_points.extend(self.points_along_polygon(path, subset_size)) + all_points.extend(points_along_polygon(path, subset_size)) return all_points def update_selected_points(self): @@ -236,33 +237,6 @@ def set_image(self, img: np.ndarray): """Display image in the manual tab.""" self.image_item.setImage(img) - def points_along_polygon(self, polygon, subset_size): - if len(polygon) < 2: - return [] - - # List of points along the path - result_points = [] - - for i in range(len(polygon) - 1): - p1 = np.array(polygon[i]) - p2 = np.array(polygon[i + 1]) - segment = p2 - p1 - length = np.linalg.norm(segment) - - if length == 0: - continue - - direction = segment / length - n_points = int(length // subset_size) - - for j in range(n_points + 1): - pt = p1 + j * subset_size * direction - result_points.append((round(pt[0] - 0.5) + 0.5, round(pt[1] - 0.5) + 0.5)) - - return result_points - - - def main(): app = QtWidgets.QApplication(sys.argv) gui = SelectionGUI() From 3b146ee5e07f07d6ac6019588f906b1bd82d0ea7 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 05:33:22 +0200 Subject: [PATCH 04/63] Improved along the line selection --- pyidi/selection/main_window.py | 79 ++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 5f21e29..97da168 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -1,3 +1,4 @@ +from operator import index from PyQt6 import QtWidgets, QtCore from pyqtgraph import GraphicsLayoutWidget, ImageItem, ScatterPlotItem import pyqtgraph as pg @@ -14,8 +15,8 @@ def __init__(self): self.selected_points = [] self.selection_rects = [] - self.drawing_polygons = [[]] # list of paths, each path = list of (x, y) - + self.drawing_polygons = [{'points': [], 'roi_points': []}] + self.active_polygon_index = 0 self.scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 100, 100, 200), size=8) @@ -34,9 +35,9 @@ def __init__(self): self.polygon_line = pg.PlotDataItem(pen=pg.mkPen('y', width=2)) self.polygon_points_scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 0, 200), size=6) self.view.addItem(self.image_item) - self.view.addItem(self.scatter) # Add scatter for showing points self.view.addItem(self.polygon_line) self.view.addItem(self.polygon_points_scatter) + self.view.addItem(self.scatter) # Add scatter for showing points self.manual_layout.addWidget(self.pg_widget, stretch=1) # Method buttons on the right @@ -93,6 +94,17 @@ def __init__(self): self.method_layout.addStretch(1) self.manual_layout.addWidget(self.method_widget) + # Polygon manager (visible only for "Along the line") + self.polygon_list = QtWidgets.QListWidget() + self.polygon_list.setVisible(False) + self.polygon_list.currentRowChanged.connect(self.on_polygon_selected) + self.method_layout.addWidget(self.polygon_list) + + self.delete_polygon_button = QtWidgets.QPushButton("Delete selected polygon") + self.delete_polygon_button.clicked.connect(self.delete_selected_polygon) + self.delete_polygon_button.setVisible(False) + self.method_layout.addWidget(self.delete_polygon_button) + self.tabs.addTab(self.manual_tab, "Manual") # --- Automatic Tab --- @@ -135,7 +147,10 @@ def __init__(self): def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") - self.start_new_line_button.setVisible(method_name == "Along the line") + is_along = method_name == "Along the line" + self.start_new_line_button.setVisible(is_along) + self.polygon_list.setVisible(is_along) + self.delete_polygon_button.setVisible(is_along) def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): @@ -160,22 +175,29 @@ def handle_polygon_drawing(self, event): x, y = mouse_point.x(), mouse_point.y() x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 - self.drawing_polygons[-1].append((x_int, y_int)) + # Add first polygon to the list if not yet shown + if self.polygon_list.count() == 0: + self.polygon_list.addItem("Polygon 1") + self.polygon_list.setCurrentRow(0) - self.update_polygon_display() + poly = self.drawing_polygons[self.active_polygon_index] + poly['points'].append((x_int, y_int)) + + # Update ROI points only for this polygon + if len(poly['points']) >= 2: + subset_size = self.subset_size_spinbox.value() + poly['roi_points'] = points_along_polygon(poly['points'], subset_size) - self.selected_points = self.points_from_all_polygons() + self.update_polygon_display() self.update_selected_points() def update_polygon_display(self): - # Flatten all points for scatter - all_pts = [pt for path in self.drawing_polygons for pt in path] + all_pts = [pt for poly in self.drawing_polygons for pt in poly['points']] self.polygon_points_scatter.setData(pos=all_pts) - # Combine segments into one continuous line for display - xs = [] - ys = [] - for path in self.drawing_polygons: + xs, ys = [], [] + for poly in self.drawing_polygons: + path = poly['points'] if len(path) >= 2: xs.extend([p[0] for p in path] + [np.nan]) ys.extend([p[1] for p in path] + [np.nan]) @@ -192,14 +214,28 @@ def points_from_all_polygons(self): if len(path) >= 2: all_points.extend(points_along_polygon(path, subset_size)) return all_points + + def delete_selected_polygon(self): + row = self.polygon_list.currentRow() + if row >= 0 and len(self.drawing_polygons) > 1: + del self.drawing_polygons[row] + self.polygon_list.takeItem(row) + self.active_polygon_index = max(0, row - 1) + self.polygon_list.setCurrentRow(self.active_polygon_index) + self.update_polygon_display() + self.update_selected_points() + + def on_polygon_selected(self, index): + if 0 <= index < len(self.drawing_polygons): + self.active_polygon_index = index def update_selected_points(self): - # Clear previous rectangles + self.selected_points = [pt for poly in self.drawing_polygons for pt in poly['roi_points']] + for rect in self.selection_rects: self.view.removeItem(rect) self.selection_rects.clear() - # Update scatter points if self.selected_points: spots = [{'pos': pt} for pt in self.selected_points] self.scatter.setData(spots) @@ -219,16 +255,21 @@ def update_selected_points(self): def start_new_line(self): print("Starting a new line...") - self.drawing_polygons.append([]) + self.drawing_polygons.append({'points': [], 'roi_points': []}) + self.active_polygon_index = len(self.drawing_polygons) - 1 + self.polygon_list.addItem(f"Polygon {self.active_polygon_index + 1}") + self.polygon_list.setCurrentRow(self.active_polygon_index) self.update_polygon_display() - self.selected_points = self.points_from_all_polygons() self.update_selected_points() def clear_selection(self): print("Clearing selections...") - self.selected_points = [] - self.drawing_polygons = [[]] + self.drawing_polygons = [{'points': [], 'roi_points': []}] + self.polygon_list.clear() + self.polygon_list.addItem("Polygon 1") + self.polygon_list.setCurrentRow(0) + self.active_polygon_index = 0 self.polygon_line.clear() self.polygon_points_scatter.clear() self.update_selected_points() From 74b7abaf60eaf18da9cd1a17514413fa9c26e85f Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 05:38:00 +0200 Subject: [PATCH 05/63] select from manual and along the line and combine the points --- pyidi/selection/main_window.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 97da168..1dbc609 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -15,6 +15,7 @@ def __init__(self): self.selected_points = [] self.selection_rects = [] + self.manual_points = [] self.drawing_polygons = [{'points': [], 'roi_points': []}] self.active_polygon_index = 0 @@ -165,7 +166,7 @@ def handle_manual_selection(self, event): mouse_point = self.view.mapSceneToView(pos) x, y = mouse_point.x(), mouse_point.y() x_int, y_int = round(x-0.5)+0.5, round(y-0.5)+0.5 - self.selected_points.append((x_int, y_int)) + self.manual_points.append((x_int, y_int)) self.update_selected_points() def handle_polygon_drawing(self, event): @@ -230,7 +231,8 @@ def on_polygon_selected(self, index): self.active_polygon_index = index def update_selected_points(self): - self.selected_points = [pt for poly in self.drawing_polygons for pt in poly['roi_points']] + polygon_points = [pt for poly in self.drawing_polygons for pt in poly['roi_points']] + self.selected_points = self.manual_points + polygon_points for rect in self.selection_rects: self.view.removeItem(rect) @@ -266,6 +268,7 @@ def start_new_line(self): def clear_selection(self): print("Clearing selections...") self.drawing_polygons = [{'points': [], 'roi_points': []}] + self.manual_points = [] self.polygon_list.clear() self.polygon_list.addItem("Polygon 1") self.polygon_list.setCurrentRow(0) @@ -278,6 +281,10 @@ def set_image(self, img: np.ndarray): """Display image in the manual tab.""" self.image_item.setImage(img) + def get_points(self): + """Get all selected points from manual and polygons.""" + return self.selected_points + def main(): app = QtWidgets.QApplication(sys.argv) gui = SelectionGUI() From 67289af421c159be226eecebb8cceee60b7d0155 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 05:55:57 +0200 Subject: [PATCH 06/63] Call selection gui directly as a class --- pyidi/selection/main_window.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 1dbc609..2805266 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -8,8 +8,13 @@ from along_line import points_along_polygon class SelectionGUI(QtWidgets.QMainWindow): - def __init__(self): + def __init__(self, video): + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + super().__init__() + self.setWindowTitle("ROI Selection Tool") self.resize(1200, 800) @@ -145,6 +150,14 @@ def __init__(self): # Connect mouse click self.pg_widget.scene().sigMouseClicked.connect(self.on_mouse_click) + # Set the initial image + self.image_item.setImage(video) + + # Start the GUI + self.show() + if app is not None: + app.exec() + def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") @@ -285,17 +298,8 @@ def get_points(self): """Get all selected points from manual and polygons.""" return self.selected_points -def main(): - app = QtWidgets.QApplication(sys.argv) - gui = SelectionGUI() - - # Example grayscale image +if __name__ == "__main__": example_image = np.random.rand(512, 512) * 255 - gui.set_image(example_image.astype(np.uint8)) - - gui.show() - sys.exit(app.exec()) + Points = SelectionGUI(example_image.astype(np.uint8)) - -if __name__ == "__main__": - main() + print(Points.get_points()) # Print selected points for testing From aa6fed5769567f9b2046ad7c225a6960bede5c6b Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 06:08:16 +0200 Subject: [PATCH 07/63] Added splitter for the right menu --- pyidi/selection/main_window.py | 113 +++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 2805266..a99b2ea 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -32,8 +32,64 @@ def __init__(self, video): # --- Manual Tab --- self.manual_tab = QtWidgets.QWidget() + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) self.manual_layout = QtWidgets.QHBoxLayout(self.manual_tab) + self.manual_layout.addWidget(self.splitter) + # Graphics layout for image and points display + self.ui_graphics() + + # Right-side menu for methods + self.ui_manual_right_menu() + + self.tabs.addTab(self.manual_tab, "Manual") + + # --- Automatic Tab --- + self.automatic_tab = QtWidgets.QWidget() + self.automatic_layout = QtWidgets.QVBoxLayout(self.automatic_tab) + self.tabs.addTab(self.automatic_tab, "Automatic") + + # Style + self.setStyleSheet(""" + QTabWidget::pane { border: 0; } + QTabBar::tab { + background: #333; + color: white; + padding: 10px; + border-radius: 4px; + margin: 2px; + } + QTabBar::tab:selected { + background: #0078d7; + } + QPushButton { + background-color: #444; + color: white; + padding: 6px 12px; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:checked { + background-color: #0078d7; + border: 1px solid #005bb5; + } + """) + + # Connect selection change handler + self.button_group.idClicked.connect(self.method_selected) + + # Connect mouse click + self.pg_widget.scene().sigMouseClicked.connect(self.on_mouse_click) + + # Set the initial image + self.image_item.setImage(video) + + # Start the GUI + self.show() + if app is not None: + app.exec() + + def ui_graphics(self): # Image viewer self.pg_widget = GraphicsLayoutWidget() self.view = self.pg_widget.addViewBox(lockAspect=True) @@ -44,8 +100,9 @@ def __init__(self, video): self.view.addItem(self.polygon_line) self.view.addItem(self.polygon_points_scatter) self.view.addItem(self.scatter) # Add scatter for showing points - self.manual_layout.addWidget(self.pg_widget, stretch=1) + self.splitter.addWidget(self.pg_widget) + def ui_manual_right_menu(self): # Method buttons on the right self.method_widget = QtWidgets.QWidget() self.method_layout = QtWidgets.QVBoxLayout(self.method_widget) @@ -98,7 +155,6 @@ def __init__(self, video): self.method_layout.addWidget(self.start_new_line_button) self.method_layout.addStretch(1) - self.manual_layout.addWidget(self.method_widget) # Polygon manager (visible only for "Along the line") self.polygon_list = QtWidgets.QListWidget() @@ -111,52 +167,15 @@ def __init__(self, video): self.delete_polygon_button.setVisible(False) self.method_layout.addWidget(self.delete_polygon_button) - self.tabs.addTab(self.manual_tab, "Manual") - - # --- Automatic Tab --- - self.automatic_tab = QtWidgets.QWidget() - self.automatic_layout = QtWidgets.QVBoxLayout(self.automatic_tab) - self.tabs.addTab(self.automatic_tab, "Automatic") - - # Style - self.setStyleSheet(""" - QTabWidget::pane { border: 0; } - QTabBar::tab { - background: #333; - color: white; - padding: 10px; - border-radius: 4px; - margin: 2px; - } - QTabBar::tab:selected { - background: #0078d7; - } - QPushButton { - background-color: #444; - color: white; - padding: 6px 12px; - border: 1px solid #555; - border-radius: 4px; - } - QPushButton:checked { - background-color: #0078d7; - border: 1px solid #005bb5; - } - """) - - # Connect selection change handler - self.button_group.idClicked.connect(self.method_selected) - - # Connect mouse click - self.pg_widget.scene().sigMouseClicked.connect(self.on_mouse_click) + # Set the layout and add to splitter + self.splitter.addWidget(self.method_widget) + self.splitter.setStretchFactor(0, 5) # Image area grows more + self.splitter.setStretchFactor(1, 0) # Menu fixed by content - # Set the initial image - self.image_item.setImage(video) - - # Start the GUI - self.show() - if app is not None: - app.exec() + # Set initial width for right panel + self.method_widget.setMinimumWidth(150) + self.method_widget.setMaximumWidth(600) + self.splitter.setSizes([1000, 220]) # Initial left/right width def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] From 1378b38d8ebfcf13531c09ac8272d8f091745752 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 06:09:25 +0200 Subject: [PATCH 08/63] simplified method options --- pyidi/selection/main_window.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index a99b2ea..387e0bc 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -112,9 +112,7 @@ def ui_manual_right_menu(self): self.method_buttons = {} method_names = [ - "ROI grid", - "Deselect polygon", - "Only polygon", + "Grid", "Manual", "Along the line" ] From 871ba507157f55368f5969cd31479d87c054e266 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 06:39:46 +0200 Subject: [PATCH 09/63] added grid method, optimized performance --- pyidi/selection/main_window.py | 222 ++++++++++++++++++++++++++++----- 1 file changed, 193 insertions(+), 29 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 387e0bc..9e67f11 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -19,12 +19,11 @@ def __init__(self, video): self.resize(1200, 800) self.selected_points = [] - self.selection_rects = [] self.manual_points = [] self.drawing_polygons = [{'points': [], 'roi_points': []}] self.active_polygon_index = 0 - - self.scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 100, 100, 200), size=8) + self.grid_polygons = [{'points': [], 'roi_points': []}] + self.active_grid_index = 0 # Central widget with tab layout self.tabs = QtWidgets.QTabWidget() @@ -93,13 +92,19 @@ def ui_graphics(self): # Image viewer self.pg_widget = GraphicsLayoutWidget() self.view = self.pg_widget.addViewBox(lockAspect=True) + self.image_item = ImageItem() self.polygon_line = pg.PlotDataItem(pen=pg.mkPen('y', width=2)) self.polygon_points_scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 0, 200), size=6) + self.scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 100, 100, 200), size=8) + self.roi_overlay = ImageItem() + self.view.addItem(self.image_item) self.view.addItem(self.polygon_line) self.view.addItem(self.polygon_points_scatter) + self.view.addItem(self.roi_overlay) # Add scatter for showing square points self.view.addItem(self.scatter) # Add scatter for showing points + self.splitter.addWidget(self.pg_widget) def ui_manual_right_menu(self): @@ -165,6 +170,17 @@ def ui_manual_right_menu(self): self.delete_polygon_button.setVisible(False) self.method_layout.addWidget(self.delete_polygon_button) + # Grid polygon manager + self.grid_list = QtWidgets.QListWidget() + self.grid_list.setVisible(False) + self.grid_list.currentRowChanged.connect(self.on_grid_selected) + self.method_layout.addWidget(self.grid_list) + + self.delete_grid_button = QtWidgets.QPushButton("Delete selected grid") + self.delete_grid_button.clicked.connect(self.delete_selected_grid) + self.delete_grid_button.setVisible(False) + self.method_layout.addWidget(self.delete_grid_button) + # Set the layout and add to splitter self.splitter.addWidget(self.method_widget) self.splitter.setStretchFactor(0, 5) # Image area grows more @@ -179,16 +195,23 @@ def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") is_along = method_name == "Along the line" - self.start_new_line_button.setVisible(is_along) + is_grid = method_name == "Grid" + + self.start_new_line_button.setVisible(is_along or is_grid) self.polygon_list.setVisible(is_along) self.delete_polygon_button.setVisible(is_along) + self.grid_list.setVisible(is_grid) + self.delete_grid_button.setVisible(is_grid) + def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) elif self.method_buttons["Along the line"].isChecked(): self.handle_polygon_drawing(event) - + elif self.method_buttons["Grid"].isChecked(): + self.handle_grid_drawing(event) + def handle_manual_selection(self, event): """Handle manual selection of points.""" pos = event.scenePos() @@ -260,30 +283,148 @@ def on_polygon_selected(self, index): if 0 <= index < len(self.drawing_polygons): self.active_polygon_index = index + def handle_grid_drawing(self, event): + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 + + # Add first grid polygon to the list if not yet shown + if self.grid_list.count() == 0: + self.grid_list.addItem("Grid 1") + self.grid_list.setCurrentRow(0) + + grid = self.grid_polygons[self.active_grid_index] + grid['points'].append((x_int, y_int)) + + # Compute ROI points only if closed polygon + if len(grid['points']) >= 3: + subset_size = self.subset_size_spinbox.value() + grid['roi_points'] = self.rois_inside_polygon(grid['points'], subset_size) + + self.update_grid_display() + self.update_selected_points() + + def rois_inside_polygon(self, polygon, subset_size): + from matplotlib.path import Path + + if len(polygon) < 3: + return [] + + polygon = np.array(polygon) + min_x, max_x = int(np.min(polygon[:, 0])), int(np.max(polygon[:, 0])) + min_y, max_y = int(np.min(polygon[:, 1])), int(np.max(polygon[:, 1])) + + xs = np.arange(min_x, max_x + 1, subset_size) + ys = np.arange(min_y, max_y + 1, subset_size) + grid_x, grid_y = np.meshgrid(xs, ys) + points = np.vstack([grid_x.ravel(), grid_y.ravel()]).T + + mask = Path(polygon).contains_points(points) + return [tuple(p) for p in points[mask]] + + def on_grid_selected(self, index): + if 0 <= index < len(self.grid_polygons): + self.active_grid_index = index + + def delete_selected_grid(self): + row = self.grid_list.currentRow() + if row >= 0 and len(self.grid_polygons) > 1: + del self.grid_polygons[row] + self.grid_list.takeItem(row) + self.active_grid_index = max(0, row - 1) + self.grid_list.setCurrentRow(self.active_grid_index) + self.update_grid_display() + self.update_selected_points() + + def update_grid_display(self): + # Combine all points from all grid polygons for scatter + all_pts = [pt for poly in self.grid_polygons for pt in poly['points']] + + # Create or update scatter plot for grid polygon vertices + if not hasattr(self, 'grid_points_scatter'): + self.grid_points_scatter = ScatterPlotItem( + pen=pg.mkPen(None), + brush=pg.mkBrush(255, 200, 0, 200), + size=6 + ) + self.view.addItem(self.grid_points_scatter) + self.grid_points_scatter.setData(pos=all_pts) + + # Combine all polygon outlines with np.nan-separated segments + xs, ys = [], [] + for poly in self.grid_polygons: + path = poly['points'] + if len(path) >= 2: + xs.extend([p[0] for p in path] + [path[0][0], np.nan]) # Close polygon + ys.extend([p[1] for p in path] + [path[0][1], np.nan]) + elif len(path) == 1: + xs.extend([path[0][0], path[0][0], np.nan]) + ys.extend([path[0][1], path[0][1], np.nan]) + + # Create or update line plot for polygon outlines + if not hasattr(self, 'grid_line'): + self.grid_line = pg.PlotDataItem( + pen=pg.mkPen('c', width=2) # Cyan line + ) + self.view.addItem(self.grid_line) + self.grid_line.setData(xs, ys) + def update_selected_points(self): polygon_points = [pt for poly in self.drawing_polygons for pt in poly['roi_points']] - self.selected_points = self.manual_points + polygon_points - - for rect in self.selection_rects: - self.view.removeItem(rect) - self.selection_rects.clear() - - if self.selected_points: - spots = [{'pos': pt} for pt in self.selected_points] - self.scatter.setData(spots) - - subset_size = self.subset_size_spinbox.value() - half_size = subset_size / 2 - - for x, y in self.selected_points: - rect = pg.QtWidgets.QGraphicsRectItem( - x - half_size, y - half_size, subset_size, subset_size - ) - rect.setPen(pg.mkPen((100, 255, 100, 200), width=2)) - self.view.addItem(rect) - self.selection_rects.append(rect) - else: + grid_points = [pt for g in self.grid_polygons for pt in g['roi_points']] + self.selected_points = self.manual_points + polygon_points + grid_points + + if not self.selected_points: self.scatter.clear() + self.roi_overlay.clear() + return + + subset_size = self.subset_size_spinbox.value() + half = subset_size // 2 + + # Prepare overlay + h, w = self.image_item.image.shape[:2] + overlay = np.zeros((h, w, 4), dtype=np.uint8) # RGBA + + for y, x in self.selected_points: + x0 = int(round(x - half)) + y0 = int(round(y - half)) + x1 = int(round(x + half)) + y1 = int(round(y + half)) + + # Ensure bounds + if x0 < 0 or y0 < 0 or x1 >= w or y1 >= h: + continue + + # Fill interior (semi-transparent green) + overlay[y0:y1, x0:x1, 1] = 180 # green channel + overlay[y0:y1, x0:x1, 3] = 40 # alpha + + # Outline (more opaque green) + overlay[y0, x0:x1, 1] = 255 # top + overlay[y1 - 1, x0:x1, 1] = 255 # bottom + overlay[y0:y1, x0, 1] = 255 # left + overlay[y0:y1, x1 - 1, 1] = 255 # right + + overlay[y0, x0:x1, 3] = 150 # alpha + overlay[y1 - 1, x0:x1, 3] = 150 + overlay[y0:y1, x0, 3] = 150 + overlay[y0:y1, x1 - 1, 3] = 150 + + self.roi_overlay.setImage(overlay, autoLevels=False) + self.roi_overlay.setZValue(1) + + # Dots + self.scatter.setData( + pos=self.selected_points, + symbol='o', + size=6, + brush=pg.mkBrush(50, 50, 50, 200), + pen=pg.mkPen(None) + ) + def start_new_line(self): print("Starting a new line...") @@ -294,18 +435,41 @@ def start_new_line(self): self.update_polygon_display() self.update_selected_points() - def clear_selection(self): print("Clearing selections...") - self.drawing_polygons = [{'points': [], 'roi_points': []}] + + # Clear manual points self.manual_points = [] + + # Clear line-based polygons + self.drawing_polygons = [{'points': [], 'roi_points': []}] self.polygon_list.clear() self.polygon_list.addItem("Polygon 1") self.polygon_list.setCurrentRow(0) self.active_polygon_index = 0 self.polygon_line.clear() self.polygon_points_scatter.clear() - self.update_selected_points() + + # Clear grid-based polygons + self.grid_polygons = [{'points': [], 'roi_points': []}] + self.grid_list.clear() + self.grid_list.addItem("Grid 1") + self.grid_list.setCurrentRow(0) + self.active_grid_index = 0 + + if hasattr(self, 'grid_line'): + self.grid_line.clear() + if hasattr(self, 'grid_points_scatter'): + self.grid_points_scatter.clear() + + # Clear selected points and visual overlays + self.selected_points = [] + + if hasattr(self, 'scatter'): + self.scatter.clear() + if hasattr(self, 'roi_overlay'): + self.roi_overlay.clear() + def set_image(self, img: np.ndarray): """Display image in the manual tab.""" From d30d87a770d2e9ed98f28386e648852f1404554c Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 06:48:44 +0200 Subject: [PATCH 10/63] grid list works --- pyidi/selection/main_window.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 9e67f11..9b38805 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -83,6 +83,9 @@ def __init__(self, video): # Set the initial image self.image_item.setImage(video) + # Ensure method-specific widgets are visible on startup + self.method_selected(self.button_group.checkedId()) + # Start the GUI self.show() if app is not None: @@ -425,14 +428,23 @@ def update_selected_points(self): pen=pg.mkPen(None) ) - def start_new_line(self): print("Starting a new line...") - self.drawing_polygons.append({'points': [], 'roi_points': []}) - self.active_polygon_index = len(self.drawing_polygons) - 1 - self.polygon_list.addItem(f"Polygon {self.active_polygon_index + 1}") - self.polygon_list.setCurrentRow(self.active_polygon_index) - self.update_polygon_display() + + if self.method_buttons["Along the line"].isChecked(): + self.drawing_polygons.append({'points': [], 'roi_points': []}) + self.active_polygon_index = len(self.drawing_polygons) - 1 + self.polygon_list.addItem(f"Polygon {self.active_polygon_index + 1}") + self.polygon_list.setCurrentRow(self.active_polygon_index) + self.update_polygon_display() + + elif self.method_buttons["Grid"].isChecked(): + self.grid_polygons.append({'points': [], 'roi_points': []}) + self.active_grid_index = len(self.grid_polygons) - 1 + self.grid_list.addItem(f"Grid {self.active_grid_index + 1}") + self.grid_list.setCurrentRow(self.active_grid_index) + self.update_grid_display() + self.update_selected_points() def clear_selection(self): From bc3fb5efc19b7e62166470be288a9d2206901c88 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 06:51:57 +0200 Subject: [PATCH 11/63] color fix for points --- pyidi/selection/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 9b38805..cdfb60a 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -424,7 +424,7 @@ def update_selected_points(self): pos=self.selected_points, symbol='o', size=6, - brush=pg.mkBrush(50, 50, 50, 200), + brush=pg.mkBrush(255, 100, 100, 200), pen=pg.mkPen(None) ) From 78efcd0488dc0741702afe4091c1f580897ed760 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 06:53:44 +0200 Subject: [PATCH 12/63] show subsets checkbox --- pyidi/selection/main_window.py | 74 +++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index cdfb60a..654f000 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -148,6 +148,12 @@ def ui_manual_right_menu(self): self.subset_size_spinbox.valueChanged.connect(self.update_selected_points) self.method_layout.addWidget(self.subset_size_spinbox) + # Show ROI rectangles + self.show_roi_checkbox = QtWidgets.QCheckBox("Show subsets") + self.show_roi_checkbox.setChecked(True) + self.show_roi_checkbox.stateChanged.connect(self.update_selected_points) + self.method_layout.addWidget(self.show_roi_checkbox) + # Clear button self.method_layout.addSpacing(20) self.clear_button = QtWidgets.QPushButton("Clear selections") @@ -387,39 +393,42 @@ def update_selected_points(self): subset_size = self.subset_size_spinbox.value() half = subset_size // 2 - # Prepare overlay - h, w = self.image_item.image.shape[:2] - overlay = np.zeros((h, w, 4), dtype=np.uint8) # RGBA - - for y, x in self.selected_points: - x0 = int(round(x - half)) - y0 = int(round(y - half)) - x1 = int(round(x + half)) - y1 = int(round(y + half)) - - # Ensure bounds - if x0 < 0 or y0 < 0 or x1 >= w or y1 >= h: - continue - - # Fill interior (semi-transparent green) - overlay[y0:y1, x0:x1, 1] = 180 # green channel - overlay[y0:y1, x0:x1, 3] = 40 # alpha - - # Outline (more opaque green) - overlay[y0, x0:x1, 1] = 255 # top - overlay[y1 - 1, x0:x1, 1] = 255 # bottom - overlay[y0:y1, x0, 1] = 255 # left - overlay[y0:y1, x1 - 1, 1] = 255 # right - - overlay[y0, x0:x1, 3] = 150 # alpha - overlay[y1 - 1, x0:x1, 3] = 150 - overlay[y0:y1, x0, 3] = 150 - overlay[y0:y1, x1 - 1, 3] = 150 - - self.roi_overlay.setImage(overlay, autoLevels=False) - self.roi_overlay.setZValue(1) + # --- Rectangles --- + if self.show_roi_checkbox.isChecked(): + h, w = self.image_item.image.shape[:2] + overlay = np.zeros((h, w, 4), dtype=np.uint8) # RGBA + + for y, x in self.selected_points: + x0 = int(round(x - half)) + y0 = int(round(y - half)) + x1 = int(round(x + half)) + y1 = int(round(y + half)) + + # Ensure bounds + if x0 < 0 or y0 < 0 or x1 >= w or y1 >= h: + continue + + # Fill interior (semi-transparent green) + overlay[y0:y1, x0:x1, 1] = 180 # green + overlay[y0:y1, x0:x1, 3] = 40 # alpha + + # Outline (more opaque green) + overlay[y0, x0:x1, 1] = 255 # top + overlay[y1 - 1, x0:x1, 1] = 255 # bottom + overlay[y0:y1, x0, 1] = 255 # left + overlay[y0:y1, x1 - 1, 1] = 255 # right + + overlay[y0, x0:x1, 3] = 150 + overlay[y1 - 1, x0:x1, 3] = 150 + overlay[y0:y1, x0, 3] = 150 + overlay[y0:y1, x1 - 1, 3] = 150 + + self.roi_overlay.setImage(overlay, autoLevels=False) + self.roi_overlay.setZValue(1) + else: + self.roi_overlay.clear() - # Dots + # --- Center Dots --- self.scatter.setData( pos=self.selected_points, symbol='o', @@ -428,6 +437,7 @@ def update_selected_points(self): pen=pg.mkPen(None) ) + def start_new_line(self): print("Starting a new line...") From 4c8dd7267599b8727aae367d6066a254239c3d33 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 07:02:45 +0200 Subject: [PATCH 13/63] Remove point functionality --- pyidi/selection/main_window.py | 36 +++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 654f000..63d57da 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -122,7 +122,8 @@ def ui_manual_right_menu(self): method_names = [ "Grid", "Manual", - "Along the line" + "Along the line", + "Remove point", ] for i, name in enumerate(method_names): button = QtWidgets.QPushButton(name) @@ -220,6 +221,8 @@ def on_mouse_click(self, event): self.handle_polygon_drawing(event) elif self.method_buttons["Grid"].isChecked(): self.handle_grid_drawing(event) + elif self.method_buttons["Remove point"].isChecked(): + self.handle_remove_point(event) def handle_manual_selection(self, event): """Handle manual selection of points.""" @@ -253,6 +256,37 @@ def handle_polygon_drawing(self, event): self.update_polygon_display() self.update_selected_points() + + def handle_remove_point(self, event): + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + + # Find nearest point + if not self.selected_points: + return + + pts = np.array(self.selected_points) + distances = np.linalg.norm(pts - np.array([x, y]), axis=1) + idx = np.argmin(distances) + closest = tuple(pts[idx]) + + # Remove from manual if present + if closest in self.manual_points: + self.manual_points.remove(closest) + + # Remove from polygons + for poly in self.drawing_polygons: + if closest in poly['roi_points']: + poly['roi_points'].remove(closest) + + # Remove from grid + for grid in self.grid_polygons: + if closest in grid['roi_points']: + grid['roi_points'].remove(closest) + + self.update_selected_points() def update_polygon_display(self): all_pts = [pt for poly in self.drawing_polygons for pt in poly['points']] From 300e0b68fd4d1b83df479941e099092c8ac775fc Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 07:13:11 +0200 Subject: [PATCH 14/63] Track the number of selected subsets --- pyidi/selection/main_window.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 63d57da..49cff32 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -111,10 +111,19 @@ def ui_graphics(self): self.splitter.addWidget(self.pg_widget) def ui_manual_right_menu(self): - # Method buttons on the right + # The right-side menu self.method_widget = QtWidgets.QWidget() self.method_layout = QtWidgets.QVBoxLayout(self.method_widget) + # Number of selected subsets + self.points_label = QtWidgets.QLabel("Selected subsets: 0") + font = self.points_label.font() + font.setPointSize(10) + font.setBold(True) + self.points_label.setFont(font) + self.method_layout.addWidget(self.points_label) + + # Method selection buttons self.button_group = QtWidgets.QButtonGroup(self.method_widget) self.button_group.setExclusive(True) @@ -470,6 +479,7 @@ def update_selected_points(self): brush=pg.mkBrush(255, 100, 100, 200), pen=pg.mkPen(None) ) + self.points_label.setText(f"Selected subsets: {len(self.selected_points)}") def start_new_line(self): @@ -526,6 +536,8 @@ def clear_selection(self): if hasattr(self, 'roi_overlay'): self.roi_overlay.clear() + self.points_label.setText("Selected subsets: 0") + def set_image(self, img: np.ndarray): """Display image in the manual tab.""" From c224202662e2be623c75754a6b200c248eee45a7 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 07:27:38 +0200 Subject: [PATCH 15/63] Added distance between the subsets --- pyidi/selection/along_line.py | 26 -------- pyidi/selection/main_window.py | 108 ++++++++++++++++++++++++++------- 2 files changed, 87 insertions(+), 47 deletions(-) delete mode 100644 pyidi/selection/along_line.py diff --git a/pyidi/selection/along_line.py b/pyidi/selection/along_line.py deleted file mode 100644 index 6853375..0000000 --- a/pyidi/selection/along_line.py +++ /dev/null @@ -1,26 +0,0 @@ -import numpy as np - -def points_along_polygon(polygon, subset_size): - if len(polygon) < 2: - return [] - - # List of points along the path - result_points = [] - - for i in range(len(polygon) - 1): - p1 = np.array(polygon[i]) - p2 = np.array(polygon[i + 1]) - segment = p2 - p1 - length = np.linalg.norm(segment) - - if length == 0: - continue - - direction = segment / length - n_points = int(length // subset_size) - - for j in range(n_points + 1): - pt = p1 + j * subset_size * direction - result_points.append((round(pt[0] - 0.5) + 0.5, round(pt[1] - 0.5) + 0.5)) - - return result_points \ No newline at end of file diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 49cff32..6164118 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -5,7 +5,6 @@ import numpy as np import sys -from along_line import points_along_polygon class SelectionGUI(QtWidgets.QMainWindow): def __init__(self, video): @@ -164,6 +163,20 @@ def ui_manual_right_menu(self): self.show_roi_checkbox.stateChanged.connect(self.update_selected_points) self.method_layout.addWidget(self.show_roi_checkbox) + # Distance between subsets (only visible for Grid and Along the line) + self.distance_label = QtWidgets.QLabel("Distance between subsets:") + self.distance_label.setVisible(False) # Hidden by default + self.distance_spinbox = QtWidgets.QSpinBox() + self.distance_spinbox.setVisible(False) # Hidden by default + self.distance_spinbox.setRange(-1000, 1000) + self.distance_spinbox.setValue(0) + self.distance_spinbox.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + self.distance_spinbox.setSingleStep(1) + self.distance_spinbox.valueChanged.connect(self.recompute_roi_points) + + self.method_layout.addWidget(self.distance_label) + self.method_layout.addWidget(self.distance_spinbox) + # Clear button self.method_layout.addSpacing(20) self.clear_button = QtWidgets.QPushButton("Clear selections") @@ -215,6 +228,7 @@ def method_selected(self, id: int): print(f"Selected method: {method_name}") is_along = method_name == "Along the line" is_grid = method_name == "Grid" + show_spacing = is_along or is_grid self.start_new_line_button.setVisible(is_along or is_grid) self.polygon_list.setVisible(is_along) @@ -223,6 +237,9 @@ def method_selected(self, id: int): self.grid_list.setVisible(is_grid) self.delete_grid_button.setVisible(is_grid) + self.distance_label.setVisible(show_spacing) + self.distance_spinbox.setVisible(show_spacing) + def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) @@ -261,7 +278,8 @@ def handle_polygon_drawing(self, event): # Update ROI points only for this polygon if len(poly['points']) >= 2: subset_size = self.subset_size_spinbox.value() - poly['roi_points'] = points_along_polygon(poly['points'], subset_size) + spacing = self.distance_spinbox.value() + poly['roi_points'] = points_along_polygon(poly['points'], subset_size, spacing) self.update_polygon_display() self.update_selected_points() @@ -353,28 +371,11 @@ def handle_grid_drawing(self, event): # Compute ROI points only if closed polygon if len(grid['points']) >= 3: subset_size = self.subset_size_spinbox.value() - grid['roi_points'] = self.rois_inside_polygon(grid['points'], subset_size) + spacing = self.distance_spinbox.value() + grid['roi_points'] = rois_inside_polygon(grid['points'], subset_size, spacing) self.update_grid_display() self.update_selected_points() - - def rois_inside_polygon(self, polygon, subset_size): - from matplotlib.path import Path - - if len(polygon) < 3: - return [] - - polygon = np.array(polygon) - min_x, max_x = int(np.min(polygon[:, 0])), int(np.max(polygon[:, 0])) - min_y, max_y = int(np.min(polygon[:, 1])), int(np.max(polygon[:, 1])) - - xs = np.arange(min_x, max_x + 1, subset_size) - ys = np.arange(min_y, max_y + 1, subset_size) - grid_x, grid_y = np.meshgrid(xs, ys) - points = np.vstack([grid_x.ravel(), grid_y.ravel()]).T - - mask = Path(polygon).contains_points(points) - return [tuple(p) for p in points[mask]] def on_grid_selected(self, index): if 0 <= index < len(self.grid_polygons): @@ -481,6 +482,21 @@ def update_selected_points(self): ) self.points_label.setText(f"Selected subsets: {len(self.selected_points)}") + def recompute_roi_points(self): + subset_size = self.subset_size_spinbox.value() + spacing = self.distance_spinbox.value() + + # Update all "along the line" polygons + for poly in self.drawing_polygons: + if len(poly['points']) >= 2: + poly['roi_points'] = points_along_polygon(poly['points'], subset_size, spacing) + + # Update all "grid" polygons + for grid in self.grid_polygons: + if len(grid['points']) >= 3: + grid['roi_points'] = rois_inside_polygon(grid['points'], subset_size, spacing) + + self.update_selected_points() def start_new_line(self): print("Starting a new line...") @@ -546,6 +562,56 @@ def set_image(self, img: np.ndarray): def get_points(self): """Get all selected points from manual and polygons.""" return self.selected_points + +def points_along_polygon(polygon, subset_size, spacing=0): + if len(polygon) < 2: + return [] + + step = subset_size + spacing + if step <= 0: + step = 1 + + result_points = [] + + for i in range(len(polygon) - 1): + p1 = np.array(polygon[i]) + p2 = np.array(polygon[i + 1]) + segment = p2 - p1 + length = np.linalg.norm(segment) + + if length == 0: + continue + + direction = segment / length + n_points = int(length // step) + + for j in range(n_points + 1): + pt = p1 + j * step * direction + result_points.append((round(pt[0] - 0.5) + 0.5, round(pt[1] - 0.5) + 0.5)) + + return result_points + +def rois_inside_polygon(polygon, subset_size, spacing): + from matplotlib.path import Path + + if len(polygon) < 3: + return [] + + polygon = np.array(polygon) + min_x, max_x = int(np.min(polygon[:, 0])), int(np.max(polygon[:, 0])) + min_y, max_y = int(np.min(polygon[:, 1])), int(np.max(polygon[:, 1])) + + step = subset_size + spacing + if step <= 0: + step = 1 # minimum step to avoid infinite loop + xs = np.arange(min_x, max_x + 1, step) + ys = np.arange(min_y, max_y + 1, step) + + grid_x, grid_y = np.meshgrid(xs, ys) + points = np.vstack([grid_x.ravel(), grid_y.ravel()]).T + + mask = Path(polygon).contains_points(points) + return [tuple(p) for p in points[mask]] if __name__ == "__main__": example_image = np.random.rand(512, 512) * 255 From 958b83329ac5816fe7578e6e7804e0d112aeb3d9 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 07:46:55 +0200 Subject: [PATCH 16/63] Code cleanup --- pyidi/selection/main_window.py | 348 ++++++++++++++++----------------- 1 file changed, 171 insertions(+), 177 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 6164118..987d986 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -1,11 +1,9 @@ -from operator import index from PyQt6 import QtWidgets, QtCore from pyqtgraph import GraphicsLayoutWidget, ImageItem, ScatterPlotItem import pyqtgraph as pg import numpy as np import sys - class SelectionGUI(QtWidgets.QMainWindow): def __init__(self, video): app = QtWidgets.QApplication.instance() @@ -249,180 +247,6 @@ def on_mouse_click(self, event): self.handle_grid_drawing(event) elif self.method_buttons["Remove point"].isChecked(): self.handle_remove_point(event) - - def handle_manual_selection(self, event): - """Handle manual selection of points.""" - pos = event.scenePos() - if self.view.sceneBoundingRect().contains(pos): - mouse_point = self.view.mapSceneToView(pos) - x, y = mouse_point.x(), mouse_point.y() - x_int, y_int = round(x-0.5)+0.5, round(y-0.5)+0.5 - self.manual_points.append((x_int, y_int)) - self.update_selected_points() - - def handle_polygon_drawing(self, event): - pos = event.scenePos() - if self.view.sceneBoundingRect().contains(pos): - mouse_point = self.view.mapSceneToView(pos) - x, y = mouse_point.x(), mouse_point.y() - x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 - - # Add first polygon to the list if not yet shown - if self.polygon_list.count() == 0: - self.polygon_list.addItem("Polygon 1") - self.polygon_list.setCurrentRow(0) - - poly = self.drawing_polygons[self.active_polygon_index] - poly['points'].append((x_int, y_int)) - - # Update ROI points only for this polygon - if len(poly['points']) >= 2: - subset_size = self.subset_size_spinbox.value() - spacing = self.distance_spinbox.value() - poly['roi_points'] = points_along_polygon(poly['points'], subset_size, spacing) - - self.update_polygon_display() - self.update_selected_points() - - def handle_remove_point(self, event): - pos = event.scenePos() - if self.view.sceneBoundingRect().contains(pos): - mouse_point = self.view.mapSceneToView(pos) - x, y = mouse_point.x(), mouse_point.y() - - # Find nearest point - if not self.selected_points: - return - - pts = np.array(self.selected_points) - distances = np.linalg.norm(pts - np.array([x, y]), axis=1) - idx = np.argmin(distances) - closest = tuple(pts[idx]) - - # Remove from manual if present - if closest in self.manual_points: - self.manual_points.remove(closest) - - # Remove from polygons - for poly in self.drawing_polygons: - if closest in poly['roi_points']: - poly['roi_points'].remove(closest) - - # Remove from grid - for grid in self.grid_polygons: - if closest in grid['roi_points']: - grid['roi_points'].remove(closest) - - self.update_selected_points() - - def update_polygon_display(self): - all_pts = [pt for poly in self.drawing_polygons for pt in poly['points']] - self.polygon_points_scatter.setData(pos=all_pts) - - xs, ys = [], [] - for poly in self.drawing_polygons: - path = poly['points'] - if len(path) >= 2: - xs.extend([p[0] for p in path] + [np.nan]) - ys.extend([p[1] for p in path] + [np.nan]) - elif len(path) == 1: - xs.extend([path[0][0], path[0][0], np.nan]) - ys.extend([path[0][1], path[0][1], np.nan]) - - self.polygon_line.setData(xs, ys) - - def points_from_all_polygons(self): - subset_size = self.subset_size_spinbox.value() - all_points = [] - for path in self.drawing_polygons: - if len(path) >= 2: - all_points.extend(points_along_polygon(path, subset_size)) - return all_points - - def delete_selected_polygon(self): - row = self.polygon_list.currentRow() - if row >= 0 and len(self.drawing_polygons) > 1: - del self.drawing_polygons[row] - self.polygon_list.takeItem(row) - self.active_polygon_index = max(0, row - 1) - self.polygon_list.setCurrentRow(self.active_polygon_index) - self.update_polygon_display() - self.update_selected_points() - - def on_polygon_selected(self, index): - if 0 <= index < len(self.drawing_polygons): - self.active_polygon_index = index - - def handle_grid_drawing(self, event): - pos = event.scenePos() - if self.view.sceneBoundingRect().contains(pos): - mouse_point = self.view.mapSceneToView(pos) - x, y = mouse_point.x(), mouse_point.y() - x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 - - # Add first grid polygon to the list if not yet shown - if self.grid_list.count() == 0: - self.grid_list.addItem("Grid 1") - self.grid_list.setCurrentRow(0) - - grid = self.grid_polygons[self.active_grid_index] - grid['points'].append((x_int, y_int)) - - # Compute ROI points only if closed polygon - if len(grid['points']) >= 3: - subset_size = self.subset_size_spinbox.value() - spacing = self.distance_spinbox.value() - grid['roi_points'] = rois_inside_polygon(grid['points'], subset_size, spacing) - - self.update_grid_display() - self.update_selected_points() - - def on_grid_selected(self, index): - if 0 <= index < len(self.grid_polygons): - self.active_grid_index = index - - def delete_selected_grid(self): - row = self.grid_list.currentRow() - if row >= 0 and len(self.grid_polygons) > 1: - del self.grid_polygons[row] - self.grid_list.takeItem(row) - self.active_grid_index = max(0, row - 1) - self.grid_list.setCurrentRow(self.active_grid_index) - self.update_grid_display() - self.update_selected_points() - - def update_grid_display(self): - # Combine all points from all grid polygons for scatter - all_pts = [pt for poly in self.grid_polygons for pt in poly['points']] - - # Create or update scatter plot for grid polygon vertices - if not hasattr(self, 'grid_points_scatter'): - self.grid_points_scatter = ScatterPlotItem( - pen=pg.mkPen(None), - brush=pg.mkBrush(255, 200, 0, 200), - size=6 - ) - self.view.addItem(self.grid_points_scatter) - self.grid_points_scatter.setData(pos=all_pts) - - # Combine all polygon outlines with np.nan-separated segments - xs, ys = [], [] - for poly in self.grid_polygons: - path = poly['points'] - if len(path) >= 2: - xs.extend([p[0] for p in path] + [path[0][0], np.nan]) # Close polygon - ys.extend([p[1] for p in path] + [path[0][1], np.nan]) - elif len(path) == 1: - xs.extend([path[0][0], path[0][0], np.nan]) - ys.extend([path[0][1], path[0][1], np.nan]) - - # Create or update line plot for polygon outlines - if not hasattr(self, 'grid_line'): - self.grid_line = pg.PlotDataItem( - pen=pg.mkPen('c', width=2) # Cyan line - ) - self.view.addItem(self.grid_line) - self.grid_line.setData(xs, ys) def update_selected_points(self): polygon_points = [pt for poly in self.drawing_polygons for pt in poly['roi_points']] @@ -554,7 +378,6 @@ def clear_selection(self): self.points_label.setText("Selected subsets: 0") - def set_image(self, img: np.ndarray): """Display image in the manual tab.""" self.image_item.setImage(img) @@ -562,7 +385,178 @@ def set_image(self, img: np.ndarray): def get_points(self): """Get all selected points from manual and polygons.""" return self.selected_points + + # Grid selection + def handle_grid_drawing(self, event): + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 + + # Add first grid polygon to the list if not yet shown + if self.grid_list.count() == 0: + self.grid_list.addItem("Grid 1") + self.grid_list.setCurrentRow(0) + + grid = self.grid_polygons[self.active_grid_index] + grid['points'].append((x_int, y_int)) + + # Compute ROI points only if closed polygon + if len(grid['points']) >= 3: + subset_size = self.subset_size_spinbox.value() + spacing = self.distance_spinbox.value() + grid['roi_points'] = rois_inside_polygon(grid['points'], subset_size, spacing) + + self.update_grid_display() + self.update_selected_points() + + def on_grid_selected(self, index): + if 0 <= index < len(self.grid_polygons): + self.active_grid_index = index + + def delete_selected_grid(self): + row = self.grid_list.currentRow() + if row >= 0 and len(self.grid_polygons) > 1: + del self.grid_polygons[row] + self.grid_list.takeItem(row) + self.active_grid_index = max(0, row - 1) + self.grid_list.setCurrentRow(self.active_grid_index) + self.update_grid_display() + self.update_selected_points() + + def update_grid_display(self): + # Combine all points from all grid polygons for scatter + all_pts = [pt for poly in self.grid_polygons for pt in poly['points']] + + # Create or update scatter plot for grid polygon vertices + if not hasattr(self, 'grid_points_scatter'): + self.grid_points_scatter = ScatterPlotItem( + pen=pg.mkPen(None), + brush=pg.mkBrush(255, 200, 0, 200), + size=6 + ) + self.view.addItem(self.grid_points_scatter) + self.grid_points_scatter.setData(pos=all_pts) + + # Combine all polygon outlines with np.nan-separated segments + xs, ys = [], [] + for poly in self.grid_polygons: + path = poly['points'] + if len(path) >= 2: + xs.extend([p[0] for p in path] + [path[0][0], np.nan]) # Close polygon + ys.extend([p[1] for p in path] + [path[0][1], np.nan]) + elif len(path) == 1: + xs.extend([path[0][0], path[0][0], np.nan]) + ys.extend([path[0][1], path[0][1], np.nan]) + + # Create or update line plot for polygon outlines + if not hasattr(self, 'grid_line'): + self.grid_line = pg.PlotDataItem( + pen=pg.mkPen('c', width=2) # Cyan line + ) + self.view.addItem(self.grid_line) + self.grid_line.setData(xs, ys) + + # Manual selection + def handle_manual_selection(self, event): + """Handle manual selection of points.""" + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + x_int, y_int = round(x-0.5)+0.5, round(y-0.5)+0.5 + self.manual_points.append((x_int, y_int)) + self.update_selected_points() + + # Along the line selection + def handle_polygon_drawing(self, event): + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 + + # Add first polygon to the list if not yet shown + if self.polygon_list.count() == 0: + self.polygon_list.addItem("Polygon 1") + self.polygon_list.setCurrentRow(0) + + poly = self.drawing_polygons[self.active_polygon_index] + poly['points'].append((x_int, y_int)) + + # Update ROI points only for this polygon + if len(poly['points']) >= 2: + subset_size = self.subset_size_spinbox.value() + spacing = self.distance_spinbox.value() + poly['roi_points'] = points_along_polygon(poly['points'], subset_size, spacing) + + self.update_polygon_display() + self.update_selected_points() + + def delete_selected_polygon(self): + row = self.polygon_list.currentRow() + if row >= 0 and len(self.drawing_polygons) > 1: + del self.drawing_polygons[row] + self.polygon_list.takeItem(row) + self.active_polygon_index = max(0, row - 1) + self.polygon_list.setCurrentRow(self.active_polygon_index) + self.update_polygon_display() + self.update_selected_points() + + def update_polygon_display(self): + all_pts = [pt for poly in self.drawing_polygons for pt in poly['points']] + self.polygon_points_scatter.setData(pos=all_pts) + + xs, ys = [], [] + for poly in self.drawing_polygons: + path = poly['points'] + if len(path) >= 2: + xs.extend([p[0] for p in path] + [np.nan]) + ys.extend([p[1] for p in path] + [np.nan]) + elif len(path) == 1: + xs.extend([path[0][0], path[0][0], np.nan]) + ys.extend([path[0][1], path[0][1], np.nan]) + + self.polygon_line.setData(xs, ys) + + def on_polygon_selected(self, index): + if 0 <= index < len(self.drawing_polygons): + self.active_polygon_index = index + + # Remove point selection + def handle_remove_point(self, event): + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + x, y = mouse_point.x(), mouse_point.y() + + # Find nearest point + if not self.selected_points: + return + + pts = np.array(self.selected_points) + distances = np.linalg.norm(pts - np.array([x, y]), axis=1) + idx = np.argmin(distances) + closest = tuple(pts[idx]) + + # Remove from manual if present + if closest in self.manual_points: + self.manual_points.remove(closest) + + # Remove from polygons + for poly in self.drawing_polygons: + if closest in poly['roi_points']: + poly['roi_points'].remove(closest) + + # Remove from grid + for grid in self.grid_polygons: + if closest in grid['roi_points']: + grid['roi_points'].remove(closest) + + self.update_selected_points() + def points_along_polygon(polygon, subset_size, spacing=0): if len(polygon) < 2: return [] From 589a2875107ea7add46c49bd2c0eeb69d89af86c Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 07:58:00 +0200 Subject: [PATCH 17/63] SelectionGUI import (has pyqt6 or not) --- pyidi/selection/__init__.py | 16 ++++++++++++++++ pyidi/selection/main_window.py | 3 +-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 pyidi/selection/__init__.py diff --git a/pyidi/selection/__init__.py b/pyidi/selection/__init__.py new file mode 100644 index 0000000..532ba05 --- /dev/null +++ b/pyidi/selection/__init__.py @@ -0,0 +1,16 @@ +try: + import pyqt6 + + HAS_PYQT6 = True +except ImportError: + HAS_PYQT6 = False + +if HAS_PYQT6: + from .main_window import SelectionGUI +else: + class DisplacementGUI: + def __init__(self): + pass + + def show_displacement(self, data): + raise RuntimeError("GUI requires PyQt6: pip install pyidi[gui]") \ No newline at end of file diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 987d986..b25ac9f 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -1,8 +1,7 @@ +import numpy as np from PyQt6 import QtWidgets, QtCore from pyqtgraph import GraphicsLayoutWidget, ImageItem, ScatterPlotItem import pyqtgraph as pg -import numpy as np -import sys class SelectionGUI(QtWidgets.QMainWindow): def __init__(self, video): From d6309a8043470d52b46e11ab9c5a823cc016b504 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 08:15:04 +0200 Subject: [PATCH 18/63] added placeholders for automatic feature selection --- pyidi/selection/main_window.py | 52 ++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index b25ac9f..fe50a70 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -32,7 +32,7 @@ def __init__(self, video): self.manual_layout.addWidget(self.splitter) # Graphics layout for image and points display - self.ui_graphics() + self.ui_manual_graphics() # Right-side menu for methods self.ui_manual_right_menu() @@ -41,9 +41,17 @@ def __init__(self, video): # --- Automatic Tab --- self.automatic_tab = QtWidgets.QWidget() - self.automatic_layout = QtWidgets.QVBoxLayout(self.automatic_tab) + self.automatic_splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + self.automatic_layout = QtWidgets.QHBoxLayout(self.automatic_tab) + self.automatic_layout.addWidget(self.automatic_splitter) self.tabs.addTab(self.automatic_tab, "Automatic") + # Graphics layout for automatic image display + self.ui_automatic_graphics() + + # Right-side menu for automatic controls + self.ui_automatic_right_menu() + # Style self.setStyleSheet(""" QTabWidget::pane { border: 0; } @@ -78,6 +86,7 @@ def __init__(self, video): # Set the initial image self.image_item.setImage(video) + self.automatic_image_item.setImage(video) # Ensure method-specific widgets are visible on startup self.method_selected(self.button_group.checkedId()) @@ -87,7 +96,7 @@ def __init__(self, video): if app is not None: app.exec() - def ui_graphics(self): + def ui_manual_graphics(self): # Image viewer self.pg_widget = GraphicsLayoutWidget() self.view = self.pg_widget.addViewBox(lockAspect=True) @@ -106,6 +115,14 @@ def ui_graphics(self): self.splitter.addWidget(self.pg_widget) + def ui_automatic_graphics(self): + self.automatic_pg_widget = GraphicsLayoutWidget() + self.automatic_view = self.automatic_pg_widget.addViewBox(lockAspect=True) + self.automatic_image_item = ImageItem() + self.automatic_view.addItem(self.automatic_image_item) + + self.automatic_splitter.addWidget(self.automatic_pg_widget) + def ui_manual_right_menu(self): # The right-side menu self.method_widget = QtWidgets.QWidget() @@ -220,6 +237,31 @@ def ui_manual_right_menu(self): self.method_widget.setMaximumWidth(600) self.splitter.setSizes([1000, 220]) # Initial left/right width + def ui_automatic_right_menu(self): + self.automatic_controls = QtWidgets.QWidget() + self.automatic_layout_right = QtWidgets.QVBoxLayout(self.automatic_controls) + + # Title + label = QtWidgets.QLabel("Automatic Tools") + font = label.font() + font.setPointSize(10) + font.setBold(True) + label.setFont(font) + self.automatic_layout_right.addWidget(label) + + # Spacer + self.automatic_layout_right.addStretch(1) + + # Set the layout and add to splitter + self.automatic_splitter.addWidget(self.automatic_controls) + self.automatic_splitter.setStretchFactor(0, 5) # Image area grows more + self.automatic_splitter.setStretchFactor(1, 0) # Menu fixed by content + + # Set initial width for right panel + self.automatic_controls.setMinimumWidth(150) + self.automatic_controls.setMaximumWidth(600) + self.automatic_splitter.setSizes([1000, 220]) # Initial left/right width + def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") @@ -555,6 +597,10 @@ def handle_remove_point(self, event): self.update_selected_points() + ################################################################################################ + # Automatic subset detection + ################################################################################################ + def points_along_polygon(polygon, subset_size, spacing=0): if len(polygon) < 2: From 1a934961e71f95e30ae31527cf420d5af6c032ae Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 08:30:42 +0200 Subject: [PATCH 19/63] added test image --- pyidi/selection/main_window.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index fe50a70..4c764b8 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -2,6 +2,7 @@ from PyQt6 import QtWidgets, QtCore from pyqtgraph import GraphicsLayoutWidget, ImageItem, ScatterPlotItem import pyqtgraph as pg +# import pyidi # Assuming pyidi is a custom module for video handling class SelectionGUI(QtWidgets.QMainWindow): def __init__(self, video): @@ -653,7 +654,26 @@ def rois_inside_polygon(polygon, subset_size, spacing): return [tuple(p) for p in points[mask]] if __name__ == "__main__": - example_image = np.random.rand(512, 512) * 255 + # import pyidi + # filename = "data/data_showcase.cih" + # video = pyidi.VideoReader(filename) + # example_image = (video.get_frame(0).T)[:, ::-1] + + + import requests + from PIL import Image + import io + import numpy as np + import matplotlib.pyplot as plt + # Example black and white image (public domain) + url = "https://raw.githubusercontent.com/scikit-image/scikit-image/main/skimage/data/camera.png" + # Fetch the image + response = requests.get(url) + img = Image.open(io.BytesIO(response.content)).convert("L") # Convert to grayscale + # Convert to numpy array + example_image = (np.array(img).T)[:, ::-1] + + Points = SelectionGUI(example_image.astype(np.uint8)) print(Points.get_points()) # Print selected points for testing From 0f08f0ffc06acc79624ca1b5c24172ac32563fba Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 09:15:49 +0200 Subject: [PATCH 20/63] automatic filtering of selected points based on eigenvalues --- pyidi/selection/main_window.py | 136 +++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 6 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 4c764b8..cf78614 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -108,11 +108,18 @@ def ui_manual_graphics(self): self.scatter = ScatterPlotItem(pen=pg.mkPen(None), brush=pg.mkBrush(255, 100, 100, 200), size=8) self.roi_overlay = ImageItem() + self.candidate_scatter = ScatterPlotItem( + pen=pg.mkPen(None), + brush=pg.mkBrush(0, 255, 0, 200), + size=6 + ) + self.view.addItem(self.image_item) self.view.addItem(self.polygon_line) self.view.addItem(self.polygon_points_scatter) self.view.addItem(self.roi_overlay) # Add scatter for showing square points self.view.addItem(self.scatter) # Add scatter for showing points + self.view.addItem(self.candidate_scatter) self.splitter.addWidget(self.pg_widget) @@ -147,6 +154,7 @@ def ui_manual_right_menu(self): "Manual", "Along the line", "Remove point", + "Automatic filtering", # NEW ] for i, name in enumerate(method_names): button = QtWidgets.QPushButton(name) @@ -178,6 +186,18 @@ def ui_manual_right_menu(self): self.show_roi_checkbox.stateChanged.connect(self.update_selected_points) self.method_layout.addWidget(self.show_roi_checkbox) + # Clear button + self.method_layout.addSpacing(20) + self.clear_button = QtWidgets.QPushButton("Clear selections") + self.clear_button.clicked.connect(self.clear_selection) + self.method_layout.addWidget(self.clear_button) + + # Separator line + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.Shape.HLine) + separator.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.method_layout.addWidget(separator) + # Distance between subsets (only visible for Grid and Along the line) self.distance_label = QtWidgets.QLabel("Distance between subsets:") self.distance_label.setVisible(False) # Hidden by default @@ -192,11 +212,29 @@ def ui_manual_right_menu(self): self.method_layout.addWidget(self.distance_label) self.method_layout.addWidget(self.distance_spinbox) - # Clear button - self.method_layout.addSpacing(20) - self.clear_button = QtWidgets.QPushButton("Clear selections") - self.clear_button.clicked.connect(self.clear_selection) - self.method_layout.addWidget(self.clear_button) + # --- Automatic Filtering UI --- + self.threshold_label = QtWidgets.QLabel("Threshold:") + self.threshold_label.setVisible(False) + self.method_layout.addWidget(self.threshold_label) + + self.threshold_spinbox = QtWidgets.QDoubleSpinBox() + self.threshold_spinbox.setRange(1, 100) + self.threshold_spinbox.setSingleStep(1) + self.threshold_spinbox.setValue(10) + self.threshold_spinbox.setDecimals(1) + self.threshold_spinbox.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + self.threshold_spinbox.setVisible(False) + self.method_layout.addWidget(self.threshold_spinbox) + + self.compute_button = QtWidgets.QPushButton("Compute") + self.compute_button.setVisible(False) + self.compute_button.clicked.connect(self.compute_candidate_points) + self.method_layout.addWidget(self.compute_button) + + self.confirm_button = QtWidgets.QPushButton("Confirm") + self.confirm_button.setVisible(False) + self.confirm_button.clicked.connect(self.confirm_candidate_points) + self.method_layout.addWidget(self.confirm_button) # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") @@ -268,18 +306,25 @@ def method_selected(self, id: int): print(f"Selected method: {method_name}") is_along = method_name == "Along the line" is_grid = method_name == "Grid" + is_auto = method_name == "Automatic filtering" show_spacing = is_along or is_grid self.start_new_line_button.setVisible(is_along or is_grid) self.polygon_list.setVisible(is_along) self.delete_polygon_button.setVisible(is_along) - self.grid_list.setVisible(is_grid) self.delete_grid_button.setVisible(is_grid) self.distance_label.setVisible(show_spacing) self.distance_spinbox.setVisible(show_spacing) + # Show automatic filtering controls only in that mode + self.threshold_label.setVisible(is_auto) + self.threshold_spinbox.setVisible(is_auto) + self.compute_button.setVisible(is_auto) + self.confirm_button.setVisible(False) + + def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) @@ -418,6 +463,8 @@ def clear_selection(self): if hasattr(self, 'roi_overlay'): self.roi_overlay.clear() + self.candidate_scatter.clear() + self.points_label.setText("Selected subsets: 0") def set_image(self, img: np.ndarray): @@ -598,6 +645,83 @@ def handle_remove_point(self, event): self.update_selected_points() + # Automatic filtering + def compute_candidate_points(self): + """Compute good feature points using structure tensor analysis (Shi–Tomasi style).""" + from scipy.ndimage import sobel + + subset_size = self.subset_size_spinbox.value() + roi_size = subset_size // 2 + threshold_ratio = self.threshold_spinbox.value() / 1000.0 + + img = self.image_item.image.astype(np.float32) + candidates = [] + + # All selected points (not just manual) + for row, col in self.selected_points: + y, x = int(round(row)), int(round(col)) + + if (y - roi_size < 0 or y + roi_size + 1 > img.shape[0] or + x - roi_size < 0 or x + roi_size + 1 > img.shape[1]): + continue + + roi = img[y - roi_size: y + roi_size + 1, + x - roi_size: x + roi_size + 1] + + # Compute gradients + gx = sobel(roi, axis=1) + gy = sobel(roi, axis=0) + + Gx2 = np.sum(gx ** 2) + Gy2 = np.sum(gy ** 2) + GxGy = np.sum(gx * gy) + + matrix = np.array([[Gx2, GxGy], + [GxGy, Gy2]]) + + eigvals = np.linalg.eigvalsh(matrix) # sorted ascending + min_eig = eigvals[0] + + candidates.append((x + 0.0, y + 0.0, min_eig)) + + if not candidates: + self.candidate_points = [] + self.update_candidate_display() + return + + # Threshold by normalized eigenvalue + eigvals = np.array([v[2] for v in candidates]) + max_eig = np.max(eigvals) + eig_threshold = max_eig * threshold_ratio + + self.candidate_points = [(y, x) for (x, y, e) in candidates if e > eig_threshold] + self.update_candidate_display() + + + def update_candidate_display(self): + """Show candidate points as scatter dots on the image.""" + if not hasattr(self, 'candidate_scatter'): + self.candidate_scatter = ScatterPlotItem( + pen=pg.mkPen(None), + brush=pg.mkBrush(0, 255, 0, 150), # green with transparency + size=6, + symbol='o' + ) + self.view.addItem(self.candidate_scatter) + + if self.candidate_points: + self.candidate_scatter.setData(pos=self.candidate_points) + else: + self.candidate_scatter.clear() + + + + def confirm_candidate_points(self): + self.manual_points = self.candidate_points.copy() + self.candidate_scatter.clear() + self.confirm_button.setVisible(False) + self.recompute_roi_points() + ################################################################################################ # Automatic subset detection ################################################################################################ From 65eed3a7b8fa204233f315f68d900991ce5f7648 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 09:33:12 +0200 Subject: [PATCH 21/63] fixed subset display --- pyidi/selection/main_window.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index cf78614..ccf80de 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -348,16 +348,18 @@ def update_selected_points(self): subset_size = self.subset_size_spinbox.value() half = subset_size // 2 + selected_points = np.round(np.array(self.selected_points) - 0.5) + # --- Rectangles --- if self.show_roi_checkbox.isChecked(): h, w = self.image_item.image.shape[:2] overlay = np.zeros((h, w, 4), dtype=np.uint8) # RGBA - for y, x in self.selected_points: + for y, x in selected_points: x0 = int(round(x - half)) y0 = int(round(y - half)) - x1 = int(round(x + half)) - y1 = int(round(y + half)) + x1 = int(round(x + half+1)) + y1 = int(round(y + half+1)) # Ensure bounds if x0 < 0 or y0 < 0 or x1 >= w or y1 >= h: @@ -385,7 +387,7 @@ def update_selected_points(self): # --- Center Dots --- self.scatter.setData( - pos=self.selected_points, + pos=selected_points + 0.5, symbol='o', size=6, brush=pg.mkBrush(255, 100, 100, 200), @@ -481,7 +483,6 @@ def handle_grid_drawing(self, event): if self.view.sceneBoundingRect().contains(pos): mouse_point = self.view.mapSceneToView(pos) x, y = mouse_point.x(), mouse_point.y() - x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 # Add first grid polygon to the list if not yet shown if self.grid_list.count() == 0: @@ -489,7 +490,7 @@ def handle_grid_drawing(self, event): self.grid_list.setCurrentRow(0) grid = self.grid_polygons[self.active_grid_index] - grid['points'].append((x_int, y_int)) + grid['points'].append((x, y)) # Compute ROI points only if closed polygon if len(grid['points']) >= 3: @@ -554,8 +555,7 @@ def handle_manual_selection(self, event): if self.view.sceneBoundingRect().contains(pos): mouse_point = self.view.mapSceneToView(pos) x, y = mouse_point.x(), mouse_point.y() - x_int, y_int = round(x-0.5)+0.5, round(y-0.5)+0.5 - self.manual_points.append((x_int, y_int)) + self.manual_points.append((x, y)) self.update_selected_points() # Along the line selection @@ -564,7 +564,6 @@ def handle_polygon_drawing(self, event): if self.view.sceneBoundingRect().contains(pos): mouse_point = self.view.mapSceneToView(pos) x, y = mouse_point.x(), mouse_point.y() - x_int, y_int = round(x - 0.5) + 0.5, round(y - 0.5) + 0.5 # Add first polygon to the list if not yet shown if self.polygon_list.count() == 0: @@ -572,7 +571,7 @@ def handle_polygon_drawing(self, event): self.polygon_list.setCurrentRow(0) poly = self.drawing_polygons[self.active_polygon_index] - poly['points'].append((x_int, y_int)) + poly['points'].append((x, y)) # Update ROI points only for this polygon if len(poly['points']) >= 2: @@ -697,7 +696,6 @@ def compute_candidate_points(self): self.candidate_points = [(y, x) for (x, y, e) in candidates if e > eig_threshold] self.update_candidate_display() - def update_candidate_display(self): """Show candidate points as scatter dots on the image.""" if not hasattr(self, 'candidate_scatter'): @@ -714,8 +712,6 @@ def update_candidate_display(self): else: self.candidate_scatter.clear() - - def confirm_candidate_points(self): self.manual_points = self.candidate_points.copy() self.candidate_scatter.clear() From df2d0e11760541b6db25db7a927a7a9518e047ec Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 10:05:32 +0200 Subject: [PATCH 22/63] fixed subset display --- pyidi/selection/main_window.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index ccf80de..311e8f1 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -2,6 +2,7 @@ from PyQt6 import QtWidgets, QtCore from pyqtgraph import GraphicsLayoutWidget, ImageItem, ScatterPlotItem import pyqtgraph as pg +from matplotlib.path import Path # import pyidi # Assuming pyidi is a custom module for video handling class SelectionGUI(QtWidgets.QMainWindow): @@ -348,7 +349,8 @@ def update_selected_points(self): subset_size = self.subset_size_spinbox.value() half = subset_size // 2 - selected_points = np.round(np.array(self.selected_points) - 0.5) + # selected_points = np.round(np.array(self.selected_points) - 0.5) + selected_points = np.array(self.selected_points) # --- Rectangles --- if self.show_roi_checkbox.isChecked(): @@ -555,7 +557,8 @@ def handle_manual_selection(self, event): if self.view.sceneBoundingRect().contains(pos): mouse_point = self.view.mapSceneToView(pos) x, y = mouse_point.x(), mouse_point.y() - self.manual_points.append((x, y)) + x_int, y_int = round(x-0.5), round(y-0.5) + self.manual_points.append((x_int, y_int)) self.update_selected_points() # Along the line selection @@ -752,20 +755,18 @@ def points_along_polygon(polygon, subset_size, spacing=0): return result_points def rois_inside_polygon(polygon, subset_size, spacing): - from matplotlib.path import Path - if len(polygon) < 3: return [] polygon = np.array(polygon) - min_x, max_x = int(np.min(polygon[:, 0])), int(np.max(polygon[:, 0])) - min_y, max_y = int(np.min(polygon[:, 1])), int(np.max(polygon[:, 1])) + min_x, max_x = int(np.floor(np.min(polygon[:, 0]))), int(np.ceil(np.max(polygon[:, 0]))) + min_y, max_y = int(np.floor(np.min(polygon[:, 1]))), int(np.ceil(np.max(polygon[:, 1]))) step = subset_size + spacing if step <= 0: step = 1 # minimum step to avoid infinite loop - xs = np.arange(min_x, max_x + 1, step) - ys = np.arange(min_y, max_y + 1, step) + xs = np.arange(min_x, max_x+1, step) + ys = np.arange(min_y, max_y+1, step) grid_x, grid_y = np.meshgrid(xs, ys) points = np.vstack([grid_x.ravel(), grid_y.ravel()]).T From 23559cd1523415a3707546776328f398a1a4db4e Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 10:10:02 +0200 Subject: [PATCH 23/63] fixed along the line computation --- pyidi/selection/main_window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 311e8f1..51af868 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -325,7 +325,6 @@ def method_selected(self, id: int): self.compute_button.setVisible(is_auto) self.confirm_button.setVisible(False) - def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) @@ -750,7 +749,7 @@ def points_along_polygon(polygon, subset_size, spacing=0): for j in range(n_points + 1): pt = p1 + j * step * direction - result_points.append((round(pt[0] - 0.5) + 0.5, round(pt[1] - 0.5) + 0.5)) + result_points.append((round(pt[0] - 0.5), round(pt[1] - 0.5))) return result_points From 21aa9737ce852ecb289df5703d0d68c6bb92f952 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 10:12:08 +0200 Subject: [PATCH 24/63] fixed the display of filtered points --- pyidi/selection/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 51af868..7650b95 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -695,7 +695,7 @@ def compute_candidate_points(self): max_eig = np.max(eigvals) eig_threshold = max_eig * threshold_ratio - self.candidate_points = [(y, x) for (x, y, e) in candidates if e > eig_threshold] + self.candidate_points = [(round(y)+0.5, round(x)+0.5) for (x, y, e) in candidates if e > eig_threshold] self.update_candidate_display() def update_candidate_display(self): From 08cdf123d6c46f53fa47b053da2860e59ff4fbd8 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 10:28:15 +0200 Subject: [PATCH 25/63] show candidate points count and added a method to get the filtered points --- pyidi/selection/main_window.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 7650b95..90e6b79 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -227,16 +227,15 @@ def ui_manual_right_menu(self): self.threshold_spinbox.setVisible(False) self.method_layout.addWidget(self.threshold_spinbox) + self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") + self.candidate_count_label.setVisible(False) + self.method_layout.addWidget(self.candidate_count_label) + self.compute_button = QtWidgets.QPushButton("Compute") self.compute_button.setVisible(False) self.compute_button.clicked.connect(self.compute_candidate_points) self.method_layout.addWidget(self.compute_button) - self.confirm_button = QtWidgets.QPushButton("Confirm") - self.confirm_button.setVisible(False) - self.confirm_button.clicked.connect(self.confirm_candidate_points) - self.method_layout.addWidget(self.confirm_button) - # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") self.start_new_line_button.clicked.connect(self.start_new_line) @@ -323,7 +322,7 @@ def method_selected(self, id: int): self.threshold_label.setVisible(is_auto) self.threshold_spinbox.setVisible(is_auto) self.compute_button.setVisible(is_auto) - self.confirm_button.setVisible(False) + self.candidate_count_label.setVisible(is_auto) def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): @@ -476,7 +475,11 @@ def set_image(self, img: np.ndarray): def get_points(self): """Get all selected points from manual and polygons.""" - return self.selected_points + return np.array(self.selected_points) + + def get_filtered_points(self): + """Get candidate points from automatic filtering.""" + return self.candidate_points.copy() if hasattr(self, 'candidate_points') else [] # Grid selection def handle_grid_drawing(self, event): @@ -697,6 +700,16 @@ def compute_candidate_points(self): self.candidate_points = [(round(y)+0.5, round(x)+0.5) for (x, y, e) in candidates if e > eig_threshold] self.update_candidate_display() + self.update_candidate_points_count() + + def update_candidate_points_count(self): + """Update the displayed count of candidate points.""" + if self.candidate_points: + count_text = f"N candidate points: {len(self.candidate_points)}" + else: + count_text = "N candidate points: 0" + + self.candidate_count_label.setText(count_text) def update_candidate_display(self): """Show candidate points as scatter dots on the image.""" @@ -714,12 +727,6 @@ def update_candidate_display(self): else: self.candidate_scatter.clear() - def confirm_candidate_points(self): - self.manual_points = self.candidate_points.copy() - self.candidate_scatter.clear() - self.confirm_button.setVisible(False) - self.recompute_roi_points() - ################################################################################################ # Automatic subset detection ################################################################################################ From d21160580fda69967808228eda27754267d4e528 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 10:46:36 +0200 Subject: [PATCH 26/63] Slider for automatic filtering --- pyidi/selection/main_window.py | 50 ++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 90e6b79..7796646 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -198,6 +198,7 @@ def ui_manual_right_menu(self): separator.setFrameShape(QtWidgets.QFrame.Shape.HLine) separator.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) self.method_layout.addWidget(separator) + self.method_layout.addSpacing(20) # Distance between subsets (only visible for Grid and Along the line) self.distance_label = QtWidgets.QLabel("Distance between subsets:") @@ -218,23 +219,26 @@ def ui_manual_right_menu(self): self.threshold_label.setVisible(False) self.method_layout.addWidget(self.threshold_label) - self.threshold_spinbox = QtWidgets.QDoubleSpinBox() - self.threshold_spinbox.setRange(1, 100) - self.threshold_spinbox.setSingleStep(1) - self.threshold_spinbox.setValue(10) - self.threshold_spinbox.setDecimals(1) - self.threshold_spinbox.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) - self.threshold_spinbox.setVisible(False) - self.method_layout.addWidget(self.threshold_spinbox) + self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.threshold_slider.setRange(1, 100) + self.threshold_slider.setSingleStep(1) + self.threshold_slider.setValue(10) + self.threshold_slider.setVisible(False) + self.method_layout.addWidget(self.threshold_slider) + + def update_label_and_recompute(val): + self.threshold_label.setText(f"Threshold: {str(val)}") + self.compute_candidate_points() + self.threshold_slider.valueChanged.connect(update_label_and_recompute) self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") self.candidate_count_label.setVisible(False) self.method_layout.addWidget(self.candidate_count_label) - self.compute_button = QtWidgets.QPushButton("Compute") - self.compute_button.setVisible(False) - self.compute_button.clicked.connect(self.compute_candidate_points) - self.method_layout.addWidget(self.compute_button) + self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") + self.clear_candidates_button.setVisible(False) + self.clear_candidates_button.clicked.connect(self.clear_candidates) + self.method_layout.addWidget(self.clear_candidates_button) # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") @@ -320,9 +324,11 @@ def method_selected(self, id: int): # Show automatic filtering controls only in that mode self.threshold_label.setVisible(is_auto) - self.threshold_spinbox.setVisible(is_auto) - self.compute_button.setVisible(is_auto) + self.threshold_slider.setVisible(is_auto) + self.clear_candidates_button.setVisible(is_auto) self.candidate_count_label.setVisible(is_auto) + if is_auto: + self.compute_candidate_points() def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): @@ -464,8 +470,9 @@ def clear_selection(self): self.scatter.clear() if hasattr(self, 'roi_overlay'): self.roi_overlay.clear() - - self.candidate_scatter.clear() + + # Clear candidate points from automatic filtering + self.clear_candidates() self.points_label.setText("Selected subsets: 0") @@ -656,7 +663,7 @@ def compute_candidate_points(self): subset_size = self.subset_size_spinbox.value() roi_size = subset_size // 2 - threshold_ratio = self.threshold_spinbox.value() / 1000.0 + threshold_ratio = self.threshold_slider.value() / 1000.0 img = self.image_item.image.astype(np.float32) candidates = [] @@ -727,6 +734,15 @@ def update_candidate_display(self): else: self.candidate_scatter.clear() + def clear_candidates(self): + """Clear candidate points.""" + print("Clearing candidate points...") + self.candidate_points = [] + self.update_candidate_points_count() + if hasattr(self, 'candidate_scatter'): + self.candidate_scatter.clear() + + self.update_selected_points() # Update main display to remove candidates ################################################################################################ # Automatic subset detection ################################################################################################ From eaec5a0fefce148cc765e81c46997d83d7e20bb0 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 10:53:04 +0200 Subject: [PATCH 27/63] option to show only the candidates or all points in automatic filtering --- pyidi/selection/main_window.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 7796646..9d8832e 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -235,6 +235,15 @@ def update_label_and_recompute(val): self.candidate_count_label.setVisible(False) self.method_layout.addWidget(self.candidate_count_label) + # Checkbox to show/hide scatter and ROI overlay + self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") + self.show_points_checkbox.setChecked(False) + def toggle_points_and_roi(state): + self.roi_overlay.setVisible(state) + self.scatter.setVisible(state) + self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) + self.method_layout.addWidget(self.show_points_checkbox) + self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") self.clear_candidates_button.setVisible(False) self.clear_candidates_button.clicked.connect(self.clear_candidates) @@ -330,6 +339,9 @@ def method_selected(self, id: int): if is_auto: self.compute_candidate_points() + self.roi_overlay.setVisible(not is_auto) + self.scatter.setVisible(not is_auto) + def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) From 338a272294a7c5e6bd0a60c46834d0d296f779fd Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 12:21:09 +0200 Subject: [PATCH 28/63] removed the automatic tab --- pyidi/selection/main_window.py | 79 +++++----------------------------- 1 file changed, 11 insertions(+), 68 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 9d8832e..4bc1647 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -18,55 +18,30 @@ def __init__(self, video): self.selected_points = [] self.manual_points = [] + self.candidate_points = [] self.drawing_polygons = [{'points': [], 'roi_points': []}] self.active_polygon_index = 0 self.grid_polygons = [{'points': [], 'roi_points': []}] self.active_grid_index = 0 - # Central widget with tab layout - self.tabs = QtWidgets.QTabWidget() - self.setCentralWidget(self.tabs) + # Central widget + self.central_widget = QtWidgets.QWidget() + self.setCentralWidget(self.central_widget) + self.main_layout = QtWidgets.QHBoxLayout(self.central_widget) - # --- Manual Tab --- - self.manual_tab = QtWidgets.QWidget() + # Main layout and controls self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) - self.manual_layout = QtWidgets.QHBoxLayout(self.manual_tab) + self.manual_layout = self.main_layout self.manual_layout.addWidget(self.splitter) # Graphics layout for image and points display - self.ui_manual_graphics() + self.ui_graphics() - # Right-side menu for methods self.ui_manual_right_menu() - self.tabs.addTab(self.manual_tab, "Manual") - - # --- Automatic Tab --- - self.automatic_tab = QtWidgets.QWidget() - self.automatic_splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) - self.automatic_layout = QtWidgets.QHBoxLayout(self.automatic_tab) - self.automatic_layout.addWidget(self.automatic_splitter) - self.tabs.addTab(self.automatic_tab, "Automatic") - - # Graphics layout for automatic image display - self.ui_automatic_graphics() - - # Right-side menu for automatic controls - self.ui_automatic_right_menu() - # Style self.setStyleSheet(""" QTabWidget::pane { border: 0; } - QTabBar::tab { - background: #333; - color: white; - padding: 10px; - border-radius: 4px; - margin: 2px; - } - QTabBar::tab:selected { - background: #0078d7; - } QPushButton { background-color: #444; color: white; @@ -88,7 +63,6 @@ def __init__(self, video): # Set the initial image self.image_item.setImage(video) - self.automatic_image_item.setImage(video) # Ensure method-specific widgets are visible on startup self.method_selected(self.button_group.checkedId()) @@ -98,7 +72,7 @@ def __init__(self, video): if app is not None: app.exec() - def ui_manual_graphics(self): + def ui_graphics(self): # Image viewer self.pg_widget = GraphicsLayoutWidget() self.view = self.pg_widget.addViewBox(lockAspect=True) @@ -124,14 +98,6 @@ def ui_manual_graphics(self): self.splitter.addWidget(self.pg_widget) - def ui_automatic_graphics(self): - self.automatic_pg_widget = GraphicsLayoutWidget() - self.automatic_view = self.automatic_pg_widget.addViewBox(lockAspect=True) - self.automatic_image_item = ImageItem() - self.automatic_view.addItem(self.automatic_image_item) - - self.automatic_splitter.addWidget(self.automatic_pg_widget) - def ui_manual_right_menu(self): # The right-side menu self.method_widget = QtWidgets.QWidget() @@ -289,31 +255,6 @@ def toggle_points_and_roi(state): self.method_widget.setMaximumWidth(600) self.splitter.setSizes([1000, 220]) # Initial left/right width - def ui_automatic_right_menu(self): - self.automatic_controls = QtWidgets.QWidget() - self.automatic_layout_right = QtWidgets.QVBoxLayout(self.automatic_controls) - - # Title - label = QtWidgets.QLabel("Automatic Tools") - font = label.font() - font.setPointSize(10) - font.setBold(True) - label.setFont(font) - self.automatic_layout_right.addWidget(label) - - # Spacer - self.automatic_layout_right.addStretch(1) - - # Set the layout and add to splitter - self.automatic_splitter.addWidget(self.automatic_controls) - self.automatic_splitter.setStretchFactor(0, 5) # Image area grows more - self.automatic_splitter.setStretchFactor(1, 0) # Menu fixed by content - - # Set initial width for right panel - self.automatic_controls.setMinimumWidth(150) - self.automatic_controls.setMaximumWidth(600) - self.automatic_splitter.setSizes([1000, 220]) # Initial left/right width - def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") @@ -488,6 +429,8 @@ def clear_selection(self): self.points_label.setText("Selected subsets: 0") + self.update_selected_points() # Refresh display + def set_image(self, img: np.ndarray): """Display image in the manual tab.""" self.image_item.setImage(img) From ff31d2af9e4d21a161032bffa25ff4a75edd3e9c Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 12:35:48 +0200 Subject: [PATCH 29/63] Added an "automatic" tab (which is not really a tab). For now empty --- pyidi/selection/main_window.py | 102 +++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 4bc1647..a8e326c 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -27,12 +27,36 @@ def __init__(self, video): # Central widget self.central_widget = QtWidgets.QWidget() self.setCentralWidget(self.central_widget) - self.main_layout = QtWidgets.QHBoxLayout(self.central_widget) - - # Main layout and controls + # Top-level layout for the central widget + self.main_layout = QtWidgets.QVBoxLayout(self.central_widget) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.setSpacing(0) + + # Toolbar (fixed height) + self.mode_toolbar = QtWidgets.QWidget() + self.mode_toolbar_layout = QtWidgets.QHBoxLayout(self.mode_toolbar) + self.mode_toolbar_layout.setContentsMargins(5, 4, 5, 4) + self.mode_toolbar_layout.setSpacing(6) + + self.manual_mode_button = QtWidgets.QPushButton("Manual") + self.automatic_mode_button = QtWidgets.QPushButton("Automatic") + for btn in [self.manual_mode_button, self.automatic_mode_button]: + btn.setCheckable(True) + btn.setMinimumWidth(100) + self.mode_toolbar_layout.addWidget(btn) + + self.manual_mode_button.setChecked(True) + self.manual_mode_button.clicked.connect(lambda: self.switch_mode("manual")) + self.automatic_mode_button.clicked.connect(lambda: self.switch_mode("automatic")) + + self.mode_toolbar.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + self.mode_toolbar.setMaximumHeight(self.manual_mode_button.sizeHint().height() + 12) + + self.main_layout.addWidget(self.mode_toolbar) + + # Add splitter directly and stretch it self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) - self.manual_layout = self.main_layout - self.manual_layout.addWidget(self.splitter) + self.main_layout.addWidget(self.splitter, stretch=1) # Graphics layout for image and points display self.ui_graphics() @@ -101,7 +125,15 @@ def ui_graphics(self): def ui_manual_right_menu(self): # The right-side menu self.method_widget = QtWidgets.QWidget() - self.method_layout = QtWidgets.QVBoxLayout(self.method_widget) + self.stack = QtWidgets.QStackedLayout(self.method_widget) + + self.manual_widget = QtWidgets.QWidget() + self.manual_layout = QtWidgets.QVBoxLayout(self.manual_widget) + self.stack.addWidget(self.manual_widget) + + self.automatic_widget = QtWidgets.QWidget() + self.automatic_layout = QtWidgets.QVBoxLayout(self.automatic_widget) + self.stack.addWidget(self.automatic_widget) # Number of selected subsets self.points_label = QtWidgets.QLabel("Selected subsets: 0") @@ -109,7 +141,7 @@ def ui_manual_right_menu(self): font.setPointSize(10) font.setBold(True) self.points_label.setFont(font) - self.method_layout.addWidget(self.points_label) + self.manual_layout.addWidget(self.points_label) # Method selection buttons self.button_group = QtWidgets.QButtonGroup(self.method_widget) @@ -129,12 +161,12 @@ def ui_manual_right_menu(self): if i == 0: button.setChecked(True) # Default selection self.button_group.addButton(button, i) - self.method_layout.addWidget(button) + self.manual_layout.addWidget(button) self.method_buttons[name] = button # Subset size input - self.method_layout.addSpacing(20) - self.method_layout.addWidget(QtWidgets.QLabel("Subset size:")) + self.manual_layout.addSpacing(20) + self.manual_layout.addWidget(QtWidgets.QLabel("Subset size:")) self.subset_size_spinbox = QtWidgets.QSpinBox() self.subset_size_spinbox.setRange(1, 1000) @@ -145,26 +177,26 @@ def ui_manual_right_menu(self): self.subset_size_spinbox.setMaximum(999) self.subset_size_spinbox.setWrapping(False) self.subset_size_spinbox.valueChanged.connect(self.update_selected_points) - self.method_layout.addWidget(self.subset_size_spinbox) + self.manual_layout.addWidget(self.subset_size_spinbox) # Show ROI rectangles self.show_roi_checkbox = QtWidgets.QCheckBox("Show subsets") self.show_roi_checkbox.setChecked(True) self.show_roi_checkbox.stateChanged.connect(self.update_selected_points) - self.method_layout.addWidget(self.show_roi_checkbox) + self.manual_layout.addWidget(self.show_roi_checkbox) # Clear button - self.method_layout.addSpacing(20) + self.manual_layout.addSpacing(20) self.clear_button = QtWidgets.QPushButton("Clear selections") self.clear_button.clicked.connect(self.clear_selection) - self.method_layout.addWidget(self.clear_button) + self.manual_layout.addWidget(self.clear_button) # Separator line separator = QtWidgets.QFrame() separator.setFrameShape(QtWidgets.QFrame.Shape.HLine) separator.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.method_layout.addWidget(separator) - self.method_layout.addSpacing(20) + self.manual_layout.addWidget(separator) + self.manual_layout.addSpacing(20) # Distance between subsets (only visible for Grid and Along the line) self.distance_label = QtWidgets.QLabel("Distance between subsets:") @@ -177,20 +209,20 @@ def ui_manual_right_menu(self): self.distance_spinbox.setSingleStep(1) self.distance_spinbox.valueChanged.connect(self.recompute_roi_points) - self.method_layout.addWidget(self.distance_label) - self.method_layout.addWidget(self.distance_spinbox) + self.manual_layout.addWidget(self.distance_label) + self.manual_layout.addWidget(self.distance_spinbox) # --- Automatic Filtering UI --- self.threshold_label = QtWidgets.QLabel("Threshold:") self.threshold_label.setVisible(False) - self.method_layout.addWidget(self.threshold_label) + self.manual_layout.addWidget(self.threshold_label) self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.threshold_slider.setRange(1, 100) self.threshold_slider.setSingleStep(1) self.threshold_slider.setValue(10) self.threshold_slider.setVisible(False) - self.method_layout.addWidget(self.threshold_slider) + self.manual_layout.addWidget(self.threshold_slider) def update_label_and_recompute(val): self.threshold_label.setText(f"Threshold: {str(val)}") @@ -199,7 +231,7 @@ def update_label_and_recompute(val): self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") self.candidate_count_label.setVisible(False) - self.method_layout.addWidget(self.candidate_count_label) + self.manual_layout.addWidget(self.candidate_count_label) # Checkbox to show/hide scatter and ROI overlay self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") @@ -208,42 +240,42 @@ def toggle_points_and_roi(state): self.roi_overlay.setVisible(state) self.scatter.setVisible(state) self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) - self.method_layout.addWidget(self.show_points_checkbox) + self.manual_layout.addWidget(self.show_points_checkbox) self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") self.clear_candidates_button.setVisible(False) self.clear_candidates_button.clicked.connect(self.clear_candidates) - self.method_layout.addWidget(self.clear_candidates_button) + self.manual_layout.addWidget(self.clear_candidates_button) # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") self.start_new_line_button.clicked.connect(self.start_new_line) self.start_new_line_button.setVisible(False) # Hidden by default - self.method_layout.addWidget(self.start_new_line_button) + self.manual_layout.addWidget(self.start_new_line_button) - self.method_layout.addStretch(1) + self.manual_layout.addStretch(1) # Polygon manager (visible only for "Along the line") self.polygon_list = QtWidgets.QListWidget() self.polygon_list.setVisible(False) self.polygon_list.currentRowChanged.connect(self.on_polygon_selected) - self.method_layout.addWidget(self.polygon_list) + self.manual_layout.addWidget(self.polygon_list) self.delete_polygon_button = QtWidgets.QPushButton("Delete selected polygon") self.delete_polygon_button.clicked.connect(self.delete_selected_polygon) self.delete_polygon_button.setVisible(False) - self.method_layout.addWidget(self.delete_polygon_button) + self.manual_layout.addWidget(self.delete_polygon_button) # Grid polygon manager self.grid_list = QtWidgets.QListWidget() self.grid_list.setVisible(False) self.grid_list.currentRowChanged.connect(self.on_grid_selected) - self.method_layout.addWidget(self.grid_list) + self.manual_layout.addWidget(self.grid_list) self.delete_grid_button = QtWidgets.QPushButton("Delete selected grid") self.delete_grid_button.clicked.connect(self.delete_selected_grid) self.delete_grid_button.setVisible(False) - self.method_layout.addWidget(self.delete_grid_button) + self.manual_layout.addWidget(self.delete_grid_button) # Set the layout and add to splitter self.splitter.addWidget(self.method_widget) @@ -255,6 +287,8 @@ def toggle_points_and_roi(state): self.method_widget.setMaximumWidth(600) self.splitter.setSizes([1000, 220]) # Initial left/right width + self.automatic_layout.addStretch(1) + def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") @@ -283,6 +317,16 @@ def method_selected(self, id: int): self.roi_overlay.setVisible(not is_auto) self.scatter.setVisible(not is_auto) + def switch_mode(self, mode: str): + if mode == "manual": + self.manual_mode_button.setChecked(True) + self.automatic_mode_button.setChecked(False) + self.stack.setCurrentWidget(self.manual_widget) + elif mode == "automatic": + self.manual_mode_button.setChecked(False) + self.automatic_mode_button.setChecked(True) + self.stack.setCurrentWidget(self.automatic_widget) + def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) From df9a9008536bd582b8c1753f700dc14bb2c5ed43 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 12:49:05 +0200 Subject: [PATCH 30/63] moved the filtering to new "tab" --- pyidi/selection/main_window.py | 38 ++++++++++++++-------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index a8e326c..d59629a 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -153,7 +153,6 @@ def ui_manual_right_menu(self): "Manual", "Along the line", "Remove point", - "Automatic filtering", # NEW ] for i, name in enumerate(method_names): button = QtWidgets.QPushButton(name) @@ -214,15 +213,13 @@ def ui_manual_right_menu(self): # --- Automatic Filtering UI --- self.threshold_label = QtWidgets.QLabel("Threshold:") - self.threshold_label.setVisible(False) - self.manual_layout.addWidget(self.threshold_label) + self.automatic_layout.addWidget(self.threshold_label) self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.threshold_slider.setRange(1, 100) self.threshold_slider.setSingleStep(1) self.threshold_slider.setValue(10) - self.threshold_slider.setVisible(False) - self.manual_layout.addWidget(self.threshold_slider) + self.automatic_layout.addWidget(self.threshold_slider) def update_label_and_recompute(val): self.threshold_label.setText(f"Threshold: {str(val)}") @@ -230,8 +227,7 @@ def update_label_and_recompute(val): self.threshold_slider.valueChanged.connect(update_label_and_recompute) self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") - self.candidate_count_label.setVisible(False) - self.manual_layout.addWidget(self.candidate_count_label) + self.automatic_layout.addWidget(self.candidate_count_label) # Checkbox to show/hide scatter and ROI overlay self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") @@ -240,12 +236,11 @@ def toggle_points_and_roi(state): self.roi_overlay.setVisible(state) self.scatter.setVisible(state) self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) - self.manual_layout.addWidget(self.show_points_checkbox) + self.automatic_layout.addWidget(self.show_points_checkbox) self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") - self.clear_candidates_button.setVisible(False) self.clear_candidates_button.clicked.connect(self.clear_candidates) - self.manual_layout.addWidget(self.clear_candidates_button) + self.automatic_layout.addWidget(self.clear_candidates_button) # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") @@ -294,7 +289,6 @@ def method_selected(self, id: int): print(f"Selected method: {method_name}") is_along = method_name == "Along the line" is_grid = method_name == "Grid" - is_auto = method_name == "Automatic filtering" show_spacing = is_along or is_grid self.start_new_line_button.setVisible(is_along or is_grid) @@ -306,27 +300,27 @@ def method_selected(self, id: int): self.distance_label.setVisible(show_spacing) self.distance_spinbox.setVisible(show_spacing) - # Show automatic filtering controls only in that mode - self.threshold_label.setVisible(is_auto) - self.threshold_slider.setVisible(is_auto) - self.clear_candidates_button.setVisible(is_auto) - self.candidate_count_label.setVisible(is_auto) - if is_auto: - self.compute_candidate_points() - - self.roi_overlay.setVisible(not is_auto) - self.scatter.setVisible(not is_auto) - def switch_mode(self, mode: str): if mode == "manual": self.manual_mode_button.setChecked(True) self.automatic_mode_button.setChecked(False) self.stack.setCurrentWidget(self.manual_widget) + + self.roi_overlay.setVisible(True) + self.scatter.setVisible(True) + self.candidate_scatter.setVisible(False) + elif mode == "automatic": self.manual_mode_button.setChecked(False) self.automatic_mode_button.setChecked(True) self.stack.setCurrentWidget(self.automatic_widget) + self.compute_candidate_points() + self.show_points_checkbox.setChecked(False) + self.roi_overlay.setVisible(False) + self.scatter.setVisible(False) + self.candidate_scatter.setVisible(True) + def on_mouse_click(self, event): if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) From 639a54af69b96ad3df7efe4d540c90ab476f2f0f Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 12:51:37 +0200 Subject: [PATCH 31/63] disable selection in filtering mode --- pyidi/selection/main_window.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index d59629a..6792a7d 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -91,6 +91,9 @@ def __init__(self, video): # Ensure method-specific widgets are visible on startup self.method_selected(self.button_group.checkedId()) + # Set the initial mode + self.switch_mode("manual") # Default to manual mode + # Start the GUI self.show() if app is not None: @@ -301,6 +304,7 @@ def method_selected(self, id: int): self.distance_spinbox.setVisible(show_spacing) def switch_mode(self, mode: str): + self.mode = mode if mode == "manual": self.manual_mode_button.setChecked(True) self.automatic_mode_button.setChecked(False) @@ -322,6 +326,9 @@ def switch_mode(self, mode: str): self.candidate_scatter.setVisible(True) def on_mouse_click(self, event): + if self.mode == "automatic": + return + if self.method_buttons["Manual"].isChecked(): self.handle_manual_selection(event) elif self.method_buttons["Along the line"].isChecked(): From e74be71cdf3a84807cff9a537e8dd07c15da9f4c Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 12:57:35 +0200 Subject: [PATCH 32/63] code cleanup --- pyidi/selection/main_window.py | 89 ++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 6792a7d..73ec6ec 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -61,7 +61,7 @@ def __init__(self, video): # Graphics layout for image and points display self.ui_graphics() - self.ui_manual_right_menu() + self.ui_right_menu() # Style self.setStyleSheet(""" @@ -125,7 +125,7 @@ def ui_graphics(self): self.splitter.addWidget(self.pg_widget) - def ui_manual_right_menu(self): + def ui_right_menu(self): # The right-side menu self.method_widget = QtWidgets.QWidget() self.stack = QtWidgets.QStackedLayout(self.method_widget) @@ -138,6 +138,23 @@ def ui_manual_right_menu(self): self.automatic_layout = QtWidgets.QVBoxLayout(self.automatic_widget) self.stack.addWidget(self.automatic_widget) + self.ui_manual_right_menu() # The manual right menu + + self.ui_auto_right_menu() # The automatic right menu + + # Set the layout and add to splitter + self.splitter.addWidget(self.method_widget) + self.splitter.setStretchFactor(0, 5) # Image area grows more + self.splitter.setStretchFactor(1, 0) # Menu fixed by content + + # Set initial width for right panel + self.method_widget.setMinimumWidth(150) + self.method_widget.setMaximumWidth(600) + self.splitter.setSizes([1000, 220]) # Initial left/right width + + self.automatic_layout.addStretch(1) + + def ui_manual_right_menu(self): # Number of selected subsets self.points_label = QtWidgets.QLabel("Selected subsets: 0") font = self.points_label.font() @@ -214,37 +231,6 @@ def ui_manual_right_menu(self): self.manual_layout.addWidget(self.distance_label) self.manual_layout.addWidget(self.distance_spinbox) - # --- Automatic Filtering UI --- - self.threshold_label = QtWidgets.QLabel("Threshold:") - self.automatic_layout.addWidget(self.threshold_label) - - self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) - self.threshold_slider.setRange(1, 100) - self.threshold_slider.setSingleStep(1) - self.threshold_slider.setValue(10) - self.automatic_layout.addWidget(self.threshold_slider) - - def update_label_and_recompute(val): - self.threshold_label.setText(f"Threshold: {str(val)}") - self.compute_candidate_points() - self.threshold_slider.valueChanged.connect(update_label_and_recompute) - - self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") - self.automatic_layout.addWidget(self.candidate_count_label) - - # Checkbox to show/hide scatter and ROI overlay - self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") - self.show_points_checkbox.setChecked(False) - def toggle_points_and_roi(state): - self.roi_overlay.setVisible(state) - self.scatter.setVisible(state) - self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) - self.automatic_layout.addWidget(self.show_points_checkbox) - - self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") - self.clear_candidates_button.clicked.connect(self.clear_candidates) - self.automatic_layout.addWidget(self.clear_candidates_button) - # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") self.start_new_line_button.clicked.connect(self.start_new_line) @@ -275,17 +261,36 @@ def toggle_points_and_roi(state): self.delete_grid_button.setVisible(False) self.manual_layout.addWidget(self.delete_grid_button) - # Set the layout and add to splitter - self.splitter.addWidget(self.method_widget) - self.splitter.setStretchFactor(0, 5) # Image area grows more - self.splitter.setStretchFactor(1, 0) # Menu fixed by content + def ui_auto_right_menu(self): + self.threshold_label = QtWidgets.QLabel("Threshold:") + self.automatic_layout.addWidget(self.threshold_label) - # Set initial width for right panel - self.method_widget.setMinimumWidth(150) - self.method_widget.setMaximumWidth(600) - self.splitter.setSizes([1000, 220]) # Initial left/right width + self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.threshold_slider.setRange(1, 100) + self.threshold_slider.setSingleStep(1) + self.threshold_slider.setValue(10) + self.automatic_layout.addWidget(self.threshold_slider) - self.automatic_layout.addStretch(1) + def update_label_and_recompute(val): + self.threshold_label.setText(f"Threshold: {str(val)}") + self.compute_candidate_points() + self.threshold_slider.valueChanged.connect(update_label_and_recompute) + + self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") + self.automatic_layout.addWidget(self.candidate_count_label) + + # Checkbox to show/hide scatter and ROI overlay + self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") + self.show_points_checkbox.setChecked(False) + def toggle_points_and_roi(state): + self.roi_overlay.setVisible(state) + self.scatter.setVisible(state) + self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) + self.automatic_layout.addWidget(self.show_points_checkbox) + + self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") + self.clear_candidates_button.clicked.connect(self.clear_candidates) + self.automatic_layout.addWidget(self.clear_candidates_button) def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] From 7d73ab0d062f87b01036bc394e1919d8abfce52e Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 13:10:24 +0200 Subject: [PATCH 33/63] Distance between subsets is a slider --- pyidi/selection/main_window.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 73ec6ec..c93e909 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -220,16 +220,21 @@ def ui_manual_right_menu(self): # Distance between subsets (only visible for Grid and Along the line) self.distance_label = QtWidgets.QLabel("Distance between subsets:") self.distance_label.setVisible(False) # Hidden by default - self.distance_spinbox = QtWidgets.QSpinBox() - self.distance_spinbox.setVisible(False) # Hidden by default - self.distance_spinbox.setRange(-1000, 1000) - self.distance_spinbox.setValue(0) - self.distance_spinbox.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) - self.distance_spinbox.setSingleStep(1) - self.distance_spinbox.valueChanged.connect(self.recompute_roi_points) - + self.distance_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.distance_slider.setRange(-50, 50) + self.distance_slider.setSingleStep(1) + self.distance_slider.setValue(0) + self.distance_slider.setVisible(False) + self.manual_layout.addWidget(self.distance_slider) self.manual_layout.addWidget(self.distance_label) - self.manual_layout.addWidget(self.distance_spinbox) + + def update_label_and_recompute(val): + self.distance_label.setText(f"Distance between subsets: {str(val)}") + self.recompute_roi_points() + self.distance_slider.valueChanged.connect(update_label_and_recompute) + + # self.manual_layout.addWidget(self.distance_label) + self.manual_layout.addWidget(self.distance_slider) # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") @@ -306,7 +311,7 @@ def method_selected(self, id: int): self.delete_grid_button.setVisible(is_grid) self.distance_label.setVisible(show_spacing) - self.distance_spinbox.setVisible(show_spacing) + self.distance_slider.setVisible(show_spacing) def switch_mode(self, mode: str): self.mode = mode @@ -406,7 +411,7 @@ def update_selected_points(self): def recompute_roi_points(self): subset_size = self.subset_size_spinbox.value() - spacing = self.distance_spinbox.value() + spacing = self.distance_slider.value() # Update all "along the line" polygons for poly in self.drawing_polygons: @@ -511,7 +516,7 @@ def handle_grid_drawing(self, event): # Compute ROI points only if closed polygon if len(grid['points']) >= 3: subset_size = self.subset_size_spinbox.value() - spacing = self.distance_spinbox.value() + spacing = self.distance_slider.value() grid['roi_points'] = rois_inside_polygon(grid['points'], subset_size, spacing) self.update_grid_display() @@ -593,7 +598,7 @@ def handle_polygon_drawing(self, event): # Update ROI points only for this polygon if len(poly['points']) >= 2: subset_size = self.subset_size_spinbox.value() - spacing = self.distance_spinbox.value() + spacing = self.distance_slider.value() poly['roi_points'] = points_along_polygon(poly['points'], subset_size, spacing) self.update_polygon_display() From e24f933eebc8db2aebe71732de56621a4de7647c Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 13:32:57 +0200 Subject: [PATCH 34/63] Code cleanup --- pyidi/selection/main_window.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index c93e909..22149be 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -99,6 +99,25 @@ def __init__(self, video): if app is not None: app.exec() + def create_help_button(self, tooltip_text: str) -> QtWidgets.QToolButton: + """Create a small '?' help button with a tooltip.""" + button = QtWidgets.QToolButton() + button.setIcon(self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxQuestion)) + button.setToolTip(tooltip_text) + button.setCursor(QtCore.Qt.CursorShape.WhatsThisCursor) + button.setStyleSheet(""" + QToolButton { + border: none; + background: transparent; + padding: 0px; + } + QToolButton:hover { + color: #0078d7; + } + """) + button.setFixedSize(20, 20) + return button + def ui_graphics(self): # Image viewer self.pg_widget = GraphicsLayoutWidget() @@ -267,9 +286,16 @@ def update_label_and_recompute(val): self.manual_layout.addWidget(self.delete_grid_button) def ui_auto_right_menu(self): + self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") + font = self.candidate_count_label.font() + font.setPointSize(10) + font.setBold(True) + self.candidate_count_label.setFont(font) + self.automatic_layout.addWidget(self.candidate_count_label) + self.threshold_label = QtWidgets.QLabel("Threshold:") self.automatic_layout.addWidget(self.threshold_label) - + self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.threshold_slider.setRange(1, 100) self.threshold_slider.setSingleStep(1) @@ -281,8 +307,6 @@ def update_label_and_recompute(val): self.compute_candidate_points() self.threshold_slider.valueChanged.connect(update_label_and_recompute) - self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") - self.automatic_layout.addWidget(self.candidate_count_label) # Checkbox to show/hide scatter and ROI overlay self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") @@ -322,7 +346,7 @@ def switch_mode(self, mode: str): self.roi_overlay.setVisible(True) self.scatter.setVisible(True) - self.candidate_scatter.setVisible(False) + # self.candidate_scatter.setVisible(False) elif mode == "automatic": self.manual_mode_button.setChecked(False) @@ -333,7 +357,7 @@ def switch_mode(self, mode: str): self.show_points_checkbox.setChecked(False) self.roi_overlay.setVisible(False) self.scatter.setVisible(False) - self.candidate_scatter.setVisible(True) + # self.candidate_scatter.setVisible(True) def on_mouse_click(self, event): if self.mode == "automatic": From f23126707ac4f18ac6aaf5779d7c3be963d117a8 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 13:43:10 +0200 Subject: [PATCH 35/63] Added space for other filtering methods --- pyidi/selection/main_window.py | 74 ++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 22149be..6806f13 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -90,6 +90,7 @@ def __init__(self, video): # Ensure method-specific widgets are visible on startup self.method_selected(self.button_group.checkedId()) + self.auto_method_selected(0) # Set the initial mode self.switch_mode("manual") # Default to manual mode @@ -286,6 +287,30 @@ def update_label_and_recompute(val): self.manual_layout.addWidget(self.delete_grid_button) def ui_auto_right_menu(self): + # Title and method selector + self.automatic_layout.addWidget(QtWidgets.QLabel("Automatic method:")) + + self.auto_method_group = QtWidgets.QButtonGroup(self.automatic_widget) + self.auto_method_group.setExclusive(True) + + self.auto_method_buttons = {} + method_names = [ + "Shi-Tomasi", + ] + for i, name in enumerate(method_names): + button = QtWidgets.QPushButton(name) + button.setCheckable(True) + if i == 0: + button.setChecked(True) + self.auto_method_group.addButton(button, i) + self.automatic_layout.addWidget(button) + self.auto_method_buttons[name] = button + + self.auto_method_group.idClicked.connect(self.auto_method_selected) + + self.automatic_layout.addSpacing(10) + + # Dynamic method-specific widgets (for now shared) self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") font = self.candidate_count_label.font() font.setPointSize(10) @@ -293,33 +318,56 @@ def ui_auto_right_menu(self): self.candidate_count_label.setFont(font) self.automatic_layout.addWidget(self.candidate_count_label) + self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") + self.clear_candidates_button.clicked.connect(self.clear_candidates) + self.automatic_layout.addWidget(self.clear_candidates_button) + + # Checkbox to show/hide scatter and ROI overlay + self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") + self.show_points_checkbox.setChecked(False) + def toggle_points_and_roi(state): + self.roi_overlay.setVisible(state) + self.scatter.setVisible(state) + self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) + self.automatic_layout.addWidget(self.show_points_checkbox) + + # Horizontal line separator for visual clarity + hline = QtWidgets.QFrame() + hline.setFrameShape(QtWidgets.QFrame.Shape.HLine) + hline.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.automatic_layout.addWidget(hline) + + self.automatic_layout.addSpacing(10) + + # Shi-Tomasi method settings self.threshold_label = QtWidgets.QLabel("Threshold:") + self.threshold_label.setVisible(False) self.automatic_layout.addWidget(self.threshold_label) self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.threshold_slider.setRange(1, 100) self.threshold_slider.setSingleStep(1) self.threshold_slider.setValue(10) + self.threshold_slider.setVisible(False) self.automatic_layout.addWidget(self.threshold_slider) def update_label_and_recompute(val): self.threshold_label.setText(f"Threshold: {str(val)}") - self.compute_candidate_points() + self.compute_candidate_points_shi_tomasi() # Placeholder method self.threshold_slider.valueChanged.connect(update_label_and_recompute) + self.automatic_layout.addStretch(1) - # Checkbox to show/hide scatter and ROI overlay - self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") - self.show_points_checkbox.setChecked(False) - def toggle_points_and_roi(state): - self.roi_overlay.setVisible(state) - self.scatter.setVisible(state) - self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) - self.automatic_layout.addWidget(self.show_points_checkbox) + def auto_method_selected(self, id: int): + method_name = list(self.auto_method_buttons.keys())[id] + print(f"Selected automatic method: {method_name}") + # Here you can switch method behavior, show/hide widgets, etc. + is_shi_tomasi = method_name == "Shi-Tomasi" + self.threshold_label.setVisible(is_shi_tomasi) + self.threshold_slider.setVisible(is_shi_tomasi) - self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") - self.clear_candidates_button.clicked.connect(self.clear_candidates) - self.automatic_layout.addWidget(self.clear_candidates_button) + if is_shi_tomasi: + self.compute_candidate_points_shi_tomasi() def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] @@ -691,7 +739,7 @@ def handle_remove_point(self, event): self.update_selected_points() # Automatic filtering - def compute_candidate_points(self): + def compute_candidate_points_shi_tomasi(self): """Compute good feature points using structure tensor analysis (Shi–Tomasi style).""" from scipy.ndimage import sobel From 24574ee7c0c3b8d1f2f549de40e5fdbc869e56d1 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 14:07:37 +0200 Subject: [PATCH 36/63] Brush initial functionality --- pyidi/selection/main_window.py | 113 ++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 6806f13..f9fe9c8 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -5,6 +5,29 @@ from matplotlib.path import Path # import pyidi # Assuming pyidi is a custom module for video handling +class BrushViewBox(pg.ViewBox): + def __init__(self, parent_gui, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setMouseMode(self.PanMode) + self.parent_gui = parent_gui + + def mouseClickEvent(self, ev): + if self.parent_gui.mode == "manual" and self.parent_gui.method_buttons["Brush"].isChecked(): + ev.accept() # Prevent normal event + self.parent_gui.handle_brush_start(ev) + + def mouseDragEvent(self, ev, axis=None): + if self.parent_gui.mode == "manual" and self.parent_gui.method_buttons["Brush"].isChecked(): + ev.accept() + if ev.isStart(): + self.parent_gui._painting = True + self.parent_gui._brush_path = [] + elif ev.isFinish(): + self.parent_gui._painting = False + self.parent_gui.handle_brush_end(ev) + else: + self.parent_gui.handle_brush_move(ev) + class SelectionGUI(QtWidgets.QMainWindow): def __init__(self, video): app = QtWidgets.QApplication.instance() @@ -16,6 +39,9 @@ def __init__(self, video): self.setWindowTitle("ROI Selection Tool") self.resize(1200, 800) + self._paint_mask = None # Same shape as the image + self._paint_radius = 10 # pixels + self.selected_points = [] self.manual_points = [] self.candidate_points = [] @@ -122,7 +148,9 @@ def create_help_button(self, tooltip_text: str) -> QtWidgets.QToolButton: def ui_graphics(self): # Image viewer self.pg_widget = GraphicsLayoutWidget() - self.view = self.pg_widget.addViewBox(lockAspect=True) + self.view = BrushViewBox(parent_gui=self, lockAspect=True) + self.pg_widget.addItem(self.view) + self.image_item = ImageItem() self.polygon_line = pg.PlotDataItem(pen=pg.mkPen('y', width=2)) @@ -192,6 +220,7 @@ def ui_manual_right_menu(self): "Grid", "Manual", "Along the line", + "Brush", "Remove point", ] for i, name in enumerate(method_names): @@ -374,6 +403,11 @@ def method_selected(self, id: int): print(f"Selected method: {method_name}") is_along = method_name == "Along the line" is_grid = method_name == "Grid" + is_brush = method_name == "Brush" + + # Disable panning + self.view.setMouseEnabled(not is_brush, not is_brush) + show_spacing = is_along or is_grid self.start_new_line_button.setVisible(is_along or is_grid) @@ -401,7 +435,7 @@ def switch_mode(self, mode: str): self.automatic_mode_button.setChecked(True) self.stack.setCurrentWidget(self.automatic_widget) - self.compute_candidate_points() + self.compute_candidate_points_shi_tomasi() self.show_points_checkbox.setChecked(False) self.roi_overlay.setVisible(False) self.scatter.setVisible(False) @@ -419,6 +453,8 @@ def on_mouse_click(self, event): self.handle_grid_drawing(event) elif self.method_buttons["Remove point"].isChecked(): self.handle_remove_point(event) + elif self.method_buttons["Brush"].isChecked(): + self.handle_brush_start(event) def update_selected_points(self): polygon_points = [pt for poly in self.drawing_polygons for pt in poly['roi_points']] @@ -825,6 +861,63 @@ def clear_candidates(self): self.candidate_scatter.clear() self.update_selected_points() # Update main display to remove candidates + + # Brush + def handle_brush_start(self, ev): + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.CrossCursor) + if self.image_item.image is None: + return + h, w = self.image_item.image.shape[:2] + self._paint_mask = np.zeros((h, w), dtype=bool) + self.handle_brush_move(ev) + + def handle_brush_move(self, ev): + if self._paint_mask is None: + return + + pos = ev.pos() + if self.view.sceneBoundingRect().contains(pos): + mouse_point = self.view.mapSceneToView(pos) + y, x = int(round(mouse_point.x())), int(round(mouse_point.y())) + r = self._paint_radius + + h, w = self._paint_mask.shape + yy, xx = np.ogrid[max(0, y - r):min(h, y + r + 1), + max(0, x - r):min(w, x + r + 1)] + mask = (yy - y) ** 2 + (xx - x) ** 2 <= r ** 2 + self._paint_mask[max(0, y - r):min(h, y + r + 1), + max(0, x - r):min(w, x + r + 1)][mask] = True + + self.update_brush_overlay() + + def handle_brush_end(self, ev): + QtWidgets.QApplication.restoreOverrideCursor() + + if self._paint_mask is None: + return + + subset_size = self.subset_size_spinbox.value() + spacing = self.distance_slider.value() + brush_rois = rois_inside_mask(self._paint_mask, subset_size, spacing) + self.manual_points.extend(brush_rois) + + self._paint_mask = None + self.update_selected_points() + self.update_brush_overlay() + + + def update_brush_overlay(self): + if not hasattr(self, 'brush_overlay'): + self.brush_overlay = ImageItem() + self.view.addItem(self.brush_overlay) + + if self._paint_mask is not None: + rgba = np.zeros((*self._paint_mask.shape, 4), dtype=np.uint8) + rgba[self._paint_mask] = [0, 200, 255, 80] # Cyan with transparency + self.brush_overlay.setImage(rgba, autoLevels=False) + self.brush_overlay.setZValue(2) + else: + self.brush_overlay.clear() ################################################################################################ # Automatic subset detection ################################################################################################ @@ -878,6 +971,22 @@ def rois_inside_polygon(polygon, subset_size, spacing): mask = Path(polygon).contains_points(points) return [tuple(p) for p in points[mask]] +def rois_inside_mask(mask, subset_size, spacing): + step = subset_size + spacing + if step <= 0: + step = 1 + + h, w = mask.shape + xs = np.arange(0, w, step) + ys = np.arange(0, h, step) + grid_x, grid_y = np.meshgrid(xs, ys) + + candidate_points = np.vstack([grid_y.ravel(), grid_x.ravel()]).T # (y, x) + + # Only keep points where the mask is True + selected = [tuple(p) for p in candidate_points if mask[p[0], p[1]]] + return selected + if __name__ == "__main__": # import pyidi # filename = "data/data_showcase.cih" From 182c4cc45b8c51b177d77a48add6eb080782e918 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 15:01:43 +0200 Subject: [PATCH 37/63] fixed brush finctionality --- pyidi/selection/main_window.py | 47 ++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index f9fe9c8..bfa501d 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -13,20 +13,30 @@ def __init__(self, parent_gui, *args, **kwargs): def mouseClickEvent(self, ev): if self.parent_gui.mode == "manual" and self.parent_gui.method_buttons["Brush"].isChecked(): - ev.accept() # Prevent normal event - self.parent_gui.handle_brush_start(ev) + if self.parent_gui.ctrl_held: + ev.accept() + self.parent_gui.handle_brush_start(ev) + else: + ev.ignore() + else: + super().mouseClickEvent(ev) def mouseDragEvent(self, ev, axis=None): if self.parent_gui.mode == "manual" and self.parent_gui.method_buttons["Brush"].isChecked(): - ev.accept() - if ev.isStart(): - self.parent_gui._painting = True - self.parent_gui._brush_path = [] - elif ev.isFinish(): - self.parent_gui._painting = False - self.parent_gui.handle_brush_end(ev) - else: - self.parent_gui.handle_brush_move(ev) + if self.parent_gui.ctrl_held: + ev.accept() + if ev.isStart(): + self.parent_gui._painting = True + self.parent_gui._brush_path = [] + self.parent_gui.handle_brush_start(ev) + elif ev.isFinish(): + self.parent_gui._painting = False + self.parent_gui.handle_brush_end(ev) + else: + self.parent_gui.handle_brush_move(ev) + return + # fallback: pan + super().mouseDragEvent(ev, axis) class SelectionGUI(QtWidgets.QMainWindow): def __init__(self, video): @@ -41,6 +51,8 @@ def __init__(self, video): self._paint_mask = None # Same shape as the image self._paint_radius = 10 # pixels + self.ctrl_held = False + self.installEventFilter(self) self.selected_points = [] self.manual_points = [] @@ -126,6 +138,15 @@ def __init__(self, video): if app is not None: app.exec() + def eventFilter(self, source, event): + if event.type() == QtCore.QEvent.Type.KeyPress: + if event.key() == QtCore.Qt.Key.Key_Control: + self.ctrl_held = True + elif event.type() == QtCore.QEvent.Type.KeyRelease: + if event.key() == QtCore.Qt.Key.Key_Control: + self.ctrl_held = False + return super().eventFilter(source, event) + def create_help_button(self, tooltip_text: str) -> QtWidgets.QToolButton: """Create a small '?' help button with a tooltip.""" button = QtWidgets.QToolButton() @@ -405,8 +426,8 @@ def method_selected(self, id: int): is_grid = method_name == "Grid" is_brush = method_name == "Brush" - # Disable panning - self.view.setMouseEnabled(not is_brush, not is_brush) + # Always enable mouse; painting is now conditional on Ctrl + self.view.setMouseEnabled(True, True) show_spacing = is_along or is_grid From 896f0d118ee30c9c3725cb2056fdc9c99263e140 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Wed, 9 Jul 2025 15:14:22 +0200 Subject: [PATCH 38/63] deselect with brush (currently only works for brush selected points, should work for all) --- pyidi/selection/main_window.py | 52 ++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index bfa501d..4e7a092 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -52,6 +52,7 @@ def __init__(self, video): self._paint_mask = None # Same shape as the image self._paint_radius = 10 # pixels self.ctrl_held = False + self.brush_deselect_mode = False self.installEventFilter(self) self.selected_points = [] @@ -312,6 +313,13 @@ def update_label_and_recompute(val): self.start_new_line_button.setVisible(False) # Hidden by default self.manual_layout.addWidget(self.start_new_line_button) + # Brush mode + self.brush_deselect_button = QtWidgets.QPushButton("Deselect painted area") + self.brush_deselect_button.setCheckable(True) + self.brush_deselect_button.setVisible(False) # shown only for Brush mode + self.brush_deselect_button.clicked.connect(self.activate_brush_deselect) + self.manual_layout.addWidget(self.brush_deselect_button) + self.manual_layout.addStretch(1) # Polygon manager (visible only for "Along the line") @@ -440,6 +448,8 @@ def method_selected(self, id: int): self.distance_label.setVisible(show_spacing) self.distance_slider.setVisible(show_spacing) + self.brush_deselect_button.setVisible(is_brush) + def switch_mode(self, mode: str): self.mode = mode if mode == "manual": @@ -885,7 +895,6 @@ def clear_candidates(self): # Brush def handle_brush_start(self, ev): - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.CrossCursor) if self.image_item.image is None: return h, w = self.image_item.image.shape[:2] @@ -912,21 +921,49 @@ def handle_brush_move(self, ev): self.update_brush_overlay() def handle_brush_end(self, ev): - QtWidgets.QApplication.restoreOverrideCursor() - if self._paint_mask is None: return subset_size = self.subset_size_spinbox.value() spacing = self.distance_slider.value() + + # Generate (row, col) points inside the painted mask brush_rois = rois_inside_mask(self._paint_mask, subset_size, spacing) - self.manual_points.extend(brush_rois) + + # Convert to set of tuples for fast comparison + roi_set = set((int(round(y)), int(round(x))) for y, x in brush_rois) + + if self.brush_deselect_mode: + # Remove from manual points + self.manual_points = [ + pt for pt in self.manual_points + if (int(round(pt[0])), int(round(pt[1]))) not in roi_set + ] + + # Remove from polygon ROI points + for poly in self.drawing_polygons: + poly['roi_points'] = [ + pt for pt in poly['roi_points'] + if (int(round(pt[0])), int(round(pt[1]))) not in roi_set + ] + + # Remove from grid ROI points + for grid in self.grid_polygons: + grid['roi_points'] = [ + pt for pt in grid['roi_points'] + if (int(round(pt[0])), int(round(pt[1]))) not in roi_set + ] + + self.brush_deselect_mode = False + self.brush_deselect_button.setChecked(False) + + else: + self.manual_points.extend(brush_rois) self._paint_mask = None self.update_selected_points() self.update_brush_overlay() - def update_brush_overlay(self): if not hasattr(self, 'brush_overlay'): self.brush_overlay = ImageItem() @@ -939,6 +976,11 @@ def update_brush_overlay(self): self.brush_overlay.setZValue(2) else: self.brush_overlay.clear() + + def activate_brush_deselect(self): + if self.brush_deselect_button.isChecked(): + self.brush_deselect_mode = True + ################################################################################################ # Automatic subset detection ################################################################################################ From 3e3af87214493d121c9f360ea0ec3e4a6b4fdf96 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 05:47:33 +0200 Subject: [PATCH 39/63] Cleanup --- pyidi/selection/main_window.py | 38 +++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 4e7a092..e6f7692 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -77,8 +77,8 @@ def __init__(self, video): self.mode_toolbar_layout.setContentsMargins(5, 4, 5, 4) self.mode_toolbar_layout.setSpacing(6) - self.manual_mode_button = QtWidgets.QPushButton("Manual") - self.automatic_mode_button = QtWidgets.QPushButton("Automatic") + self.manual_mode_button = QtWidgets.QPushButton("Select") # Manual mode + self.automatic_mode_button = QtWidgets.QPushButton("Filter") # Automatic mode for btn in [self.manual_mode_button, self.automatic_mode_button]: btn.setCheckable(True) btn.setMinimumWidth(100) @@ -345,8 +345,16 @@ def update_label_and_recompute(val): self.manual_layout.addWidget(self.delete_grid_button) def ui_auto_right_menu(self): + self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") + font = self.candidate_count_label.font() + font.setPointSize(10) + font.setBold(True) + self.candidate_count_label.setFont(font) + self.automatic_layout.addWidget(self.candidate_count_label) + + # Title and method selector - self.automatic_layout.addWidget(QtWidgets.QLabel("Automatic method:")) + self.automatic_layout.addWidget(QtWidgets.QLabel("Filter method:")) self.auto_method_group = QtWidgets.QButtonGroup(self.automatic_widget) self.auto_method_group.setExclusive(True) @@ -365,21 +373,9 @@ def ui_auto_right_menu(self): self.auto_method_buttons[name] = button self.auto_method_group.idClicked.connect(self.auto_method_selected) - + self.automatic_layout.addSpacing(10) - # Dynamic method-specific widgets (for now shared) - self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") - font = self.candidate_count_label.font() - font.setPointSize(10) - font.setBold(True) - self.candidate_count_label.setFont(font) - self.automatic_layout.addWidget(self.candidate_count_label) - - self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") - self.clear_candidates_button.clicked.connect(self.clear_candidates) - self.automatic_layout.addWidget(self.clear_candidates_button) - # Checkbox to show/hide scatter and ROI overlay self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") self.show_points_checkbox.setChecked(False) @@ -388,6 +384,11 @@ def toggle_points_and_roi(state): self.scatter.setVisible(state) self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) self.automatic_layout.addWidget(self.show_points_checkbox) + + # Clear the candidates button + self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") + self.clear_candidates_button.clicked.connect(self.clear_candidates) + self.automatic_layout.addWidget(self.clear_candidates_button) # Horizontal line separator for visual clarity hline = QtWidgets.QFrame() @@ -971,7 +972,10 @@ def update_brush_overlay(self): if self._paint_mask is not None: rgba = np.zeros((*self._paint_mask.shape, 4), dtype=np.uint8) - rgba[self._paint_mask] = [0, 200, 255, 80] # Cyan with transparency + if self.brush_deselect_mode: + rgba[self._paint_mask] = [255, 0, 0, 80] # Red with transparency + else: + rgba[self._paint_mask] = [0, 200, 255, 80] # Cyan with transparency self.brush_overlay.setImage(rgba, autoLevels=False) self.brush_overlay.setZValue(2) else: From 1d44778ac4344ef8640c1184213f34b7fb4c28a0 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 05:50:47 +0200 Subject: [PATCH 40/63] cleanup --- pyidi/selection/main_window.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index e6f7692..bcff55b 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -303,8 +303,6 @@ def update_label_and_recompute(val): self.distance_label.setText(f"Distance between subsets: {str(val)}") self.recompute_roi_points() self.distance_slider.valueChanged.connect(update_label_and_recompute) - - # self.manual_layout.addWidget(self.distance_label) self.manual_layout.addWidget(self.distance_slider) # Start new line (only visible in "Along the line" mode) @@ -320,8 +318,6 @@ def update_label_and_recompute(val): self.brush_deselect_button.clicked.connect(self.activate_brush_deselect) self.manual_layout.addWidget(self.brush_deselect_button) - self.manual_layout.addStretch(1) - # Polygon manager (visible only for "Along the line") self.polygon_list = QtWidgets.QListWidget() self.polygon_list.setVisible(False) @@ -344,6 +340,8 @@ def update_label_and_recompute(val): self.delete_grid_button.setVisible(False) self.manual_layout.addWidget(self.delete_grid_button) + self.manual_layout.addStretch(1) + def ui_auto_right_menu(self): self.candidate_count_label = QtWidgets.QLabel("N candidate points: 0") font = self.candidate_count_label.font() From fe6494b09d35d67cb26cd5cca9a0f351e5f65325 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 06:00:17 +0200 Subject: [PATCH 41/63] set brush radius with slider --- pyidi/selection/main_window.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index bcff55b..07034f6 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -312,6 +312,18 @@ def update_label_and_recompute(val): self.manual_layout.addWidget(self.start_new_line_button) # Brush mode + self.brush_radius_label = QtWidgets.QLabel(f"Brush radius (px): {self._paint_radius}") + self.brush_radius_label.setVisible(False) # shown only for Brush mode + self.manual_layout.addWidget(self.brush_radius_label) + + self.brush_radius_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.brush_radius_slider.setRange(1, 50) + self.brush_radius_slider.setSingleStep(1) + self.brush_radius_slider.setValue(self._paint_radius) + self.brush_radius_slider.setVisible(False) # shown only for Brush mode + self.brush_radius_slider.valueChanged.connect(lambda val: self.brush_radius_label.setText(f"Brush radius (px): {val}")) + self.manual_layout.addWidget(self.brush_radius_slider) + self.brush_deselect_button = QtWidgets.QPushButton("Deselect painted area") self.brush_deselect_button.setCheckable(True) self.brush_deselect_button.setVisible(False) # shown only for Brush mode @@ -397,14 +409,15 @@ def toggle_points_and_roi(state): self.automatic_layout.addSpacing(10) # Shi-Tomasi method settings - self.threshold_label = QtWidgets.QLabel("Threshold:") + self.shi_tomasi_threshold = 10 # Default threshold value + self.threshold_label = QtWidgets.QLabel(f"Threshold: {self.shi_tomasi_threshold}") self.threshold_label.setVisible(False) self.automatic_layout.addWidget(self.threshold_label) self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.threshold_slider.setRange(1, 100) self.threshold_slider.setSingleStep(1) - self.threshold_slider.setValue(10) + self.threshold_slider.setValue(self.shi_tomasi_threshold) self.threshold_slider.setVisible(False) self.automatic_layout.addWidget(self.threshold_slider) @@ -448,6 +461,8 @@ def method_selected(self, id: int): self.distance_slider.setVisible(show_spacing) self.brush_deselect_button.setVisible(is_brush) + self.brush_radius_label.setVisible(is_brush) + self.brush_radius_slider.setVisible(is_brush) def switch_mode(self, mode: str): self.mode = mode @@ -908,7 +923,7 @@ def handle_brush_move(self, ev): if self.view.sceneBoundingRect().contains(pos): mouse_point = self.view.mapSceneToView(pos) y, x = int(round(mouse_point.x())), int(round(mouse_point.y())) - r = self._paint_radius + r = self.brush_radius_slider.value() h, w = self._paint_mask.shape yy, xx = np.ogrid[max(0, y - r):min(h, y + r + 1), From b12b74858e3efd05dae2656391db3e28a48cd839 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 06:14:58 +0200 Subject: [PATCH 42/63] brush deselction is working for all selected points --- pyidi/selection/main_window.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 07034f6..3c8c740 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -948,25 +948,24 @@ def handle_brush_end(self, ev): roi_set = set((int(round(y)), int(round(x))) for y, x in brush_rois) if self.brush_deselect_mode: + def point_inside_mask(pt, mask): + y, x = int(round(pt[0])), int(round(pt[1])) + h, w = mask.shape + return 0 <= y < h and 0 <= x < w and mask[y, x] + # Remove from manual points self.manual_points = [ pt for pt in self.manual_points - if (int(round(pt[0])), int(round(pt[1]))) not in roi_set + if not point_inside_mask(pt, self._paint_mask) ] - # Remove from polygon ROI points + # Remove from polygons for poly in self.drawing_polygons: - poly['roi_points'] = [ - pt for pt in poly['roi_points'] - if (int(round(pt[0])), int(round(pt[1]))) not in roi_set - ] - - # Remove from grid ROI points + poly['roi_points'] = [pt for pt in poly['roi_points'] if not point_inside_mask(pt, self._paint_mask)] + + # Remove from grid polygons for grid in self.grid_polygons: - grid['roi_points'] = [ - pt for pt in grid['roi_points'] - if (int(round(pt[0])), int(round(pt[1]))) not in roi_set - ] + grid['roi_points'] = [pt for pt in grid['roi_points'] if not point_inside_mask(pt, self._paint_mask)] self.brush_deselect_mode = False self.brush_deselect_button.setChecked(False) @@ -998,11 +997,6 @@ def activate_brush_deselect(self): if self.brush_deselect_button.isChecked(): self.brush_deselect_mode = True - ################################################################################################ - # Automatic subset detection - ################################################################################################ - - def points_along_polygon(polygon, subset_size, spacing=0): if len(polygon) < 2: return [] From 84e1cf5a8deac6d6e2e6e18db4cfb3435be36842 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 06:41:08 +0200 Subject: [PATCH 43/63] shi tomasi computes the eigenvalues just once, then only changes the threshold and updates the view --- pyidi/selection/main_window.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 3c8c740..0c2d786 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -423,7 +423,7 @@ def toggle_points_and_roi(state): def update_label_and_recompute(val): self.threshold_label.setText(f"Threshold: {str(val)}") - self.compute_candidate_points_shi_tomasi() # Placeholder method + self.update_threshold_and_show_shi_tomsi() # Placeholder method self.threshold_slider.valueChanged.connect(update_label_and_recompute) self.automatic_layout.addStretch(1) @@ -826,7 +826,6 @@ def compute_candidate_points_shi_tomasi(self): subset_size = self.subset_size_spinbox.value() roi_size = subset_size // 2 - threshold_ratio = self.threshold_slider.value() / 1000.0 img = self.image_item.image.astype(np.float32) candidates = [] @@ -865,10 +864,18 @@ def compute_candidate_points_shi_tomasi(self): # Threshold by normalized eigenvalue eigvals = np.array([v[2] for v in candidates]) - max_eig = np.max(eigvals) - eig_threshold = max_eig * threshold_ratio + self.max_eig_shi_tomasi = np.max(eigvals) + + self.candidates_shi_tomasi = candidates + + self.update_threshold_and_show_shi_tomsi() + + def update_threshold_and_show_shi_tomsi(self): + threshold_ratio = self.threshold_slider.value() / 1000.0 + + eig_threshold = self.max_eig_shi_tomasi * threshold_ratio - self.candidate_points = [(round(y)+0.5, round(x)+0.5) for (x, y, e) in candidates if e > eig_threshold] + self.candidate_points = [(round(y)+0.5, round(x)+0.5) for (x, y, e) in self.candidates_shi_tomasi if e > eig_threshold] self.update_candidate_display() self.update_candidate_points_count() From cf9780fa2f01cf363c0863fc5a6464dc3072b196 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 08:00:24 +0200 Subject: [PATCH 44/63] Added filtering based on gradient in direction --- pyidi/selection/main_window.py | 131 +++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 0c2d786..b943087 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -55,6 +55,10 @@ def __init__(self, video): self.brush_deselect_mode = False self.installEventFilter(self) + self.gradient_direction_points = [] + self.gradient_direction = None + self.setting_direction = False + self.selected_points = [] self.manual_points = [] self.candidate_points = [] @@ -185,6 +189,7 @@ def ui_graphics(self): brush=pg.mkBrush(0, 255, 0, 200), size=6 ) + self.direction_line = pg.PlotDataItem(pen=pg.mkPen('r', width=2)) self.view.addItem(self.image_item) self.view.addItem(self.polygon_line) @@ -192,6 +197,7 @@ def ui_graphics(self): self.view.addItem(self.roi_overlay) # Add scatter for showing square points self.view.addItem(self.scatter) # Add scatter for showing points self.view.addItem(self.candidate_scatter) + self.view.addItem(self.direction_line) self.splitter.addWidget(self.pg_widget) @@ -372,6 +378,7 @@ def ui_auto_right_menu(self): self.auto_method_buttons = {} method_names = [ "Shi-Tomasi", + "Gradient in direction", ] for i, name in enumerate(method_names): button = QtWidgets.QPushButton(name) @@ -426,6 +433,30 @@ def update_label_and_recompute(val): self.update_threshold_and_show_shi_tomsi() # Placeholder method self.threshold_slider.valueChanged.connect(update_label_and_recompute) + # Gradient in a specified direction settings + self.direction_button = QtWidgets.QPushButton("Set direction on image") + self.direction_button.setVisible(False) + self.direction_button.setCheckable(True) + self.direction_button.clicked.connect(self.set_gradient_direction_mode) + self.automatic_layout.addWidget(self.direction_button) + + self.direction_threshold = 10 + self.gradient_thresh_label = QtWidgets.QLabel(f"Threshold (grad): {self.direction_threshold}") + self.gradient_thresh_label.setVisible(False) + self.automatic_layout.addWidget(self.gradient_thresh_label) + + self.gradient_thresh_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.gradient_thresh_slider.setRange(1, 100) + self.gradient_thresh_slider.setSingleStep(1) + self.gradient_thresh_slider.setValue(self.direction_threshold) + self.gradient_thresh_slider.setVisible(False) + self.automatic_layout.addWidget(self.gradient_thresh_slider) + + def update_direction_thresh(val): + self.gradient_thresh_label.setText(f"Threshold (grad): {val}") + self.update_threshold_and_show_gradient_direction() + self.gradient_thresh_slider.valueChanged.connect(update_direction_thresh) + self.automatic_layout.addStretch(1) def auto_method_selected(self, id: int): @@ -433,12 +464,20 @@ def auto_method_selected(self, id: int): print(f"Selected automatic method: {method_name}") # Here you can switch method behavior, show/hide widgets, etc. is_shi_tomasi = method_name == "Shi-Tomasi" + is_gradient_dir = method_name == "Gradient in direction" + self.threshold_label.setVisible(is_shi_tomasi) self.threshold_slider.setVisible(is_shi_tomasi) if is_shi_tomasi: self.compute_candidate_points_shi_tomasi() + self.direction_button.setVisible(is_gradient_dir) + self.gradient_thresh_label.setVisible(is_gradient_dir) + self.gradient_thresh_slider.setVisible(is_gradient_dir) + if is_gradient_dir and self.gradient_direction is not None: + self.compute_candidate_points_gradient_direction() + def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") @@ -487,6 +526,20 @@ def switch_mode(self, mode: str): # self.candidate_scatter.setVisible(True) def on_mouse_click(self, event): + if self.setting_direction: + pos = event.scenePos() + if self.view.sceneBoundingRect().contains(pos): + point = self.view.mapSceneToView(pos) + self.gradient_direction_points.append((point.x(), point.y())) + if len(self.gradient_direction_points) == 2: + self.compute_direction_vector() + self.update_direction_line() + self.setting_direction = False + self.direction_button.setChecked(False) + print(f"Gradient direction set: {self.gradient_direction}") + self.compute_candidate_points_gradient_direction() + return + if self.mode == "automatic": return @@ -820,6 +873,7 @@ def handle_remove_point(self, event): self.update_selected_points() # Automatic filtering + # Shi-Tomasi method def compute_candidate_points_shi_tomasi(self): """Compute good feature points using structure tensor analysis (Shi–Tomasi style).""" from scipy.ndimage import sobel @@ -914,6 +968,83 @@ def clear_candidates(self): self.update_selected_points() # Update main display to remove candidates + # Gradient in a specified direction + def set_gradient_direction_mode(self): + self.setting_direction = True + self.gradient_direction_points = [] + self.direction_button.setChecked(True) # Keep it visually pressed + print("Click two points to set the gradient direction.") + + def compute_direction_vector(self): + p1, p2 = self.gradient_direction_points + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + norm = np.sqrt(dx**2 + dy**2) + if norm == 0: + self.gradient_direction = None + else: + self.gradient_direction = (dx / norm, dy / norm) + + def compute_candidate_points_gradient_direction(self): + from scipy.ndimage import sobel + + if self.gradient_direction is None: + return + + dy, dx = self.gradient_direction + subset_size = self.subset_size_spinbox.value() + roi_size = subset_size // 2 + + img = self.image_item.image.astype(np.float32) + candidates = [] + + for row, col in self.selected_points: + y, x = int(round(row)), int(round(col)) + + if (y - roi_size < 0 or y + roi_size + 1 > img.shape[0] or + x - roi_size < 0 or x + roi_size + 1 > img.shape[1]): + continue + + roi = img[y - roi_size: y + roi_size + 1, + x - roi_size: x + roi_size + 1] + + gx = sobel(roi, axis=1) + gy = sobel(roi, axis=0) + + gdir = np.abs(gx * dx) + np.abs(gy * dy) + strength = np.sum(np.abs(gdir)) + + candidates.append((x + 0.0, y + 0.0, strength)) + + if not candidates: + self.candidate_points = [] + self.update_candidate_display() + return + + values = np.array([v[2] for v in candidates]) + self.max_grad_dir = np.max(values) + self.candidates_grad_dir = candidates + self.update_threshold_and_show_gradient_direction() + + def update_threshold_and_show_gradient_direction(self): + threshold_ratio = self.gradient_thresh_slider.value() / 100.0 + threshold = self.max_grad_dir * threshold_ratio + + self.candidate_points = [ + (round(y)+0.5, round(x)+0.5) + for (x, y, v) in self.candidates_grad_dir + if v > threshold + ] + self.update_candidate_display() + self.update_candidate_points_count() + + def update_direction_line(self): + if len(self.gradient_direction_points) == 2: + xs = [p[0] for p in self.gradient_direction_points] + ys = [p[1] for p in self.gradient_direction_points] + self.direction_line.setData(xs, ys) + else: + self.direction_line.clear() + # Brush def handle_brush_start(self, ev): if self.image_item.image is None: From 56ff4b9ea32429850efa407212ebcbaf05748bc8 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 08:16:38 +0200 Subject: [PATCH 45/63] Renamed the Manual and Automatic mode to Selection and Filter mode. Also refactored code. --- pyidi/selection/main_window.py | 37 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index b943087..90a0aec 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -12,7 +12,7 @@ def __init__(self, parent_gui, *args, **kwargs): self.parent_gui = parent_gui def mouseClickEvent(self, ev): - if self.parent_gui.mode == "manual" and self.parent_gui.method_buttons["Brush"].isChecked(): + if self.parent_gui.mode == "selection" and self.parent_gui.method_buttons["Brush"].isChecked(): if self.parent_gui.ctrl_held: ev.accept() self.parent_gui.handle_brush_start(ev) @@ -22,7 +22,7 @@ def mouseClickEvent(self, ev): super().mouseClickEvent(ev) def mouseDragEvent(self, ev, axis=None): - if self.parent_gui.mode == "manual" and self.parent_gui.method_buttons["Brush"].isChecked(): + if self.parent_gui.mode == "selection" and self.parent_gui.method_buttons["Brush"].isChecked(): if self.parent_gui.ctrl_held: ev.accept() if ev.isStart(): @@ -81,19 +81,19 @@ def __init__(self, video): self.mode_toolbar_layout.setContentsMargins(5, 4, 5, 4) self.mode_toolbar_layout.setSpacing(6) - self.manual_mode_button = QtWidgets.QPushButton("Select") # Manual mode - self.automatic_mode_button = QtWidgets.QPushButton("Filter") # Automatic mode - for btn in [self.manual_mode_button, self.automatic_mode_button]: + self.selection_mode_button = QtWidgets.QPushButton("Select") # Selection mode + self.filter_mode_button = QtWidgets.QPushButton("Filter") # Filter mode + for btn in [self.selection_mode_button, self.filter_mode_button]: btn.setCheckable(True) btn.setMinimumWidth(100) self.mode_toolbar_layout.addWidget(btn) - self.manual_mode_button.setChecked(True) - self.manual_mode_button.clicked.connect(lambda: self.switch_mode("manual")) - self.automatic_mode_button.clicked.connect(lambda: self.switch_mode("automatic")) + self.selection_mode_button.setChecked(True) + self.selection_mode_button.clicked.connect(lambda: self.switch_mode("selection")) + self.filter_mode_button.clicked.connect(lambda: self.switch_mode("filter")) self.mode_toolbar.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) - self.mode_toolbar.setMaximumHeight(self.manual_mode_button.sizeHint().height() + 12) + self.mode_toolbar.setMaximumHeight(self.selection_mode_button.sizeHint().height() + 12) self.main_layout.addWidget(self.mode_toolbar) @@ -105,7 +105,7 @@ def __init__(self, video): self.ui_graphics() self.ui_right_menu() - + # Style self.setStyleSheet(""" QTabWidget::pane { border: 0; } @@ -136,7 +136,7 @@ def __init__(self, video): self.auto_method_selected(0) # Set the initial mode - self.switch_mode("manual") # Default to manual mode + self.switch_mode("selection") # Default to selection mode # Start the GUI self.show() @@ -505,18 +505,17 @@ def method_selected(self, id: int): def switch_mode(self, mode: str): self.mode = mode - if mode == "manual": - self.manual_mode_button.setChecked(True) - self.automatic_mode_button.setChecked(False) + if mode == "selection": + self.selection_mode_button.setChecked(True) + self.filter_mode_button.setChecked(False) self.stack.setCurrentWidget(self.manual_widget) self.roi_overlay.setVisible(True) self.scatter.setVisible(True) - # self.candidate_scatter.setVisible(False) - elif mode == "automatic": - self.manual_mode_button.setChecked(False) - self.automatic_mode_button.setChecked(True) + elif mode == "filter": + self.selection_mode_button.setChecked(False) + self.filter_mode_button.setChecked(True) self.stack.setCurrentWidget(self.automatic_widget) self.compute_candidate_points_shi_tomasi() @@ -540,7 +539,7 @@ def on_mouse_click(self, event): self.compute_candidate_points_gradient_direction() return - if self.mode == "automatic": + if self.mode == "filter": return if self.method_buttons["Manual"].isChecked(): From 85a7935ab1fa99a18a5800295dbefe829dcab45c Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 08:34:30 +0200 Subject: [PATCH 46/63] added instructions for tools in the statusBar --- pyidi/selection/main_window.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 90a0aec..6fa85f4 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -67,6 +67,10 @@ def __init__(self, video): self.grid_polygons = [{'points': [], 'roi_points': []}] self.active_grid_index = 0 + # Add status bar for instructions + self.statusBar = self.statusBar() + self.statusBar.showMessage("Ready. Select a method to begin.") + # Central widget self.central_widget = QtWidgets.QWidget() self.setCentralWidget(self.central_widget) @@ -478,6 +482,14 @@ def auto_method_selected(self, id: int): if is_gradient_dir and self.gradient_direction is not None: self.compute_candidate_points_gradient_direction() + if is_shi_tomasi: + self.show_instruction("Use the threshold slider to filter points.") + elif is_gradient_dir: + self.show_instruction("Define the gradient direction by clicking 'Set direction on image' and defining two pointsy.") + + def show_instruction(self, message: str): + self.statusBar.showMessage(message) + def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] print(f"Selected method: {method_name}") @@ -485,9 +497,6 @@ def method_selected(self, id: int): is_grid = method_name == "Grid" is_brush = method_name == "Brush" - # Always enable mouse; painting is now conditional on Ctrl - self.view.setMouseEnabled(True, True) - show_spacing = is_along or is_grid self.start_new_line_button.setVisible(is_along or is_grid) @@ -503,6 +512,20 @@ def method_selected(self, id: int): self.brush_radius_label.setVisible(is_brush) self.brush_radius_slider.setVisible(is_brush) + # Show context-sensitive instructions + if is_brush: + self.show_instruction("Hold Ctrl and drag to paint selection area.") + elif is_along: + self.show_instruction("Click to add points along the line. Click 'Start new line' to begin a new one.") + elif is_grid: + self.show_instruction("Click to define grid corners. Click 'Start new line' to begin a new grid.") + elif method_name == "Manual": + self.show_instruction("Click to add points manually.") + elif method_name == "Remove point": + self.show_instruction("Click on a point to remove it.") + else: + self.show_instruction("Ready.") + def switch_mode(self, mode: str): self.mode = mode if mode == "selection": @@ -512,6 +535,7 @@ def switch_mode(self, mode: str): self.roi_overlay.setVisible(True) self.scatter.setVisible(True) + self.show_instruction("Selection mode: choose a method on the left.") elif mode == "filter": self.selection_mode_button.setChecked(False) @@ -522,7 +546,7 @@ def switch_mode(self, mode: str): self.show_points_checkbox.setChecked(False) self.roi_overlay.setVisible(False) self.scatter.setVisible(False) - # self.candidate_scatter.setVisible(True) + self.show_instruction("Filter mode: choose a filter method and adjust settings.") def on_mouse_click(self, event): if self.setting_direction: From 33fecd4ef8c812d71c049802519d25e0b1378404 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Thu, 10 Jul 2025 12:01:51 +0200 Subject: [PATCH 47/63] clear gradient line when clearing selection --- pyidi/selection/main_window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyidi/selection/main_window.py b/pyidi/selection/main_window.py index 6fa85f4..b7234c4 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/selection/main_window.py @@ -713,6 +713,8 @@ def clear_selection(self): self.points_label.setText("Selected subsets: 0") + self.direction_line.clear() + self.update_selected_points() # Refresh display def set_image(self, img: np.ndarray): From 7b3a735bf935fd3895199c768033086c6671320c Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Fri, 11 Jul 2025 09:52:40 +0200 Subject: [PATCH 48/63] Added the "GUIs" folder and re-organized the files. Added "result_viewer.py" --- pyidi/GUIs/__init__.py | 27 ++ pyidi/{ => GUIs}/gui.py | 8 +- pyidi/GUIs/result_viewer.py | 290 ++++++++++++++++++ pyidi/{selection => GUIs}/selection.py | 0 .../subset_selection.py} | 24 +- pyidi/__init__.py | 3 +- pyidi/methods/idi_method.py | 2 +- pyidi/pyidi.py | 4 +- pyidi/selection/__init__.py | 16 - 9 files changed, 339 insertions(+), 35 deletions(-) create mode 100644 pyidi/GUIs/__init__.py rename pyidi/{ => GUIs}/gui.py (99%) create mode 100644 pyidi/GUIs/result_viewer.py rename pyidi/{selection => GUIs}/selection.py (100%) rename pyidi/{selection/main_window.py => GUIs/subset_selection.py} (98%) delete mode 100644 pyidi/selection/__init__.py diff --git a/pyidi/GUIs/__init__.py b/pyidi/GUIs/__init__.py new file mode 100644 index 0000000..f5b4565 --- /dev/null +++ b/pyidi/GUIs/__init__.py @@ -0,0 +1,27 @@ +try: + import pyqt6 + + HAS_PYQT6 = True +except ImportError: + HAS_PYQT6 = False + +if HAS_PYQT6: + from .subset_selection import SelectionGUI + from .result_viewer import ResultViewer +else: + class SelectionGUI: + def __init__(self): + pass + + def show_displacement(self, data): + raise RuntimeError("SelectionGUI requires PyQt6: pip install pyidi[qt]") + + class ResultViewer: + def __init__(self): + pass + + def show_displacement(self, data): + raise RuntimeError("ResultViewer requires PyQt6: pip install pyidi[qt]") + +from .selection import SubsetSelection +from .gui import GUI \ No newline at end of file diff --git a/pyidi/gui.py b/pyidi/GUIs/gui.py similarity index 99% rename from pyidi/gui.py rename to pyidi/GUIs/gui.py index 0ef332b..121f65b 100644 --- a/pyidi/gui.py +++ b/pyidi/GUIs/gui.py @@ -6,10 +6,10 @@ import warnings warnings.simplefilter("default") -from . import tools -from .selection import selection -from .methods import SimplifiedOpticalFlow -from .methods import LucasKanade +from .. import tools +from . import selection +from ..methods import SimplifiedOpticalFlow +from ..methods import LucasKanade NO_METHOD = '---' add_vertical_stretch = True diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py new file mode 100644 index 0000000..0e82d2a --- /dev/null +++ b/pyidi/GUIs/result_viewer.py @@ -0,0 +1,290 @@ +import numpy as np +import pyqtgraph as pg +from PyQt6 import QtWidgets, QtCore, QtGui +import matplotlib.pyplot as plt +import matplotlib.cm as cm +import matplotlib.colors as mcolors +import sys + +class ResultViewer(QtWidgets.QMainWindow): + def __init__(self, video, displacements, grid, fps=30, magnification=1, point_size=10, colormap="cool"): + super().__init__() + self.video = video + self.displacements = displacements + self.grid = grid + self.fps = fps + self.magnification = magnification + self.points_size = point_size + self.current_frame = 0 + + self.disp_max = np.max(displacements) + self.colormap = colormap + + self.timer = QtCore.QTimer() + self.timer.timeout.connect(self.next_frame) + + self.init_ui() + self.update_frame() + + def init_ui(self): + # Set Fusion style (looks modern & compact) + QtWidgets.QApplication.setStyle(QtWidgets.QStyleFactory.create("Fusion")) + + # Optional: make it a bit darker but safe + dark_palette = QtGui.QPalette() + dark_palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor(53, 53, 53)) + dark_palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtCore.Qt.GlobalColor.white) + dark_palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(35, 35, 35)) + dark_palette.setColor(QtGui.QPalette.ColorRole.AlternateBase, QtGui.QColor(53, 53, 53)) + dark_palette.setColor(QtGui.QPalette.ColorRole.ToolTipBase, QtCore.Qt.GlobalColor.white) + dark_palette.setColor(QtGui.QPalette.ColorRole.ToolTipText, QtCore.Qt.GlobalColor.white) + dark_palette.setColor(QtGui.QPalette.ColorRole.Text, QtCore.Qt.GlobalColor.white) + dark_palette.setColor(QtGui.QPalette.ColorRole.Button, QtGui.QColor(53, 53, 53)) + dark_palette.setColor(QtGui.QPalette.ColorRole.ButtonText, QtCore.Qt.GlobalColor.white) + dark_palette.setColor(QtGui.QPalette.ColorRole.BrightText, QtCore.Qt.GlobalColor.red) + dark_palette.setColor(QtGui.QPalette.ColorRole.Highlight, QtGui.QColor(42, 130, 218)) + dark_palette.setColor(QtGui.QPalette.ColorRole.HighlightedText, QtCore.Qt.GlobalColor.black) + QtWidgets.QApplication.setPalette(dark_palette) + + central_widget = QtWidgets.QWidget() + main_layout = QtWidgets.QVBoxLayout() + + # Top Controls: keep horizontal as requested + top_ctrl_layout = QtWidgets.QHBoxLayout() + + self.point_size_spin = QtWidgets.QSpinBox() + self.point_size_spin.setRange(1, 100) + self.point_size_spin.setValue(self.points_size) + self.point_size_spin.valueChanged.connect(self.update_point_size) + top_ctrl_layout.addWidget(QtWidgets.QLabel("Point size (px):")) + top_ctrl_layout.addWidget(self.point_size_spin) + + self.mag_spin = QtWidgets.QSpinBox() + self.mag_spin.setRange(1, 10000) + self.mag_spin.setValue(self.magnification) + self.mag_spin.valueChanged.connect(self.update_frame) + top_ctrl_layout.addWidget(QtWidgets.QLabel("Magnify:")) + top_ctrl_layout.addWidget(self.mag_spin) + + self.arrows_checkbox = QtWidgets.QCheckBox("Show arrows") + self.arrows_checkbox.stateChanged.connect(self.update_frame) + top_ctrl_layout.addWidget(self.arrows_checkbox) + + top_ctrl_layout.addStretch() + main_layout.addLayout(top_ctrl_layout) + + # === Video Display === + self.view = pg.GraphicsLayoutWidget() + self.img_item = pg.ImageItem() + self.scatter = pg.ScatterPlotItem(size=self.points_size, brush='r', pxMode=True) + self.viewbox = self.view.addViewBox() + self.viewbox.addItem(self.img_item) + self.viewbox.addItem(self.scatter) + self.viewbox.setAspectLocked(True) + self.viewbox.invertY(True) + self.arrow_shafts = [] + main_layout.addWidget(self.view, stretch=1) + + # === Playback Controls === + playback_layout = QtWidgets.QHBoxLayout() + playback_layout.setContentsMargins(0, 0, 0, 0) + + self.play_button = QtWidgets.QPushButton("▶️ Play") + self.play_button.clicked.connect(self.toggle_playback) + playback_layout.addWidget(self.play_button) + + self.slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.slider.setMinimum(0) + self.slider.setMaximum(self.video.shape[0] - 1) + self.slider.valueChanged.connect(self.on_slider) + playback_layout.addWidget(QtWidgets.QLabel("Frame:")) + playback_layout.addWidget(self.slider) + + self.fps_spin = QtWidgets.QSpinBox() + self.fps_spin.setRange(1, 240) + self.fps_spin.setValue(self.fps) + self.fps_spin.valueChanged.connect(self.on_fps_change) + playback_layout.addWidget(QtWidgets.QLabel("FPS:")) + playback_layout.addWidget(self.fps_spin) + + main_layout.addLayout(playback_layout) + + # === Finalize === + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + self.setWindowTitle("Displacement Viewer") + + + def toggle_playback(self): + if self.timer.isActive(): + self.timer.stop() + self.play_button.setText("▶️ Play") + else: + self.set_timer_interval() + self.timer.start() + self.play_button.setText("⏹️ Pause") + + def set_timer_interval(self): + self.fps = self.fps_spin.value() + interval_ms = int(1000 / self.fps) + self.timer.setInterval(interval_ms) + + def on_fps_change(self): + if self.timer.isActive(): + self.set_timer_interval() + + def update_point_size(self): + size = self.point_size_spin.value() + self.scatter.setSize(size) + + def next_frame(self): + self.current_frame = (self.current_frame + 1) % self.video.shape[0] + self.slider.setValue(self.current_frame) + + def on_slider(self, val): + self.current_frame = val + self.update_frame() + + def update_frame(self): + frame = self.video[self.current_frame] + self.img_item.setImage(frame.T) + + scale = self.mag_spin.value() + displ = self.displacements[:, self.current_frame, :] * scale + displaced_pts = self.grid + displ + self.scatter.setData(pos=displaced_pts[:, [0, 1]]) + + if self.arrows_checkbox.isChecked(): + self.scatter.setVisible(False) + + displ = displaced_pts - self.grid + magnitudes = np.linalg.norm(displ, axis=1) + + norm = mcolors.Normalize(vmin=0, vmax=self.disp_max*scale) + cmap = plt.colormaps[self.colormap] # Use the specified colormap + + # Clear old shafts + for shaft in self.arrow_shafts: + self.viewbox.removeItem(shaft) + self.arrow_shafts.clear() + + # Add colored shafts + for pt0, pt1, mag in zip(self.grid, displaced_pts, magnitudes): + color = cmap(norm(mag)) + color_rgb = tuple(int(255 * c) for c in color[:3]) + + shaft = pg.PlotCurveItem( + x=[pt0[0], pt1[0]], + y=[pt0[1], pt1[1]], + pen=pg.mkPen(color_rgb, width=self.point_size_spin.value()) + ) + self.arrow_shafts.append(shaft) + self.viewbox.addItem(shaft) + + else: + self.scatter.setVisible(True) + for shaft in self.arrow_shafts: + self.viewbox.removeItem(shaft) + self.arrow_shafts.clear() + + ########################################################################################~ + # # Uncomment this method if you want to simulate sinusoidal motion (mode shape.) + ########################################################################################~ + # def update_frame(self): + # # Always show the first frame + # frame = self.video[0] + # self.img_item.setImage(frame.T) + + # # Get current time index + # t = self.current_frame + # scale = self.mag_spin.value() / 100 + + # # Simulate sinusoidal motion (like a mode shape oscillating over time) + # omega = 2 * np.pi / 100 # You can adjust this "period" + # factor = np.sin(omega * t) + + # # Assume mode shape is stored in self.displacements[:, 0, :] (only the shape) + # simulated_displ = self.displacements[:, 0, :] * scale * factor + # displaced_pts = self.grid + simulated_displ + # self.scatter.setData(pos=displaced_pts[:, [0, 1]]) + + # if self.arrows_checkbox.isChecked(): + # self.scatter.setVisible(False) + + # # Arrow color represents displacement magnitude + # magnitudes = np.linalg.norm(simulated_displ, axis=1) + # norm = mcolors.Normalize(vmin=0, vmax=self.disp_max * scale) + # cmap = plt.colormaps[self.colormap] + + # for shaft in self.arrow_shafts: + # self.viewbox.removeItem(shaft) + # self.arrow_shafts.clear() + + # for pt0, pt1, mag in zip(self.grid, displaced_pts, magnitudes): + # color = cmap(norm(mag)) + # color_rgb = tuple(int(255 * c) for c in color[:3]) + + # shaft = pg.PlotCurveItem( + # x=[pt0[0], pt1[0]], + # y=[pt0[1], pt1[1]], + # pen=pg.mkPen(color_rgb, width=self.point_size_spin.value()) + # ) + # self.arrow_shafts.append(shaft) + # self.viewbox.addItem(shaft) + # else: + # self.scatter.setVisible(True) + # for shaft in self.arrow_shafts: + # self.viewbox.removeItem(shaft) + # self.arrow_shafts.clear() + + +def viewer(frames, displacements, points, fps=30, magnification=1, point_size=10, colormap="cool"): + """Viewer for the videos and displacements. + + Parameters + ---------- + frames : np.ndarray + Array of shape (n_frames, height, width) containing the video frames. + displacements : np.ndarray + Array of shape (n_points, n_frames, 2) containing the displacements for + each point in each frame. The directions of the last axis are the vertical and horizontal + displacements, respectively. + points : np.ndarray + Array of shape (n_points, 2) containing the initial positions of the points. The first + column is the vertical coordinate (y) and the second column is the horizontal coordinate (x). + fps : int, optional + Frames per second for the video playback, by default 30. + magnification : int, optional + Magnification factor for the displacements, by default 1. + point_size : int, optional + Size of the points in pixels, by default 10. + colormap : str, optional + Name of the colormap to use for the arrows, by default "cool". + """ + points = points[:, ::-1] + displacements = displacements[:, :, ::-1] + + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + + win = ResultViewer(frames, displacements, points, fps=fps, magnification=magnification, point_size=point_size, colormap=colormap) + win.resize(800, 600) + win.show() + + # Only call sys.exit if not in IPython + if not hasattr(sys, 'ps1'): # Not interactive + sys.exit(app.exec()) + else: + app.exec() # Don't raise SystemExit in IPythonys + + +if __name__ == "__main__": + n_frames, height, width = 200, 300, 400 + n_points = 100 + frames = np.random.randint(0, 255, size=(n_frames, height, width), dtype=np.uint8) + displacements = 2 * (np.random.rand(n_points, n_frames, 2) - 0.5) + grid = np.stack(np.meshgrid(np.linspace(50, 350, int(np.sqrt(n_points))), + np.linspace(50, 250, int(np.sqrt(n_points)))), axis=-1).reshape(-1, 2)[:n_points] + grid = grid[:, ::-1] + viewer(frames, displacements, grid) \ No newline at end of file diff --git a/pyidi/selection/selection.py b/pyidi/GUIs/selection.py similarity index 100% rename from pyidi/selection/selection.py rename to pyidi/GUIs/selection.py diff --git a/pyidi/selection/main_window.py b/pyidi/GUIs/subset_selection.py similarity index 98% rename from pyidi/selection/main_window.py rename to pyidi/GUIs/subset_selection.py index b7234c4..665c153 100644 --- a/pyidi/selection/main_window.py +++ b/pyidi/GUIs/subset_selection.py @@ -1,3 +1,4 @@ +import sys import numpy as np from PyQt6 import QtWidgets, QtCore from pyqtgraph import GraphicsLayoutWidget, ImageItem, ScatterPlotItem @@ -144,8 +145,11 @@ def __init__(self, video): # Start the GUI self.show() - if app is not None: - app.exec() + # Only call sys.exit if not in IPython + if not hasattr(sys, 'ps1'): # Not interactive + sys.exit(app.exec()) + else: + app.exec() # Don't raise SystemExit in IPythonys def eventFilter(self, source, event): if event.type() == QtCore.QEvent.Type.KeyPress: @@ -465,7 +469,7 @@ def update_direction_thresh(val): def auto_method_selected(self, id: int): method_name = list(self.auto_method_buttons.keys())[id] - print(f"Selected automatic method: {method_name}") + # print(f"Selected automatic method: {method_name}") # Here you can switch method behavior, show/hide widgets, etc. is_shi_tomasi = method_name == "Shi-Tomasi" is_gradient_dir = method_name == "Gradient in direction" @@ -492,7 +496,7 @@ def show_instruction(self, message: str): def method_selected(self, id: int): method_name = list(self.method_buttons.keys())[id] - print(f"Selected method: {method_name}") + # print(f"Selected method: {method_name}") is_along = method_name == "Along the line" is_grid = method_name == "Grid" is_brush = method_name == "Brush" @@ -559,7 +563,7 @@ def on_mouse_click(self, event): self.update_direction_line() self.setting_direction = False self.direction_button.setChecked(False) - print(f"Gradient direction set: {self.gradient_direction}") + # print(f"Gradient direction set: {self.gradient_direction}") self.compute_candidate_points_gradient_direction() return @@ -655,7 +659,7 @@ def recompute_roi_points(self): self.update_selected_points() def start_new_line(self): - print("Starting a new line...") + # print("Starting a new line...") if self.method_buttons["Along the line"].isChecked(): self.drawing_polygons.append({'points': [], 'roi_points': []}) @@ -674,7 +678,7 @@ def start_new_line(self): self.update_selected_points() def clear_selection(self): - print("Clearing selections...") + # print("Clearing selections...") # Clear manual points self.manual_points = [] @@ -985,7 +989,7 @@ def update_candidate_display(self): def clear_candidates(self): """Clear candidate points.""" - print("Clearing candidate points...") + # print("Clearing candidate points...") self.candidate_points = [] self.update_candidate_points_count() if hasattr(self, 'candidate_scatter'): @@ -998,7 +1002,7 @@ def set_gradient_direction_mode(self): self.setting_direction = True self.gradient_direction_points = [] self.direction_button.setChecked(True) # Keep it visually pressed - print("Click two points to set the gradient direction.") + # print("Click two points to set the gradient direction.") def compute_direction_vector(self): p1, p2 = self.gradient_direction_points @@ -1247,4 +1251,4 @@ def rois_inside_mask(mask, subset_size, spacing): Points = SelectionGUI(example_image.astype(np.uint8)) - print(Points.get_points()) # Print selected points for testing + print(Points.get_points()) # # print selected points for testing diff --git a/pyidi/__init__.py b/pyidi/__init__.py index 9008264..315c361 100644 --- a/pyidi/__init__.py +++ b/pyidi/__init__.py @@ -3,9 +3,8 @@ from .pyidi_legacy import pyIDI from . import tools from . import postprocessing -from .selection.selection import SubsetSelection from .load_analysis import load_analysis from .video_reader import VideoReader from .methods import * -from .gui import GUI +from .GUIs import * from .fiducial import * \ No newline at end of file diff --git a/pyidi/methods/idi_method.py b/pyidi/methods/idi_method.py index 3d451f7..751f921 100644 --- a/pyidi/methods/idi_method.py +++ b/pyidi/methods/idi_method.py @@ -8,7 +8,7 @@ import inspect import matplotlib.pyplot as plt -from ..selection.selection import SubsetSelection +from ..GUIs.selection import SubsetSelection from ..video_reader import VideoReader from ..tools import setup_logger diff --git a/pyidi/pyidi.py b/pyidi/pyidi.py index 49d9a35..f6bc5d7 100644 --- a/pyidi/pyidi.py +++ b/pyidi/pyidi.py @@ -11,7 +11,7 @@ from .methods import IDIMethod, SimplifiedOpticalFlow, LucasKanade, DirectionalLucasKanade #, LucasKanadeSc, LucasKanadeSc2, GradientBasedOpticalFlow from .video_reader import VideoReader from . import tools -from .selection import selection +from .GUIs import selection available_method_shortcuts = [ ('sof', SimplifiedOpticalFlow), @@ -266,7 +266,7 @@ def __repr__(self): return rep def gui(self): - from . import gui + from .GUIs import gui self.gui_obj = gui.gui(self) @property diff --git a/pyidi/selection/__init__.py b/pyidi/selection/__init__.py deleted file mode 100644 index 532ba05..0000000 --- a/pyidi/selection/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -try: - import pyqt6 - - HAS_PYQT6 = True -except ImportError: - HAS_PYQT6 = False - -if HAS_PYQT6: - from .main_window import SelectionGUI -else: - class DisplacementGUI: - def __init__(self): - pass - - def show_displacement(self, data): - raise RuntimeError("GUI requires PyQt6: pip install pyidi[gui]") \ No newline at end of file From 81124288648ba088ff5085fd81b84a91c1043f53 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Fri, 11 Jul 2025 10:35:04 +0200 Subject: [PATCH 49/63] Updated the result viewer layout and styling --- pyidi/GUIs/result_viewer.py | 166 ++++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 56 deletions(-) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index 0e82d2a..da093fe 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -27,90 +27,128 @@ def __init__(self, video, displacements, grid, fps=30, magnification=1, point_si self.update_frame() def init_ui(self): - # Set Fusion style (looks modern & compact) - QtWidgets.QApplication.setStyle(QtWidgets.QStyleFactory.create("Fusion")) - - # Optional: make it a bit darker but safe - dark_palette = QtGui.QPalette() - dark_palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor(53, 53, 53)) - dark_palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtCore.Qt.GlobalColor.white) - dark_palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(35, 35, 35)) - dark_palette.setColor(QtGui.QPalette.ColorRole.AlternateBase, QtGui.QColor(53, 53, 53)) - dark_palette.setColor(QtGui.QPalette.ColorRole.ToolTipBase, QtCore.Qt.GlobalColor.white) - dark_palette.setColor(QtGui.QPalette.ColorRole.ToolTipText, QtCore.Qt.GlobalColor.white) - dark_palette.setColor(QtGui.QPalette.ColorRole.Text, QtCore.Qt.GlobalColor.white) - dark_palette.setColor(QtGui.QPalette.ColorRole.Button, QtGui.QColor(53, 53, 53)) - dark_palette.setColor(QtGui.QPalette.ColorRole.ButtonText, QtCore.Qt.GlobalColor.white) - dark_palette.setColor(QtGui.QPalette.ColorRole.BrightText, QtCore.Qt.GlobalColor.red) - dark_palette.setColor(QtGui.QPalette.ColorRole.Highlight, QtGui.QColor(42, 130, 218)) - dark_palette.setColor(QtGui.QPalette.ColorRole.HighlightedText, QtCore.Qt.GlobalColor.black) - QtWidgets.QApplication.setPalette(dark_palette) + # Style + self.setStyleSheet(""" + QTabWidget::pane { border: 0; } + QPushButton { + background-color: #444; + color: white; + padding: 6px 12px; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:checked { + background-color: #0078d7; + border: 1px solid #005bb5; + } + """) central_widget = QtWidgets.QWidget() - main_layout = QtWidgets.QVBoxLayout() + main_layout = QtWidgets.QVBoxLayout(central_widget) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) - # Top Controls: keep horizontal as requested - top_ctrl_layout = QtWidgets.QHBoxLayout() + # Add splitter + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + main_layout.addWidget(self.splitter, stretch=1) + # === Video Display === + self.view = pg.GraphicsLayoutWidget() + self.img_item = pg.ImageItem() + self.scatter = pg.ScatterPlotItem(size=self.points_size, brush='r', pxMode=True) + self.viewbox = self.view.addViewBox() + self.viewbox.addItem(self.img_item) + self.viewbox.addItem(self.scatter) + self.viewbox.setAspectLocked(True) + self.viewbox.invertY(True) + self.arrow_shafts = [] + self.splitter.addWidget(self.view) + + # === Right Control Panel === + self.control_widget = QtWidgets.QWidget() + self.control_layout = QtWidgets.QVBoxLayout(self.control_widget) + + # Display controls + self.control_layout.addWidget(QtWidgets.QLabel("Display Controls")) + + # Point size control + self.control_layout.addWidget(QtWidgets.QLabel("Point size (px):")) self.point_size_spin = QtWidgets.QSpinBox() self.point_size_spin.setRange(1, 100) self.point_size_spin.setValue(self.points_size) self.point_size_spin.valueChanged.connect(self.update_point_size) - top_ctrl_layout.addWidget(QtWidgets.QLabel("Point size (px):")) - top_ctrl_layout.addWidget(self.point_size_spin) + self.control_layout.addWidget(self.point_size_spin) + # Magnification control + self.control_layout.addWidget(QtWidgets.QLabel("Magnify:")) self.mag_spin = QtWidgets.QSpinBox() self.mag_spin.setRange(1, 10000) self.mag_spin.setValue(self.magnification) self.mag_spin.valueChanged.connect(self.update_frame) - top_ctrl_layout.addWidget(QtWidgets.QLabel("Magnify:")) - top_ctrl_layout.addWidget(self.mag_spin) + self.control_layout.addWidget(self.mag_spin) + # Show arrows checkbox self.arrows_checkbox = QtWidgets.QCheckBox("Show arrows") self.arrows_checkbox.stateChanged.connect(self.update_frame) - top_ctrl_layout.addWidget(self.arrows_checkbox) - - top_ctrl_layout.addStretch() - main_layout.addLayout(top_ctrl_layout) - - # === Video Display === - self.view = pg.GraphicsLayoutWidget() - self.img_item = pg.ImageItem() - self.scatter = pg.ScatterPlotItem(size=self.points_size, brush='r', pxMode=True) - self.viewbox = self.view.addViewBox() - self.viewbox.addItem(self.img_item) - self.viewbox.addItem(self.scatter) - self.viewbox.setAspectLocked(True) - self.viewbox.invertY(True) - self.arrow_shafts = [] - main_layout.addWidget(self.view, stretch=1) - - # === Playback Controls === + self.control_layout.addWidget(self.arrows_checkbox) + + # Separator + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.Shape.HLine) + separator.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.control_layout.addWidget(separator) + + # Playback controls + self.control_layout.addWidget(QtWidgets.QLabel("Playback Controls")) + + # FPS control + self.fps_label = QtWidgets.QLabel(f"FPS: {self.fps}") + self.control_layout.addWidget(self.fps_label) + + self.fps_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.fps_slider.setRange(1, 240) + self.fps_slider.setValue(self.fps) + self.fps_slider.valueChanged.connect(self.update_fps_from_slider) + self.control_layout.addWidget(self.fps_slider) + + self.fps_spin = QtWidgets.QSpinBox() + self.fps_spin.setRange(1, 240) + self.fps_spin.setValue(self.fps) + self.fps_spin.valueChanged.connect(self.update_fps_from_spinbox) + self.control_layout.addWidget(self.fps_spin) + + self.control_layout.addStretch(1) + + self.splitter.addWidget(self.control_widget) + + # Set splitter proportions + self.splitter.setStretchFactor(0, 5) # Video area grows more + self.splitter.setStretchFactor(1, 0) # Controls panel fixed by content + + # Set initial width for right panel + self.control_widget.setMinimumWidth(150) + self.control_widget.setMaximumWidth(300) + self.splitter.setSizes([800, 200]) # Initial left/right width + + # === Bottom Playback Controls === playback_layout = QtWidgets.QHBoxLayout() - playback_layout.setContentsMargins(0, 0, 0, 0) + playback_layout.setContentsMargins(5, 5, 5, 5) - self.play_button = QtWidgets.QPushButton("▶️ Play") + self.play_button = QtWidgets.QPushButton("▶️") self.play_button.clicked.connect(self.toggle_playback) playback_layout.addWidget(self.play_button) + + playback_layout.addWidget(QtWidgets.QLabel(" Frame: ")) self.slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.slider.setMinimum(0) self.slider.setMaximum(self.video.shape[0] - 1) self.slider.valueChanged.connect(self.on_slider) - playback_layout.addWidget(QtWidgets.QLabel("Frame:")) playback_layout.addWidget(self.slider) - self.fps_spin = QtWidgets.QSpinBox() - self.fps_spin.setRange(1, 240) - self.fps_spin.setValue(self.fps) - self.fps_spin.valueChanged.connect(self.on_fps_change) - playback_layout.addWidget(QtWidgets.QLabel("FPS:")) - playback_layout.addWidget(self.fps_spin) - main_layout.addLayout(playback_layout) # === Finalize === - central_widget.setLayout(main_layout) self.setCentralWidget(central_widget) self.setWindowTitle("Displacement Viewer") @@ -118,11 +156,11 @@ def init_ui(self): def toggle_playback(self): if self.timer.isActive(): self.timer.stop() - self.play_button.setText("▶️ Play") + self.play_button.setText("▶️") else: self.set_timer_interval() self.timer.start() - self.play_button.setText("⏹️ Pause") + self.play_button.setText("⏹️") def set_timer_interval(self): self.fps = self.fps_spin.value() @@ -133,6 +171,22 @@ def on_fps_change(self): if self.timer.isActive(): self.set_timer_interval() + def update_fps_from_slider(self, value): + self.fps = value + self.fps_label.setText(f"FPS: {value}") + self.fps_spin.blockSignals(True) # Prevent recursive updates + self.fps_spin.setValue(value) + self.fps_spin.blockSignals(False) + self.on_fps_change() + + def update_fps_from_spinbox(self, value): + self.fps = value + self.fps_label.setText(f"FPS: {value}") + self.fps_slider.blockSignals(True) # Prevent recursive updates + self.fps_slider.setValue(value) + self.fps_slider.blockSignals(False) + self.on_fps_change() + def update_point_size(self): size = self.point_size_spin.value() self.scatter.setSize(size) From 398c320793c29772b059904bf163eacbad4de5bb Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Fri, 11 Jul 2025 10:41:52 +0200 Subject: [PATCH 50/63] updated styling and layout --- pyidi/GUIs/result_viewer.py | 60 +++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index da093fe..dbedf63 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -41,6 +41,28 @@ def init_ui(self): background-color: #0078d7; border: 1px solid #005bb5; } + QGroupBox { + font-weight: bold; + border: 2px solid #555; + border-radius: 8px; + margin-top: 15px; + padding-top: 15px; + background-color: #3a3a3a; + color: white; + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 15px; + top: 4px; + padding: 2px 10px; + color: #e0e0e0; + background-color: #4a4a4a; + border: 1px solid #666; + border-radius: 4px; + font-size: 11px; + font-weight: bold; + } """) central_widget = QtWidgets.QWidget() @@ -68,54 +90,54 @@ def init_ui(self): self.control_widget = QtWidgets.QWidget() self.control_layout = QtWidgets.QVBoxLayout(self.control_widget) - # Display controls - self.control_layout.addWidget(QtWidgets.QLabel("Display Controls")) + # Display controls group + display_group = QtWidgets.QGroupBox("Display Controls") + display_layout = QtWidgets.QVBoxLayout(display_group) # Point size control - self.control_layout.addWidget(QtWidgets.QLabel("Point size (px):")) + display_layout.addWidget(QtWidgets.QLabel("Point size (px):")) self.point_size_spin = QtWidgets.QSpinBox() self.point_size_spin.setRange(1, 100) self.point_size_spin.setValue(self.points_size) self.point_size_spin.valueChanged.connect(self.update_point_size) - self.control_layout.addWidget(self.point_size_spin) + display_layout.addWidget(self.point_size_spin) # Magnification control - self.control_layout.addWidget(QtWidgets.QLabel("Magnify:")) + display_layout.addWidget(QtWidgets.QLabel("Magnify:")) self.mag_spin = QtWidgets.QSpinBox() self.mag_spin.setRange(1, 10000) self.mag_spin.setValue(self.magnification) self.mag_spin.valueChanged.connect(self.update_frame) - self.control_layout.addWidget(self.mag_spin) + display_layout.addWidget(self.mag_spin) # Show arrows checkbox self.arrows_checkbox = QtWidgets.QCheckBox("Show arrows") self.arrows_checkbox.stateChanged.connect(self.update_frame) - self.control_layout.addWidget(self.arrows_checkbox) - - # Separator - separator = QtWidgets.QFrame() - separator.setFrameShape(QtWidgets.QFrame.Shape.HLine) - separator.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.control_layout.addWidget(separator) + display_layout.addWidget(self.arrows_checkbox) - # Playback controls - self.control_layout.addWidget(QtWidgets.QLabel("Playback Controls")) + self.control_layout.addWidget(display_group) + + # Playback controls group + playback_group = QtWidgets.QGroupBox("Playback Controls") + playback_layout = QtWidgets.QVBoxLayout(playback_group) # FPS control self.fps_label = QtWidgets.QLabel(f"FPS: {self.fps}") - self.control_layout.addWidget(self.fps_label) + playback_layout.addWidget(self.fps_label) self.fps_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.fps_slider.setRange(1, 240) self.fps_slider.setValue(self.fps) self.fps_slider.valueChanged.connect(self.update_fps_from_slider) - self.control_layout.addWidget(self.fps_slider) + playback_layout.addWidget(self.fps_slider) self.fps_spin = QtWidgets.QSpinBox() self.fps_spin.setRange(1, 240) self.fps_spin.setValue(self.fps) self.fps_spin.valueChanged.connect(self.update_fps_from_spinbox) - self.control_layout.addWidget(self.fps_spin) + playback_layout.addWidget(self.fps_spin) + + self.control_layout.addWidget(playback_group) self.control_layout.addStretch(1) @@ -127,7 +149,7 @@ def init_ui(self): # Set initial width for right panel self.control_widget.setMinimumWidth(150) - self.control_widget.setMaximumWidth(300) + self.control_widget.setMaximumWidth(600) self.splitter.setSizes([800, 200]) # Initial left/right width # === Bottom Playback Controls === From 6d2557c7f97a6c940fde115f254c4575b60d3f3f Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Fri, 11 Jul 2025 10:51:10 +0200 Subject: [PATCH 51/63] updated styling of the right menu --- pyidi/GUIs/subset_selection.py | 124 +++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 44 deletions(-) diff --git a/pyidi/GUIs/subset_selection.py b/pyidi/GUIs/subset_selection.py index 665c153..9392e32 100644 --- a/pyidi/GUIs/subset_selection.py +++ b/pyidi/GUIs/subset_selection.py @@ -125,6 +125,28 @@ def __init__(self, video): background-color: #0078d7; border: 1px solid #005bb5; } + QGroupBox { + font-weight: bold; + border: 2px solid #555; + border-radius: 8px; + margin-top: 15px; + padding-top: 15px; + background-color: #3a3a3a; + color: white; + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 15px; + top: 4px; + padding: 2px 10px; + color: #e0e0e0; + background-color: #4a4a4a; + border: 1px solid #666; + border-radius: 4px; + font-size: 11px; + font-weight: bold; + } """) # Connect selection change handler @@ -245,8 +267,13 @@ def ui_manual_right_menu(self): font.setPointSize(10) font.setBold(True) self.points_label.setFont(font) + self.manual_layout.addWidget(self.points_label) + # Method selection group + method_group = QtWidgets.QGroupBox("Selection Methods") + method_layout = QtWidgets.QVBoxLayout(method_group) + # Method selection buttons self.button_group = QtWidgets.QButtonGroup(self.method_widget) self.button_group.setExclusive(True) @@ -265,13 +292,17 @@ def ui_manual_right_menu(self): if i == 0: button.setChecked(True) # Default selection self.button_group.addButton(button, i) - self.manual_layout.addWidget(button) + method_layout.addWidget(button) self.method_buttons[name] = button + + self.manual_layout.addWidget(method_group) + # Subset configuration group + config_group = QtWidgets.QGroupBox("Subset Configuration") + config_layout = QtWidgets.QVBoxLayout(config_group) + # Subset size input - self.manual_layout.addSpacing(20) - self.manual_layout.addWidget(QtWidgets.QLabel("Subset size:")) - + config_layout.addWidget(QtWidgets.QLabel("Subset size:")) self.subset_size_spinbox = QtWidgets.QSpinBox() self.subset_size_spinbox.setRange(1, 1000) self.subset_size_spinbox.setValue(11) @@ -281,54 +312,52 @@ def ui_manual_right_menu(self): self.subset_size_spinbox.setMaximum(999) self.subset_size_spinbox.setWrapping(False) self.subset_size_spinbox.valueChanged.connect(self.update_selected_points) - self.manual_layout.addWidget(self.subset_size_spinbox) + config_layout.addWidget(self.subset_size_spinbox) # Show ROI rectangles self.show_roi_checkbox = QtWidgets.QCheckBox("Show subsets") self.show_roi_checkbox.setChecked(True) self.show_roi_checkbox.stateChanged.connect(self.update_selected_points) - self.manual_layout.addWidget(self.show_roi_checkbox) + config_layout.addWidget(self.show_roi_checkbox) # Clear button - self.manual_layout.addSpacing(20) self.clear_button = QtWidgets.QPushButton("Clear selections") self.clear_button.clicked.connect(self.clear_selection) - self.manual_layout.addWidget(self.clear_button) + config_layout.addWidget(self.clear_button) + + self.manual_layout.addWidget(config_group) - # Separator line - separator = QtWidgets.QFrame() - separator.setFrameShape(QtWidgets.QFrame.Shape.HLine) - separator.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.manual_layout.addWidget(separator) - self.manual_layout.addSpacing(20) + # Method-specific controls group + method_controls_group = QtWidgets.QGroupBox("Method-Specific Controls") + method_controls_layout = QtWidgets.QVBoxLayout(method_controls_group) # Distance between subsets (only visible for Grid and Along the line) self.distance_label = QtWidgets.QLabel("Distance between subsets:") self.distance_label.setVisible(False) # Hidden by default + method_controls_layout.addWidget(self.distance_label) + self.distance_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.distance_slider.setRange(-50, 50) self.distance_slider.setSingleStep(1) self.distance_slider.setValue(0) self.distance_slider.setVisible(False) - self.manual_layout.addWidget(self.distance_slider) - self.manual_layout.addWidget(self.distance_label) + method_controls_layout.addWidget(self.distance_slider) def update_label_and_recompute(val): self.distance_label.setText(f"Distance between subsets: {str(val)}") self.recompute_roi_points() self.distance_slider.valueChanged.connect(update_label_and_recompute) - self.manual_layout.addWidget(self.distance_slider) # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") self.start_new_line_button.clicked.connect(self.start_new_line) self.start_new_line_button.setVisible(False) # Hidden by default - self.manual_layout.addWidget(self.start_new_line_button) + method_controls_layout.addWidget(self.start_new_line_button) # Brush mode self.brush_radius_label = QtWidgets.QLabel(f"Brush radius (px): {self._paint_radius}") self.brush_radius_label.setVisible(False) # shown only for Brush mode - self.manual_layout.addWidget(self.brush_radius_label) + method_controls_layout.addWidget(self.brush_radius_label) self.brush_radius_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.brush_radius_slider.setRange(1, 50) @@ -336,35 +365,37 @@ def update_label_and_recompute(val): self.brush_radius_slider.setValue(self._paint_radius) self.brush_radius_slider.setVisible(False) # shown only for Brush mode self.brush_radius_slider.valueChanged.connect(lambda val: self.brush_radius_label.setText(f"Brush radius (px): {val}")) - self.manual_layout.addWidget(self.brush_radius_slider) + method_controls_layout.addWidget(self.brush_radius_slider) self.brush_deselect_button = QtWidgets.QPushButton("Deselect painted area") self.brush_deselect_button.setCheckable(True) self.brush_deselect_button.setVisible(False) # shown only for Brush mode self.brush_deselect_button.clicked.connect(self.activate_brush_deselect) - self.manual_layout.addWidget(self.brush_deselect_button) + method_controls_layout.addWidget(self.brush_deselect_button) # Polygon manager (visible only for "Along the line") self.polygon_list = QtWidgets.QListWidget() self.polygon_list.setVisible(False) self.polygon_list.currentRowChanged.connect(self.on_polygon_selected) - self.manual_layout.addWidget(self.polygon_list) + method_controls_layout.addWidget(self.polygon_list) self.delete_polygon_button = QtWidgets.QPushButton("Delete selected polygon") self.delete_polygon_button.clicked.connect(self.delete_selected_polygon) self.delete_polygon_button.setVisible(False) - self.manual_layout.addWidget(self.delete_polygon_button) + method_controls_layout.addWidget(self.delete_polygon_button) # Grid polygon manager self.grid_list = QtWidgets.QListWidget() self.grid_list.setVisible(False) self.grid_list.currentRowChanged.connect(self.on_grid_selected) - self.manual_layout.addWidget(self.grid_list) + method_controls_layout.addWidget(self.grid_list) self.delete_grid_button = QtWidgets.QPushButton("Delete selected grid") self.delete_grid_button.clicked.connect(self.delete_selected_grid) self.delete_grid_button.setVisible(False) - self.manual_layout.addWidget(self.delete_grid_button) + method_controls_layout.addWidget(self.delete_grid_button) + + self.manual_layout.addWidget(method_controls_group) self.manual_layout.addStretch(1) @@ -374,11 +405,12 @@ def ui_auto_right_menu(self): font.setPointSize(10) font.setBold(True) self.candidate_count_label.setFont(font) + self.automatic_layout.addWidget(self.candidate_count_label) - - # Title and method selector - self.automatic_layout.addWidget(QtWidgets.QLabel("Filter method:")) + # Filter method selection group + filter_method_group = QtWidgets.QGroupBox("Filter Methods") + filter_method_layout = QtWidgets.QVBoxLayout(filter_method_group) self.auto_method_group = QtWidgets.QButtonGroup(self.automatic_widget) self.auto_method_group.setExclusive(True) @@ -394,12 +426,16 @@ def ui_auto_right_menu(self): if i == 0: button.setChecked(True) self.auto_method_group.addButton(button, i) - self.automatic_layout.addWidget(button) + filter_method_layout.addWidget(button) self.auto_method_buttons[name] = button self.auto_method_group.idClicked.connect(self.auto_method_selected) - self.automatic_layout.addSpacing(10) + self.automatic_layout.addWidget(filter_method_group) + + # Display options group + display_options_group = QtWidgets.QGroupBox("Display Options") + display_options_layout = QtWidgets.QVBoxLayout(display_options_group) # Checkbox to show/hide scatter and ROI overlay self.show_points_checkbox = QtWidgets.QCheckBox("Show points/ROIs") @@ -408,33 +444,31 @@ def toggle_points_and_roi(state): self.roi_overlay.setVisible(state) self.scatter.setVisible(state) self.show_points_checkbox.stateChanged.connect(toggle_points_and_roi) - self.automatic_layout.addWidget(self.show_points_checkbox) + display_options_layout.addWidget(self.show_points_checkbox) # Clear the candidates button self.clear_candidates_button = QtWidgets.QPushButton("Clear candidates") self.clear_candidates_button.clicked.connect(self.clear_candidates) - self.automatic_layout.addWidget(self.clear_candidates_button) - - # Horizontal line separator for visual clarity - hline = QtWidgets.QFrame() - hline.setFrameShape(QtWidgets.QFrame.Shape.HLine) - hline.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.automatic_layout.addWidget(hline) + display_options_layout.addWidget(self.clear_candidates_button) + + self.automatic_layout.addWidget(display_options_group) - self.automatic_layout.addSpacing(10) + # Method settings group + method_settings_group = QtWidgets.QGroupBox("Method Settings") + method_settings_layout = QtWidgets.QVBoxLayout(method_settings_group) # Shi-Tomasi method settings self.shi_tomasi_threshold = 10 # Default threshold value self.threshold_label = QtWidgets.QLabel(f"Threshold: {self.shi_tomasi_threshold}") self.threshold_label.setVisible(False) - self.automatic_layout.addWidget(self.threshold_label) + method_settings_layout.addWidget(self.threshold_label) self.threshold_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.threshold_slider.setRange(1, 100) self.threshold_slider.setSingleStep(1) self.threshold_slider.setValue(self.shi_tomasi_threshold) self.threshold_slider.setVisible(False) - self.automatic_layout.addWidget(self.threshold_slider) + method_settings_layout.addWidget(self.threshold_slider) def update_label_and_recompute(val): self.threshold_label.setText(f"Threshold: {str(val)}") @@ -446,24 +480,26 @@ def update_label_and_recompute(val): self.direction_button.setVisible(False) self.direction_button.setCheckable(True) self.direction_button.clicked.connect(self.set_gradient_direction_mode) - self.automatic_layout.addWidget(self.direction_button) + method_settings_layout.addWidget(self.direction_button) self.direction_threshold = 10 self.gradient_thresh_label = QtWidgets.QLabel(f"Threshold (grad): {self.direction_threshold}") self.gradient_thresh_label.setVisible(False) - self.automatic_layout.addWidget(self.gradient_thresh_label) + method_settings_layout.addWidget(self.gradient_thresh_label) self.gradient_thresh_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.gradient_thresh_slider.setRange(1, 100) self.gradient_thresh_slider.setSingleStep(1) self.gradient_thresh_slider.setValue(self.direction_threshold) self.gradient_thresh_slider.setVisible(False) - self.automatic_layout.addWidget(self.gradient_thresh_slider) + method_settings_layout.addWidget(self.gradient_thresh_slider) def update_direction_thresh(val): self.gradient_thresh_label.setText(f"Threshold (grad): {val}") self.update_threshold_and_show_gradient_direction() self.gradient_thresh_slider.valueChanged.connect(update_direction_thresh) + + self.automatic_layout.addWidget(method_settings_group) self.automatic_layout.addStretch(1) From b6e12e2bb776f5fab3e4d30c8d017a55d5a6f473 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Fri, 11 Jul 2025 11:06:42 +0200 Subject: [PATCH 52/63] import fixes --- pyidi/GUIs/__init__.py | 4 ++-- pyidi/methods/idi_method.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyidi/GUIs/__init__.py b/pyidi/GUIs/__init__.py index f5b4565..72edcee 100644 --- a/pyidi/GUIs/__init__.py +++ b/pyidi/GUIs/__init__.py @@ -1,5 +1,5 @@ try: - import pyqt6 + import PyQt6 HAS_PYQT6 = True except ImportError: @@ -10,7 +10,7 @@ from .result_viewer import ResultViewer else: class SelectionGUI: - def __init__(self): + def __init__(self, video): pass def show_displacement(self, data): diff --git a/pyidi/methods/idi_method.py b/pyidi/methods/idi_method.py index 751f921..78d96e1 100644 --- a/pyidi/methods/idi_method.py +++ b/pyidi/methods/idi_method.py @@ -8,7 +8,6 @@ import inspect import matplotlib.pyplot as plt -from ..GUIs.selection import SubsetSelection from ..video_reader import VideoReader from ..tools import setup_logger @@ -263,6 +262,8 @@ def _make_comparison_dict(self): return settings def set_points(self, points): + from ..GUIs.selection import SubsetSelection + if isinstance(points, list): points = np.array(points) elif isinstance(points, SubsetSelection): From 09c6435c72ee92ec45c6139423c8571839ce20b9 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Fri, 11 Jul 2025 13:33:45 +0200 Subject: [PATCH 53/63] Changes for seamless combatibility with pyidi analyses returns --- pyidi/GUIs/__init__.py | 4 +- pyidi/GUIs/result_viewer.py | 78 ++++++++++++++++++++++++---------- pyidi/GUIs/subset_selection.py | 24 +++++++++-- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/pyidi/GUIs/__init__.py b/pyidi/GUIs/__init__.py index 72edcee..4052991 100644 --- a/pyidi/GUIs/__init__.py +++ b/pyidi/GUIs/__init__.py @@ -1,3 +1,5 @@ +import typing + try: import PyQt6 @@ -5,7 +7,7 @@ except ImportError: HAS_PYQT6 = False -if HAS_PYQT6: +if HAS_PYQT6 or typing.TYPE_CHECKING: from .subset_selection import SelectionGUI from .result_viewer import ResultViewer else: diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index dbedf63..cc91963 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -7,11 +7,47 @@ import sys class ResultViewer(QtWidgets.QMainWindow): - def __init__(self, video, displacements, grid, fps=30, magnification=1, point_size=10, colormap="cool"): + def __init__(self, video, displacements, points, fps=30, magnification=1, point_size=10, colormap="cool"): + """ + The results from the pyidi analysis can directly be passed to this class: + + - ``video``: can be a ``VideoReader`` object (or numpy array of correct shape). + - ``displacements``: directly the return from the ``get_displacements`` method. + - ``points``: the points used for the analysis, which were passed to the ``set_points`` method. + + Parameters + ---------- + video : np.ndarray or VideoReader + Array of shape (n_frames, height, width) containing the video frames. + displacements : np.ndarray + Array of shape (n_frames, n_points, 2) containing the displacement vectors. + points : np.ndarray + Array of shape (n_points, 2) containing the grid points. + fps : int, optional + Frames per second for the video playback, by default 30. + magnification : int, optional + Magnification factor for the displacements, by default 1. + point_size : int, optional + Size of the points in pixels, by default 10. + colormap : str, optional + Name of the colormap to use for the arrows, by default "cool". + """ + # Create QApplication if it doesn't exist + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + super().__init__() - self.video = video - self.displacements = displacements - self.grid = grid + + # Coordinate transformation to match viewer function behavior + from ..video_reader import VideoReader + if isinstance(video, VideoReader): + self.video = video.get_frames() + else: + self.video = video + + self.displacements = displacements[:, :, ::-1] # Flip x,y coordinates + self.grid = points[:, ::-1] + 0.5 # Flip x,y coordinates self.fps = fps self.magnification = magnification self.points_size = point_size @@ -25,6 +61,14 @@ def __init__(self, video, displacements, grid, fps=30, magnification=1, point_si self.init_ui() self.update_frame() + + # Start the GUI + self.show() + # Only call sys.exit if not in IPython + if not hasattr(sys, 'ps1'): # Not interactive + sys.exit(app.exec()) + else: + app.exec() # Don't raise SystemExit in IPython def init_ui(self): # Style @@ -173,6 +217,7 @@ def init_ui(self): # === Finalize === self.setCentralWidget(central_widget) self.setWindowTitle("Displacement Viewer") + self.resize(800, 600) def toggle_playback(self): @@ -337,22 +382,10 @@ def viewer(frames, displacements, points, fps=30, magnification=1, point_size=10 colormap : str, optional Name of the colormap to use for the arrows, by default "cool". """ - points = points[:, ::-1] - displacements = displacements[:, :, ::-1] - - app = QtWidgets.QApplication.instance() - if app is None: - app = QtWidgets.QApplication([]) - - win = ResultViewer(frames, displacements, points, fps=fps, magnification=magnification, point_size=point_size, colormap=colormap) - win.resize(800, 600) - win.show() - - # Only call sys.exit if not in IPython - if not hasattr(sys, 'ps1'): # Not interactive - sys.exit(app.exec()) - else: - app.exec() # Don't raise SystemExit in IPythonys + # This function is now just a wrapper for backward compatibility + # The ResultViewer class handles everything internally + ResultViewer(frames, displacements, points, fps=fps, magnification=magnification, + point_size=point_size, colormap=colormap) if __name__ == "__main__": @@ -362,5 +395,6 @@ def viewer(frames, displacements, points, fps=30, magnification=1, point_size=10 displacements = 2 * (np.random.rand(n_points, n_frames, 2) - 0.5) grid = np.stack(np.meshgrid(np.linspace(50, 350, int(np.sqrt(n_points))), np.linspace(50, 250, int(np.sqrt(n_points)))), axis=-1).reshape(-1, 2)[:n_points] - grid = grid[:, ::-1] - viewer(frames, displacements, grid) \ No newline at end of file + + # Now you can directly call ResultViewer (no need to flip coordinates here since it's done internally) + ResultViewer(frames, displacements, grid) \ No newline at end of file diff --git a/pyidi/GUIs/subset_selection.py b/pyidi/GUIs/subset_selection.py index 9392e32..0c49c41 100644 --- a/pyidi/GUIs/subset_selection.py +++ b/pyidi/GUIs/subset_selection.py @@ -41,6 +41,15 @@ def mouseDragEvent(self, ev, axis=None): class SelectionGUI(QtWidgets.QMainWindow): def __init__(self, video): + """Initialize the selection GUI for manual subset selection. + + To extract the points, use the ``get_points`` method or the ``points`` attribute. + + Parameters + ---------- + video : VideoReader or np.ndarray + The video to be analyzed. If a VideoReader object, it should be initialized with the video file. + """ app = QtWidgets.QApplication.instance() if app is None: app = QtWidgets.QApplication([]) @@ -156,7 +165,11 @@ def __init__(self, video): self.pg_widget.scene().sigMouseClicked.connect(self.on_mouse_click) # Set the initial image - self.image_item.setImage(video) + from ..video_reader import VideoReader + if isinstance(video, VideoReader): + self.frame = video.get_frame(0) + + self.image_item.setImage(self.frame.T) # axis 0 is x, while image axis 0 is y # Ensure method-specific widgets are visible on startup self.method_selected(self.button_group.checkedId()) @@ -204,7 +217,7 @@ def create_help_button(self, tooltip_text: str) -> QtWidgets.QToolButton: def ui_graphics(self): # Image viewer self.pg_widget = GraphicsLayoutWidget() - self.view = BrushViewBox(parent_gui=self, lockAspect=True) + self.view = BrushViewBox(parent_gui=self, lockAspect=True, invertY=True) self.pg_widget.addItem(self.view) @@ -763,7 +776,12 @@ def set_image(self, img: np.ndarray): def get_points(self): """Get all selected points from manual and polygons.""" - return np.array(self.selected_points) + points = np.array(self.selected_points)[:, ::-1] # set axis 0 to y and axis 1 to x + return points + + @property + def points(self): + return self.get_points() def get_filtered_points(self): """Get candidate points from automatic filtering.""" From 4d6a2af2ec975387342a5561ebfadb6c052c1470 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Mon, 14 Jul 2025 12:34:40 +0200 Subject: [PATCH 54/63] Added automatic mode shape visualization --- pyidi/GUIs/result_viewer.py | 154 ++++++++++++++---------------------- 1 file changed, 61 insertions(+), 93 deletions(-) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index cc91963..c73a314 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -12,7 +12,7 @@ def __init__(self, video, displacements, points, fps=30, magnification=1, point_ The results from the pyidi analysis can directly be passed to this class: - ``video``: can be a ``VideoReader`` object (or numpy array of correct shape). - - ``displacements``: directly the return from the ``get_displacements`` method. + - ``displacements``: directly the return from the ``get_displacements`` method or mode shapes. - ``points``: the points used for the analysis, which were passed to the ``set_points`` method. Parameters @@ -20,7 +20,8 @@ def __init__(self, video, displacements, points, fps=30, magnification=1, point_ video : np.ndarray or VideoReader Array of shape (n_frames, height, width) containing the video frames. displacements : np.ndarray - Array of shape (n_frames, n_points, 2) containing the displacement vectors. + Array of shape (n_frames, n_points, 2) for time-series displacements OR + Array of shape (n_points, 2) for mode shapes. points : np.ndarray Array of shape (n_points, 2) containing the grid points. fps : int, optional @@ -46,14 +47,24 @@ def __init__(self, video, displacements, points, fps=30, magnification=1, point_ else: self.video = video - self.displacements = displacements[:, :, ::-1] # Flip x,y coordinates + # Check if displacements are 2D (mode shapes) or 3D (time-series) + if displacements.ndim == 2: + # Mode shapes: shape (n_points, 2) + self.is_mode_shape = True + self.displacements = displacements[:, ::-1] # Flip x,y coordinates + self.time_per_period = 1.0 # Seconds + else: + # Time-series displacements: shape (n_frames, n_points, 2) + self.is_mode_shape = False + self.displacements = displacements[:, :, ::-1] # Flip x,y coordinates + self.grid = points[:, ::-1] + 0.5 # Flip x,y coordinates self.fps = fps self.magnification = magnification self.points_size = point_size self.current_frame = 0 - self.disp_max = np.max(displacements) + self.disp_max = np.max(np.abs(displacements)) self.colormap = colormap self.timer = QtCore.QTimer() @@ -219,7 +230,6 @@ def init_ui(self): self.setWindowTitle("Displacement Viewer") self.resize(800, 600) - def toggle_playback(self): if self.timer.isActive(): self.timer.stop() @@ -238,6 +248,9 @@ def on_fps_change(self): if self.timer.isActive(): self.set_timer_interval() + if self.is_mode_shape: + self.slider.setMaximum(int(self.fps * self.time_per_period) - 1) + def update_fps_from_slider(self, value): self.fps = value self.fps_label.setText(f"FPS: {value}") @@ -259,7 +272,11 @@ def update_point_size(self): self.scatter.setSize(size) def next_frame(self): - self.current_frame = (self.current_frame + 1) % self.video.shape[0] + if self.is_mode_shape: + self.current_frame = (self.current_frame + 1) % int(self.fps * self.time_per_period) + else: + self.current_frame = (self.current_frame + 1) % self.video.shape[0] + self.slider.setValue(self.current_frame) def on_slider(self, val): @@ -267,11 +284,31 @@ def on_slider(self, val): self.update_frame() def update_frame(self): - frame = self.video[self.current_frame] - self.img_item.setImage(frame.T) - scale = self.mag_spin.value() - displ = self.displacements[:, self.current_frame, :] * scale + + if self.is_mode_shape: + frame = self.video[0] + self.img_item.setImage(frame.T) + + # Calculate time for sinusoidal motion + t = self.current_frame / self.fps # Convert frame to time in seconds + + # Calculate displacement amplitude using sinusoidal motion + displ_raw = self.displacements + amplitude = np.abs(displ_raw) + phase = np.angle(displ_raw) + + # Calculate displacement using sinusoidal motion + displ = scale * amplitude * np.sin(2 * np.pi * t - phase) + + else: + # Regular displacement animation + frame = self.video[self.current_frame] + self.img_item.setImage(frame.T) + + displ = self.displacements[:, self.current_frame, :] * scale + + # Update scatter plot with displaced points displaced_pts = self.grid + displ self.scatter.setData(pos=displaced_pts[:, [0, 1]]) @@ -307,94 +344,25 @@ def update_frame(self): for shaft in self.arrow_shafts: self.viewbox.removeItem(shaft) self.arrow_shafts.clear() - - ########################################################################################~ - # # Uncomment this method if you want to simulate sinusoidal motion (mode shape.) - ########################################################################################~ - # def update_frame(self): - # # Always show the first frame - # frame = self.video[0] - # self.img_item.setImage(frame.T) - - # # Get current time index - # t = self.current_frame - # scale = self.mag_spin.value() / 100 - - # # Simulate sinusoidal motion (like a mode shape oscillating over time) - # omega = 2 * np.pi / 100 # You can adjust this "period" - # factor = np.sin(omega * t) - - # # Assume mode shape is stored in self.displacements[:, 0, :] (only the shape) - # simulated_displ = self.displacements[:, 0, :] * scale * factor - # displaced_pts = self.grid + simulated_displ - # self.scatter.setData(pos=displaced_pts[:, [0, 1]]) - - # if self.arrows_checkbox.isChecked(): - # self.scatter.setVisible(False) - - # # Arrow color represents displacement magnitude - # magnitudes = np.linalg.norm(simulated_displ, axis=1) - # norm = mcolors.Normalize(vmin=0, vmax=self.disp_max * scale) - # cmap = plt.colormaps[self.colormap] - - # for shaft in self.arrow_shafts: - # self.viewbox.removeItem(shaft) - # self.arrow_shafts.clear() - - # for pt0, pt1, mag in zip(self.grid, displaced_pts, magnitudes): - # color = cmap(norm(mag)) - # color_rgb = tuple(int(255 * c) for c in color[:3]) - - # shaft = pg.PlotCurveItem( - # x=[pt0[0], pt1[0]], - # y=[pt0[1], pt1[1]], - # pen=pg.mkPen(color_rgb, width=self.point_size_spin.value()) - # ) - # self.arrow_shafts.append(shaft) - # self.viewbox.addItem(shaft) - # else: - # self.scatter.setVisible(True) - # for shaft in self.arrow_shafts: - # self.viewbox.removeItem(shaft) - # self.arrow_shafts.clear() - - -def viewer(frames, displacements, points, fps=30, magnification=1, point_size=10, colormap="cool"): - """Viewer for the videos and displacements. - - Parameters - ---------- - frames : np.ndarray - Array of shape (n_frames, height, width) containing the video frames. - displacements : np.ndarray - Array of shape (n_points, n_frames, 2) containing the displacements for - each point in each frame. The directions of the last axis are the vertical and horizontal - displacements, respectively. - points : np.ndarray - Array of shape (n_points, 2) containing the initial positions of the points. The first - column is the vertical coordinate (y) and the second column is the horizontal coordinate (x). - fps : int, optional - Frames per second for the video playback, by default 30. - magnification : int, optional - Magnification factor for the displacements, by default 1. - point_size : int, optional - Size of the points in pixels, by default 10. - colormap : str, optional - Name of the colormap to use for the arrows, by default "cool". - """ - # This function is now just a wrapper for backward compatibility - # The ResultViewer class handles everything internally - ResultViewer(frames, displacements, points, fps=fps, magnification=magnification, - point_size=point_size, colormap=colormap) - if __name__ == "__main__": n_frames, height, width = 200, 300, 400 n_points = 100 frames = np.random.randint(0, 255, size=(n_frames, height, width), dtype=np.uint8) + + # Test with regular time-series displacements displacements = 2 * (np.random.rand(n_points, n_frames, 2) - 0.5) - grid = np.stack(np.meshgrid(np.linspace(50, 350, int(np.sqrt(n_points))), - np.linspace(50, 250, int(np.sqrt(n_points)))), axis=-1).reshape(-1, 2)[:n_points] - # Now you can directly call ResultViewer (no need to flip coordinates here since it's done internally) + # Test with mode shapes (2D array) + grid = np.stack(np.meshgrid(np.linspace(50, 250, int(np.sqrt(n_points))), + np.linspace(50, 350, int(np.sqrt(n_points)))), axis=-1).reshape(-1, 2)[:n_points] + + # Create a simple mode shape (e.g., first bending mode) + # displacements = np.zeros((n_points, 2)) + # for i, point in enumerate(grid): + # # Simple sinusoidal mode shape in y-direction + # displacements[i, 0] = 5 * np.sin(np.pi * point[0] / width) # y displacement + # displacements[i, 1] = 0 # no x displacement + + # Test mode shape viewer ResultViewer(frames, displacements, grid) \ No newline at end of file From e7788727a2d3673a2f9721a59a78775e7ac01a51 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Mon, 14 Jul 2025 12:40:09 +0200 Subject: [PATCH 55/63] Current frame spinbox (synced to the slider) --- pyidi/GUIs/result_viewer.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index c73a314..2887efc 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -218,11 +218,24 @@ def init_ui(self): playback_layout.addWidget(QtWidgets.QLabel(" Frame: ")) self.slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) - self.slider.setMinimum(0) - self.slider.setMaximum(self.video.shape[0] - 1) + if self.is_mode_shape: + self.slider.setRange(0, int(self.fps * self.time_per_period) - 1) + else: + self.slider.setRange(0, self.video.shape[0] - 1) + self.slider.setValue(0) self.slider.valueChanged.connect(self.on_slider) playback_layout.addWidget(self.slider) + self.frame_spinbox = QtWidgets.QSpinBox() + if self.is_mode_shape: + self.frame_spinbox.setRange(0, int(self.fps * self.time_per_period) - 1) + else: + self.frame_spinbox.setRange(0, self.video.shape[0] - 1) + + self.frame_spinbox.valueChanged.connect(self.on_slider) + self.frame_spinbox.setValue(0) + playback_layout.addWidget(self.frame_spinbox) + main_layout.addLayout(playback_layout) # === Finalize === @@ -250,6 +263,7 @@ def on_fps_change(self): if self.is_mode_shape: self.slider.setMaximum(int(self.fps * self.time_per_period) - 1) + self.frame_spinbox.setMaximum(int(self.fps * self.time_per_period) - 1) def update_fps_from_slider(self, value): self.fps = value @@ -281,6 +295,8 @@ def next_frame(self): def on_slider(self, val): self.current_frame = val + self.frame_spinbox.setValue(val) + self.slider.setValue(val) self.update_frame() def update_frame(self): From de47c919ca18ad782a7d91eeb69deca51246ee29 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 05:42:11 +0200 Subject: [PATCH 56/63] export to MP4 implemented --- pyidi/GUIs/result_viewer.py | 222 ++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index 2887efc..39e8914 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -194,6 +194,50 @@ def init_ui(self): self.control_layout.addWidget(playback_group) + # Export controls group + export_group = QtWidgets.QGroupBox("Export Video") + export_layout = QtWidgets.QVBoxLayout(export_group) + + # Quality/FPS for export + export_layout.addWidget(QtWidgets.QLabel("Export FPS:")) + self.export_fps_spin = QtWidgets.QSpinBox() + self.export_fps_spin.setRange(1, 120) + self.export_fps_spin.setValue(30) + export_layout.addWidget(self.export_fps_spin) + + # Export resolution + export_layout.addWidget(QtWidgets.QLabel("Export Resolution:")) + self.export_resolution_combo = QtWidgets.QComboBox() + self.export_resolution_combo.addItems([ + "2x pixel scale", + "4x pixel scale", + "6x pixel scale", + "8x pixel scale", + ]) + self.export_resolution_combo.setCurrentText("4x pixel scale") + export_layout.addWidget(self.export_resolution_combo) + + # Duration for mode shapes + if self.is_mode_shape: + export_layout.addWidget(QtWidgets.QLabel("Duration (seconds):")) + self.duration_spin = QtWidgets.QDoubleSpinBox() + self.duration_spin.setRange(0.5, 60.0) + self.duration_spin.setValue(2.0) + self.duration_spin.setSingleStep(0.5) + export_layout.addWidget(self.duration_spin) + + # Export button + self.export_button = QtWidgets.QPushButton("Export Video") + self.export_button.clicked.connect(self.export_video) + export_layout.addWidget(self.export_button) + + # Progress bar + self.export_progress = QtWidgets.QProgressBar() + self.export_progress.setVisible(False) + export_layout.addWidget(self.export_progress) + + self.control_layout.addWidget(export_group) + self.control_layout.addStretch(1) self.splitter.addWidget(self.control_widget) @@ -361,6 +405,184 @@ def update_frame(self): self.viewbox.removeItem(shaft) self.arrow_shafts.clear() + def export_video(self): + """Export the current visualization as a video file with pixel-perfect rendering.""" + try: + import cv2 + except ImportError: + QtWidgets.QMessageBox.warning(self, "Missing Dependency", + "OpenCV (cv2) is required for video export.\n" + "Install it with: pip install opencv-python") + return + + # Get export parameters + export_fps = self.export_fps_spin.value() + + # Get pixel scaling factor from resolution selection + resolution_text = self.export_resolution_combo.currentText() + if "4x pixel scale" in resolution_text: + pixel_scale = 4 # Each video pixel becomes 4x4 pixels in export + elif "2x pixel scale" in resolution_text: + pixel_scale = 2 # Each video pixel becomes 2x2 pixels in export + elif "6x pixel scale" in resolution_text: + pixel_scale = 6 # Each video pixel becomes 6x6 pixels in export + else: # 4K + pixel_scale = 8 # Each video pixel becomes 8x8 pixels in export + + # Calculate export dimensions based on video dimensions and pixel scaling + video_height, video_width = self.video[0].shape + export_width = video_width * pixel_scale + export_height = video_height * pixel_scale + + # Use MP4 with high quality settings + default_ext = "mp4" + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + + # File dialog for save location + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Export Video", f"displacement_video.{default_ext}", + f"MP4 files (*.{default_ext});;All files (*.*)" + ) + + if not file_path: + return + + # Store current state to restore later + original_frame = self.current_frame + original_timer_active = self.timer.isActive() + if original_timer_active: + self.timer.stop() + + # Calculate total frames for export + if self.is_mode_shape: + duration = self.duration_spin.value() + total_frames = int(export_fps * duration) + else: + total_frames = self.video.shape[0] + + # Initialize video writer + writer = cv2.VideoWriter(file_path, fourcc, export_fps, (export_width, export_height)) + + if not writer.isOpened(): + QtWidgets.QMessageBox.critical(self, "Export Error", + "Failed to create video writer. Check file path and format.") + return + + # Show progress bar + self.export_progress.setVisible(True) + self.export_progress.setRange(0, total_frames) + self.export_button.setText("Exporting...") + self.export_button.setEnabled(False) + + try: + # Get current visualization parameters + scale = self.mag_spin.value() + show_arrows = self.arrows_checkbox.isChecked() + point_size = self.point_size_spin.value() + + for frame_idx in range(total_frames): + # Update progress + self.export_progress.setValue(frame_idx) + QtWidgets.QApplication.processEvents() # Keep UI responsive + + # Set the current frame + if self.is_mode_shape: + self.current_frame = frame_idx % int(self.fps * self.time_per_period) + # Get base frame for mode shapes + base_frame = self.video[0] + + # Calculate time for sinusoidal motion + t = self.current_frame / self.fps + displ_raw = self.displacements + amplitude = np.abs(displ_raw) + phase = np.angle(displ_raw) + displ = scale * amplitude * np.sin(2 * np.pi * t - phase) + else: + self.current_frame = frame_idx + base_frame = self.video[self.current_frame] + displ = self.displacements[:, self.current_frame, :] * scale + + # Create the export frame by scaling the video frame pixel-perfectly + # Convert to RGB for proper color handling + if len(base_frame.shape) == 2: # Grayscale + frame_rgb = np.stack([base_frame, base_frame, base_frame], axis=2) + else: + frame_rgb = base_frame + + # Scale up the frame without interpolation (nearest neighbor) + export_frame = np.repeat(np.repeat(frame_rgb, pixel_scale, axis=0), pixel_scale, axis=1) + + # Calculate displaced points + displaced_pts = self.grid + displ + + # Draw displacement visualization on the scaled frame + if show_arrows: + # Draw arrows showing displacement + magnitudes = np.linalg.norm(displ, axis=1) + norm = mcolors.Normalize(vmin=0, vmax=self.disp_max*scale) + cmap = plt.colormaps[self.colormap] + + for i, (pt0, pt1, mag) in enumerate(zip(self.grid, displaced_pts, magnitudes)): + # Scale coordinates to export resolution + start_pt = (int(pt0[0] * pixel_scale), int(pt0[1] * pixel_scale)) + end_pt = (int(pt1[0] * pixel_scale), int(pt1[1] * pixel_scale)) + + # Get color for this magnitude + color = cmap(norm(mag)) + color_bgr = tuple(int(255 * c) for c in color[2::-1]) # Convert RGB to BGR + + # Draw arrow line + cv2.line(export_frame, start_pt, end_pt, color_bgr, + max(1, point_size * pixel_scale // 10)) + + # Draw arrow head + cv2.circle(export_frame, end_pt, max(1, point_size * pixel_scale // 5), + color_bgr, -1) + else: + # Draw points at displaced positions + for pt in displaced_pts: + center = (int(pt[0] * pixel_scale), int(pt[1] * pixel_scale)) + cv2.circle(export_frame, center, max(1, point_size * pixel_scale // 5), + (0, 0, 255), -1) # Red circles + + # Ensure the frame is in the correct format and size + export_frame = np.clip(export_frame, 0, 255).astype(np.uint8) + + # Convert RGB to BGR for OpenCV + if len(export_frame.shape) == 3: + export_frame_bgr = cv2.cvtColor(export_frame, cv2.COLOR_RGB2BGR) + else: + export_frame_bgr = export_frame + + writer.write(export_frame_bgr) + + writer.release() + + QtWidgets.QMessageBox.information(self, "Export Complete", + f"Video exported successfully to:\n{file_path}\n" + f"Resolution: {export_width}x{export_height} " + f"(pixel scale: {pixel_scale}x)") + + except Exception as e: + import traceback + traceback.print_exc() + QtWidgets.QMessageBox.critical(self, "Export Error", + f"An error occurred during export:\n{str(e)}") + finally: + # Restore original state + self.current_frame = original_frame + self.update_frame() + if original_timer_active: + self.timer.start() + + # Reset UI + self.export_progress.setVisible(False) + self.export_button.setText("Export Video") + self.export_button.setEnabled(True) + writer.release() + + + if __name__ == "__main__": n_frames, height, width = 200, 300, 400 n_points = 100 From fcdc2ed1c1ed11b5d31d6ea2890e891d1ec742a6 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 06:07:36 +0200 Subject: [PATCH 57/63] ui update and cleanup --- pyidi/GUIs/result_viewer.py | 120 ++++++++++++++++++++++++++++++------ 1 file changed, 100 insertions(+), 20 deletions(-) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index 39e8914..01800e3 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -150,20 +150,47 @@ def init_ui(self): display_layout = QtWidgets.QVBoxLayout(display_group) # Point size control - display_layout.addWidget(QtWidgets.QLabel("Point size (px):")) + point_size_layout = QtWidgets.QHBoxLayout() + point_size_layout.addWidget(QtWidgets.QLabel("Point size:")) + self.point_size_spin = QtWidgets.QSpinBox() self.point_size_spin.setRange(1, 100) self.point_size_spin.setValue(self.points_size) - self.point_size_spin.valueChanged.connect(self.update_point_size) - display_layout.addWidget(self.point_size_spin) + self.point_size_spin.setSuffix("px") + self.point_size_spin.setFixedWidth(80) + self.point_size_spin.valueChanged.connect(self.update_point_size_from_spinbox) + point_size_layout.addWidget(self.point_size_spin) + + point_size_layout.addStretch() # Push everything to the left + display_layout.addLayout(point_size_layout) + + self.point_size_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.point_size_slider.setRange(1, 20) + self.point_size_slider.setValue(min(20, self.points_size)) + self.point_size_slider.valueChanged.connect(self.update_point_size_from_slider) + display_layout.addWidget(self.point_size_slider) # Magnification control - display_layout.addWidget(QtWidgets.QLabel("Magnify:")) - self.mag_spin = QtWidgets.QSpinBox() - self.mag_spin.setRange(1, 10000) + mag_layout = QtWidgets.QHBoxLayout() + mag_layout.addWidget(QtWidgets.QLabel("Magnify:")) + + self.mag_spin = QtWidgets.QDoubleSpinBox() + self.mag_spin.setRange(0.01, 999999) # No practical upper limit + self.mag_spin.setSingleStep(0.01) self.mag_spin.setValue(self.magnification) - self.mag_spin.valueChanged.connect(self.update_frame) - display_layout.addWidget(self.mag_spin) + self.mag_spin.setSuffix("x") + self.mag_spin.setFixedWidth(80) + self.mag_spin.valueChanged.connect(self.update_mag_from_spinbox) + mag_layout.addWidget(self.mag_spin) + + mag_layout.addStretch() # Push everything to the left + display_layout.addLayout(mag_layout) + + self.mag_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.mag_slider.setRange(1, 1000) # 0.1x to 10x (in percent: 10% to 1000%) + self.mag_slider.setValue(int(self.magnification * 100)) + self.mag_slider.valueChanged.connect(self.update_mag_from_slider) + display_layout.addWidget(self.mag_slider) # Show arrows checkbox self.arrows_checkbox = QtWidgets.QCheckBox("Show arrows") @@ -177,8 +204,18 @@ def init_ui(self): playback_layout = QtWidgets.QVBoxLayout(playback_group) # FPS control - self.fps_label = QtWidgets.QLabel(f"FPS: {self.fps}") - playback_layout.addWidget(self.fps_label) + fps_layout = QtWidgets.QHBoxLayout() + fps_layout.addWidget(QtWidgets.QLabel("FPS:")) + + self.fps_spin = QtWidgets.QSpinBox() + self.fps_spin.setRange(1, 240) + self.fps_spin.setValue(self.fps) + self.fps_spin.setFixedWidth(80) + self.fps_spin.valueChanged.connect(self.update_fps_from_spinbox) + fps_layout.addWidget(self.fps_spin) + + fps_layout.addStretch() # Push everything to the left + playback_layout.addLayout(fps_layout) self.fps_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.fps_slider.setRange(1, 240) @@ -186,12 +223,6 @@ def init_ui(self): self.fps_slider.valueChanged.connect(self.update_fps_from_slider) playback_layout.addWidget(self.fps_slider) - self.fps_spin = QtWidgets.QSpinBox() - self.fps_spin.setRange(1, 240) - self.fps_spin.setValue(self.fps) - self.fps_spin.valueChanged.connect(self.update_fps_from_spinbox) - playback_layout.addWidget(self.fps_spin) - self.control_layout.addWidget(playback_group) # Export controls group @@ -285,7 +316,7 @@ def init_ui(self): # === Finalize === self.setCentralWidget(central_widget) self.setWindowTitle("Displacement Viewer") - self.resize(800, 600) + self.resize(1200, 600) def toggle_playback(self): if self.timer.isActive(): @@ -311,7 +342,6 @@ def on_fps_change(self): def update_fps_from_slider(self, value): self.fps = value - self.fps_label.setText(f"FPS: {value}") self.fps_spin.blockSignals(True) # Prevent recursive updates self.fps_spin.setValue(value) self.fps_spin.blockSignals(False) @@ -319,13 +349,63 @@ def update_fps_from_slider(self, value): def update_fps_from_spinbox(self, value): self.fps = value - self.fps_label.setText(f"FPS: {value}") self.fps_slider.blockSignals(True) # Prevent recursive updates self.fps_slider.setValue(value) self.fps_slider.blockSignals(False) self.on_fps_change() + def update_mag_from_slider(self, value): + # Convert slider value (1-1000) to magnification (0.01-10.0) + magnification = value / 100.0 + self.magnification = magnification + + # Update spinbox without triggering its signal + self.mag_spin.blockSignals(True) + self.mag_spin.setValue(magnification) + self.mag_spin.blockSignals(False) + + self.update_frame() + + def update_mag_from_spinbox(self, value): + # Convert spinbox value to magnification + magnification = value + self.magnification = magnification + + # Update slider, clamping to its range and converting to int + slider_value = int(max(1, min(1000, value * 100))) + self.mag_slider.blockSignals(True) + self.mag_slider.setValue(slider_value) + self.mag_slider.blockSignals(False) + + self.update_frame() + + def update_point_size_from_slider(self, value): + # Update the internal point size + self.points_size = value + + # Update spinbox without triggering its signal + self.point_size_spin.blockSignals(True) + self.point_size_spin.setValue(value) + self.point_size_spin.blockSignals(False) + + # Update the actual display + self.scatter.setSize(value) + + def update_point_size_from_spinbox(self, value): + # Update the internal point size + self.points_size = value + + # Update slider, clamping to its range + slider_value = min(20, max(1, value)) + self.point_size_slider.blockSignals(True) + self.point_size_slider.setValue(slider_value) + self.point_size_slider.blockSignals(False) + + # Update the actual display + self.scatter.setSize(value) + def update_point_size(self): + # Keep this method for backward compatibility if needed size = self.point_size_spin.value() self.scatter.setSize(size) @@ -344,7 +424,7 @@ def on_slider(self, val): self.update_frame() def update_frame(self): - scale = self.mag_spin.value() + scale = self.magnification if self.is_mode_shape: frame = self.video[0] From 25bc0df53ff24b9f7c00bb4b48ae7ba0ad59d7d6 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 06:22:48 +0200 Subject: [PATCH 58/63] Added frame range to export functionality --- pyidi/GUIs/result_viewer.py | 119 ++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 5 deletions(-) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index 01800e3..abb0494 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -248,6 +248,40 @@ def init_ui(self): self.export_resolution_combo.setCurrentText("4x pixel scale") export_layout.addWidget(self.export_resolution_combo) + # Frame range controls (only for non-mode shape videos) + if not self.is_mode_shape: + self.frame_range_label = QtWidgets.QLabel("Frame Range:") + export_layout.addWidget(self.frame_range_label) + + frame_range_layout = QtWidgets.QHBoxLayout() + + # Start frame + self.start_frame_spin = QtWidgets.QSpinBox() + self.start_frame_spin.setRange(0, self.video.shape[0] - 1) + self.start_frame_spin.setValue(0) + self.start_frame_spin.setFixedWidth(80) + self.start_frame_spin.valueChanged.connect(self.on_start_frame_changed) + frame_range_layout.addWidget(self.start_frame_spin) + + # Stop frame + self.stop_frame_spin = QtWidgets.QSpinBox() + self.stop_frame_spin.setRange(0, self.video.shape[0] - 1) + self.stop_frame_spin.setValue(self.video.shape[0] - 1) + self.stop_frame_spin.setFixedWidth(80) + self.stop_frame_spin.valueChanged.connect(self.on_stop_frame_changed) + frame_range_layout.addWidget(self.stop_frame_spin) + + export_layout.addLayout(frame_range_layout) + + # Update the label with initial frame count + self.update_frame_range_label() + + # Full range checkbox + self.full_range_checkbox = QtWidgets.QCheckBox("Full Range") + self.full_range_checkbox.setChecked(True) # Initially checked since defaults are full range + self.full_range_checkbox.stateChanged.connect(self.on_full_range_checkbox_changed) + export_layout.addWidget(self.full_range_checkbox) + # Duration for mode shapes if self.is_mode_shape: export_layout.addWidget(QtWidgets.QLabel("Duration (seconds):")) @@ -409,6 +443,68 @@ def update_point_size(self): size = self.point_size_spin.value() self.scatter.setSize(size) + def on_start_frame_changed(self, value): + # Ensure start frame is not greater than stop frame + if hasattr(self, 'stop_frame_spin') and value > self.stop_frame_spin.value(): + self.stop_frame_spin.setValue(value) + + # Update the frame range label + self.update_frame_range_label() + + # Update checkbox state based on whether we have full range + self.update_full_range_checkbox_state() + + def on_stop_frame_changed(self, value): + # Ensure stop frame is not less than start frame + if hasattr(self, 'start_frame_spin') and value < self.start_frame_spin.value(): + self.start_frame_spin.setValue(value) + + # Update the frame range label + self.update_frame_range_label() + + # Update checkbox state based on whether we have full range + self.update_full_range_checkbox_state() + + def update_frame_range_label(self): + """Update the frame range label with current frame count.""" + if not self.is_mode_shape and hasattr(self, 'frame_range_label'): + start_frame = self.start_frame_spin.value() + stop_frame = self.stop_frame_spin.value() + total_frames = stop_frame - start_frame + 1 + self.frame_range_label.setText(f"Frame Range: ({total_frames} frames)") + + def on_full_range_checkbox_changed(self, state): + """Handle full range checkbox state changes.""" + if not self.is_mode_shape: + if state == QtCore.Qt.CheckState.Checked.value: + # Set to full range + self.start_frame_spin.blockSignals(True) + self.stop_frame_spin.blockSignals(True) + self.start_frame_spin.setValue(0) + self.stop_frame_spin.setValue(self.video.shape[0] - 1) + self.start_frame_spin.blockSignals(False) + self.stop_frame_spin.blockSignals(False) + + # Update the frame range label + self.update_frame_range_label() + + def update_full_range_checkbox_state(self): + """Update the checkbox state based on current spinbox values.""" + if not self.is_mode_shape and hasattr(self, 'full_range_checkbox'): + is_full_range = (self.start_frame_spin.value() == 0 and + self.stop_frame_spin.value() == self.video.shape[0] - 1) + + # Block signals to prevent recursive calls + self.full_range_checkbox.blockSignals(True) + self.full_range_checkbox.setChecked(is_full_range) + self.full_range_checkbox.blockSignals(False) + + def set_full_range(self): + """Set the frame range to cover the full video.""" + if not self.is_mode_shape: + self.start_frame_spin.setValue(0) + self.stop_frame_spin.setValue(self.video.shape[0] - 1) + def next_frame(self): if self.is_mode_shape: self.current_frame = (self.current_frame + 1) % int(self.fps * self.time_per_period) @@ -537,8 +633,12 @@ def export_video(self): if self.is_mode_shape: duration = self.duration_spin.value() total_frames = int(export_fps * duration) + start_frame = 0 + stop_frame = total_frames - 1 else: - total_frames = self.video.shape[0] + start_frame = self.start_frame_spin.value() + stop_frame = self.stop_frame_spin.value() + total_frames = stop_frame - start_frame + 1 # Initialize video writer writer = cv2.VideoWriter(file_path, fourcc, export_fps, (export_width, export_height)) @@ -578,9 +678,11 @@ def export_video(self): phase = np.angle(displ_raw) displ = scale * amplitude * np.sin(2 * np.pi * t - phase) else: - self.current_frame = frame_idx - base_frame = self.video[self.current_frame] - displ = self.displacements[:, self.current_frame, :] * scale + # For regular videos, use the actual frame index within the specified range + actual_frame_idx = start_frame + frame_idx + self.current_frame = actual_frame_idx + base_frame = self.video[actual_frame_idx] + displ = self.displacements[:, actual_frame_idx, :] * scale # Create the export frame by scaling the video frame pixel-perfectly # Convert to RGB for proper color handling @@ -638,10 +740,17 @@ def export_video(self): writer.release() + # Create success message with frame range info + if self.is_mode_shape: + frame_info = f"Duration: {self.duration_spin.value():.1f}s" + else: + frame_info = f"Frames: {start_frame} to {stop_frame} ({total_frames} total)" + QtWidgets.QMessageBox.information(self, "Export Complete", f"Video exported successfully to:\n{file_path}\n" f"Resolution: {export_width}x{export_height} " - f"(pixel scale: {pixel_scale}x)") + f"(pixel scale: {pixel_scale}x)\n" + f"{frame_info}") except Exception as e: import traceback From 5940c335e199cc6f4a1c05c337e1ae0430bd14ff Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 07:14:47 +0200 Subject: [PATCH 59/63] select region to export to mp4 --- pyidi/GUIs/result_viewer.py | 268 +++++++++++++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 21 deletions(-) diff --git a/pyidi/GUIs/result_viewer.py b/pyidi/GUIs/result_viewer.py index abb0494..2e57f34 100644 --- a/pyidi/GUIs/result_viewer.py +++ b/pyidi/GUIs/result_viewer.py @@ -6,6 +6,47 @@ import matplotlib.colors as mcolors import sys +class RegionSelectViewBox(pg.ViewBox): + """Custom ViewBox that handles region selection with mouse events.""" + + def __init__(self, parent_viewer): + super().__init__() + self.parent_viewer = parent_viewer + self.region_start = None + self.region_current = None + self.dragging = False + + def mousePressEvent(self, ev): + if (self.parent_viewer.region_selection_active and + ev.button() == QtCore.Qt.MouseButton.LeftButton and + ev.modifiers() & QtCore.Qt.KeyboardModifier.ControlModifier): + # Start region selection + self.region_start = self.mapSceneToView(ev.scenePos()) + self.dragging = True + ev.accept() + else: + super().mousePressEvent(ev) + + def mouseMoveEvent(self, ev): + if self.dragging and self.parent_viewer.region_selection_active: + # Update region selection + self.region_current = self.mapSceneToView(ev.scenePos()) + self.parent_viewer.update_region_selection(self.region_start, self.region_current) + ev.accept() + else: + super().mouseMoveEvent(ev) + + def mouseReleaseEvent(self, ev): + if (self.dragging and self.parent_viewer.region_selection_active and + ev.button() == QtCore.Qt.MouseButton.LeftButton): + # Finish region selection + self.region_current = self.mapSceneToView(ev.scenePos()) + self.parent_viewer.finish_region_selection(self.region_start, self.region_current) + self.dragging = False + ev.accept() + else: + super().mouseReleaseEvent(ev) + class ResultViewer(QtWidgets.QMainWindow): def __init__(self, video, displacements, points, fps=30, magnification=1, point_size=10, colormap="cool"): """ @@ -67,6 +108,14 @@ def __init__(self, video, displacements, points, fps=30, magnification=1, point_ self.disp_max = np.max(np.abs(displacements)) self.colormap = colormap + # Region selection variables + self.region_selection_active = False + self.region_start_point = None + self.region_end_point = None + self.region_rect = None + self.region_overlay = None + self.selected_region = None # (x, y, width, height) in image coordinates + self.timer = QtCore.QTimer() self.timer.timeout.connect(self.next_frame) @@ -133,7 +182,11 @@ def init_ui(self): self.view = pg.GraphicsLayoutWidget() self.img_item = pg.ImageItem() self.scatter = pg.ScatterPlotItem(size=self.points_size, brush='r', pxMode=True) - self.viewbox = self.view.addViewBox() + + # Create custom viewbox for region selection + self.viewbox = RegionSelectViewBox(self) + self.view.addItem(self.viewbox) + self.viewbox.addItem(self.img_item) self.viewbox.addItem(self.scatter) self.viewbox.setAspectLocked(True) @@ -248,6 +301,30 @@ def init_ui(self): self.export_resolution_combo.setCurrentText("4x pixel scale") export_layout.addWidget(self.export_resolution_combo) + # Region selection controls + export_layout.addWidget(QtWidgets.QLabel("Region Selection:")) + + region_layout = QtWidgets.QHBoxLayout() + + # Region selection button + self.region_select_button = QtWidgets.QPushButton("Select Region") + self.region_select_button.setCheckable(True) + self.region_select_button.clicked.connect(self.toggle_region_selection) + region_layout.addWidget(self.region_select_button) + + # Clear region button + self.clear_region_button = QtWidgets.QPushButton("Clear") + self.clear_region_button.clicked.connect(self.clear_region_selection) + self.clear_region_button.setEnabled(False) + region_layout.addWidget(self.clear_region_button) + + export_layout.addLayout(region_layout) + + # Region info label + self.region_info_label = QtWidgets.QLabel("Full frame will be exported") + self.region_info_label.setStyleSheet("font-size: 10px; color: #aaa;") + export_layout.addWidget(self.region_info_label) + # Frame range controls (only for non-mode shape videos) if not self.is_mode_shape: self.frame_range_label = QtWidgets.QLabel("Frame Range:") @@ -505,6 +582,117 @@ def set_full_range(self): self.start_frame_spin.setValue(0) self.stop_frame_spin.setValue(self.video.shape[0] - 1) + def toggle_region_selection(self): + """Toggle region selection mode.""" + self.region_selection_active = self.region_select_button.isChecked() + + if self.region_selection_active: + self.region_select_button.setText("Cancel Selection") + self.region_select_button.setStyleSheet("background-color: #d73a00;") + # Clear any existing region + self.clear_region_graphics() + else: + self.region_select_button.setText("Select Region") + self.region_select_button.setStyleSheet("") + # Clear any temporary selection graphics + self.clear_region_graphics() + + def clear_region_selection(self): + """Clear the current region selection.""" + self.selected_region = None + self.clear_region_graphics() + self.clear_region_button.setEnabled(False) + self.region_info_label.setText("Full frame will be exported") + + # Reset the selection button if it was active + if self.region_selection_active: + self.region_select_button.setChecked(False) + self.toggle_region_selection() + + def clear_region_graphics(self): + """Remove region selection graphics from the view.""" + if self.region_rect is not None: + self.viewbox.removeItem(self.region_rect) + self.region_rect = None + if self.region_overlay is not None: + self.viewbox.removeItem(self.region_overlay) + self.region_overlay = None + + def update_region_selection(self, start_point, current_point): + """Update the region selection rectangle during dragging.""" + if start_point is None or current_point is None: + return + + # Clear previous rectangle + if self.region_rect is not None: + self.viewbox.removeItem(self.region_rect) + + # Create new rectangle + x1, y1 = start_point.x(), start_point.y() + x2, y2 = current_point.x(), current_point.y() + + # Ensure proper ordering + x_min, x_max = min(x1, x2), max(x1, x2) + y_min, y_max = min(y1, y2), max(y1, y2) + + # Create rectangle item + self.region_rect = pg.RectROI([x_min, y_min], [x_max - x_min, y_max - y_min], + pen=pg.mkPen(color='red', width=2), + movable=False, removable=False) + self.viewbox.addItem(self.region_rect) + + def finish_region_selection(self, start_point, end_point): + """Finish region selection and apply overlay.""" + if start_point is None or end_point is None: + return + + # Calculate region bounds + x1, y1 = start_point.x(), start_point.y() + x2, y2 = end_point.x(), end_point.y() + + # Ensure proper ordering and clip to image bounds + video_height, video_width = self.video[0].shape + x_min = max(0, min(x1, x2)) + x_max = min(video_width, max(x1, x2)) + y_min = max(0, min(y1, y2)) + y_max = min(video_height, max(y1, y2)) + + # Store the selected region + self.selected_region = (int(x_min), int(y_min), int(x_max - x_min), int(y_max - y_min)) + + # Update UI + self.region_select_button.setChecked(False) + self.toggle_region_selection() + self.clear_region_button.setEnabled(True) + self.region_info_label.setText(f"Region: {self.selected_region[2]}x{self.selected_region[3]} pixels") + + # Create overlay effect + self.create_region_overlay() + + def create_region_overlay(self): + """Create a semi-transparent overlay outside the selected region.""" + if self.selected_region is None: + return + + # Clear existing overlay + if self.region_overlay is not None: + self.viewbox.removeItem(self.region_overlay) + + # Create overlay using ImageItem with alpha channel + video_height, video_width = self.video[0].shape + overlay = np.zeros((video_height, video_width, 4), dtype=np.uint8) + + # Set alpha to 128 (semi-transparent) for the entire overlay + overlay[:, :, 3] = 128 + + # Make the selected region fully transparent + x, y, w, h = self.selected_region + overlay[y:y+h, x:x+w, 3] = 0 + + # Create ImageItem for overlay + self.region_overlay = pg.ImageItem(overlay.transpose((1, 0, 2))) + self.viewbox.addItem(self.region_overlay) + def next_frame(self): if self.is_mode_shape: self.current_frame = (self.current_frame + 1) % int(self.fps * self.time_per_period) @@ -581,6 +769,11 @@ def update_frame(self): self.viewbox.removeItem(shaft) self.arrow_shafts.clear() + # Ensure region overlay is on top if it exists + if self.region_overlay is not None: + self.viewbox.removeItem(self.region_overlay) + self.viewbox.addItem(self.region_overlay) + def export_video(self): """Export the current visualization as a video file with pixel-perfect rendering.""" try: @@ -607,8 +800,15 @@ def export_video(self): # Calculate export dimensions based on video dimensions and pixel scaling video_height, video_width = self.video[0].shape - export_width = video_width * pixel_scale - export_height = video_height * pixel_scale + + # Handle region selection + if self.selected_region is not None: + region_x, region_y, region_width, region_height = self.selected_region + export_width = region_width * pixel_scale + export_height = region_height * pixel_scale + else: + export_width = video_width * pixel_scale + export_height = video_height * pixel_scale # Use MP4 with high quality settings default_ext = "mp4" @@ -691,12 +891,23 @@ def export_video(self): else: frame_rgb = base_frame + # Apply region cropping if selected + if self.selected_region is not None: + region_x, region_y, region_width, region_height = self.selected_region + frame_rgb = frame_rgb[region_y:region_y+region_height, region_x:region_x+region_width] + # Scale up the frame without interpolation (nearest neighbor) export_frame = np.repeat(np.repeat(frame_rgb, pixel_scale, axis=0), pixel_scale, axis=1) # Calculate displaced points displaced_pts = self.grid + displ + # Calculate region offset for coordinate transformation + region_offset_x = 0 + region_offset_y = 0 + if self.selected_region is not None: + region_offset_x, region_offset_y = self.selected_region[0], self.selected_region[1] + # Draw displacement visualization on the scaled frame if show_arrows: # Draw arrows showing displacement @@ -705,27 +916,37 @@ def export_video(self): cmap = plt.colormaps[self.colormap] for i, (pt0, pt1, mag) in enumerate(zip(self.grid, displaced_pts, magnitudes)): - # Scale coordinates to export resolution - start_pt = (int(pt0[0] * pixel_scale), int(pt0[1] * pixel_scale)) - end_pt = (int(pt1[0] * pixel_scale), int(pt1[1] * pixel_scale)) - - # Get color for this magnitude - color = cmap(norm(mag)) - color_bgr = tuple(int(255 * c) for c in color[2::-1]) # Convert RGB to BGR - - # Draw arrow line - cv2.line(export_frame, start_pt, end_pt, color_bgr, - max(1, point_size * pixel_scale // 10)) + # Apply region offset and scale coordinates to export resolution + start_pt = (int((pt0[0] - region_offset_x) * pixel_scale), + int((pt0[1] - region_offset_y) * pixel_scale)) + end_pt = (int((pt1[0] - region_offset_x) * pixel_scale), + int((pt1[1] - region_offset_y) * pixel_scale)) - # Draw arrow head - cv2.circle(export_frame, end_pt, max(1, point_size * pixel_scale // 5), - color_bgr, -1) + # Check if points are within the export frame bounds + if (0 <= start_pt[0] < export_width and 0 <= start_pt[1] < export_height and + 0 <= end_pt[0] < export_width and 0 <= end_pt[1] < export_height): + + # Get color for this magnitude + color = cmap(norm(mag)) + color_bgr = tuple(int(255 * c) for c in color[2::-1]) # Convert RGB to BGR + + # Draw arrow line + cv2.line(export_frame, start_pt, end_pt, color_bgr, + max(1, point_size * pixel_scale // 10)) + + # Draw arrow head + cv2.circle(export_frame, end_pt, max(1, point_size * pixel_scale // 5), + color_bgr, -1) else: # Draw points at displaced positions for pt in displaced_pts: - center = (int(pt[0] * pixel_scale), int(pt[1] * pixel_scale)) - cv2.circle(export_frame, center, max(1, point_size * pixel_scale // 5), - (0, 0, 255), -1) # Red circles + center = (int((pt[0] - region_offset_x) * pixel_scale), + int((pt[1] - region_offset_y) * pixel_scale)) + + # Check if point is within the export frame bounds + if (0 <= center[0] < export_width and 0 <= center[1] < export_height): + cv2.circle(export_frame, center, max(1, point_size * pixel_scale // 5), + (0, 0, 255), -1) # Red circles # Ensure the frame is in the correct format and size export_frame = np.clip(export_frame, 0, 255).astype(np.uint8) @@ -746,11 +967,16 @@ def export_video(self): else: frame_info = f"Frames: {start_frame} to {stop_frame} ({total_frames} total)" + # Add region info if applicable + region_info = "" + if self.selected_region is not None: + region_info = f"Region: {self.selected_region[2]}x{self.selected_region[3]} pixels\n" + QtWidgets.QMessageBox.information(self, "Export Complete", f"Video exported successfully to:\n{file_path}\n" f"Resolution: {export_width}x{export_height} " f"(pixel scale: {pixel_scale}x)\n" - f"{frame_info}") + f"{region_info}{frame_info}") except Exception as e: import traceback From c07175f2c3b2b9dc95440784f50a58f73a0e7408 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 08:55:53 +0200 Subject: [PATCH 60/63] Refactor subset size and distance controls in SelectionGUI for improved layout and functionality --- pyidi/GUIs/subset_selection.py | 101 +++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 17 deletions(-) diff --git a/pyidi/GUIs/subset_selection.py b/pyidi/GUIs/subset_selection.py index 0c49c41..532255a 100644 --- a/pyidi/GUIs/subset_selection.py +++ b/pyidi/GUIs/subset_selection.py @@ -269,7 +269,7 @@ def ui_right_menu(self): # Set initial width for right panel self.method_widget.setMinimumWidth(150) self.method_widget.setMaximumWidth(600) - self.splitter.setSizes([1000, 220]) # Initial left/right width + self.splitter.setSizes([1000, 300]) # Initial left/right width self.automatic_layout.addStretch(1) @@ -315,7 +315,9 @@ def ui_manual_right_menu(self): config_layout = QtWidgets.QVBoxLayout(config_group) # Subset size input - config_layout.addWidget(QtWidgets.QLabel("Subset size:")) + self.subset_size_layout = QtWidgets.QHBoxLayout() + self.subset_size_layout.addWidget(QtWidgets.QLabel("Subset size:")) + self.subset_size_spinbox = QtWidgets.QSpinBox() self.subset_size_spinbox.setRange(1, 1000) self.subset_size_spinbox.setValue(11) @@ -324,8 +326,20 @@ def ui_manual_right_menu(self): self.subset_size_spinbox.setMinimum(1) self.subset_size_spinbox.setMaximum(999) self.subset_size_spinbox.setWrapping(False) - self.subset_size_spinbox.valueChanged.connect(self.update_selected_points) - config_layout.addWidget(self.subset_size_spinbox) + self.subset_size_spinbox.setSuffix("px") + self.subset_size_spinbox.setFixedWidth(80) + self.subset_size_spinbox.valueChanged.connect(self.update_subset_size_from_spinbox) + self.subset_size_layout.addWidget(self.subset_size_spinbox) + + self.subset_size_layout.addStretch() # Push everything to the left + config_layout.addLayout(self.subset_size_layout) + + self.subset_size_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.subset_size_slider.setRange(1, 100) + self.subset_size_slider.setValue(11) + self.subset_size_slider.setSingleStep(1) + self.subset_size_slider.valueChanged.connect(self.update_subset_size_from_slider) + config_layout.addWidget(self.subset_size_slider) # Show ROI rectangles self.show_roi_checkbox = QtWidgets.QCheckBox("Show subsets") @@ -345,22 +359,34 @@ def ui_manual_right_menu(self): method_controls_layout = QtWidgets.QVBoxLayout(method_controls_group) # Distance between subsets (only visible for Grid and Along the line) - self.distance_label = QtWidgets.QLabel("Distance between subsets:") - self.distance_label.setVisible(False) # Hidden by default - method_controls_layout.addWidget(self.distance_label) + self.distance_layout = QtWidgets.QHBoxLayout() + self.distance_layout.addWidget(QtWidgets.QLabel("Distance between subsets:")) + + self.distance_spinbox = QtWidgets.QSpinBox() + self.distance_spinbox.setRange(-50, 50) + self.distance_spinbox.setSingleStep(1) + self.distance_spinbox.setValue(0) + self.distance_spinbox.setSuffix("px") + self.distance_spinbox.setFixedWidth(80) + self.distance_spinbox.valueChanged.connect(self.update_distance_from_spinbox) + self.distance_layout.addWidget(self.distance_spinbox) + + self.distance_layout.addStretch() # Push everything to the left + + # Create a widget to hold the distance controls + self.distance_widget = QtWidgets.QWidget() + self.distance_widget.setLayout(self.distance_layout) + self.distance_widget.setVisible(False) # Hidden by default + method_controls_layout.addWidget(self.distance_widget) self.distance_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.distance_slider.setRange(-50, 50) self.distance_slider.setSingleStep(1) self.distance_slider.setValue(0) self.distance_slider.setVisible(False) + self.distance_slider.valueChanged.connect(self.update_distance_from_slider) method_controls_layout.addWidget(self.distance_slider) - def update_label_and_recompute(val): - self.distance_label.setText(f"Distance between subsets: {str(val)}") - self.recompute_roi_points() - self.distance_slider.valueChanged.connect(update_label_and_recompute) - # Start new line (only visible in "Along the line" mode) self.start_new_line_button = QtWidgets.QPushButton("Start new line") self.start_new_line_button.clicked.connect(self.start_new_line) @@ -558,7 +584,7 @@ def method_selected(self, id: int): self.grid_list.setVisible(is_grid) self.delete_grid_button.setVisible(is_grid) - self.distance_label.setVisible(show_spacing) + self.distance_widget.setVisible(show_spacing) self.distance_slider.setVisible(show_spacing) self.brush_deselect_button.setVisible(is_brush) @@ -691,9 +717,50 @@ def update_selected_points(self): ) self.points_label.setText(f"Selected subsets: {len(self.selected_points)}") + def update_distance_from_slider(self, value): + """Update distance spinbox from slider value and recompute ROI points.""" + # Update spinbox without triggering its signal + self.distance_spinbox.blockSignals(True) + self.distance_spinbox.setValue(value) + self.distance_spinbox.blockSignals(False) + + # Recompute ROI points + self.recompute_roi_points() + + def update_distance_from_spinbox(self, value): + """Update distance slider from spinbox value and recompute ROI points.""" + # Update slider without triggering its signal + self.distance_slider.blockSignals(True) + self.distance_slider.setValue(value) + self.distance_slider.blockSignals(False) + + # Recompute ROI points + self.recompute_roi_points() + + def update_subset_size_from_slider(self, value): + """Update subset size spinbox from slider value and recompute ROI points.""" + # Update spinbox without triggering its signal + self.subset_size_spinbox.blockSignals(True) + self.subset_size_spinbox.setValue(value) + self.subset_size_spinbox.blockSignals(False) + + # Recompute ROI points and update display + self.recompute_roi_points() + + def update_subset_size_from_spinbox(self, value): + """Update subset size slider from spinbox value and recompute ROI points.""" + # Update slider, clamping to its range + slider_value = min(100, max(1, value)) + self.subset_size_slider.blockSignals(True) + self.subset_size_slider.setValue(slider_value) + self.subset_size_slider.blockSignals(False) + + # Recompute ROI points and update display + self.recompute_roi_points() + def recompute_roi_points(self): subset_size = self.subset_size_spinbox.value() - spacing = self.distance_slider.value() + spacing = self.distance_spinbox.value() # Update all "along the line" polygons for poly in self.drawing_polygons: @@ -805,7 +872,7 @@ def handle_grid_drawing(self, event): # Compute ROI points only if closed polygon if len(grid['points']) >= 3: subset_size = self.subset_size_spinbox.value() - spacing = self.distance_slider.value() + spacing = self.distance_spinbox.value() grid['roi_points'] = rois_inside_polygon(grid['points'], subset_size, spacing) self.update_grid_display() @@ -887,7 +954,7 @@ def handle_polygon_drawing(self, event): # Update ROI points only for this polygon if len(poly['points']) >= 2: subset_size = self.subset_size_spinbox.value() - spacing = self.distance_slider.value() + spacing = self.distance_spinbox.value() poly['roi_points'] = points_along_polygon(poly['points'], subset_size, spacing) self.update_polygon_display() @@ -1160,7 +1227,7 @@ def handle_brush_end(self, ev): return subset_size = self.subset_size_spinbox.value() - spacing = self.distance_slider.value() + spacing = self.distance_spinbox.value() # Generate (row, col) points inside the painted mask brush_rois = rois_inside_mask(self._paint_mask, subset_size, spacing) From 301cfd629f763c77733fdb38ae0d922fcaa2c83b Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 10:00:14 +0200 Subject: [PATCH 61/63] Upgrade gradient direction selection and add preset buttons for X and Y directions in SelectionGUI --- pyidi/GUIs/subset_selection.py | 213 +++++++++++++++++++++++++++++---- 1 file changed, 190 insertions(+), 23 deletions(-) diff --git a/pyidi/GUIs/subset_selection.py b/pyidi/GUIs/subset_selection.py index 532255a..f54d8e5 100644 --- a/pyidi/GUIs/subset_selection.py +++ b/pyidi/GUIs/subset_selection.py @@ -23,6 +23,49 @@ def mouseClickEvent(self, ev): super().mouseClickEvent(ev) def mouseDragEvent(self, ev, axis=None): + # Handle gradient direction selection + if self.parent_gui.mode == "filter" and self.parent_gui.setting_direction: + if ev.isStart(): + pos = ev.scenePos() + if self.sceneBoundingRect().contains(pos): + point = self.mapSceneToView(pos) + self.parent_gui.gradient_direction_points = [(point.x(), point.y())] + self.parent_gui.gradient_direction_start = (point.x(), point.y()) + ev.accept() + return + elif ev.isFinish(): + pos = ev.scenePos() + if self.sceneBoundingRect().contains(pos): + point = self.mapSceneToView(pos) + if hasattr(self.parent_gui, 'gradient_direction_start'): + self.parent_gui.gradient_direction_points = [ + self.parent_gui.gradient_direction_start, + (point.x(), point.y()) + ] + self.parent_gui.compute_direction_vector() + self.parent_gui.update_direction_line() + # Toggle off the direction selection mode + self.parent_gui.direction_button.setChecked(False) + self.parent_gui.set_gradient_direction_mode() + self.parent_gui.compute_candidate_points_gradient_direction() + ev.accept() + return + else: + # During drag, update the line display + pos = ev.scenePos() + if self.sceneBoundingRect().contains(pos): + point = self.mapSceneToView(pos) + if hasattr(self.parent_gui, 'gradient_direction_start'): + temp_points = [ + self.parent_gui.gradient_direction_start, + (point.x(), point.y()) + ] + xs = [p[0] for p in temp_points] + ys = [p[1] for p in temp_points] + self.parent_gui.direction_line.setData(xs, ys) + ev.accept() + return + if self.parent_gui.mode == "selection" and self.parent_gui.method_buttons["Brush"].isChecked(): if self.parent_gui.ctrl_held: ev.accept() @@ -173,7 +216,7 @@ def __init__(self, video): # Ensure method-specific widgets are visible on startup self.method_selected(self.button_group.checkedId()) - self.auto_method_selected(0) + # Don't auto-select any filter method - let user choose when needed # Set the initial mode self.switch_mode("selection") # Default to selection mode @@ -462,8 +505,7 @@ def ui_auto_right_menu(self): for i, name in enumerate(method_names): button = QtWidgets.QPushButton(name) button.setCheckable(True) - if i == 0: - button.setChecked(True) + # Don't auto-select any method - let user choose self.auto_method_group.addButton(button, i) filter_method_layout.addWidget(button) self.auto_method_buttons[name] = button @@ -521,6 +563,25 @@ def update_label_and_recompute(val): self.direction_button.clicked.connect(self.set_gradient_direction_mode) method_settings_layout.addWidget(self.direction_button) + # Preset direction buttons + preset_layout = QtWidgets.QHBoxLayout() + + self.x_direction_button = QtWidgets.QPushButton("X Direction") + self.x_direction_button.setVisible(False) + self.x_direction_button.clicked.connect(self.set_x_direction_preset) + preset_layout.addWidget(self.x_direction_button) + + self.y_direction_button = QtWidgets.QPushButton("Y Direction") + self.y_direction_button.setVisible(False) + self.y_direction_button.clicked.connect(self.set_y_direction_preset) + preset_layout.addWidget(self.y_direction_button) + + # Create a widget to hold the preset buttons + self.preset_buttons_widget = QtWidgets.QWidget() + self.preset_buttons_widget.setLayout(preset_layout) + self.preset_buttons_widget.setVisible(False) + method_settings_layout.addWidget(self.preset_buttons_widget) + self.direction_threshold = 10 self.gradient_thresh_label = QtWidgets.QLabel(f"Threshold (grad): {self.direction_threshold}") self.gradient_thresh_label.setVisible(False) @@ -543,12 +604,25 @@ def update_direction_thresh(val): self.automatic_layout.addStretch(1) def auto_method_selected(self, id: int): + # Check if any button is actually checked + if self.auto_method_group.checkedButton() is None: + return + method_name = list(self.auto_method_buttons.keys())[id] # print(f"Selected automatic method: {method_name}") # Here you can switch method behavior, show/hide widgets, etc. is_shi_tomasi = method_name == "Shi-Tomasi" is_gradient_dir = method_name == "Gradient in direction" + # Reset gradient direction selection when switching away from gradient method + if not is_gradient_dir and hasattr(self, 'direction_button') and self.direction_button.isChecked(): + self.direction_button.setChecked(False) + self.set_gradient_direction_mode() + + # Hide direction line when not in gradient direction mode + if not is_gradient_dir and hasattr(self, 'direction_line'): + self.direction_line.clear() + self.threshold_label.setVisible(is_shi_tomasi) self.threshold_slider.setVisible(is_shi_tomasi) @@ -556,15 +630,23 @@ def auto_method_selected(self, id: int): self.compute_candidate_points_shi_tomasi() self.direction_button.setVisible(is_gradient_dir) + self.preset_buttons_widget.setVisible(is_gradient_dir) self.gradient_thresh_label.setVisible(is_gradient_dir) self.gradient_thresh_slider.setVisible(is_gradient_dir) + self.preset_buttons_widget.setVisible(is_gradient_dir) + self.y_direction_button.setVisible(is_gradient_dir) + self.x_direction_button.setVisible(is_gradient_dir) + if is_gradient_dir and self.gradient_direction is not None: self.compute_candidate_points_gradient_direction() + # Show the direction line if we have gradient direction points + if hasattr(self, 'gradient_direction_points') and len(self.gradient_direction_points) == 2: + self.update_direction_line() if is_shi_tomasi: self.show_instruction("Use the threshold slider to filter points.") elif is_gradient_dir: - self.show_instruction("Define the gradient direction by clicking 'Set direction on image' and defining two pointsy.") + self.show_instruction("Click 'Set direction on image' button and drag to define the gradient direction.") def show_instruction(self, message: str): self.statusBar.showMessage(message) @@ -612,6 +694,15 @@ def switch_mode(self, mode: str): self.filter_mode_button.setChecked(False) self.stack.setCurrentWidget(self.manual_widget) + # Reset gradient direction selection when leaving filter mode + if hasattr(self, 'direction_button') and self.direction_button.isChecked(): + self.direction_button.setChecked(False) + self.set_gradient_direction_mode() + + # Hide direction line when leaving filter mode + if hasattr(self, 'direction_line'): + self.direction_line.clear() + self.roi_overlay.setVisible(True) self.scatter.setVisible(True) self.show_instruction("Selection mode: choose a method on the left.") @@ -621,27 +712,13 @@ def switch_mode(self, mode: str): self.filter_mode_button.setChecked(True) self.stack.setCurrentWidget(self.automatic_widget) - self.compute_candidate_points_shi_tomasi() + # Don't automatically compute anything - let user select method first self.show_points_checkbox.setChecked(False) self.roi_overlay.setVisible(False) self.scatter.setVisible(False) self.show_instruction("Filter mode: choose a filter method and adjust settings.") def on_mouse_click(self, event): - if self.setting_direction: - pos = event.scenePos() - if self.view.sceneBoundingRect().contains(pos): - point = self.view.mapSceneToView(pos) - self.gradient_direction_points.append((point.x(), point.y())) - if len(self.gradient_direction_points) == 2: - self.compute_direction_vector() - self.update_direction_line() - self.setting_direction = False - self.direction_button.setChecked(False) - # print(f"Gradient direction set: {self.gradient_direction}") - self.compute_candidate_points_gradient_direction() - return - if self.mode == "filter": return @@ -833,6 +910,10 @@ def clear_selection(self): self.points_label.setText("Selected subsets: 0") + # Reset gradient direction selection and clear direction line + if hasattr(self, 'direction_button') and self.direction_button.isChecked(): + self.direction_button.setChecked(False) + self.set_gradient_direction_mode() self.direction_line.clear() self.update_selected_points() # Refresh display @@ -1116,14 +1197,30 @@ def clear_candidates(self): if hasattr(self, 'candidate_scatter'): self.candidate_scatter.clear() + # Reset gradient direction selection when clearing candidates + if hasattr(self, 'direction_button') and self.direction_button.isChecked(): + self.direction_button.setChecked(False) + self.set_gradient_direction_mode() + self.update_selected_points() # Update main display to remove candidates # Gradient in a specified direction def set_gradient_direction_mode(self): - self.setting_direction = True - self.gradient_direction_points = [] - self.direction_button.setChecked(True) # Keep it visually pressed - # print("Click two points to set the gradient direction.") + """Toggle gradient direction selection mode.""" + self.setting_direction = self.direction_button.isChecked() + + if self.setting_direction: + self.direction_button.setText("Cancel Direction") + self.direction_button.setStyleSheet("background-color: #d73a00;") + self.gradient_direction_points = [] + # Clear the direction line only when starting new selection + self.direction_line.clear() + self.show_instruction("Click and drag to set the gradient direction.") + else: + self.direction_button.setText("Set direction on image") + self.direction_button.setStyleSheet("") + # Don't clear the direction line when finishing selection - keep it visible + self.show_instruction("Filter mode: choose a filter method and adjust settings.") def compute_direction_vector(self): p1, p2 = self.gradient_direction_points @@ -1285,6 +1382,76 @@ def activate_brush_deselect(self): if self.brush_deselect_button.isChecked(): self.brush_deselect_mode = True + def set_x_direction_preset(self): + """Set horizontal (X) direction preset.""" + if self.image_item.image is None: + return + + # Get image dimensions + w, h = self.image_item.image.shape[:2] + + # Set horizontal line in the center of the image + center_y = h // 2 + margin = min(w // 4, 50) # Use 1/4 of width or 50 pixels, whichever is smaller + + # Create horizontal line points + start_x = margin + end_x = w - margin + + self.gradient_direction_points = [ + (start_x, center_y), + (end_x, center_y) + ] + + # Compute and set the direction vector + self.compute_direction_vector() + self.update_direction_line() + + # Ensure direction selection is off + if self.direction_button.isChecked(): + self.direction_button.setChecked(False) + self.set_gradient_direction_mode() + + # Compute candidate points + self.compute_candidate_points_gradient_direction() + + self.show_instruction("X (horizontal) direction preset applied.") + + def set_y_direction_preset(self): + """Set vertical (Y) direction preset.""" + if self.image_item.image is None: + return + + # Get image dimensions + w, h = self.image_item.image.shape[:2] + + # Set vertical line in the center of the image + center_x = w // 2 + margin = min(h // 4, 50) # Use 1/4 of height or 50 pixels, whichever is smaller + + # Create vertical line points + start_y = margin + end_y = h - margin + + self.gradient_direction_points = [ + (center_x, start_y), + (center_x, end_y) + ] + + # Compute and set the direction vector + self.compute_direction_vector() + self.update_direction_line() + + # Ensure direction selection is off + if self.direction_button.isChecked(): + self.direction_button.setChecked(False) + self.set_gradient_direction_mode() + + # Compute candidate points + self.compute_candidate_points_gradient_direction() + + self.show_instruction("Y (vertical) direction preset applied.") + def points_along_polygon(polygon, subset_size, spacing=0): if len(polygon) < 2: return [] From aec085cb94edafe62f6bcc53b2d1348db9087ba7 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 10:10:43 +0200 Subject: [PATCH 62/63] Refactor point retrieval methods in SelectionGUI for improved filtering and consistency --- pyidi/GUIs/subset_selection.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/pyidi/GUIs/subset_selection.py b/pyidi/GUIs/subset_selection.py index f54d8e5..49a6de5 100644 --- a/pyidi/GUIs/subset_selection.py +++ b/pyidi/GUIs/subset_selection.py @@ -924,16 +924,23 @@ def set_image(self, img: np.ndarray): def get_points(self): """Get all selected points from manual and polygons.""" - points = np.array(self.selected_points)[:, ::-1] # set axis 0 to y and axis 1 to x - return points + filtered_points = self.get_filtered_points() + if filtered_points.size > 0: + return filtered_points + else: + return self.get_selected_points() @property def points(self): return self.get_points() def get_filtered_points(self): - """Get candidate points from automatic filtering.""" - return self.candidate_points.copy() if hasattr(self, 'candidate_points') else [] + """Get candidate points from filtering.""" + return np.array(self.candidate_points)[:, ::-1] if hasattr(self, 'candidate_points') else [] + + def get_selected_points(self): + """Get all selected points from manual, polygons and grid.""" + return np.array(self.selected_points)[:, ::-1] if self.selected_points else [] # Grid selection def handle_grid_drawing(self, event): @@ -1160,7 +1167,7 @@ def update_threshold_and_show_shi_tomsi(self): eig_threshold = self.max_eig_shi_tomasi * threshold_ratio - self.candidate_points = [(round(y)+0.5, round(x)+0.5) for (x, y, e) in self.candidates_shi_tomasi if e > eig_threshold] + self.candidate_points = [(round(y), round(x)) for (x, y, e) in self.candidates_shi_tomasi if e > eig_threshold] self.update_candidate_display() self.update_candidate_points_count() @@ -1185,7 +1192,7 @@ def update_candidate_display(self): self.view.addItem(self.candidate_scatter) if self.candidate_points: - self.candidate_scatter.setData(pos=self.candidate_points) + self.candidate_scatter.setData(pos=np.array(self.candidate_points) + 0.5) else: self.candidate_scatter.clear() @@ -1277,7 +1284,7 @@ def update_threshold_and_show_gradient_direction(self): threshold = self.max_grad_dir * threshold_ratio self.candidate_points = [ - (round(y)+0.5, round(x)+0.5) + (round(y), round(x)) for (x, y, v) in self.candidates_grad_dir if v > threshold ] From 80803249b77d3f6b79b6b330a070a4264b9a0347 Mon Sep 17 00:00:00 2001 From: Klemen Zaletelj Date: Tue, 15 Jul 2025 10:22:19 +0200 Subject: [PATCH 63/63] Brush selection tool enables adjustment of the subset spacing --- pyidi/GUIs/subset_selection.py | 39 ++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/pyidi/GUIs/subset_selection.py b/pyidi/GUIs/subset_selection.py index 49a6de5..a0aa3b7 100644 --- a/pyidi/GUIs/subset_selection.py +++ b/pyidi/GUIs/subset_selection.py @@ -119,6 +119,8 @@ def __init__(self, video): self.active_polygon_index = 0 self.grid_polygons = [{'points': [], 'roi_points': []}] self.active_grid_index = 0 + self.brush_masks = [] # Store brush masks for recomputation + self.brush_points = [] # Store computed brush points separately # Add status bar for instructions self.statusBar = self.statusBar() @@ -658,7 +660,7 @@ def method_selected(self, id: int): is_grid = method_name == "Grid" is_brush = method_name == "Brush" - show_spacing = is_along or is_grid + show_spacing = is_along or is_grid or is_brush self.start_new_line_button.setVisible(is_along or is_grid) self.polygon_list.setVisible(is_along) @@ -675,7 +677,7 @@ def method_selected(self, id: int): # Show context-sensitive instructions if is_brush: - self.show_instruction("Hold Ctrl and drag to paint selection area.") + self.show_instruction("Hold Ctrl and drag to paint selection area. Use distance slider to control subset spacing.") elif is_along: self.show_instruction("Click to add points along the line. Click 'Start new line' to begin a new one.") elif is_grid: @@ -736,7 +738,7 @@ def on_mouse_click(self, event): def update_selected_points(self): polygon_points = [pt for poly in self.drawing_polygons for pt in poly['roi_points']] grid_points = [pt for g in self.grid_polygons for pt in g['roi_points']] - self.selected_points = self.manual_points + polygon_points + grid_points + self.selected_points = self.manual_points + polygon_points + grid_points + self.brush_points if not self.selected_points: self.scatter.clear() @@ -849,6 +851,11 @@ def recompute_roi_points(self): if len(grid['points']) >= 3: grid['roi_points'] = rois_inside_polygon(grid['points'], subset_size, spacing) + # Update all brush masks + self.brush_points = [] + for mask in self.brush_masks: + self.brush_points.extend(rois_inside_mask(mask, subset_size, spacing)) + self.update_selected_points() def start_new_line(self): @@ -876,6 +883,10 @@ def clear_selection(self): # Clear manual points self.manual_points = [] + # Clear brush data + self.brush_masks = [] + self.brush_points = [] + # Clear line-based polygons self.drawing_polygons = [{'points': [], 'roi_points': []}] self.polygon_list.clear() @@ -1108,6 +1119,10 @@ def handle_remove_point(self, event): if closest in grid['roi_points']: grid['roi_points'].remove(closest) + # Remove from brush points + if closest in self.brush_points: + self.brush_points.remove(closest) + self.update_selected_points() # Automatic filtering @@ -1336,9 +1351,6 @@ def handle_brush_end(self, ev): # Generate (row, col) points inside the painted mask brush_rois = rois_inside_mask(self._paint_mask, subset_size, spacing) - # Convert to set of tuples for fast comparison - roi_set = set((int(round(y)), int(round(x))) for y, x in brush_rois) - if self.brush_deselect_mode: def point_inside_mask(pt, mask): y, x = int(round(pt[0])), int(round(pt[1])) @@ -1359,11 +1371,24 @@ def point_inside_mask(pt, mask): for grid in self.grid_polygons: grid['roi_points'] = [pt for pt in grid['roi_points'] if not point_inside_mask(pt, self._paint_mask)] + # Remove from brush points + self.brush_points = [ + pt for pt in self.brush_points + if not point_inside_mask(pt, self._paint_mask) + ] + + # Remove brush masks that are covered by the current mask + self.brush_masks = [mask for mask in self.brush_masks + if not np.any(mask & self._paint_mask)] + self.brush_deselect_mode = False self.brush_deselect_button.setChecked(False) else: - self.manual_points.extend(brush_rois) + # Store the mask for future recomputation + self.brush_masks.append(self._paint_mask.copy()) + # Add points to brush_points + self.brush_points.extend(brush_rois) self._paint_mask = None self.update_selected_points()