diff --git a/src/gui/tabs/annotation_tab.py b/src/gui/tabs/annotation_tab.py index 7933caf..6ce6d57 100644 --- a/src/gui/tabs/annotation_tab.py +++ b/src/gui/tabs/annotation_tab.py @@ -93,6 +93,12 @@ class AnnotationTab(QWidget): self.annotation_tools.clear_annotations_requested.connect( self._on_clear_annotations ) + self.annotation_tools.process_annotations_requested.connect( + self._on_process_annotations + ) + self.annotation_tools.show_annotations_requested.connect( + self._on_show_annotations + ) self.right_splitter.addWidget(self.annotation_tools) # Image loading section @@ -237,6 +243,127 @@ class AnnotationTab(QWidget): self.annotation_canvas.clear_annotations() logger.info("Cleared all annotations") + def _on_process_annotations(self): + """Process annotations and save to database.""" + # Check if we have an image loaded + if not self.current_image or not self.current_image_id: + QMessageBox.warning( + self, "No Image", "Please load an image before processing annotations." + ) + return + + # Get current class + current_class = self.annotation_tools.get_current_class() + if not current_class: + QMessageBox.warning( + self, + "No Class Selected", + "Please select an object class before processing annotations.", + ) + return + + # Compute bounding box and polyline from annotations + bounds = self.annotation_canvas.compute_annotation_bounds() + if not bounds: + QMessageBox.warning( + self, + "No Annotations", + "Please draw some annotations before processing.", + ) + return + + polyline = self.annotation_canvas.get_annotation_polyline() + + try: + # Save annotation to database + annotation_id = self.db_manager.add_annotation( + image_id=self.current_image_id, + class_id=current_class["id"], + bbox=bounds, + annotator="manual", + segmentation_mask=polyline, + verified=False, + ) + + logger.info( + f"Saved annotation (ID: {annotation_id}) for class '{current_class['class_name']}' " + f"with {len(polyline)} polyline points" + ) + + QMessageBox.information( + self, + "Success", + f"Annotation saved successfully!\n\n" + f"Class: {current_class['class_name']}\n" + f"Bounding box: ({bounds[0]:.3f}, {bounds[1]:.3f}) to ({bounds[2]:.3f}, {bounds[3]:.3f})\n" + f"Polyline points: {len(polyline)}", + ) + + # Optionally clear annotations after saving + reply = QMessageBox.question( + self, + "Clear Annotations", + "Do you want to clear the annotations to start a new one?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + + if reply == QMessageBox.Yes: + self.annotation_canvas.clear_annotations() + + except Exception as e: + logger.error(f"Failed to save annotation: {e}") + QMessageBox.critical(self, "Error", f"Failed to save annotation:\n{str(e)}") + + def _on_show_annotations(self): + """Load and display saved annotations from database.""" + # Check if we have an image loaded + if not self.current_image or not self.current_image_id: + QMessageBox.warning( + self, "No Image", "Please load an image to view its annotations." + ) + return + + try: + # Clear current annotations + self.annotation_canvas.clear_annotations() + + # Retrieve annotations from database + annotations = self.db_manager.get_annotations_for_image( + self.current_image_id + ) + + if not annotations: + QMessageBox.information( + self, "No Annotations", "No saved annotations found for this image." + ) + return + + # Draw each annotation's polyline + drawn_count = 0 + for ann in annotations: + if ann.get("segmentation_mask"): + polyline = ann["segmentation_mask"] + color = ann.get("class_color", "#FF0000") + + # Draw the polyline + self.annotation_canvas.draw_saved_polyline(polyline, color, width=3) + drawn_count += 1 + + logger.info(f"Displayed {drawn_count} saved annotations from database") + + QMessageBox.information( + self, + "Annotations Loaded", + f"Successfully loaded and displayed {drawn_count} annotation(s).", + ) + + except Exception as e: + logger.error(f"Failed to load annotations: {e}") + QMessageBox.critical( + self, "Error", f"Failed to load annotations:\n{str(e)}" + ) + def _restore_state(self): """Restore splitter positions from settings.""" settings = QSettings("microscopy_app", "object_detection") diff --git a/src/gui/widgets/annotation_canvas_widget.py b/src/gui/widgets/annotation_canvas_widget.py index 7ff2bc5..a2a57a6 100644 --- a/src/gui/widgets/annotation_canvas_widget.py +++ b/src/gui/widgets/annotation_canvas_widget.py @@ -369,6 +369,102 @@ class AnnotationCanvasWidget(QWidget): """Get all drawn strokes with metadata.""" return self.all_strokes + def compute_annotation_bounds(self) -> Optional[Tuple[float, float, float, float]]: + """ + Compute bounding box that encompasses all annotation strokes. + + Returns: + Tuple of (x_min, y_min, x_max, y_max) in normalized coordinates (0-1), + or None if no annotations exist. + """ + if not self.all_strokes: + return None + + # Find min/max across all strokes + all_x = [] + all_y = [] + + for stroke in self.all_strokes: + for x, y in stroke["points"]: + all_x.append(x) + all_y.append(y) + + if not all_x: + return None + + x_min = min(all_x) + y_min = min(all_y) + x_max = max(all_x) + y_max = max(all_y) + + return (x_min, y_min, x_max, y_max) + + def get_annotation_polyline(self) -> List[List[float]]: + """ + Get polyline coordinates representing all annotation strokes. + + Returns: + List of [x, y] coordinate pairs in normalized coordinates (0-1). + """ + polyline = [] + + for stroke in self.all_strokes: + polyline.extend(stroke["points"]) + + return polyline + + def draw_saved_polyline( + self, polyline: List[List[float]], color: str, width: int = 3 + ): + """ + Draw a polyline from database coordinates onto the annotation canvas. + + Args: + polyline: List of [x, y] coordinate pairs in normalized coordinates (0-1) + color: Color hex string (e.g., '#FF0000') + width: Line width in pixels + """ + if not self.annotation_pixmap or not self.original_pixmap: + logger.warning("Cannot draw polyline: no image loaded") + return + + if len(polyline) < 2: + logger.warning("Polyline has less than 2 points, cannot draw") + return + + # Convert normalized coordinates to image coordinates + img_coords = [] + for x_norm, y_norm in polyline: + x = int(x_norm * self.original_pixmap.width()) + y = int(y_norm * self.original_pixmap.height()) + img_coords.append((x, y)) + + # Draw polyline on annotation pixmap + painter = QPainter(self.annotation_pixmap) + pen_color = QColor(color) + pen_color.setAlpha(128) # Add semi-transparency + pen = QPen(pen_color, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + painter.setPen(pen) + + # Draw lines between consecutive points + for i in range(len(img_coords) - 1): + x1, y1 = img_coords[i] + x2, y2 = img_coords[i + 1] + painter.drawLine(x1, y1, x2, y2) + + painter.end() + + # Store in all_strokes for consistency + self.all_strokes.append( + {"points": polyline, "color": color, "alpha": 128, "width": width} + ) + + # Update display + self._update_display() + logger.debug( + f"Drew saved polyline with {len(polyline)} points in color {color}" + ) + def keyPressEvent(self, event: QKeyEvent): """Handle keyboard events for zooming.""" if event.key() in (Qt.Key_Plus, Qt.Key_Equal): diff --git a/src/gui/widgets/annotation_tools_widget.py b/src/gui/widgets/annotation_tools_widget.py index e59e1ff..4153dc1 100644 --- a/src/gui/widgets/annotation_tools_widget.py +++ b/src/gui/widgets/annotation_tools_widget.py @@ -51,6 +51,8 @@ class AnnotationToolsWidget(QWidget): pen_width_changed = Signal(int) class_selected = Signal(dict) clear_annotations_requested = Signal() + process_annotations_requested = Signal() + show_annotations_requested = Signal() def __init__(self, db_manager: DatabaseManager, parent=None): """ @@ -146,6 +148,20 @@ class AnnotationToolsWidget(QWidget): actions_group = QGroupBox("Actions") actions_layout = QVBoxLayout() + self.process_btn = QPushButton("Process Annotations") + self.process_btn.clicked.connect(self._on_process_annotations) + self.process_btn.setStyleSheet( + "QPushButton { background-color: #2196F3; color: white; font-weight: bold; }" + ) + actions_layout.addWidget(self.process_btn) + + self.show_btn = QPushButton("Show Saved Annotations") + self.show_btn.clicked.connect(self._on_show_annotations) + self.show_btn.setStyleSheet( + "QPushButton { background-color: #4CAF50; color: white; }" + ) + actions_layout.addWidget(self.show_btn) + self.clear_btn = QPushButton("Clear All Annotations") self.clear_btn.clicked.connect(self._on_clear_annotations) actions_layout.addWidget(self.clear_btn) @@ -335,6 +351,24 @@ class AnnotationToolsWidget(QWidget): self.clear_annotations_requested.emit() logger.debug("Clear annotations requested") + def _on_process_annotations(self): + """Handle process annotations button.""" + if not self.current_class: + QMessageBox.warning( + self, + "No Class Selected", + "Please select an object class before processing annotations.", + ) + return + + self.process_annotations_requested.emit() + logger.debug("Process annotations requested") + + def _on_show_annotations(self): + """Handle show annotations button.""" + self.show_annotations_requested.emit() + logger.debug("Show annotations requested") + def get_current_class(self) -> Optional[Dict]: """Get currently selected object class.""" return self.current_class