From e6a5e74fa15bb5d11e2c14359d78d23733183e37 Mon Sep 17 00:00:00 2001 From: Martin Laasmaa Date: Wed, 10 Dec 2025 00:19:59 +0200 Subject: [PATCH] Adding feature to remove annotations --- src/database/db_manager.py | 19 +++ src/gui/tabs/annotation_tab.py | 116 +++++++++++++++- src/gui/widgets/annotation_canvas_widget.py | 141 +++++++++++++++++--- src/gui/widgets/annotation_tools_widget.py | 22 +++ 4 files changed, 278 insertions(+), 20 deletions(-) diff --git a/src/database/db_manager.py b/src/database/db_manager.py index db2da9e..53d5695 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -706,6 +706,25 @@ class DatabaseManager: finally: conn.close() + def delete_annotation(self, annotation_id: int) -> bool: + """ + Delete a manual annotation by ID. + + Args: + annotation_id: ID of the annotation to delete + + Returns: + True if an annotation was deleted, False otherwise. + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + cursor.execute("DELETE FROM annotations WHERE id = ?", (annotation_id,)) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + # ==================== Object Class Operations ==================== def get_object_classes(self) -> List[Dict]: diff --git a/src/gui/tabs/annotation_tab.py b/src/gui/tabs/annotation_tab.py index d517f56..6927aba 100644 --- a/src/gui/tabs/annotation_tab.py +++ b/src/gui/tabs/annotation_tab.py @@ -39,6 +39,8 @@ class AnnotationTab(QWidget): self.current_image_path = None self.current_image_id = None self.current_annotations = [] + # IDs of annotations currently selected on the canvas (multi-select) + self.selected_annotation_ids = [] self._setup_ui() @@ -62,6 +64,8 @@ class AnnotationTab(QWidget): self.annotation_canvas = AnnotationCanvasWidget() self.annotation_canvas.zoom_changed.connect(self._on_zoom_changed) self.annotation_canvas.annotation_drawn.connect(self._on_annotation_drawn) + # Selection of existing polylines (when tool is not in drawing mode) + self.annotation_canvas.annotation_selected.connect(self._on_annotation_selected) canvas_layout.addWidget(self.annotation_canvas) canvas_group.setLayout(canvas_layout) @@ -107,6 +111,10 @@ class AnnotationTab(QWidget): self.annotation_tools.clear_annotations_requested.connect( self._on_clear_annotations ) + # Delete selected annotation on canvas + self.annotation_tools.delete_selected_annotation_requested.connect( + self._on_delete_selected_annotation + ) self.right_splitter.addWidget(self.annotation_tools) # Image loading section @@ -292,6 +300,25 @@ class AnnotationTab(QWidget): logger.error(f"Failed to save annotation: {e}") QMessageBox.critical(self, "Error", f"Failed to save annotation:\n{str(e)}") + def _on_annotation_selected(self, annotation_ids): + """ + Handle selection of existing annotations on the canvas. + + Args: + annotation_ids: List of selected annotation IDs, or None/empty if cleared. + """ + if not annotation_ids: + self.selected_annotation_ids = [] + self.annotation_tools.set_has_selected_annotation(False) + logger.debug("Annotation selection cleared on canvas") + return + + # Normalize to a unique, sorted list of integer IDs + ids = sorted({int(aid) for aid in annotation_ids if isinstance(aid, int)}) + self.selected_annotation_ids = ids + self.annotation_tools.set_has_selected_annotation(bool(ids)) + logger.debug(f"Annotations selected on canvas: IDs={ids}") + def _on_simplify_on_finish_changed(self, enabled: bool): """Update canvas simplify-on-finish flag from tools widget.""" self.annotation_canvas.simplify_on_finish = enabled @@ -323,7 +350,7 @@ class AnnotationTab(QWidget): Handle when an object class is selected or cleared. When a specific class is selected, only annotations of that class are drawn. - When the selection is cleared (\"-- Select Class --\"), all annotations are shown. + When the selection is cleared ("-- Select Class --"), all annotations are shown. """ if class_data: logger.debug(f"Object class selected: {class_data['class_name']}") @@ -332,14 +359,89 @@ class AnnotationTab(QWidget): 'No class selected ("-- Select Class --"), showing all annotations' ) + # Changing the class filter invalidates any previous selection + self.selected_annotation_ids = [] + self.annotation_tools.set_has_selected_annotation(False) + # Whenever the selection changes, update which annotations are visible self._redraw_annotations_for_current_filter() def _on_clear_annotations(self): """Handle clearing all annotations.""" self.annotation_canvas.clear_annotations() + # Clear in-memory state and selection, but keep DB entries unchanged + self.current_annotations = [] + self.selected_annotation_ids = [] + self.annotation_tools.set_has_selected_annotation(False) logger.info("Cleared all annotations") + def _on_delete_selected_annotation(self): + """Handle deleting the currently selected annotation(s) (if any).""" + if not self.selected_annotation_ids: + QMessageBox.information( + self, + "No Selection", + "No annotation is currently selected.", + ) + return + + count = len(self.selected_annotation_ids) + if count == 1: + question = "Are you sure you want to delete the selected annotation?" + title = "Delete Annotation" + else: + question = ( + f"Are you sure you want to delete the {count} selected annotations?" + ) + title = "Delete Annotations" + + reply = QMessageBox.question( + self, + title, + question, + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + failed_ids = [] + try: + for ann_id in self.selected_annotation_ids: + try: + deleted = self.db_manager.delete_annotation(ann_id) + if not deleted: + failed_ids.append(ann_id) + except Exception as e: + logger.error(f"Failed to delete annotation ID {ann_id}: {e}") + failed_ids.append(ann_id) + + if failed_ids: + QMessageBox.warning( + self, + "Partial Failure", + "Some annotations could not be deleted:\n" + + ", ".join(str(a) for a in failed_ids), + ) + else: + logger.info( + f"Deleted {count} annotation(s): " + + ", ".join(str(a) for a in self.selected_annotation_ids) + ) + + # Clear selection and reload annotations for the current image from DB + self.selected_annotation_ids = [] + self.annotation_tools.set_has_selected_annotation(False) + self._load_annotations_for_current_image() + + except Exception as e: + logger.error(f"Failed to delete annotations: {e}") + QMessageBox.critical( + self, + "Error", + f"Failed to delete annotations:\n{str(e)}", + ) + def _load_annotations_for_current_image(self): """ Load all annotations for the current image from the database and @@ -349,12 +451,17 @@ class AnnotationTab(QWidget): if not self.current_image_id: self.current_annotations = [] self.annotation_canvas.clear_annotations() + self.selected_annotation_ids = [] + self.annotation_tools.set_has_selected_annotation(False) return try: self.current_annotations = self.db_manager.get_annotations_for_image( self.current_image_id ) + # New annotations loaded; reset any selection + self.selected_annotation_ids = [] + self.annotation_tools.set_has_selected_annotation(False) self._redraw_annotations_for_current_filter() except Exception as e: logger.error( @@ -393,7 +500,12 @@ class AnnotationTab(QWidget): polyline = ann["segmentation_mask"] color = ann.get("class_color", "#FF0000") - self.annotation_canvas.draw_saved_polyline(polyline, color, width=3) + self.annotation_canvas.draw_saved_polyline( + polyline, + color, + width=3, + annotation_id=ann["id"], + ) self.annotation_canvas.draw_saved_bbox( [ann["x_min"], ann["y_min"], ann["x_max"], ann["y_max"]], color, diff --git a/src/gui/widgets/annotation_canvas_widget.py b/src/gui/widgets/annotation_canvas_widget.py index acacc72..37523e9 100644 --- a/src/gui/widgets/annotation_canvas_widget.py +++ b/src/gui/widgets/annotation_canvas_widget.py @@ -127,6 +127,9 @@ class AnnotationCanvasWidget(QWidget): zoom_changed = Signal(float) annotation_drawn = Signal(list) # List of (x, y) points in normalized coordinates + # Emitted when the user selects an existing polyline on the canvas. + # Carries the associated annotation_id (int) or None if selection is cleared + annotation_selected = Signal(object) def __init__(self, parent=None): """Initialize the annotation canvas widget.""" @@ -141,7 +144,7 @@ class AnnotationCanvasWidget(QWidget): self.zoom_step = 0.1 self.zoom_wheel_step = 0.15 - # Drawing state + # Drawing / interaction state self.is_drawing = False self.polyline_enabled = False self.polyline_pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha @@ -152,6 +155,10 @@ class AnnotationCanvasWidget(QWidget): self.current_stroke: List[Tuple[float, float]] = [] self.polylines: List[List[Tuple[float, float]]] = [] self.stroke_meta: List[Dict[str, Any]] = [] # per-polyline style (color, width) + # Optional DB annotation_id for each stored polyline (None for temporary / unsaved) + self.polyline_annotation_ids: List[Optional[int]] = [] + # Indices in self.polylines of the currently selected polylines (multi-select) + self.selected_polyline_indices: List[int] = [] # Stored bounding boxes in normalized coordinates (x_min, y_min, x_max, y_max) self.bboxes: List[List[float]] = [] @@ -224,6 +231,8 @@ class AnnotationCanvasWidget(QWidget): self.current_stroke = [] self.polylines = [] self.stroke_meta = [] + self.polyline_annotation_ids = [] + self.selected_polyline_indices = [] self.bboxes = [] self.bbox_meta = [] self.is_drawing = False @@ -389,6 +398,41 @@ class AnnotationCanvasWidget(QWidget): return (int(x), int(y)) return None + def _find_polyline_at( + self, img_x: float, img_y: float, threshold_px: float = 5.0 + ) -> Optional[int]: + """ + Find index of polyline whose geometry is within threshold_px of (img_x, img_y). + Returns the index in self.polylines, or None if none is close enough. + """ + best_index: Optional[int] = None + best_dist: float = float("inf") + + for idx, polyline in enumerate(self.polylines): + if len(polyline) < 2: + continue + + # Quick bounding-box check to skip obviously distant polylines + xs = [p[0] for p in polyline] + ys = [p[1] for p in polyline] + if img_x < min(xs) - threshold_px or img_x > max(xs) + threshold_px: + continue + if img_y < min(ys) - threshold_px or img_y > max(ys) + threshold_px: + continue + + # Precise distance to all segments + for (x1, y1), (x2, y2) in zip(polyline[:-1], polyline[1:]): + d = perpendicular_distance( + (img_x, img_y), (float(x1), float(y1)), (float(x2), float(y2)) + ) + if d < best_dist: + best_dist = d + best_index = idx + + if best_index is not None and best_dist <= threshold_px: + return best_index + return None + def _image_to_normalized_coords(self, x: int, y: int) -> Tuple[float, float]: """Convert image coordinates to normalized coordinates (0-1).""" if self.original_pixmap is None: @@ -399,7 +443,11 @@ class AnnotationCanvasWidget(QWidget): return (norm_x, norm_y) def _add_polyline( - self, img_points: List[Tuple[float, float]], color: QColor, width: int + self, + img_points: List[Tuple[float, float]], + color: QColor, + width: int, + annotation_id: Optional[int] = None, ): """Store a polyline in image coordinates and redraw annotations.""" if not img_points or len(img_points) < 2: @@ -409,6 +457,7 @@ class AnnotationCanvasWidget(QWidget): normalized_points = [(float(x), float(y)) for x, y in img_points] self.polylines.append(normalized_points) self.stroke_meta.append({"color": QColor(color), "width": int(width)}) + self.polyline_annotation_ids.append(annotation_id) self._redraw_annotations() @@ -423,16 +472,29 @@ class AnnotationCanvasWidget(QWidget): painter = QPainter(self.annotation_pixmap) # Draw polylines - for polyline, meta in zip(self.polylines, self.stroke_meta): + for idx, (polyline, meta) in enumerate(zip(self.polylines, self.stroke_meta)): pen_color: QColor = meta.get("color", self.polyline_pen_color) width: int = meta.get("width", self.polyline_pen_width) - pen = QPen( - pen_color, - width, - Qt.SolidLine, - Qt.RoundCap, - Qt.RoundJoin, - ) + + if idx in self.selected_polyline_indices: + # Highlight selected polylines in a distinct color / width + highlight_color = QColor(255, 255, 0, 200) # yellow, semi-opaque + pen = QPen( + highlight_color, + width + 1, + Qt.SolidLine, + Qt.RoundCap, + Qt.RoundJoin, + ) + else: + pen = QPen( + pen_color, + width, + Qt.SolidLine, + Qt.RoundCap, + Qt.RoundJoin, + ) + painter.setPen(pen) for (x1, y1), (x2, y2) in zip(polyline[:-1], polyline[1:]): painter.drawLine(int(x1), int(y1), int(x2), int(y2)) @@ -472,19 +534,58 @@ class AnnotationCanvasWidget(QWidget): self._update_display() def mousePressEvent(self, event: QMouseEvent): - """Handle mouse press events for drawing.""" - if not self.polyline_enabled or self.annotation_pixmap is None: + """Handle mouse press events for drawing and selecting polylines.""" + if self.annotation_pixmap is None: super().mousePressEvent(event) return - if event.button() == Qt.LeftButton: - # Get accurate position using global coordinates - label_pos = self.canvas_label.mapFromGlobal(event.globalPos()) - img_coords = self._canvas_to_image_coords(label_pos) + # Map click to image coordinates + label_pos = self.canvas_label.mapFromGlobal(event.globalPos()) + img_coords = self._canvas_to_image_coords(label_pos) + # Left button + drawing tool enabled -> start a new stroke + if event.button() == Qt.LeftButton and self.polyline_enabled: if img_coords: self.is_drawing = True self.current_stroke = [(float(img_coords[0]), float(img_coords[1]))] + return + + # Left button + drawing tool disabled -> attempt selection of existing polyline + if event.button() == Qt.LeftButton and not self.polyline_enabled: + if img_coords: + idx = self._find_polyline_at(float(img_coords[0]), float(img_coords[1])) + if idx is not None: + if event.modifiers() & Qt.ShiftModifier: + # Multi-select mode: add to current selection (if not already selected) + if idx not in self.selected_polyline_indices: + self.selected_polyline_indices.append(idx) + else: + # Single-select mode: replace current selection + self.selected_polyline_indices = [idx] + + # Build list of selected annotation IDs (ignore None entries) + selected_ids: List[int] = [] + for sel_idx in self.selected_polyline_indices: + if 0 <= sel_idx < len(self.polyline_annotation_ids): + ann_id = self.polyline_annotation_ids[sel_idx] + if isinstance(ann_id, int): + selected_ids.append(ann_id) + + if selected_ids: + self.annotation_selected.emit(selected_ids) + else: + # No valid DB-backed annotations in selection + self.annotation_selected.emit(None) + else: + # Clicked on empty space -> clear selection + self.selected_polyline_indices = [] + self.annotation_selected.emit(None) + + self._redraw_annotations() + return + + # Fallback for other buttons / cases + super().mousePressEvent(event) def mouseMoveEvent(self, event: QMouseEvent): """Handle mouse move events for drawing.""" @@ -633,7 +734,11 @@ class AnnotationCanvasWidget(QWidget): return params or None def draw_saved_polyline( - self, polyline: List[List[float]], color: str, width: int = 3 + self, + polyline: List[List[float]], + color: str, + width: int = 3, + annotation_id: Optional[int] = None, ): """ Draw a polyline from database coordinates onto the annotation canvas. @@ -671,7 +776,7 @@ class AnnotationCanvasWidget(QWidget): # Store and redraw using common pipeline pen_color = QColor(color) pen_color.setAlpha(128) # Add semi-transparency - self._add_polyline(img_coords, pen_color, width) + self._add_polyline(img_coords, pen_color, width, annotation_id=annotation_id) # Store in all_strokes for consistency (uses normalized coordinates) self.all_strokes.append( diff --git a/src/gui/widgets/annotation_tools_widget.py b/src/gui/widgets/annotation_tools_widget.py index 70312bc..e0f68b0 100644 --- a/src/gui/widgets/annotation_tools_widget.py +++ b/src/gui/widgets/annotation_tools_widget.py @@ -58,6 +58,8 @@ class AnnotationToolsWidget(QWidget): class_selected = Signal(dict) class_color_changed = Signal() clear_annotations_requested = Signal() + # Request deletion of the currently selected annotation on the canvas + delete_selected_annotation_requested = Signal() def __init__(self, db_manager: DatabaseManager, parent=None): """ @@ -182,6 +184,12 @@ class AnnotationToolsWidget(QWidget): self.clear_btn.clicked.connect(self._on_clear_annotations) actions_layout.addWidget(self.clear_btn) + # Delete currently selected annotation (enabled when a selection exists) + self.delete_selected_btn = QPushButton("Delete Selected Annotation") + self.delete_selected_btn.clicked.connect(self._on_delete_selected_annotation) + self.delete_selected_btn.setEnabled(False) + actions_layout.addWidget(self.delete_selected_btn) + actions_group.setLayout(actions_layout) layout.addWidget(actions_group) @@ -439,6 +447,20 @@ class AnnotationToolsWidget(QWidget): self.clear_annotations_requested.emit() logger.debug("Clear annotations requested") + def _on_delete_selected_annotation(self): + """Handle delete selected annotation button.""" + self.delete_selected_annotation_requested.emit() + logger.debug("Delete selected annotation requested") + + def set_has_selected_annotation(self, has_selection: bool): + """ + Enable/disable actions that require a selected annotation. + + Args: + has_selection: True if an annotation is currently selected on the canvas. + """ + self.delete_selected_btn.setEnabled(bool(has_selection)) + def get_current_class(self) -> Optional[Dict]: """Get currently selected object class.""" return self.current_class