Adding feature to remove annotations
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,9 +472,21 @@ 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)
|
||||
|
||||
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,
|
||||
@@ -433,6 +494,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
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
|
||||
# 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user