diff --git a/GUI_draft.py b/GUI_draft.py index dc08d6c213ab82afc1e92dc698733812551e5ef9..91e9b927d5ed2599bfa26ffbf3d019be0f6e9b90 100644 --- a/GUI_draft.py +++ b/GUI_draft.py @@ -5,81 +5,113 @@ import numpy as np from PyQt5.QtWidgets import ( QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton, - QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsSimpleTextItem + QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsTextItem ) -from PyQt5.QtGui import QPixmap, QPen, QBrush +from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont from PyQt5.QtCore import Qt, QRectF class LabeledPointItem(QGraphicsEllipseItem): """ - A point item with optional label (e.g. 'S' or 'E'), color, and a flag for removability. + A circle with optional (bold) label (e.g. 'S'/'E'), + which automatically scales the text if it's bigger than the circle. """ 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._removable = removable # If False, point cannot be removed by right-click + super().__init__(0, 0, 2*radius, 2*radius, parent) + self._x = x # Center x + self._y = y # Center y + self._r = radius # Circle radius + self._removable = removable - # Ellipse styling + # Circle styling pen = QPen(color) brush = QBrush(color) self.setPen(pen) self.setBrush(brush) - # If we have a label, add a child text item + # Optional text label 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) + self._text_item = QGraphicsTextItem(self) + self._text_item.setPlainText(label) + self._text_item.setDefaultTextColor(QColor("black")) + # Bold text + font = QFont("Arial", 14) + font.setBold(True) + self._text_item.setFont(font) + + self._scale_text_to_fit() + + # Move so center is at (x, y) + self.set_pos(x, y) + + def _scale_text_to_fit(self): + """Scale the text down so it fits fully within the circle's diameter.""" + if not self._text_item: + return - # 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) + # Reset scale first + self._text_item.setScale(1.0) - def is_removable(self): - """Return True if this point can be removed by user, False otherwise.""" - return self._removable + circle_diam = 2 * self._r + raw_rect = self._text_item.boundingRect() + text_w = raw_rect.width() + text_h = raw_rect.height() - def get_pos(self): - """Return the (x, y) of this point in scene coords.""" - return (self._x, self._y) + if text_w > circle_diam or text_h > circle_diam: + scale_w = circle_diam / text_w + scale_h = circle_diam / text_h + scale_factor = min(scale_w, scale_h) + self._text_item.setScale(scale_factor) + + self._center_label() + + def _center_label(self): + """Center the text in the circle, taking into account any scaling.""" + if not self._text_item: + return + + ellipse_w = 2 * self._r + ellipse_h = 2 * self._r + + raw_rect = self._text_item.boundingRect() + scale_factor = self._text_item.scale() + scaled_w = raw_rect.width() * scale_factor + scaled_h = raw_rect.height() * scale_factor + + tx = (ellipse_w - scaled_w) * 0.5 + ty = (ellipse_h - scaled_h) * 0.5 + self._text_item.setPos(tx, ty) def set_pos(self, x, y): """ - Move point to (x, y). - Also update ellipse and label position accordingly. + Move so the circle's center is at (x,y) in scene coords. """ self._x = x self._y = y - self.setRect(x - self._r, y - self._r, 2*self._r, 2*self._r) + # Because our ellipse is (0,0,2*r,2*r) in local coords, + # we shift by (x-r, y-r). + self.setPos(x - self._r, y - 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 get_pos(self): + return (self._x, self._y) def distance_to(self, 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) + def is_removable(self): + return self._removable + class ImageGraphicsView(QGraphicsView): """ - Custom class for displaying an image and placing/dragging points. + Displays an image and allows placing/dragging labeled points. + Ensures points can't go outside the image boundary. """ def __init__(self, parent=None): super().__init__(parent) - - # Create scene and add it to the view self.scene = QGraphicsScene(self) self.setScene(self.scene) @@ -90,16 +122,19 @@ class ImageGraphicsView(QGraphicsView): self.image_item = QGraphicsPixmapItem() self.scene.addItem(self.image_item) - # Points self.points = [] self.editor_mode = False - self.dot_radius = 4 - # Enable built-in panning around image, but force arrow cursor initially + # For normal red dots + self.dot_radius = 4 + + # Keep track of image size + self._img_w = 0 + self._img_h = 0 + self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ArrowCursor) - # Track clicking vs. dragging self._mouse_pressed = False self._press_view_pos = None self._drag_threshold = 5 @@ -108,35 +143,56 @@ class ImageGraphicsView(QGraphicsView): self._drag_offset = (0, 0) def load_image(self, image_path): - """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) self.setSceneRect(QRectF(pixmap.rect())) - # Remove old points from scene - self._clear_point_items(remove_all=True) + # Save image dimensions + self._img_w = pixmap.width() + self._img_h = pixmap.height() - # Reset transform + fit + self._clear_point_items(remove_all=True) 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 - ) + # Positions for S/E + s_x = self._img_w * 0.15 + s_y = self._img_h * 0.5 + e_x = self._img_w * 0.85 + e_y = self._img_h * 0.5 + + # Create green S/E with radius=6 + s_point = self._create_point(s_x, s_y, "S", 6, Qt.green, removable=False) + e_point = self._create_point(e_x, e_y, "E", 6, Qt.green, removable=False) + + # Put S in front, E in back 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/drag dots; if False: do nothing on click.""" self.editor_mode = mode + def _create_point(self, x, y, label, radius, color, removable=True): + """ + Helper to create a LabeledPointItem at (x,y), but clamp inside image first. + """ + # Clamp coordinates so center doesn't go outside + cx = self._clamp(x, radius, self._img_w - radius) + cy = self._clamp(y, radius, self._img_h - radius) + + return LabeledPointItem( + cx, cy, + label=label, + radius=radius, + color=color, + removable=removable + ) + + def _clamp(self, val, min_val, max_val): + return max(min_val, min(val, max_val)) + def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self._mouse_pressed = True @@ -144,30 +200,24 @@ class ImageGraphicsView(QGraphicsView): self._press_view_pos = event.pos() if self.editor_mode: - # Check if we're near a point idx = self._find_point_near(event.pos(), threshold=10) if idx is not None: - # Start dragging that point self._dragging_idx = idx scene_pos = self.mapToScene(event.pos()) px, py = self.points[idx].get_pos() self._drag_offset = (scene_pos.x() - px, scene_pos.y() - py) - # Temporarily disable QGraphicsView's panning self.setDragMode(QGraphicsView.NoDrag) self.viewport().setCursor(Qt.ClosedHandCursor) return else: - # Not near a point => normal panning self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ClosedHandCursor) else: - # Editor mode off => normal panning self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ClosedHandCursor) elif event.button() == Qt.RightButton: - # If Editor Mode is ON, try removing nearest dot if self.editor_mode: self._remove_point(event.pos()) @@ -175,42 +225,44 @@ class ImageGraphicsView(QGraphicsView): def mouseMoveEvent(self, event): if self._dragging_idx is not None: - # Move the dragged point + # Dragging an existing 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) + + # Clamp center so it doesn't go out of the image + r = self.points[self._dragging_idx]._r + x_clamped = self._clamp(x_new, r, self._img_w - r) + y_clamped = self._clamp(y_new, r, self._img_h - r) + + self.points[self._dragging_idx].set_pos(x_clamped, y_clamped) return else: - # If movement > threshold => treat as panning + # If movement > threshold => treat as pan if self._mouse_pressed and (event.buttons() & Qt.LeftButton): dist = (event.pos() - self._press_view_pos).manhattanLength() if dist > self._drag_threshold: self._was_dragging = True - super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) - if event.button() == Qt.LeftButton and self._mouse_pressed: self._mouse_pressed = False self.viewport().setCursor(Qt.ArrowCursor) - # 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: - # Not dragging => maybe add a new point + # If not dragged, 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.""" zoom_in_factor = 1.25 zoom_out_factor = 1 / zoom_in_factor @@ -218,28 +270,26 @@ class ImageGraphicsView(QGraphicsView): self.scale(zoom_in_factor, zoom_in_factor) else: self.scale(zoom_out_factor, zoom_out_factor) - event.accept() - # ----------- Points ----------- - + # ---------- Points ---------- def _add_point(self, view_pos): - """Add a removable red dot at the clicked location.""" + """Add a removable red dot at the clicked location, clamped inside the image.""" scene_pos = self.mapToScene(view_pos) x, y = scene_pos.x(), scene_pos.y() - dot = LabeledPointItem( - x, y, - label="", # no label - radius=self.dot_radius, - color=Qt.red, - removable=True - ) - self.points.append(dot) + dot = self._create_point(x, y, label="", radius=self.dot_radius, color=Qt.red, removable=True) + + # Insert between S and E if they exist + if len(self.points) >= 2: + self.points.insert(len(self.points) - 1, dot) + else: + self.points.append(dot) + self.scene.addItem(dot) def _remove_point(self, view_pos): - """Right-click => remove nearest dot if within threshold, if it's removable.""" + """Right-click => remove nearest dot if it's removable.""" scene_pos = self.mapToScene(view_pos) x_click, y_click = scene_pos.x(), scene_pos.y() @@ -247,20 +297,18 @@ class ImageGraphicsView(QGraphicsView): closest_idx = None min_dist = float('inf') - for i, point_item in enumerate(self.points): - dist = point_item.distance_to(x_click, y_click) + for i, p in enumerate(self.points): + dist = p.distance_to(x_click, y_click) if dist < min_dist: min_dist = dist closest_idx = i - # Remove if near enough and is_removable if closest_idx is not None and min_dist <= threshold: 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() @@ -278,17 +326,12 @@ class ImageGraphicsView(QGraphicsView): 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. - """ + """Remove all points if remove_all=True; else just removable 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(): @@ -299,13 +342,6 @@ class ImageGraphicsView(QGraphicsView): class MainWindow(QMainWindow): - """ - Main window with: - - Load Image - - Editor mode toggle - - Export points - - Clear Points - """ def __init__(self): super().__init__() self.setWindowTitle("Test GUI") @@ -356,7 +392,6 @@ class MainWindow(QMainWindow): self.image_view.load_image(file_path) def toggle_editor_mode(self): - """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) @@ -368,10 +403,6 @@ class MainWindow(QMainWindow): self.btn_editor_mode.setStyleSheet("background-color: lightgray;") def export_points(self): - """ - 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 @@ -389,12 +420,9 @@ class MainWindow(QMainWindow): print(f"Exported {len(points_array)} points to {file_path}") def clear_points(self): - """ - 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) window = MainWindow() @@ -402,5 +430,5 @@ def main(): sys.exit(app.exec_()) -if __name__ == '__main__': +if __name__ == "__main__": main()