diff --git a/GUI_draft_live.py b/GUI_draft_live.py index cf132c7e5a4a8e8a7d8f0ad49beeb172d4ce6230..4354fe77c6166293fb8d2b520a1a393d43ced2cc 100644 --- a/GUI_draft_live.py +++ b/GUI_draft_live.py @@ -69,6 +69,7 @@ class LabeledPointItem(QGraphicsEllipseItem): self._text_item.setPos(tx, ty) def set_pos(self, x, y): + """Positions the circle so that its center is at (x, y).""" self._x = x self._y = y self.setPos(x - self._r, y - self._r) @@ -89,25 +90,24 @@ class ImageGraphicsView(QGraphicsView): self.scene = QGraphicsScene(self) self.setScene(self.scene) - # Allow zoom around mouse pointer + # Zoom around mouse pointer self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) # Image display item self.image_item = QGraphicsPixmapItem() self.scene.addItem(self.image_item) - # Parallel lists - self.anchor_points = [] # List[(x, y)] - self.point_items = [] # List[LabeledPointItem] + # Parallel lists: anchor_points + LabeledPointItem + self.anchor_points = [] # List of (x, y) + self.point_items = [] # List of LabeledPointItem - self.editor_mode = False self.dot_radius = 4 self.path_radius = 1 self.radius_cost_image = 2 # cost-lowering radius self._img_w = 0 self._img_h = 0 - # For pan/drag + # For panning/dragging self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ArrowCursor) @@ -117,15 +117,13 @@ class ImageGraphicsView(QGraphicsView): self._was_dragging = False self._dragging_idx = None self._drag_offset = (0, 0) + self._drag_counter = 0 # throttles path updates while dragging - # NEW: We'll count how many times we've updated the drag => partial path update - self._drag_counter = 0 - - # Keep original cost image to revert changes + # We will keep two copies of the cost image self.cost_image_original = None self.cost_image = None - # The path is displayed as small magenta circles in self.full_path_points + # Path circles displayed self.full_path_points = [] # -------------------------------------------------------------------- @@ -144,49 +142,57 @@ class ImageGraphicsView(QGraphicsView): self.resetTransform() self.fitInView(self.image_item, Qt.KeepAspectRatio) - # Create S/E - s_x, s_y = 0.15*self._img_w, 0.5*self._img_h - e_x, e_y = 0.85*self._img_w, 0.5*self._img_h + # Place S/E at left and right + s_x, s_y = 0.15 * self._img_w, 0.5 * self._img_h + e_x, e_y = 0.85 * self._img_w, 0.5 * self._img_h - # S => not removable + # Insert S/E self._insert_anchor_point(-1, s_x, s_y, label="S", removable=False, z_val=100, radius=6) - # E => not removable self._insert_anchor_point(-1, e_x, e_y, label="E", removable=False, z_val=100, radius=6) - def set_editor_mode(self, mode: bool): - self.editor_mode = mode - # -------------------------------------------------------------------- # ANCHOR POINTS # -------------------------------------------------------------------- def _insert_anchor_point(self, idx, x, y, label="", removable=True, z_val=0, radius=4): """ Insert at index=idx, or -1 => append just before E if E exists. + Clamps x,y so points can't go outside the image. """ + x_clamped = self._clamp(x, radius, self._img_w - radius) + y_clamped = self._clamp(y, radius, self._img_h - radius) + if idx < 0: - # If we have at least 2 anchors, the last is E => insert before that + # If we have at least 2 anchors, the last is E => insert before it if len(self.anchor_points) >= 2: idx = len(self.anchor_points) - 1 else: idx = len(self.anchor_points) - self.anchor_points.insert(idx, (x, y)) - color = Qt.green if label in ("S","E") else Qt.red - item = LabeledPointItem(x, y, label=label, radius=radius, color=color, - removable=removable, z_value=z_val) + self.anchor_points.insert(idx, (x_clamped, y_clamped)) + + color = Qt.green if label in ("S", "E") else Qt.red + item = LabeledPointItem( + x_clamped, y_clamped, label=label, + radius=radius, color=color, removable=removable, z_value=z_val + ) self.point_items.insert(idx, item) self.scene.addItem(item) def _add_guide_point(self, x, y): - """ - User added a red guide point => lower cost, insert anchor, rebuild path. - """ - # 1) Revert cost + """Called when user left-clicks an empty spot. Insert a red guide point, recalc path.""" + # clamp to image boundaries + x_clamped = self._clamp(x, self.dot_radius, self._img_w - self.dot_radius) + y_clamped = self._clamp(y, self.dot_radius, self._img_h - self.dot_radius) + + # 1) revert cost self._revert_cost_to_original() - # 2) Insert new anchor (removable) - self._insert_anchor_point(-1, x, y, label="", removable=True, z_val=1, radius=self.dot_radius) - # 3) Re-apply cost-lowering for all existing guide points + + # 2) Insert new anchor + self._insert_anchor_point(-1, x_clamped, y_clamped, label="", removable=True, z_val=1, radius=self.dot_radius) + + # 3) Re-apply cost-lowering self._apply_all_guide_points_to_cost() + # 4) Rebuild path self._rebuild_full_path() @@ -194,12 +200,11 @@ class ImageGraphicsView(QGraphicsView): # COST IMAGE # -------------------------------------------------------------------- def _revert_cost_to_original(self): - """self.cost_image <- copy of self.cost_image_original""" if self.cost_image_original is not None: self.cost_image = self.cost_image_original.copy() def _apply_all_guide_points_to_cost(self): - """Lower cost around every removable anchor.""" + """Lower cost around every REMOVABLE anchor (the red ones).""" if self.cost_image is None: return for i, (ax, ay) in enumerate(self.anchor_points): @@ -207,7 +212,6 @@ class ImageGraphicsView(QGraphicsView): self._lower_cost_in_circle(ax, ay, self.radius_cost_image) def _lower_cost_in_circle(self, x_f, y_f, radius): - """Set cost_image row,col in circle of radius -> global min.""" if self.cost_image is None: return h, w = self.cost_image.shape @@ -240,14 +244,14 @@ class ImageGraphicsView(QGraphicsView): return big_xy = [] - for i in range(len(self.anchor_points)-1): + for i in range(len(self.anchor_points) - 1): xA, yA = self.anchor_points[i] - xB, yB = self.anchor_points[i+1] + xB, yB = self.anchor_points[i + 1] sub_xy = self._compute_subpath_xy(xA, yA, xB, yB) if i == 0: big_xy.extend(sub_xy) else: - # avoid duplicating the point between subpaths + # avoid duplicating the shared anchor if len(sub_xy) > 1: big_xy.extend(sub_xy[1:]) @@ -264,7 +268,7 @@ class ImageGraphicsView(QGraphicsView): self.full_path_points.append(path_item) self.scene.addItem(path_item) - # Ensure S/E stay on top + # Ensure S/E remain on top for p_item in self.point_items: if p_item._text_item: p_item.setZValue(100) @@ -275,10 +279,10 @@ class ImageGraphicsView(QGraphicsView): h, w = self.cost_image.shape rA, cA = int(round(yA)), int(round(xA)) rB, cB = int(round(yB)), int(round(xB)) - rA = max(0, min(rA, h-1)) - cA = max(0, min(cA, w-1)) - rB = max(0, min(rB, h-1)) - cB = max(0, min(cB, w-1)) + rA = max(0, min(rA, h - 1)) + cA = max(0, min(cA, w - 1)) + rB = max(0, min(rB, h - 1)) + cB = max(0, min(cB, w - 1)) try: path_rc = find_path(self.cost_image, [(rA, cA), (rB, cB)]) except ValueError as e: @@ -287,7 +291,7 @@ class ImageGraphicsView(QGraphicsView): return [(c, r) for (r, c) in path_rc] # -------------------------------------------------------------------- - # MOUSE EVENTS (dragging, adding, removing points) + # MOUSE EVENTS # -------------------------------------------------------------------- def mousePressEvent(self, event): if event.button() == Qt.LeftButton: @@ -295,31 +299,27 @@ class ImageGraphicsView(QGraphicsView): self._was_dragging = False self._press_view_pos = event.pos() - if self.editor_mode: - idx = self._find_item_near(event.pos(), 10) - if idx is not None: - # drag existing anchor - self._dragging_idx = idx - # Reset drag counter - self._drag_counter = 0 + # Check if user clicked near an existing anchor => drag + idx = self._find_item_near(event.pos(), threshold=10) + if idx is not None: + # drag existing anchor + self._dragging_idx = idx + self._drag_counter = 0 - scene_pos = self.mapToScene(event.pos()) - px, py = self.point_items[idx].get_pos() - self._drag_offset = (scene_pos.x() - px, scene_pos.y() - py) - self.setDragMode(QGraphicsView.NoDrag) - self.viewport().setCursor(Qt.ClosedHandCursor) - return - else: - # If no anchor near, user might be panning - self.setDragMode(QGraphicsView.ScrollHandDrag) - self.viewport().setCursor(Qt.ClosedHandCursor) + scene_pos = self.mapToScene(event.pos()) + px, py = self.point_items[idx].get_pos() + self._drag_offset = (scene_pos.x() - px, scene_pos.y() - py) + self.setDragMode(QGraphicsView.NoDrag) + self.viewport().setCursor(Qt.ClosedHandCursor) + return else: + # If no anchor near => user is placing a new point self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ClosedHandCursor) elif event.button() == Qt.RightButton: - if self.editor_mode: - self._remove_point_by_click(event.pos()) + # Right-click => remove point if it's removable + self._remove_point_by_click(event.pos()) super().mousePressEvent(event) @@ -329,32 +329,30 @@ class ImageGraphicsView(QGraphicsView): scene_pos = self.mapToScene(event.pos()) x_new = scene_pos.x() - self._drag_offset[0] y_new = scene_pos.y() - self._drag_offset[1] + + # clamp so user can't drag outside r = self.point_items[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.point_items[self._dragging_idx].set_pos(x_clamped, y_clamped) - # THROTTLE: only recalc path every few frames self._drag_counter += 1 if self._drag_counter >= 4: + # partial path update => revert cost, reapply, rebuild self._drag_counter = 0 - # do partial path update: - # (We won't revert cost if you want the user to see the “final†cost-lowered path only at the end - # or you can do the entire revert+reapply if you like) self._revert_cost_to_original() self._apply_all_guide_points_to_cost() - # update anchor_points - newX, newY = self.point_items[self._dragging_idx].get_pos() - self.anchor_points[self._dragging_idx] = (newX, newY) - self._rebuild_full_path() + # anchor_points updated + self.anchor_points[self._dragging_idx] = (x_clamped, y_clamped) + self._rebuild_full_path() return else: - # if movement > threshold => 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): @@ -362,23 +360,23 @@ class ImageGraphicsView(QGraphicsView): if event.button() == Qt.LeftButton and self._mouse_pressed: self._mouse_pressed = False self.viewport().setCursor(Qt.ArrowCursor) + if self._dragging_idx is not None: + # done dragging => final path update idx = self._dragging_idx self._dragging_idx = None self._drag_offset = (0, 0) self.setDragMode(QGraphicsView.ScrollHandDrag) - # Final big update newX, newY = self.point_items[idx].get_pos() self.anchor_points[idx] = (newX, newY) self._revert_cost_to_original() self._apply_all_guide_points_to_cost() self._rebuild_full_path() - else: - if not self._was_dragging and self.editor_mode: - # user clicked an empty spot => add a guide point + # If not dragging => place a new guide point + if not self._was_dragging: scene_pos = self.mapToScene(event.pos()) x, y = scene_pos.x(), scene_pos.y() self._add_guide_point(x, y) @@ -389,16 +387,14 @@ class ImageGraphicsView(QGraphicsView): idx = self._find_item_near(view_pos, threshold=10) if idx is None: return - # check if removable => skip S/E + # skip if it's S/E if not self.point_items[idx].is_removable(): - return # do nothing + return - # remove anchor self.scene.removeItem(self.point_items[idx]) self.point_items.pop(idx) self.anchor_points.pop(idx) - # revert + re-apply cost, rebuild path self._revert_cost_to_original() self._apply_all_guide_points_to_cost() self._rebuild_full_path() @@ -421,13 +417,10 @@ class ImageGraphicsView(QGraphicsView): # ZOOM # -------------------------------------------------------------------- def wheelEvent(self, event): - """ - Zoom in/out with mouse wheel - """ + """Zoom in/out with mouse wheel.""" zoom_in_factor = 1.25 zoom_out_factor = 1 / zoom_in_factor - # If the user scrolls upward => zoom in. Otherwise => zoom out. if event.angleDelta().y() > 0: self.scale(zoom_in_factor, zoom_in_factor) else: @@ -451,10 +444,7 @@ class ImageGraphicsView(QGraphicsView): self.full_path_points.clear() def clear_guide_points(self): - """ - Removes all anchors that are 'removable' (guide points), - keeps S/E in place. Then reverts cost, re-applies, rebuilds path. - """ + """Remove all removable (guide) anchors, keep S/E. Then rebuild.""" i = 0 while i < len(self.anchor_points): if self.point_items[i].is_removable(): @@ -484,22 +474,20 @@ class MainWindow(QMainWindow): self.image_view = ImageGraphicsView() main_layout.addWidget(self.image_view) + # Buttons layout 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) - 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) + # 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) @@ -509,6 +497,7 @@ class MainWindow(QMainWindow): self.resize(900, 600) def load_image(self): + """Open file dialog, load image, compute cost image, store in view.""" options = QFileDialog.Options() file_path, _ = QFileDialog.getOpenFileName( self, "Open Image", "", @@ -521,16 +510,6 @@ class MainWindow(QMainWindow): self.image_view.cost_image_original = cost_img self.image_view.cost_image = cost_img.copy() - def toggle_editor_mode(self): - 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): if not self.image_view.anchor_points: print("No anchor points to export.")