diff --git a/GUI_draft_live.py b/GUI_draft_live.py index 4354fe77c6166293fb8d2b520a1a393d43ced2cc..83011b489fa48e972168fed6ae0cb0b6cd934f8f 100644 --- a/GUI_draft_live.py +++ b/GUI_draft_live.py @@ -69,7 +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).""" + """Positions the circle so its center is at (x, y).""" self._x = x self._y = y self.setPos(x - self._r, y - self._r) @@ -93,21 +93,24 @@ class ImageGraphicsView(QGraphicsView): # Zoom around mouse pointer self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) - # Image display item + # Image display self.image_item = QGraphicsPixmapItem() self.scene.addItem(self.image_item) - # Parallel lists: anchor_points + LabeledPointItem - self.anchor_points = [] # List of (x, y) - self.point_items = [] # List of LabeledPointItem + self.anchor_points = [] # List[(x, y)] + self.point_items = [] # LabeledPointItem objects + self.full_path_points = [] # QGraphicsEllipseItems for the path + + # We'll store the entire path coords (smoothed) for reference + self._full_path_xy = [] self.dot_radius = 4 self.path_radius = 1 - self.radius_cost_image = 2 # cost-lowering radius + self.radius_cost_image = 2 self._img_w = 0 self._img_h = 0 - # For panning/dragging + # Pan/Drag self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ArrowCursor) @@ -117,15 +120,12 @@ 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 + self._drag_counter = 0 - # We will keep two copies of the cost image + # Cost images self.cost_image_original = None self.cost_image = None - # Path circles displayed - self.full_path_points = [] - # -------------------------------------------------------------------- # LOADING # -------------------------------------------------------------------- @@ -142,11 +142,9 @@ class ImageGraphicsView(QGraphicsView): self.resetTransform() self.fitInView(self.image_item, Qt.KeepAspectRatio) - # Place S/E at left and right + # By default, add 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 - - # Insert S/E self._insert_anchor_point(-1, s_x, s_y, label="S", removable=False, z_val=100, radius=6) self._insert_anchor_point(-1, e_x, e_y, label="E", removable=False, z_val=100, radius=6) @@ -154,15 +152,11 @@ class ImageGraphicsView(QGraphicsView): # 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. - """ + """Insert anchor at index=idx (or -1 => before E). Clamps x,y to image bounds.""" 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 it if len(self.anchor_points) >= 2: idx = len(self.anchor_points) - 1 else: @@ -171,31 +165,124 @@ class ImageGraphicsView(QGraphicsView): 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 - ) + 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): - """Called when user left-clicks an empty spot. Insert a red guide point, recalc path.""" - # clamp to image boundaries + """User clicked => find the correct sub-path, insert the point in that sub-path.""" 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 - self._insert_anchor_point(-1, x_clamped, y_clamped, label="", removable=True, z_val=1, radius=self.dot_radius) + if not self._full_path_xy: + # If there's no existing path built, we just insert normally + self._insert_anchor_point(-1, x_clamped, y_clamped, + label="", removable=True, z_val=1, radius=self.dot_radius) + else: + # Insert the new anchor in between the correct anchors, + # by finding nearest coordinate in _full_path_xy, then + # walking left+right until we find bounding anchors. + self._insert_anchor_between_subpath(x_clamped, y_clamped) - # 3) Re-apply cost-lowering self._apply_all_guide_points_to_cost() - - # 4) Rebuild path self._rebuild_full_path() + def _insert_anchor_between_subpath(self, x_new, y_new): + """Find the subpath bounding (x_new,y_new) and insert the new anchor accordingly.""" + # If no path, fallback + if not self._full_path_xy: + self._insert_anchor_point(-1, x_new, y_new) + return + + # 1) Find nearest coordinate in the path + best_idx = None + best_d2 = float('inf') + for i, (px, py) in enumerate(self._full_path_xy): + dx = px - x_new + dy = py - y_new + d2 = dx*dx + dy*dy + if d2 < best_d2: + best_d2 = d2 + best_idx = i + + if best_idx is None: + # fallback + self._insert_anchor_point(-1, x_new, y_new) + return + + # 2) Identify bounding anchors by walking left / right + def approx_equal(xa, ya, xb, yb, tol=1e-3): + return (abs(xa - xb) < tol) and (abs(ya - yb) < tol) + + def is_anchor(coord): + cx, cy = coord + # check if (cx,cy) is approx any anchor in self.anchor_points + for (ax, ay) in self.anchor_points: + if approx_equal(ax, ay, cx, cy): + return True + return False + + left_anchor_pt = None + right_anchor_pt = None + + # walk left + iL = best_idx + while iL >= 0: + px, py = self._full_path_xy[iL] + if is_anchor((px, py)): + left_anchor_pt = (px, py) + break + iL -= 1 + + # walk right + iR = best_idx + while iR < len(self._full_path_xy): + px, py = self._full_path_xy[iR] + if is_anchor((px, py)): + right_anchor_pt = (px, py) + break + iR += 1 + + if not left_anchor_pt or not right_anchor_pt: + # If we can't find bounding anchors => fallback + self._insert_anchor_point(-1, x_new, y_new) + return + + # 3) Find which anchor_points indices correspond to left_anchor_pt, right_anchor_pt + left_idx = None + right_idx = None + for i, (ax, ay) in enumerate(self.anchor_points): + if approx_equal(ax, ay, left_anchor_pt[0], left_anchor_pt[1]): + left_idx = i + if approx_equal(ax, ay, right_anchor_pt[0], right_anchor_pt[1]): + right_idx = i + + if left_idx is None or right_idx is None: + # fallback + self._insert_anchor_point(-1, x_new, y_new) + return + + # We want the new anchor to be inserted right after left_idx, + # so that the subpath between left_idx and right_idx + # is effectively subdivided. + # This ensures anchor_points = [..., left_anchor, new_point, ..., right_anchor, ...] + insert_idx = right_idx + # But if left_idx < right_idx => we do insert_idx=left_idx+1 + # in case we want them consecutive. + if left_idx < right_idx: + insert_idx = left_idx + 1 + else: + # means the path might be reversed, or there's some tricky indexing + # We'll just do min or max + insert_idx = max(right_idx, left_idx) + + self._insert_anchor_point(insert_idx, x_new, y_new, label="", removable=True, + z_val=1, radius=self.dot_radius) + # -------------------------------------------------------------------- # COST IMAGE # -------------------------------------------------------------------- @@ -204,7 +291,6 @@ class ImageGraphicsView(QGraphicsView): self.cost_image = self.cost_image_original.copy() def _apply_all_guide_points_to_cost(self): - """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): @@ -234,12 +320,13 @@ class ImageGraphicsView(QGraphicsView): # PATH BUILDING # -------------------------------------------------------------------- def _rebuild_full_path(self): - # Remove old path items + """Compute subpaths between anchors, smooth them, store all coords, display all of them.""" + # Clear old path visuals for item in self.full_path_points: self.scene.removeItem(item) self.full_path_points.clear() + self._full_path_xy.clear() - # Build subpaths if len(self.anchor_points) < 2 or self.cost_image is None: return @@ -251,29 +338,33 @@ class ImageGraphicsView(QGraphicsView): if i == 0: big_xy.extend(sub_xy) else: - # avoid duplicating the shared anchor + # avoid repeating the shared anchor if len(sub_xy) > 1: big_xy.extend(sub_xy[1:]) - # Smoothing with Savitzky-Golay + # Smooth if we have enough points if len(big_xy) >= 7: - arr_xy = np.array(big_xy) # shape (N,2) + arr_xy = np.array(big_xy) smoothed = savgol_filter(arr_xy, window_length=7, polyorder=1, axis=0) big_xy = smoothed.tolist() - # Draw them + # We now store the entire path in _full_path_xy + self._full_path_xy = big_xy[:] + + # Display ALL points for (px, py) in big_xy: path_item = LabeledPointItem(px, py, label="", radius=self.path_radius, color=Qt.magenta, removable=False, z_value=0) self.full_path_points.append(path_item) self.scene.addItem(path_item) - # Ensure S/E remain on top + # Keep S/E on top for p_item in self.point_items: if p_item._text_item: p_item.setZValue(100) def _compute_subpath_xy(self, xA, yA, xB, yB): + """Return the raw path from (xA,yA)->(xB,yB).""" if self.cost_image is None: return [] h, w = self.cost_image.shape @@ -299,10 +390,9 @@ class ImageGraphicsView(QGraphicsView): self._was_dragging = False self._press_view_pos = event.pos() - # Check if user clicked near an existing anchor => drag + # See if user is clicking near an existing anchor => drag it 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 @@ -313,19 +403,19 @@ class ImageGraphicsView(QGraphicsView): self.viewport().setCursor(Qt.ClosedHandCursor) return else: - # If no anchor near => user is placing a new point + # no anchor => we'll add a new point self.setDragMode(QGraphicsView.ScrollHandDrag) self.viewport().setCursor(Qt.ClosedHandCursor) elif event.button() == Qt.RightButton: - # Right-click => remove point if it's removable + # Right-click => remove anchor if removable self._remove_point_by_click(event.pos()) super().mousePressEvent(event) def mouseMoveEvent(self, event): if self._dragging_idx is not None: - # dragging an anchor + # Dragging anchor scene_pos = self.mapToScene(event.pos()) x_new = scene_pos.x() - self._drag_offset[0] y_new = scene_pos.y() - self._drag_offset[1] @@ -338,15 +428,13 @@ class ImageGraphicsView(QGraphicsView): self._drag_counter += 1 if self._drag_counter >= 4: - # partial path update => revert cost, reapply, rebuild + # partial path update self._drag_counter = 0 self._revert_cost_to_original() self._apply_all_guide_points_to_cost() - - # anchor_points updated self.anchor_points[self._dragging_idx] = (x_clamped, y_clamped) self._rebuild_full_path() - return + else: if self._mouse_pressed and (event.buttons() & Qt.LeftButton): dist = (event.pos() - self._press_view_pos).manhattanLength() @@ -362,7 +450,7 @@ class ImageGraphicsView(QGraphicsView): self.viewport().setCursor(Qt.ArrowCursor) if self._dragging_idx is not None: - # done dragging => final path update + # finished dragging => final update idx = self._dragging_idx self._dragging_idx = None self._drag_offset = (0, 0) @@ -374,8 +462,9 @@ class ImageGraphicsView(QGraphicsView): self._revert_cost_to_original() self._apply_all_guide_points_to_cost() self._rebuild_full_path() + else: - # If not dragging => place a new guide point + # If user wasn't dragging => add new guide point if not self._was_dragging: scene_pos = self.mapToScene(event.pos()) x, y = scene_pos.x(), scene_pos.y() @@ -387,7 +476,7 @@ class ImageGraphicsView(QGraphicsView): idx = self._find_item_near(view_pos, threshold=10) if idx is None: return - # skip if it's S/E + # skip if S/E if not self.point_items[idx].is_removable(): return @@ -402,8 +491,9 @@ class ImageGraphicsView(QGraphicsView): def _find_item_near(self, view_pos, threshold=10): scene_pos = self.mapToScene(view_pos) x_click, y_click = scene_pos.x(), scene_pos.y() - min_dist = float('inf') + closest_idx = None + min_dist = float('inf') for i, itm in enumerate(self.point_items): d = itm.distance_to(x_click, y_click) if d < min_dist: @@ -417,10 +507,8 @@ class ImageGraphicsView(QGraphicsView): # ZOOM # -------------------------------------------------------------------- def wheelEvent(self, event): - """Zoom in/out with mouse wheel.""" 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: @@ -442,9 +530,10 @@ class ImageGraphicsView(QGraphicsView): for p in self.full_path_points: self.scene.removeItem(p) self.full_path_points.clear() + self._full_path_xy.clear() def clear_guide_points(self): - """Remove all removable (guide) anchors, keep S/E. Then rebuild.""" + """Remove all removable anchors, keep S/E. Rebuild path.""" i = 0 while i < len(self.anchor_points): if self.point_items[i].is_removable(): @@ -454,14 +543,19 @@ class ImageGraphicsView(QGraphicsView): else: i += 1 - for item in self.full_path_points: - self.scene.removeItem(item) + for it in self.full_path_points: + self.scene.removeItem(it) self.full_path_points.clear() + self._full_path_xy.clear() self._revert_cost_to_original() self._apply_all_guide_points_to_cost() self._rebuild_full_path() + def get_full_path_xy(self): + """Return the entire path (x,y) array after smoothing.""" + return self._full_path_xy + class MainWindow(QMainWindow): def __init__(self): @@ -482,10 +576,10 @@ class MainWindow(QMainWindow): self.btn_load_image.clicked.connect(self.load_image) btn_layout.addWidget(self.btn_load_image) - # 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) + # Export Path + self.btn_export_path = QPushButton("Export Path") + self.btn_export_path.clicked.connect(self.export_path) + btn_layout.addWidget(self.btn_export_path) # Clear Points self.btn_clear_points = QPushButton("Clear Points") @@ -497,7 +591,6 @@ 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", "", @@ -510,23 +603,25 @@ class MainWindow(QMainWindow): self.image_view.cost_image_original = cost_img self.image_view.cost_image = cost_img.copy() - def export_points(self): - if not self.image_view.anchor_points: - print("No anchor points to export.") + def export_path(self): + """Export the full path (x,y) as a .npy file.""" + full_xy = self.image_view.get_full_path_xy() + if not full_xy: + print("No path to export.") return + options = QFileDialog.Options() file_path, _ = QFileDialog.getSaveFileName( - self, "Export Points", "", + self, "Export Path", "", "NumPy Files (*.npy);;All Files (*)", options=options ) if file_path: - points_array = np.array(self.image_view.anchor_points) - np.save(file_path, points_array) - print(f"Exported {len(points_array)} points to {file_path}") + arr = np.array(full_xy) + np.save(file_path, arr) + print(f"Exported path with {len(arr)} points to {file_path}") def clear_points(self): - """Remove all removable anchors (guide points), keep S/E in place.""" self.image_view.clear_guide_points() def closeEvent(self, event):