Skip to content
Snippets Groups Projects
Commit 14d289df authored by s224389's avatar s224389
Browse files

Added:

- Added text to start/end points and fixed bugs
- Ensured points can only exist in the image
parent 0f455b57
No related branches found
No related tags found
No related merge requests found
......@@ -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()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment