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-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)
|
|
|
|
|
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)
|
|
|
|
|
self.annotation_tools.pen_enabled_changed.connect(
|
|
|
|
|
self.annotation_canvas.set_pen_enabled
|
|
|
|
|
)
|
|
|
|
|
self.annotation_tools.pen_color_changed.connect(
|
|
|
|
|
self.annotation_canvas.set_pen_color
|
|
|
|
|
)
|
|
|
|
|
self.annotation_tools.pen_width_changed.connect(
|
|
|
|
|
self.annotation_canvas.set_pen_width
|
|
|
|
|
)
|
|
|
|
|
self.annotation_tools.class_selected.connect(self._on_class_selected)
|
|
|
|
|
self.annotation_tools.clear_annotations_requested.connect(
|
|
|
|
|
self._on_clear_annotations
|
|
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
# 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):
|
|
|
|
|
"""Handle when an annotation stroke is drawn."""
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Annotation drawn with {len(points)} points for class: {current_class['class_name']}"
|
|
|
|
|
)
|
|
|
|
|
# Future: Save annotation to database or export
|
|
|
|
|
|
|
|
|
|
def _on_class_selected(self, class_data: dict):
|
|
|
|
|
"""Handle when an object class is selected."""
|
|
|
|
|
logger.debug(f"Object class selected: {class_data['class_name']}")
|
|
|
|
|
|
|
|
|
|
def _on_clear_annotations(self):
|
|
|
|
|
"""Handle clearing all annotations."""
|
|
|
|
|
self.annotation_canvas.clear_annotations()
|
|
|
|
|
logger.info("Cleared all annotations")
|
|
|
|
|
|
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
|