Files
object-segmentation/src/gui/widgets/annotation_tools_widget.py

457 lines
16 KiB
Python
Raw Normal View History

2025-12-08 23:15:54 +02:00
"""
Annotation tools widget for controlling annotation parameters.
2025-12-09 23:38:23 +02:00
Includes polyline tool, color picker, class selection, and annotation management.
2025-12-08 23:15:54 +02:00
"""
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QGroupBox,
QPushButton,
QComboBox,
QSpinBox,
2025-12-09 22:44:23 +02:00
QDoubleSpinBox,
QCheckBox,
2025-12-08 23:15:54 +02:00
QColorDialog,
QInputDialog,
QMessageBox,
)
from PySide6.QtGui import QColor, QIcon, QPixmap, QPainter
from PySide6.QtCore import Qt, Signal
from typing import Optional, Dict
from src.database.db_manager import DatabaseManager
from src.utils.logger import get_logger
logger = get_logger(__name__)
class AnnotationToolsWidget(QWidget):
"""
Widget for annotation tool controls.
Features:
2025-12-09 23:38:23 +02:00
- Enable/disable polyline tool
- Color selection for polyline pen
2025-12-08 23:15:54 +02:00
- Object class selection
- Add new object classes
- Pen width control
- Clear annotations
Signals:
2025-12-09 23:38:23 +02:00
polyline_enabled_changed: Emitted when polyline tool is enabled/disabled (bool)
polyline_pen_color_changed: Emitted when polyline pen color changes (QColor)
polyline_pen_width_changed: Emitted when polyline pen width changes (int)
2025-12-08 23:15:54 +02:00
class_selected: Emitted when object class is selected (dict)
clear_annotations_requested: Emitted when clear button is pressed
"""
2025-12-09 23:38:23 +02:00
polyline_enabled_changed = Signal(bool)
polyline_pen_color_changed = Signal(QColor)
polyline_pen_width_changed = Signal(int)
2025-12-09 22:44:23 +02:00
simplify_on_finish_changed = Signal(bool)
simplify_epsilon_changed = Signal(float)
2025-12-09 23:56:29 +02:00
# Toggle visibility of bounding boxes on the canvas
show_bboxes_changed = Signal(bool)
2025-12-08 23:15:54 +02:00
class_selected = Signal(dict)
2025-12-09 23:38:23 +02:00
class_color_changed = Signal()
2025-12-08 23:15:54 +02:00
clear_annotations_requested = Signal()
def __init__(self, db_manager: DatabaseManager, parent=None):
"""
Initialize annotation tools widget.
Args:
db_manager: Database manager instance
parent: Parent widget
"""
super().__init__(parent)
self.db_manager = db_manager
2025-12-09 23:38:23 +02:00
self.polyline_enabled = False
2025-12-08 23:15:54 +02:00
self.current_color = QColor(255, 0, 0, 128) # Red with 50% alpha
self.current_class = None
self._setup_ui()
self._load_object_classes()
def _setup_ui(self):
"""Setup user interface."""
layout = QVBoxLayout()
2025-12-09 23:38:23 +02:00
# Polyline Tool Group
polyline_group = QGroupBox("Polyline Tool")
polyline_layout = QVBoxLayout()
2025-12-08 23:15:54 +02:00
2025-12-09 23:38:23 +02:00
# Enable/Disable polyline tool
2025-12-08 23:15:54 +02:00
button_layout = QHBoxLayout()
2025-12-09 23:38:23 +02:00
self.polyline_toggle_btn = QPushButton("Start Drawing Polyline")
self.polyline_toggle_btn.setCheckable(True)
self.polyline_toggle_btn.clicked.connect(self._on_polyline_toggle)
button_layout.addWidget(self.polyline_toggle_btn)
polyline_layout.addLayout(button_layout)
2025-12-08 23:15:54 +02:00
2025-12-09 23:38:23 +02:00
# Polyline pen width control
2025-12-08 23:15:54 +02:00
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Pen Width:"))
2025-12-09 23:38:23 +02:00
self.polyline_pen_width_spin = QSpinBox()
self.polyline_pen_width_spin.setMinimum(1)
self.polyline_pen_width_spin.setMaximum(20)
self.polyline_pen_width_spin.setValue(3)
self.polyline_pen_width_spin.valueChanged.connect(
self._on_polyline_pen_width_changed
)
width_layout.addWidget(self.polyline_pen_width_spin)
2025-12-08 23:15:54 +02:00
width_layout.addStretch()
2025-12-09 23:38:23 +02:00
polyline_layout.addLayout(width_layout)
2025-12-08 23:15:54 +02:00
2025-12-09 22:44:23 +02:00
# Simplification controls (RDP)
simplify_layout = QHBoxLayout()
self.simplify_checkbox = QCheckBox("Simplify on finish")
self.simplify_checkbox.setChecked(True)
self.simplify_checkbox.stateChanged.connect(self._on_simplify_toggle)
simplify_layout.addWidget(self.simplify_checkbox)
simplify_layout.addWidget(QLabel("epsilon (px):"))
self.eps_spin = QDoubleSpinBox()
self.eps_spin.setRange(0.0, 1000.0)
self.eps_spin.setSingleStep(0.5)
self.eps_spin.setValue(2.0)
self.eps_spin.valueChanged.connect(self._on_eps_change)
simplify_layout.addWidget(self.eps_spin)
simplify_layout.addStretch()
2025-12-09 23:38:23 +02:00
polyline_layout.addLayout(simplify_layout)
2025-12-09 22:44:23 +02:00
2025-12-09 23:38:23 +02:00
polyline_group.setLayout(polyline_layout)
layout.addWidget(polyline_group)
2025-12-08 23:15:54 +02:00
# Object Class Group
class_group = QGroupBox("Object Class")
class_layout = QVBoxLayout()
# Class selection dropdown
self.class_combo = QComboBox()
self.class_combo.currentIndexChanged.connect(self._on_class_selected)
class_layout.addWidget(self.class_combo)
2025-12-09 23:38:23 +02:00
# Add / manage classes
2025-12-08 23:15:54 +02:00
class_button_layout = QHBoxLayout()
self.add_class_btn = QPushButton("Add New Class")
self.add_class_btn.clicked.connect(self._on_add_class)
class_button_layout.addWidget(self.add_class_btn)
self.refresh_classes_btn = QPushButton("Refresh")
self.refresh_classes_btn.clicked.connect(self._load_object_classes)
class_button_layout.addWidget(self.refresh_classes_btn)
class_layout.addLayout(class_button_layout)
2025-12-09 23:38:23 +02:00
# Class color (associated with selected object class)
color_layout = QHBoxLayout()
color_layout.addWidget(QLabel("Class Color:"))
self.color_btn = QPushButton()
self.color_btn.setFixedSize(40, 30)
self.color_btn.clicked.connect(self._on_color_picker)
self._update_color_button()
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
class_layout.addLayout(color_layout)
2025-12-08 23:15:54 +02:00
# Selected class info
self.class_info_label = QLabel("No class selected")
self.class_info_label.setWordWrap(True)
self.class_info_label.setStyleSheet(
"QLabel { color: #888; font-style: italic; }"
)
class_layout.addWidget(self.class_info_label)
class_group.setLayout(class_layout)
layout.addWidget(class_group)
# Actions Group
actions_group = QGroupBox("Actions")
actions_layout = QVBoxLayout()
2025-12-09 23:56:29 +02:00
# Show / hide bounding boxes
self.show_bboxes_checkbox = QCheckBox("Show bounding boxes")
self.show_bboxes_checkbox.setChecked(True)
self.show_bboxes_checkbox.stateChanged.connect(self._on_show_bboxes_toggle)
actions_layout.addWidget(self.show_bboxes_checkbox)
2025-12-08 23:15:54 +02:00
self.clear_btn = QPushButton("Clear All Annotations")
self.clear_btn.clicked.connect(self._on_clear_annotations)
actions_layout.addWidget(self.clear_btn)
actions_group.setLayout(actions_layout)
layout.addWidget(actions_group)
layout.addStretch()
self.setLayout(layout)
def _update_color_button(self):
"""Update the color button appearance with current color."""
pixmap = QPixmap(40, 30)
pixmap.fill(self.current_color)
# Add border
painter = QPainter(pixmap)
painter.setPen(Qt.black)
painter.drawRect(0, 0, pixmap.width() - 1, pixmap.height() - 1)
painter.end()
self.color_btn.setIcon(QIcon(pixmap))
self.color_btn.setStyleSheet(f"background-color: {self.current_color.name()};")
def _load_object_classes(self):
"""Load object classes from database and populate combo box."""
try:
classes = self.db_manager.get_object_classes()
# Clear and repopulate combo box
self.class_combo.clear()
2025-12-09 23:38:23 +02:00
self.class_combo.addItem("-- Select Class / Show All --", None)
2025-12-08 23:15:54 +02:00
for cls in classes:
self.class_combo.addItem(cls["class_name"], cls)
logger.debug(f"Loaded {len(classes)} object classes")
except Exception as e:
logger.error(f"Error loading object classes: {e}")
QMessageBox.warning(
self, "Error", f"Failed to load object classes:\n{str(e)}"
)
2025-12-09 23:38:23 +02:00
def _on_polyline_toggle(self, checked: bool):
"""Handle polyline tool enable/disable."""
self.polyline_enabled = checked
2025-12-08 23:15:54 +02:00
if checked:
2025-12-09 23:56:29 +02:00
self.polyline_toggle_btn.setText("Stop Drawing Polyline")
2025-12-09 23:38:23 +02:00
self.polyline_toggle_btn.setStyleSheet(
2025-12-08 23:15:54 +02:00
"QPushButton { background-color: #4CAF50; }"
)
else:
2025-12-09 23:56:29 +02:00
self.polyline_toggle_btn.setText("Start Drawing Polyline")
2025-12-09 23:38:23 +02:00
self.polyline_toggle_btn.setStyleSheet("")
2025-12-08 23:15:54 +02:00
2025-12-09 23:38:23 +02:00
self.polyline_enabled_changed.emit(self.polyline_enabled)
logger.debug(f"Polyline tool {'enabled' if checked else 'disabled'}")
2025-12-08 23:15:54 +02:00
2025-12-09 23:38:23 +02:00
def _on_polyline_pen_width_changed(self, width: int):
"""Handle polyline pen width changes."""
self.polyline_pen_width_changed.emit(width)
logger.debug(f"Polyline pen width changed to {width}")
2025-12-08 23:15:54 +02:00
2025-12-09 22:44:23 +02:00
def _on_simplify_toggle(self, state: int):
"""Handle simplify-on-finish checkbox toggle."""
enabled = bool(state)
self.simplify_on_finish_changed.emit(enabled)
logger.debug(f"Simplify on finish set to {enabled}")
def _on_eps_change(self, val: float):
"""Handle epsilon (RDP tolerance) value changes."""
epsilon = float(val)
self.simplify_epsilon_changed.emit(epsilon)
logger.debug(f"Simplification epsilon changed to {epsilon}")
2025-12-09 23:56:29 +02:00
def _on_show_bboxes_toggle(self, state: int):
"""Handle 'Show bounding boxes' checkbox toggle."""
show = bool(state)
self.show_bboxes_changed.emit(show)
logger.debug(f"Show bounding boxes set to {show}")
2025-12-08 23:15:54 +02:00
def _on_color_picker(self):
2025-12-09 23:38:23 +02:00
"""Open color picker dialog and update the selected object's class color."""
if not self.current_class:
QMessageBox.warning(
self,
"No Class Selected",
"Please select an object class before changing its color.",
)
return
# Use current class color (without alpha) as the base
base_color = QColor(self.current_class.get("color", self.current_color.name()))
2025-12-08 23:15:54 +02:00
color = QColorDialog.getColor(
2025-12-09 23:38:23 +02:00
base_color,
2025-12-08 23:15:54 +02:00
self,
2025-12-09 23:38:23 +02:00
"Select Class Color",
QColorDialog.ShowAlphaChannel, # Allow alpha in UI, but store RGB in DB
2025-12-08 23:15:54 +02:00
)
2025-12-09 23:38:23 +02:00
if not color.isValid():
return
# Normalize to opaque RGB for storage
new_color = QColor(color)
new_color.setAlpha(255)
hex_color = new_color.name()
try:
# Update in database
self.db_manager.update_object_class(
class_id=self.current_class["id"], color=hex_color
2025-12-08 23:15:54 +02:00
)
2025-12-09 23:38:23 +02:00
except Exception as e:
logger.error(f"Failed to update class color in database: {e}")
QMessageBox.critical(
self,
"Error",
f"Failed to update class color in database:\n{str(e)}",
)
return
# Update local class data and combo box item data
self.current_class["color"] = hex_color
current_index = self.class_combo.currentIndex()
if current_index >= 0:
self.class_combo.setItemData(current_index, dict(self.current_class))
# Update info label text
info_text = f"Class: {self.current_class['class_name']}\nColor: {hex_color}"
if self.current_class.get("description"):
info_text += f"\nDescription: {self.current_class['description']}"
self.class_info_label.setText(info_text)
# Use semi-transparent version for polyline pen / button preview
class_color = QColor(hex_color)
class_color.setAlpha(128)
self.current_color = class_color
self._update_color_button()
self.polyline_pen_color_changed.emit(class_color)
logger.debug(
f"Updated class '{self.current_class['class_name']}' color to "
f"{hex_color} (polyline pen alpha={class_color.alpha()})"
)
# Notify listeners (e.g., AnnotationTab) so they can reload/redraw
self.class_color_changed.emit()
2025-12-08 23:15:54 +02:00
def _on_class_selected(self, index: int):
2025-12-09 23:38:23 +02:00
"""Handle object class selection (including '-- Select Class --')."""
2025-12-08 23:15:54 +02:00
class_data = self.class_combo.currentData()
if class_data:
self.current_class = class_data
# Update info label
info_text = (
f"Class: {class_data['class_name']}\n" f"Color: {class_data['color']}"
)
if class_data.get("description"):
info_text += f"\nDescription: {class_data['description']}"
self.class_info_label.setText(info_text)
2025-12-09 23:38:23 +02:00
# Update polyline pen color to match class color with semi-transparency
2025-12-08 23:15:54 +02:00
class_color = QColor(class_data["color"])
if class_color.isValid():
# Add 50% alpha for semi-transparency
class_color.setAlpha(128)
self.current_color = class_color
self._update_color_button()
2025-12-09 23:38:23 +02:00
self.polyline_pen_color_changed.emit(class_color)
2025-12-08 23:15:54 +02:00
self.class_selected.emit(class_data)
logger.debug(f"Selected class: {class_data['class_name']}")
else:
2025-12-09 23:38:23 +02:00
# "-- Select Class --" chosen: clear current class and show all annotations
2025-12-08 23:15:54 +02:00
self.current_class = None
self.class_info_label.setText("No class selected")
2025-12-09 23:38:23 +02:00
self.class_selected.emit(None)
logger.debug("Class selection cleared: showing annotations for all classes")
2025-12-08 23:15:54 +02:00
def _on_add_class(self):
"""Handle adding a new object class."""
# Get class name
class_name, ok = QInputDialog.getText(
self, "Add Object Class", "Enter class name:"
)
if not ok or not class_name.strip():
return
class_name = class_name.strip()
# Check if class already exists
existing = self.db_manager.get_object_class_by_name(class_name)
if existing:
QMessageBox.warning(
self, "Class Exists", f"A class named '{class_name}' already exists."
)
return
# Get color
color = QColorDialog.getColor(self.current_color, self, "Select Class Color")
if not color.isValid():
return
# Get optional description
description, ok = QInputDialog.getText(
self, "Class Description", "Enter class description (optional):"
)
if not ok:
description = None
# Add to database
try:
class_id = self.db_manager.add_object_class(
class_name, color.name(), description.strip() if description else None
)
logger.info(f"Added new object class: {class_name} (ID: {class_id})")
# Reload classes and select the new one
self._load_object_classes()
# Find and select the newly added class
for i in range(self.class_combo.count()):
class_data = self.class_combo.itemData(i)
if class_data and class_data.get("id") == class_id:
self.class_combo.setCurrentIndex(i)
break
QMessageBox.information(
self, "Success", f"Class '{class_name}' added successfully!"
)
except Exception as e:
logger.error(f"Error adding object class: {e}")
QMessageBox.critical(
self, "Error", f"Failed to add object class:\n{str(e)}"
)
def _on_clear_annotations(self):
"""Handle clear annotations button."""
reply = QMessageBox.question(
self,
"Clear Annotations",
"Are you sure you want to clear all annotations?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.clear_annotations_requested.emit()
logger.debug("Clear annotations requested")
def get_current_class(self) -> Optional[Dict]:
"""Get currently selected object class."""
return self.current_class
2025-12-09 23:38:23 +02:00
def get_polyline_pen_color(self) -> QColor:
"""Get current polyline pen color."""
2025-12-08 23:15:54 +02:00
return self.current_color
2025-12-09 23:38:23 +02:00
def get_polyline_pen_width(self) -> int:
"""Get current polyline pen width."""
return self.polyline_pen_width_spin.value()
2025-12-08 23:15:54 +02:00
2025-12-09 23:38:23 +02:00
def is_polyline_enabled(self) -> bool:
"""Check if polyline tool is enabled."""
return self.polyline_enabled