2025-12-05 09:50:50 +02:00
|
|
|
"""
|
|
|
|
|
Annotation tab for the microscopy object detection application.
|
2025-12-08 23:15:54 +02:00
|
|
|
Manual annotation with pen tool and object class management.
|
2025-12-05 09:50:50 +02:00
|
|
|
"""
|
|
|
|
|
|
2025-12-08 16:28:58 +02:00
|
|
|
from PySide6.QtWidgets import (
|
|
|
|
|
QWidget,
|
|
|
|
|
QVBoxLayout,
|
|
|
|
|
QHBoxLayout,
|
|
|
|
|
QLabel,
|
|
|
|
|
QGroupBox,
|
|
|
|
|
QPushButton,
|
|
|
|
|
QFileDialog,
|
|
|
|
|
QMessageBox,
|
2025-12-08 22:40:07 +02:00
|
|
|
QSplitter,
|
2025-12-08 16:28:58 +02:00
|
|
|
)
|
2025-12-08 17:33:32 +02:00
|
|
|
from PySide6.QtCore import Qt, QSettings
|
2025-12-08 16:28:58 +02:00
|
|
|
from pathlib import Path
|
2025-12-05 09:50:50 +02:00
|
|
|
|
|
|
|
|
from src.database.db_manager import DatabaseManager
|
|
|
|
|
from src.utils.config_manager import ConfigManager
|
2025-12-08 16:28:58 +02:00
|
|
|
from src.utils.image import Image, ImageLoadError
|
|
|
|
|
from src.utils.logger import get_logger
|
2025-12-08 23:15:54 +02:00
|
|
|
from src.gui.widgets import AnnotationCanvasWidget, AnnotationToolsWidget
|
2025-12-08 16:28:58 +02:00
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
2025-12-05 09:50:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class AnnotationTab(QWidget):
|
2025-12-08 23:15:54 +02:00
|
|
|
"""Annotation tab for manual image annotation."""
|
2025-12-05 09:50:50 +02:00
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self, db_manager: DatabaseManager, config_manager: ConfigManager, parent=None
|
|
|
|
|
):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
self.db_manager = db_manager
|
|
|
|
|
self.config_manager = config_manager
|
2025-12-08 16:28:58 +02:00
|
|
|
self.current_image = None
|
|
|
|
|
self.current_image_path = None
|
2025-12-08 23:15:54 +02:00
|
|
|
self.current_image_id = None
|
2025-12-09 22:44:23 +02:00
|
|
|
self.current_annotations = []
|
2025-12-10 00:19:59 +02:00
|
|
|
# IDs of annotations currently selected on the canvas (multi-select)
|
|
|
|
|
self.selected_annotation_ids = []
|
2025-12-05 09:50:50 +02:00
|
|
|
|
|
|
|
|
self._setup_ui()
|
|
|
|
|
|
|
|
|
|
def _setup_ui(self):
|
|
|
|
|
"""Setup user interface."""
|
|
|
|
|
layout = QVBoxLayout()
|
|
|
|
|
|
2025-12-08 22:40:07 +02:00
|
|
|
# Main horizontal splitter to divide left (image) and right (controls)
|
|
|
|
|
self.main_splitter = QSplitter(Qt.Horizontal)
|
|
|
|
|
self.main_splitter.setHandleWidth(10)
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 22:40:07 +02:00
|
|
|
# { Left splitter for image display and zoom info
|
|
|
|
|
self.left_splitter = QSplitter(Qt.Vertical)
|
|
|
|
|
self.left_splitter.setHandleWidth(10)
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
# Annotation canvas section
|
|
|
|
|
canvas_group = QGroupBox("Annotation Canvas")
|
|
|
|
|
canvas_layout = QVBoxLayout()
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
# Use the AnnotationCanvasWidget
|
|
|
|
|
self.annotation_canvas = AnnotationCanvasWidget()
|
|
|
|
|
self.annotation_canvas.zoom_changed.connect(self._on_zoom_changed)
|
|
|
|
|
self.annotation_canvas.annotation_drawn.connect(self._on_annotation_drawn)
|
2025-12-10 00:19:59 +02:00
|
|
|
# Selection of existing polylines (when tool is not in drawing mode)
|
|
|
|
|
self.annotation_canvas.annotation_selected.connect(self._on_annotation_selected)
|
2025-12-08 23:15:54 +02:00
|
|
|
canvas_layout.addWidget(self.annotation_canvas)
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
canvas_group.setLayout(canvas_layout)
|
|
|
|
|
self.left_splitter.addWidget(canvas_group)
|
2025-12-08 22:40:07 +02:00
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
# Controls info
|
|
|
|
|
controls_info = QLabel(
|
|
|
|
|
"Zoom: Mouse wheel or +/- keys | Drawing: Enable pen and drag mouse"
|
|
|
|
|
)
|
|
|
|
|
controls_info.setStyleSheet("QLabel { color: #888; font-style: italic; }")
|
|
|
|
|
self.left_splitter.addWidget(controls_info)
|
2025-12-08 22:40:07 +02:00
|
|
|
# }
|
|
|
|
|
|
|
|
|
|
# { Right splitter for annotation tools and controls
|
|
|
|
|
self.right_splitter = QSplitter(Qt.Vertical)
|
|
|
|
|
self.right_splitter.setHandleWidth(10)
|
|
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
# Annotation tools section
|
|
|
|
|
self.annotation_tools = AnnotationToolsWidget(self.db_manager)
|
2025-12-09 23:38:23 +02:00
|
|
|
self.annotation_tools.polyline_enabled_changed.connect(
|
|
|
|
|
self.annotation_canvas.set_polyline_enabled
|
2025-12-08 23:15:54 +02:00
|
|
|
)
|
2025-12-09 23:38:23 +02:00
|
|
|
self.annotation_tools.polyline_pen_color_changed.connect(
|
|
|
|
|
self.annotation_canvas.set_polyline_pen_color
|
2025-12-08 23:15:54 +02:00
|
|
|
)
|
2025-12-09 23:38:23 +02:00
|
|
|
self.annotation_tools.polyline_pen_width_changed.connect(
|
|
|
|
|
self.annotation_canvas.set_polyline_pen_width
|
2025-12-08 23:15:54 +02:00
|
|
|
)
|
2025-12-09 23:56:29 +02:00
|
|
|
# Show / hide bounding boxes
|
|
|
|
|
self.annotation_tools.show_bboxes_changed.connect(
|
|
|
|
|
self.annotation_canvas.set_show_bboxes
|
|
|
|
|
)
|
2025-12-09 22:44:23 +02:00
|
|
|
# RDP simplification controls
|
|
|
|
|
self.annotation_tools.simplify_on_finish_changed.connect(
|
|
|
|
|
self._on_simplify_on_finish_changed
|
|
|
|
|
)
|
|
|
|
|
self.annotation_tools.simplify_epsilon_changed.connect(
|
|
|
|
|
self._on_simplify_epsilon_changed
|
|
|
|
|
)
|
2025-12-09 23:38:23 +02:00
|
|
|
# Class selection and class-color changes
|
2025-12-08 23:15:54 +02:00
|
|
|
self.annotation_tools.class_selected.connect(self._on_class_selected)
|
2025-12-09 23:38:23 +02:00
|
|
|
self.annotation_tools.class_color_changed.connect(self._on_class_color_changed)
|
2025-12-08 23:15:54 +02:00
|
|
|
self.annotation_tools.clear_annotations_requested.connect(
|
|
|
|
|
self._on_clear_annotations
|
|
|
|
|
)
|
2025-12-10 00:19:59 +02:00
|
|
|
# Delete selected annotation on canvas
|
|
|
|
|
self.annotation_tools.delete_selected_annotation_requested.connect(
|
|
|
|
|
self._on_delete_selected_annotation
|
|
|
|
|
)
|
2025-12-08 23:15:54 +02:00
|
|
|
self.right_splitter.addWidget(self.annotation_tools)
|
|
|
|
|
|
2025-12-08 22:40:07 +02:00
|
|
|
# Image loading section
|
|
|
|
|
load_group = QGroupBox("Image Loading")
|
|
|
|
|
load_layout = QVBoxLayout()
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 22:40:07 +02:00
|
|
|
# Load image button
|
|
|
|
|
button_layout = QHBoxLayout()
|
|
|
|
|
self.load_image_btn = QPushButton("Load Image")
|
|
|
|
|
self.load_image_btn.clicked.connect(self._load_image)
|
|
|
|
|
button_layout.addWidget(self.load_image_btn)
|
|
|
|
|
button_layout.addStretch()
|
|
|
|
|
load_layout.addLayout(button_layout)
|
|
|
|
|
|
|
|
|
|
# Image info label
|
|
|
|
|
self.image_info_label = QLabel("No image loaded")
|
|
|
|
|
load_layout.addWidget(self.image_info_label)
|
|
|
|
|
|
|
|
|
|
load_group.setLayout(load_layout)
|
|
|
|
|
self.right_splitter.addWidget(load_group)
|
|
|
|
|
# }
|
2025-12-08 17:33:32 +02:00
|
|
|
|
2025-12-08 22:40:07 +02:00
|
|
|
# Add both splitters to the main horizontal splitter
|
|
|
|
|
self.main_splitter.addWidget(self.left_splitter)
|
|
|
|
|
self.main_splitter.addWidget(self.right_splitter)
|
|
|
|
|
|
|
|
|
|
# Set initial sizes: 75% for left (image), 25% for right (controls)
|
|
|
|
|
self.main_splitter.setSizes([750, 250])
|
|
|
|
|
|
|
|
|
|
layout.addWidget(self.main_splitter)
|
2025-12-05 09:50:50 +02:00
|
|
|
self.setLayout(layout)
|
|
|
|
|
|
2025-12-08 22:40:07 +02:00
|
|
|
# Restore splitter positions from settings
|
|
|
|
|
self._restore_state()
|
|
|
|
|
|
2025-12-08 16:28:58 +02:00
|
|
|
def _load_image(self):
|
|
|
|
|
"""Load and display an image file."""
|
2025-12-08 17:33:32 +02:00
|
|
|
# Get last opened directory from QSettings
|
|
|
|
|
settings = QSettings("microscopy_app", "object_detection")
|
|
|
|
|
last_dir = settings.value("annotation_tab/last_directory", None)
|
|
|
|
|
|
|
|
|
|
# Fallback to image repository path or home directory
|
|
|
|
|
if last_dir and Path(last_dir).exists():
|
|
|
|
|
start_dir = last_dir
|
|
|
|
|
else:
|
|
|
|
|
repo_path = self.config_manager.get_image_repository_path()
|
|
|
|
|
start_dir = repo_path if repo_path else str(Path.home())
|
2025-12-08 16:28:58 +02:00
|
|
|
|
|
|
|
|
# Open file dialog
|
|
|
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
|
|
|
self,
|
|
|
|
|
"Select Image",
|
|
|
|
|
start_dir,
|
|
|
|
|
"Images (*.jpg *.jpeg *.png *.tif *.tiff *.bmp)",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not file_path:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Load image using Image class
|
|
|
|
|
self.current_image = Image(file_path)
|
|
|
|
|
self.current_image_path = file_path
|
|
|
|
|
|
2025-12-08 17:33:32 +02:00
|
|
|
# Store the directory for next time
|
|
|
|
|
settings.setValue(
|
|
|
|
|
"annotation_tab/last_directory", str(Path(file_path).parent)
|
2025-12-08 16:28:58 +02:00
|
|
|
)
|
|
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
# Get or create image in database
|
|
|
|
|
relative_path = str(Path(file_path).name) # Simplified for now
|
|
|
|
|
self.current_image_id = self.db_manager.get_or_create_image(
|
|
|
|
|
relative_path,
|
|
|
|
|
Path(file_path).name,
|
|
|
|
|
self.current_image.width,
|
|
|
|
|
self.current_image.height,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Display image using the AnnotationCanvasWidget
|
|
|
|
|
self.annotation_canvas.load_image(self.current_image)
|
2025-12-08 17:33:32 +02:00
|
|
|
|
2025-12-09 22:44:23 +02:00
|
|
|
# Load and display any existing annotations for this image
|
|
|
|
|
self._load_annotations_for_current_image()
|
|
|
|
|
|
2025-12-08 17:33:32 +02:00
|
|
|
# Update info label
|
|
|
|
|
self._update_image_info()
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
logger.info(f"Loaded image: {file_path} (DB ID: {self.current_image_id})")
|
2025-12-08 16:28:58 +02:00
|
|
|
|
|
|
|
|
except ImageLoadError as e:
|
|
|
|
|
logger.error(f"Failed to load image: {e}")
|
|
|
|
|
QMessageBox.critical(
|
|
|
|
|
self, "Error Loading Image", f"Failed to load image:\n{str(e)}"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Unexpected error loading image: {e}")
|
|
|
|
|
QMessageBox.critical(self, "Error", f"Unexpected error:\n{str(e)}")
|
|
|
|
|
|
2025-12-08 17:33:32 +02:00
|
|
|
def _update_image_info(self):
|
|
|
|
|
"""Update the image info label with current image details."""
|
2025-12-08 16:28:58 +02:00
|
|
|
if self.current_image is None:
|
2025-12-08 17:33:32 +02:00
|
|
|
self.image_info_label.setText("No image loaded")
|
2025-12-08 16:28:58 +02:00
|
|
|
return
|
|
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
zoom_percentage = self.annotation_canvas.get_zoom_percentage()
|
2025-12-08 17:33:32 +02:00
|
|
|
info_text = (
|
|
|
|
|
f"File: {Path(self.current_image_path).name}\n"
|
|
|
|
|
f"Size: {self.current_image.width}x{self.current_image.height} pixels\n"
|
|
|
|
|
f"Channels: {self.current_image.channels}\n"
|
|
|
|
|
f"Data type: {self.current_image.dtype}\n"
|
|
|
|
|
f"Format: {self.current_image.format.upper()}\n"
|
|
|
|
|
f"File size: {self.current_image.size_mb:.2f} MB\n"
|
|
|
|
|
f"Zoom: {zoom_percentage}%"
|
|
|
|
|
)
|
|
|
|
|
self.image_info_label.setText(info_text)
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 17:33:32 +02:00
|
|
|
def _on_zoom_changed(self, zoom_scale: float):
|
2025-12-08 23:15:54 +02:00
|
|
|
"""Handle zoom level changes from the annotation canvas."""
|
2025-12-08 17:33:32 +02:00
|
|
|
self._update_image_info()
|
2025-12-08 16:28:58 +02:00
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
def _on_annotation_drawn(self, points: list):
|
2025-12-09 22:44:23 +02:00
|
|
|
"""
|
|
|
|
|
Handle when an annotation stroke is drawn.
|
|
|
|
|
|
|
|
|
|
Saves the new annotation directly to the database and refreshes the
|
|
|
|
|
on-canvas display of annotations for the current image.
|
|
|
|
|
"""
|
|
|
|
|
# Ensure we have an image loaded and in the DB
|
|
|
|
|
if not self.current_image or not self.current_image_id:
|
|
|
|
|
logger.warning("Annotation drawn but no image loaded")
|
|
|
|
|
QMessageBox.warning(
|
|
|
|
|
self,
|
|
|
|
|
"No Image",
|
|
|
|
|
"Please load an image before drawing annotations.",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2025-12-08 23:15:54 +02:00
|
|
|
current_class = self.annotation_tools.get_current_class()
|
|
|
|
|
|
|
|
|
|
if not current_class:
|
|
|
|
|
logger.warning("Annotation drawn but no object class selected")
|
|
|
|
|
QMessageBox.warning(
|
|
|
|
|
self,
|
|
|
|
|
"No Class Selected",
|
|
|
|
|
"Please select an object class before drawing annotations.",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2025-12-09 22:44:23 +02:00
|
|
|
if not points:
|
|
|
|
|
logger.warning("Annotation drawn with no points, ignoring")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# points are [(x_norm, y_norm), ...]
|
|
|
|
|
xs = [p[0] for p in points]
|
|
|
|
|
ys = [p[1] for p in points]
|
|
|
|
|
x_min, x_max = min(xs), max(xs)
|
|
|
|
|
y_min, y_max = min(ys), max(ys)
|
|
|
|
|
|
|
|
|
|
# Store segmentation mask in [y_norm, x_norm] format to match DB
|
|
|
|
|
db_polyline = [[float(y), float(x)] for (x, y) in points]
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
annotation_id = self.db_manager.add_annotation(
|
|
|
|
|
image_id=self.current_image_id,
|
|
|
|
|
class_id=current_class["id"],
|
|
|
|
|
bbox=(x_min, y_min, x_max, y_max),
|
|
|
|
|
annotator="manual",
|
|
|
|
|
segmentation_mask=db_polyline,
|
|
|
|
|
verified=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Saved annotation (ID: {annotation_id}) for class "
|
|
|
|
|
f"'{current_class['class_name']}' "
|
|
|
|
|
f"Bounding box: ({x_min:.3f}, {y_min:.3f}) to ({x_max:.3f}, {y_max:.3f})\n"
|
|
|
|
|
f"with {len(points)} polyline points"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Reload annotations from DB and redraw (respecting current class filter)
|
|
|
|
|
self._load_annotations_for_current_image()
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to save annotation: {e}")
|
|
|
|
|
QMessageBox.critical(self, "Error", f"Failed to save annotation:\n{str(e)}")
|
|
|
|
|
|
2025-12-10 00:19:59 +02:00
|
|
|
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}")
|
|
|
|
|
|
2025-12-09 22:44:23 +02:00
|
|
|
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
|
|
|
|
|
logger.debug(f"Annotation simplification on finish set to {enabled}")
|
|
|
|
|
|
|
|
|
|
def _on_simplify_epsilon_changed(self, epsilon: float):
|
|
|
|
|
"""Update canvas RDP epsilon from tools widget."""
|
|
|
|
|
self.annotation_canvas.simplify_epsilon = float(epsilon)
|
|
|
|
|
logger.debug(f"Annotation simplification epsilon set to {epsilon}")
|
2025-12-08 23:15:54 +02:00
|
|
|
|
2025-12-09 23:38:23 +02:00
|
|
|
def _on_class_color_changed(self):
|
|
|
|
|
"""
|
|
|
|
|
Handle changes to the selected object's class color.
|
2025-12-08 23:15:54 +02:00
|
|
|
|
2025-12-09 23:38:23 +02:00
|
|
|
When the user updates a class color in the tools widget, reload the
|
|
|
|
|
annotations for the current image so that all polylines are redrawn
|
|
|
|
|
using the updated per-class colors.
|
|
|
|
|
"""
|
|
|
|
|
if not self.current_image_id:
|
|
|
|
|
return
|
2025-12-08 23:15:54 +02:00
|
|
|
|
2025-12-09 23:38:23 +02:00
|
|
|
logger.debug(
|
|
|
|
|
f"Class color changed; reloading annotations for image ID {self.current_image_id}"
|
|
|
|
|
)
|
|
|
|
|
self._load_annotations_for_current_image()
|
|
|
|
|
|
|
|
|
|
def _on_class_selected(self, class_data):
|
2025-12-09 22:44:23 +02:00
|
|
|
"""
|
2025-12-09 23:38:23 +02:00
|
|
|
Handle when an object class is selected or cleared.
|
2025-12-08 23:59:44 +02:00
|
|
|
|
2025-12-09 23:38:23 +02:00
|
|
|
When a specific class is selected, only annotations of that class are drawn.
|
2025-12-10 00:19:59 +02:00
|
|
|
When the selection is cleared ("-- Select Class --"), all annotations are shown.
|
2025-12-09 22:44:23 +02:00
|
|
|
"""
|
2025-12-09 23:38:23 +02:00
|
|
|
if class_data:
|
|
|
|
|
logger.debug(f"Object class selected: {class_data['class_name']}")
|
|
|
|
|
else:
|
|
|
|
|
logger.debug(
|
|
|
|
|
'No class selected ("-- Select Class --"), showing all annotations'
|
2025-12-08 23:59:44 +02:00
|
|
|
)
|
|
|
|
|
|
2025-12-10 00:19:59 +02:00
|
|
|
# Changing the class filter invalidates any previous selection
|
|
|
|
|
self.selected_annotation_ids = []
|
|
|
|
|
self.annotation_tools.set_has_selected_annotation(False)
|
|
|
|
|
|
2025-12-09 23:38:23 +02:00
|
|
|
# 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()
|
2025-12-10 00:19:59 +02:00
|
|
|
# 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)
|
2025-12-09 23:38:23 +02:00
|
|
|
logger.info("Cleared all annotations")
|
2025-12-08 23:59:44 +02:00
|
|
|
|
2025-12-10 00:19:59 +02:00
|
|
|
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)}",
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-09 22:44:23 +02:00
|
|
|
def _load_annotations_for_current_image(self):
|
|
|
|
|
"""
|
|
|
|
|
Load all annotations for the current image from the database and
|
|
|
|
|
redraw them on the canvas, honoring the currently selected class
|
|
|
|
|
filter (if any).
|
|
|
|
|
"""
|
|
|
|
|
if not self.current_image_id:
|
|
|
|
|
self.current_annotations = []
|
2025-12-09 15:42:42 +02:00
|
|
|
self.annotation_canvas.clear_annotations()
|
2025-12-10 00:19:59 +02:00
|
|
|
self.selected_annotation_ids = []
|
|
|
|
|
self.annotation_tools.set_has_selected_annotation(False)
|
2025-12-08 23:59:44 +02:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
2025-12-09 22:44:23 +02:00
|
|
|
self.current_annotations = self.db_manager.get_annotations_for_image(
|
2025-12-08 23:59:44 +02:00
|
|
|
self.current_image_id
|
|
|
|
|
)
|
2025-12-10 00:19:59 +02:00
|
|
|
# New annotations loaded; reset any selection
|
|
|
|
|
self.selected_annotation_ids = []
|
|
|
|
|
self.annotation_tools.set_has_selected_annotation(False)
|
2025-12-09 22:44:23 +02:00
|
|
|
self._redraw_annotations_for_current_filter()
|
2025-12-08 23:59:44 +02:00
|
|
|
except Exception as e:
|
2025-12-09 22:44:23 +02:00
|
|
|
logger.error(
|
|
|
|
|
f"Failed to load annotations for image {self.current_image_id}: {e}"
|
|
|
|
|
)
|
2025-12-08 23:59:44 +02:00
|
|
|
QMessageBox.critical(
|
2025-12-09 22:44:23 +02:00
|
|
|
self,
|
|
|
|
|
"Error",
|
|
|
|
|
f"Failed to load annotations for this image:\n{str(e)}",
|
2025-12-08 23:59:44 +02:00
|
|
|
)
|
|
|
|
|
|
2025-12-09 22:44:23 +02:00
|
|
|
def _redraw_annotations_for_current_filter(self):
|
|
|
|
|
"""
|
|
|
|
|
Redraw annotations for the current image, optionally filtered by the
|
|
|
|
|
currently selected object class.
|
|
|
|
|
"""
|
|
|
|
|
# Clear current on-canvas annotations but keep the image
|
|
|
|
|
self.annotation_canvas.clear_annotations()
|
|
|
|
|
|
|
|
|
|
if not self.current_annotations:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
current_class = self.annotation_tools.get_current_class()
|
|
|
|
|
selected_class_id = current_class["id"] if current_class else None
|
|
|
|
|
|
|
|
|
|
drawn_count = 0
|
|
|
|
|
for ann in self.current_annotations:
|
|
|
|
|
# Filter by class if one is selected
|
|
|
|
|
if (
|
|
|
|
|
selected_class_id is not None
|
|
|
|
|
and ann.get("class_id") != selected_class_id
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if ann.get("segmentation_mask"):
|
|
|
|
|
polyline = ann["segmentation_mask"]
|
|
|
|
|
color = ann.get("class_color", "#FF0000")
|
|
|
|
|
|
2025-12-10 00:19:59 +02:00
|
|
|
self.annotation_canvas.draw_saved_polyline(
|
|
|
|
|
polyline,
|
|
|
|
|
color,
|
|
|
|
|
width=3,
|
|
|
|
|
annotation_id=ann["id"],
|
|
|
|
|
)
|
2025-12-09 22:44:23 +02:00
|
|
|
self.annotation_canvas.draw_saved_bbox(
|
|
|
|
|
[ann["x_min"], ann["y_min"], ann["x_max"], ann["y_max"]],
|
|
|
|
|
color,
|
|
|
|
|
width=3,
|
|
|
|
|
)
|
|
|
|
|
drawn_count += 1
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Displayed {drawn_count} annotation(s) for current image with "
|
|
|
|
|
f"{'no class filter' if selected_class_id is None else f'class_id={selected_class_id}'}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-08 22:40:07 +02:00
|
|
|
def _restore_state(self):
|
|
|
|
|
"""Restore splitter positions from settings."""
|
|
|
|
|
settings = QSettings("microscopy_app", "object_detection")
|
|
|
|
|
|
|
|
|
|
# Restore main splitter state
|
|
|
|
|
main_state = settings.value("annotation_tab/main_splitter_state")
|
|
|
|
|
if main_state:
|
|
|
|
|
self.main_splitter.restoreState(main_state)
|
|
|
|
|
logger.debug("Restored main splitter state")
|
|
|
|
|
|
|
|
|
|
# Restore left splitter state
|
|
|
|
|
left_state = settings.value("annotation_tab/left_splitter_state")
|
|
|
|
|
if left_state:
|
|
|
|
|
self.left_splitter.restoreState(left_state)
|
|
|
|
|
logger.debug("Restored left splitter state")
|
|
|
|
|
|
|
|
|
|
# Restore right splitter state
|
|
|
|
|
right_state = settings.value("annotation_tab/right_splitter_state")
|
|
|
|
|
if right_state:
|
|
|
|
|
self.right_splitter.restoreState(right_state)
|
|
|
|
|
logger.debug("Restored right splitter state")
|
|
|
|
|
|
|
|
|
|
def save_state(self):
|
|
|
|
|
"""Save splitter positions to settings."""
|
|
|
|
|
settings = QSettings("microscopy_app", "object_detection")
|
|
|
|
|
|
|
|
|
|
# Save main splitter state
|
|
|
|
|
settings.setValue(
|
|
|
|
|
"annotation_tab/main_splitter_state", self.main_splitter.saveState()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Save left splitter state
|
|
|
|
|
settings.setValue(
|
|
|
|
|
"annotation_tab/left_splitter_state", self.left_splitter.saveState()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Save right splitter state
|
|
|
|
|
settings.setValue(
|
|
|
|
|
"annotation_tab/right_splitter_state", self.right_splitter.saveState()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.debug("Saved annotation tab splitter states")
|
|
|
|
|
|
2025-12-05 09:50:50 +02:00
|
|
|
def refresh(self):
|
|
|
|
|
"""Refresh the tab."""
|
|
|
|
|
pass
|