Newer
Older
import sys
import numpy as np
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QGraphicsView, QGraphicsScene,
QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton,
QHBoxLayout, QVBoxLayout, QWidget, QFileDialog
)
from PyQt5.QtGui import QPixmap, QPen, QBrush
from PyQt5.QtCore import Qt, QRectF
s224389
committed
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import math
class PointItem(QGraphicsEllipseItem):
"""
Represents a single draggable point on the scene.
"""
def __init__(self, x, y, radius=4, 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))
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).
This also updates the ellipse rectangle so the visual dot moves.
"""
self._x = x
self._y = y
self.setRect(x - self._r, y - self._r, 2*self._r, 2*self._r)
def distance_to(self, x_other, y_other):
"""Euclidean distance from this point to arbitrary (x_other, y_other)."""
dx = self._x - x_other
dy = self._y - y_other
return math.sqrt(dx*dx + dy*dy)
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class ImageGraphicsView(QGraphicsView):
"""
Custom class inheriting from QGraphicsView for displaying an image and placing red dots.
"""
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)
# Points and dot items
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 and fit it 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()
# Reset transform then fit image in view
self.resetTransform()
self.fitInView(self.image_item, Qt.KeepAspectRatio)
def set_editor_mode(self, mode: bool):
"""If True: place/remove 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
# 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)
# Temporarily disable QGraphicsView's panning
self.setDragMode(QGraphicsView.NoDrag)
self.viewport().setCursor(Qt.ClosedHandCursor)
return
else:
# Not near a point, so we do normal panning
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.viewport().setCursor(Qt.ClosedHandCursor)
else:
# Editor mode is off => always do 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 self.editor_mode:
self._remove_point(event.pos())
super().mousePressEvent(event)
s224389
committed
def mouseMoveEvent(self, event):
s224389
committed
if self._dragging_idx is not None:
# Move that point to new coords
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
else:
# Old logic: if movement > threshold -> set _was_dragging = True
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)
s224389
committed
# If we were dragging a point, stop.
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
if not self._was_dragging and self.editor_mode:
self._add_point(event.pos())
self._was_dragging = False
s224389
committed
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()
# -------------- Red Dots --------------
def _add_point(self, view_pos):
"""Add a red dot at scene coords corresponding to view_pos."""
scene_pos = self.mapToScene(view_pos)
x, y = scene_pos.x(), scene_pos.y()
s224389
committed
dot = PointItem(x, y, radius=self.dot_radius)
self.points.append(dot)
self.scene.addItem(dot)
def _remove_point(self, view_pos):
"""Right-click: remove nearest dot if within threshold."""
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
s224389
committed
# Remove if within threshold
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."""
s224389
committed
for p in self.points:
self.scene.removeItem(p)
self.points.clear()
def _find_point_near(self, view_pos, threshold=10):
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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
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
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
class MainWindow(QMainWindow):
"""
Main window with:
- Button to load in image
- Editor mode toggle button
- Button for exporting placed points
"""
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)
# Remove 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."""
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 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 list of dot coords to a .npy file."""
options = QFileDialog.Options()
file_path, _ = QFileDialog.getSaveFileName(
self, "Export Points", "",
"NumPy Files (*.npy);;All Files (*)",
options=options
)
if file_path:
points_array = np.array(self.image_view.points)
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()
def main():
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()