diff --git a/GUI_draft.py b/GUI_draft.py index 299b7ab794d24d040507410fbfdc2186814f544a..dc08d6c213ab82afc1e92dc698733812551e5ef9 100644 --- a/GUI_draft.py +++ b/GUI_draft.py @@ -1,29 +1,49 @@ import sys +import math import numpy as np from PyQt5.QtWidgets import ( QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton, - QHBoxLayout, QVBoxLayout, QWidget, QFileDialog + QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsSimpleTextItem ) from PyQt5.QtGui import QPixmap, QPen, QBrush from PyQt5.QtCore import Qt, QRectF -import math - -class PointItem(QGraphicsEllipseItem): +class LabeledPointItem(QGraphicsEllipseItem): """ - Represents a single draggable point on the scene. + A point item with optional label (e.g. 'S' or 'E'), color, and a flag for removability. """ - def __init__(self, x, y, radius=4, parent=None): + def __init__(self, x, y, label="", radius=4, color=Qt.red, removable=True, parent=None): super().__init__(x - radius, y - radius, 2*radius, 2*radius, parent) self._x = x self._y = y self._r = radius - - self.setBrush(QBrush(Qt.red)) - self.setPen(QPen(Qt.red)) + self._removable = removable # If False, point cannot be removed by right-click + + # Ellipse styling + pen = QPen(color) + brush = QBrush(color) + self.setPen(pen) + self.setBrush(brush) + + # If we have a label, add a child text item + self._text_item = None + if label: + self._text_item = QGraphicsSimpleTextItem(label, self) + # So the text doesn't scale/rotate with zoom: + self._text_item.setFlag(QGraphicsSimpleTextItem.ItemIgnoresTransformations) + + # Position label inside the ellipse + text_rect = self._text_item.boundingRect() + text_x = (self.rect().width() - text_rect.width()) * 0.5 + text_y = (self.rect().height() - text_rect.height()) * 0.5 + self._text_item.setPos(text_x, text_y) + + def is_removable(self): + """Return True if this point can be removed by user, False otherwise.""" + return self._removable def get_pos(self): """Return the (x, y) of this point in scene coords.""" @@ -31,24 +51,30 @@ class PointItem(QGraphicsEllipseItem): def set_pos(self, x, y): """ - Move point to (x,y). - This also updates the ellipse rectangle so the visual dot moves. + Move point to (x, y). + Also update ellipse and label position accordingly. """ self._x = x self._y = y self.setRect(x - self._r, y - self._r, 2*self._r, 2*self._r) + if self._text_item: + # Recenter text + text_rect = self._text_item.boundingRect() + text_x = (self.rect().width() - text_rect.width()) * 0.5 + text_y = (self.rect().height() - text_rect.height()) * 0.5 + self._text_item.setPos(text_x, text_y) + def distance_to(self, x_other, y_other): - """Euclidean distance from this point to arbitrary (x_other, y_other).""" + """Euclidean distance from this point to (x_other, y_other).""" dx = self._x - x_other dy = self._y - y_other return math.sqrt(dx*dx + dy*dy) - class ImageGraphicsView(QGraphicsView): """ - Custom class inheriting from QGraphicsView for displaying an image and placing red dots. + Custom class for displaying an image and placing/dragging points. """ def __init__(self, parent=None): super().__init__(parent) @@ -64,7 +90,7 @@ class ImageGraphicsView(QGraphicsView): self.image_item = QGraphicsPixmapItem() self.scene.addItem(self.image_item) - # Points and dot items + # Points self.points = [] self.editor_mode = False self.dot_radius = 4 @@ -82,24 +108,33 @@ class ImageGraphicsView(QGraphicsView): self._drag_offset = (0, 0) def load_image(self, image_path): - """Load an image and fit it in the view.""" + """Load an image, clear old points, add 'S'/'E' points, fit in the view.""" pixmap = QPixmap(image_path) - if not pixmap.isNull(): self.image_item.setPixmap(pixmap) - - # Avoid TypeError by converting to QRectF self.setSceneRect(QRectF(pixmap.rect())) - # Clear existing dots from previous image - self.points.clear() + # Remove old points from scene + self._clear_point_items(remove_all=True) - # Reset transform then fit image in view + # Reset transform + fit self.resetTransform() self.fitInView(self.image_item, Qt.KeepAspectRatio) + # Add two special green labeled points + # Choose coordinates (50,50) and (150,150) or any suitable coords + s_point = LabeledPointItem( + 50, 50, label="S", radius=8, color=Qt.green, removable=False + ) + e_point = LabeledPointItem( + 150, 150, label="E", radius=8, color=Qt.green, removable=False + ) + self.points = [s_point, e_point] + self.scene.addItem(s_point) + self.scene.addItem(e_point) + def set_editor_mode(self, mode: bool): - """If True: place/remove dots; if False: do nothing on click.""" + """If True: place/remove/drag dots; if False: do nothing on click.""" self.editor_mode = mode def mousePressEvent(self, event): @@ -114,7 +149,6 @@ class ImageGraphicsView(QGraphicsView): if idx is not None: # Start dragging that point self._dragging_idx = idx - # Compute offset so point doesn't jump if clicked off-center scene_pos = self.mapToScene(event.pos()) px, py = self.points[idx].get_pos() self._drag_offset = (scene_pos.x() - px, scene_pos.y() - py) @@ -124,33 +158,31 @@ class ImageGraphicsView(QGraphicsView): self.viewport().setCursor(Qt.ClosedHandCursor) return else: - # Not near a point, so we do normal panning + # Not near a point => normal panning self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ClosedHandCursor) else: - # Editor mode is off => always do normal panning + # Editor mode off => normal panning self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ClosedHandCursor) elif event.button() == Qt.RightButton: - # If Editor Mode is on remove the nearest dot + # If Editor Mode is ON, try removing nearest dot if self.editor_mode: self._remove_point(event.pos()) super().mousePressEvent(event) - def mouseMoveEvent(self, event): if self._dragging_idx is not None: - # Move that point to new coords + # Move the dragged point scene_pos = self.mapToScene(event.pos()) x_new = scene_pos.x() - self._drag_offset[0] y_new = scene_pos.y() - self._drag_offset[1] - self.points[self._dragging_idx].set_pos(x_new, y_new) - return # Skip QGraphicsView's panning logic + return else: - # Old logic: if movement > threshold -> set _was_dragging = True + # If movement > threshold => treat as panning if self._mouse_pressed and (event.buttons() & Qt.LeftButton): dist = (event.pos() - self._press_view_pos).manhattanLength() if dist > self._drag_threshold: @@ -158,7 +190,6 @@ class ImageGraphicsView(QGraphicsView): super().mouseMoveEvent(event) - def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) @@ -166,21 +197,20 @@ class ImageGraphicsView(QGraphicsView): self._mouse_pressed = False self.viewport().setCursor(Qt.ArrowCursor) - # If we were dragging a point, stop. + # If we were dragging a point, stop dragging if self._dragging_idx is not None: self._dragging_idx = None self._drag_offset = (0, 0) self.setDragMode(QGraphicsView.ScrollHandDrag) else: - # We were NOT dragging a point => check if it was a click to add a new point + # Not dragging => maybe add a new point if not self._was_dragging and self.editor_mode: self._add_point(event.pos()) self._was_dragging = False - def wheelEvent(self, event): - """Mouse wheel = zoom.""" + """Mouse wheel => zoom.""" zoom_in_factor = 1.25 zoom_out_factor = 1 / zoom_in_factor @@ -191,19 +221,25 @@ class ImageGraphicsView(QGraphicsView): event.accept() + # ----------- Points ----------- - # -------------- Red Dots -------------- def _add_point(self, view_pos): - """Add a red dot at scene coords corresponding to view_pos.""" + """Add a removable red dot at the clicked location.""" scene_pos = self.mapToScene(view_pos) x, y = scene_pos.x(), scene_pos.y() - dot = PointItem(x, y, radius=self.dot_radius) + dot = LabeledPointItem( + x, y, + label="", # no label + radius=self.dot_radius, + color=Qt.red, + removable=True + ) self.points.append(dot) self.scene.addItem(dot) def _remove_point(self, view_pos): - """Right-click: remove nearest dot if within threshold.""" + """Right-click => remove nearest dot if within threshold, if it's removable.""" scene_pos = self.mapToScene(view_pos) x_click, y_click = scene_pos.x(), scene_pos.y() @@ -217,26 +253,14 @@ class ImageGraphicsView(QGraphicsView): min_dist = dist closest_idx = i - # Remove if within threshold + # Remove if near enough and is_removable if closest_idx is not None and min_dist <= threshold: - self.scene.removeItem(self.points[closest_idx]) - del self.points[closest_idx] - - def _create_dot_item(self, x, y): - """Helper for creating a small red ellipse item.""" - r = self.dot_radius - ellipse = QGraphicsEllipseItem(x - r, y - r, 2*r, 2*r) - ellipse.setBrush(QBrush(Qt.red)) - ellipse.setPen(QPen(Qt.red)) - return ellipse - - def _clear_point_items(self): - """Remove all dot items from the scene.""" - for p in self.points: - self.scene.removeItem(p) - self.points.clear() + if self.points[closest_idx].is_removable(): + self.scene.removeItem(self.points[closest_idx]) + del self.points[closest_idx] def _find_point_near(self, view_pos, threshold=10): + """Return idx of nearest point if within threshold, else None.""" scene_pos = self.mapToScene(view_pos) x_click, y_click = scene_pos.x(), scene_pos.y() @@ -253,16 +277,34 @@ class ImageGraphicsView(QGraphicsView): return closest_idx return None - - + def _clear_point_items(self, remove_all=False): + """ + Remove points from the scene. + - If remove_all=True, remove *all* points (including S/E). + - Otherwise, remove only the removable (red) ones. + """ + if remove_all: + for p in self.points: + self.scene.removeItem(p) + self.points.clear() + else: + # Keep non-removable (like S/E) + still_needed = [] + for p in self.points: + if p.is_removable(): + self.scene.removeItem(p) + else: + still_needed.append(p) + self.points = still_needed class MainWindow(QMainWindow): """ Main window with: - - Button to load in image - - Editor mode toggle button - - Button for exporting placed points + - Load Image + - Editor mode toggle + - Export points + - Clear Points """ def __init__(self): super().__init__() @@ -293,18 +335,17 @@ class MainWindow(QMainWindow): self.btn_export_points.clicked.connect(self.export_points) btn_layout.addWidget(self.btn_export_points) - # Remove Points + # Clear Points self.btn_clear_points = QPushButton("Clear Points") self.btn_clear_points.clicked.connect(self.clear_points) btn_layout.addWidget(self.btn_clear_points) - main_layout.addLayout(btn_layout) self.setCentralWidget(main_widget) self.resize(900, 600) def load_image(self): - """Open a file dialog to pick an image then load it.""" + """Open file dialog to pick an image, then load it.""" options = QFileDialog.Options() file_path, _ = QFileDialog.getOpenFileName( self, "Open Image", "", @@ -315,7 +356,7 @@ class MainWindow(QMainWindow): self.image_view.load_image(file_path) def toggle_editor_mode(self): - """Toggle whether left-click places dots and right-click removes dots.""" + """Toggle whether left-click places/drags dots and right-click removes dots.""" is_checked = self.btn_editor_mode.isChecked() self.image_view.set_editor_mode(is_checked) @@ -327,7 +368,14 @@ class MainWindow(QMainWindow): self.btn_editor_mode.setStyleSheet("background-color: lightgray;") def export_points(self): - """Save the list of dot coords to a .npy file.""" + """ + Save the (x, y) of each point to a .npy file. + (Excludes label, color, etc. Just x,y.) + """ + if not self.image_view.points: + print("No points to export.") + return + options = QFileDialog.Options() file_path, _ = QFileDialog.getSaveFileName( self, "Export Points", "", @@ -335,17 +383,17 @@ class MainWindow(QMainWindow): options=options ) if file_path: - points_array = np.array(self.image_view.points) + coords = [p.get_pos() for p in self.image_view.points] + points_array = np.array(coords) np.save(file_path, points_array) print(f"Exported {len(points_array)} points to {file_path}") - def clear_points(self): - """Remove all placed points (list & scene).""" - self.image_view.points.clear() - self.image_view._clear_point_items() - - + """ + Remove all *removable* points from the scene; + keep S/E if they were added (non-removable). + """ + self.image_view._clear_point_items(remove_all=False) def main(): app = QApplication(sys.argv)