Newer
Older
import numpy as np
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QGraphicsView, QGraphicsScene,
QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton,
QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsSimpleTextItem
)
from PyQt5.QtGui import QPixmap, QPen, QBrush
from PyQt5.QtCore import Qt, QRectF
s224389
committed
class LabeledPointItem(QGraphicsEllipseItem):
s224389
committed
"""
A point item with optional label (e.g. 'S' or 'E'), color, and a flag for removability.
s224389
committed
"""
def __init__(self, x, y, label="", radius=4, color=Qt.red, removable=True, parent=None):
s224389
committed
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
# 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
s224389
committed
def get_pos(self):
"""Return the (x, y) of this point in scene coords."""
return (self._x, self._y)
def set_pos(self, x, y):
"""
Move point to (x, y).
Also update ellipse and label position accordingly.
s224389
committed
"""
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)
s224389
committed
def distance_to(self, x_other, y_other):
"""Euclidean distance from this point to (x_other, y_other)."""
s224389
committed
dx = self._x - x_other
dy = self._y - y_other
return math.sqrt(dx*dx + dy*dy)
class ImageGraphicsView(QGraphicsView):
"""
Custom class for displaying an image and placing/dragging points.
"""
def __init__(self, parent=None):
super().__init__(parent)
# Create scene and add it to the view
self.scene = QGraphicsScene(self)
self.setScene(self.scene)
# Zoom around mouse pointer
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
# Image item
self.image_item = QGraphicsPixmapItem()
self.scene.addItem(self.image_item)
self.points = []
self.editor_mode = False
self.dot_radius = 4
# Enable built-in panning around image, but force arrow cursor initially
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
self._was_dragging = False
s224389
committed
self._dragging_idx = None
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)
# 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/drag dots; if False: do nothing on click."""
self.editor_mode = mode
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self._mouse_pressed = True
self._was_dragging = False
self._press_view_pos = event.pos()
s224389
committed
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
s224389
committed
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.viewport().setCursor(Qt.ClosedHandCursor)
else:
# Editor mode off => normal panning
s224389
committed
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())
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
s224389
committed
if self._dragging_idx is not None:
# Move the dragged point
s224389
committed
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)
s224389
committed
else:
# If movement > threshold => treat as panning
s224389
committed
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):
s224389
committed
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
s224389
committed
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
s224389
committed
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
if event.angleDelta().y() > 0:
self.scale(zoom_in_factor, zoom_in_factor)
else:
self.scale(zoom_out_factor, zoom_out_factor)
event.accept()
# ----------- Points -----------
def _add_point(self, 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 = LabeledPointItem(
x, y,
label="", # no label
radius=self.dot_radius,
color=Qt.red,
removable=True
)
s224389
committed
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."""
scene_pos = self.mapToScene(view_pos)
x_click, y_click = scene_pos.x(), scene_pos.y()
threshold = 10
closest_idx = None
min_dist = float('inf')
s224389
committed
for i, point_item in enumerate(self.points):
dist = point_item.distance_to(x_click, y_click)
if dist < min_dist:
min_dist = dist
# Remove if near enough and is_removable
s224389
committed
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]
s224389
committed
def _find_point_near(self, view_pos, threshold=10):
"""Return idx of nearest point if within threshold, else None."""
s224389
committed
scene_pos = self.mapToScene(view_pos)
x_click, y_click = scene_pos.x(), scene_pos.y()
closest_idx = None
min_dist = float('inf')
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
if closest_idx is not None and min_dist <= threshold:
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:
- Load Image
- Editor mode toggle
- Export points
- Clear Points
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
"""
def __init__(self):
super().__init__()
self.setWindowTitle("Test GUI")
main_widget = QWidget()
main_layout = QVBoxLayout(main_widget)
self.image_view = ImageGraphicsView()
main_layout.addWidget(self.image_view)
btn_layout = QHBoxLayout()
# Load Image
self.btn_load_image = QPushButton("Load Image")
self.btn_load_image.clicked.connect(self.load_image)
btn_layout.addWidget(self.btn_load_image)
# Editor Mode
self.btn_editor_mode = QPushButton("Editor Mode: OFF")
self.btn_editor_mode.setCheckable(True)
self.btn_editor_mode.setStyleSheet("background-color: lightgray;")
self.btn_editor_mode.clicked.connect(self.toggle_editor_mode)
btn_layout.addWidget(self.btn_editor_mode)
# Export Points
self.btn_export_points = QPushButton("Export Points")
self.btn_export_points.clicked.connect(self.export_points)
btn_layout.addWidget(self.btn_export_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 file dialog to pick an image, then load it."""
options = QFileDialog.Options()
file_path, _ = QFileDialog.getOpenFileName(
self, "Open Image", "",
"Images (*.png *.jpg *.jpeg *.bmp *.tif)",
options=options
)
if file_path:
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)
if is_checked:
self.btn_editor_mode.setText("Editor Mode: ON")
self.btn_editor_mode.setStyleSheet("background-color: #ffcccc;")
else:
self.btn_editor_mode.setText("Editor Mode: OFF")
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
options = QFileDialog.Options()
file_path, _ = QFileDialog.getSaveFileName(
self, "Export Points", "",
"NumPy Files (*.npy);;All Files (*)",
options=options
)
if file_path:
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 *removable* points from the scene;
keep S/E if they were added (non-removable).
"""
self.image_view._clear_point_items(remove_all=False)