Renaming Pen tool to polyline tool

This commit is contained in:
2025-12-09 23:38:23 +02:00
parent dad5c2bf74
commit c3d44ac945
3 changed files with 200 additions and 246 deletions

View File

@@ -81,14 +81,14 @@ class AnnotationTab(QWidget):
# 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.polyline_enabled_changed.connect(
self.annotation_canvas.set_polyline_enabled
)
self.annotation_tools.pen_color_changed.connect(
self.annotation_canvas.set_pen_color
self.annotation_tools.polyline_pen_color_changed.connect(
self.annotation_canvas.set_polyline_pen_color
)
self.annotation_tools.pen_width_changed.connect(
self.annotation_canvas.set_pen_width
self.annotation_tools.polyline_pen_width_changed.connect(
self.annotation_canvas.set_polyline_pen_width
)
# RDP simplification controls
self.annotation_tools.simplify_on_finish_changed.connect(
@@ -97,13 +97,12 @@ class AnnotationTab(QWidget):
self.annotation_tools.simplify_epsilon_changed.connect(
self._on_simplify_epsilon_changed
)
# Class selection and class-color changes
self.annotation_tools.class_selected.connect(self._on_class_selected)
self.annotation_tools.class_color_changed.connect(self._on_class_color_changed)
self.annotation_tools.clear_annotations_requested.connect(
self._on_clear_annotations
)
self.annotation_tools.process_annotations_requested.connect(
self._on_process_annotations
)
self.right_splitter.addWidget(self.annotation_tools)
# Image loading section
@@ -299,10 +298,37 @@ class AnnotationTab(QWidget):
self.annotation_canvas.simplify_epsilon = float(epsilon)
logger.debug(f"Annotation simplification epsilon set to {epsilon}")
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']}")
# When a class is selected, update which annotations are visible
def _on_class_color_changed(self):
"""
Handle changes to the selected object's class color.
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
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):
"""
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.
"""
if class_data:
logger.debug(f"Object class selected: {class_data['class_name']}")
else:
logger.debug(
'No class selected ("-- Select Class --"), showing all annotations'
)
# Whenever the selection changes, update which annotations are visible
self._redraw_annotations_for_current_filter()
def _on_clear_annotations(self):
@@ -310,28 +336,6 @@ class AnnotationTab(QWidget):
self.annotation_canvas.clear_annotations()
logger.info("Cleared all annotations")
def _on_process_annotations(self):
"""
Legacy hook kept for UI compatibility.
Annotations are now saved automatically when a stroke is completed,
so this handler does not perform any additional database writes.
"""
if not self.current_image or not self.current_image_id:
QMessageBox.warning(
self,
"No Image",
"Please load an image before working with annotations.",
)
return
QMessageBox.information(
self,
"Annotations Already Saved",
"Annotations are saved automatically as you draw. "
"There is no separate processing step required.",
)
def _load_annotations_for_current_image(self):
"""
Load all annotations for the current image from the database and

View File

@@ -1,6 +1,6 @@
"""
Annotation canvas widget for drawing annotations on images.
Supports pen tool with color selection for manual annotation.
Currently supports polyline drawing tool with color selection for manual annotation.
"""
import numpy as np
@@ -111,11 +111,11 @@ def simplify_polyline(
class AnnotationCanvasWidget(QWidget):
"""
Widget for displaying images and drawing annotations with pen tool.
Widget for displaying images and drawing annotations with zoom and drawing tools.
Features:
- Display images with zoom functionality
- Pen tool for drawing annotations
- Polyline tool for drawing annotations
- Configurable pen color and width
- Mouse-based drawing interface
- Zoom in/out with mouse wheel and keyboard
@@ -143,9 +143,9 @@ class AnnotationCanvasWidget(QWidget):
# Drawing state
self.is_drawing = False
self.pen_enabled = False
self.pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha
self.pen_width = 3
self.polyline_enabled = False
self.polyline_pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha
self.polyline_pen_width = 3
# Current stroke and stored polylines (in image coordinates, pixel units)
self.current_stroke: List[Tuple[float, float]] = []
@@ -309,21 +309,21 @@ class AnnotationCanvasWidget(QWidget):
"""Update display after drawing."""
self._apply_zoom()
def set_pen_enabled(self, enabled: bool):
"""Enable or disable pen tool."""
self.pen_enabled = enabled
def set_polyline_enabled(self, enabled: bool):
"""Enable or disable polyline tool."""
self.polyline_enabled = enabled
if enabled:
self.canvas_label.setCursor(Qt.CrossCursor)
else:
self.canvas_label.setCursor(Qt.ArrowCursor)
def set_pen_color(self, color: QColor):
"""Set pen color."""
self.pen_color = color
def set_polyline_pen_color(self, color: QColor):
"""Set polyline pen color."""
self.polyline_pen_color = color
def set_pen_width(self, width: int):
"""Set pen width."""
self.pen_width = max(1, width)
def set_polyline_pen_width(self, width: int):
"""Set polyline pen width."""
self.polyline_pen_width = max(1, width)
def get_zoom_percentage(self) -> int:
"""Get current zoom level as percentage."""
@@ -415,8 +415,8 @@ class AnnotationCanvasWidget(QWidget):
painter = QPainter(self.annotation_pixmap)
for polyline, meta in zip(self.polylines, self.stroke_meta):
pen_color: QColor = meta.get("color", self.pen_color)
width: int = meta.get("width", self.pen_width)
pen_color: QColor = meta.get("color", self.polyline_pen_color)
width: int = meta.get("width", self.polyline_pen_width)
pen = QPen(
pen_color,
width,
@@ -433,7 +433,7 @@ class AnnotationCanvasWidget(QWidget):
def mousePressEvent(self, event: QMouseEvent):
"""Handle mouse press events for drawing."""
if not self.pen_enabled or self.annotation_pixmap is None:
if not self.polyline_enabled or self.annotation_pixmap is None:
super().mousePressEvent(event)
return
@@ -450,7 +450,7 @@ class AnnotationCanvasWidget(QWidget):
"""Handle mouse move events for drawing."""
if (
not self.is_drawing
or not self.pen_enabled
or not self.polyline_enabled
or self.annotation_pixmap is None
):
super().mouseMoveEvent(event)
@@ -472,8 +472,8 @@ class AnnotationCanvasWidget(QWidget):
# Draw line from last point to current point for interactive feedback
painter = QPainter(self.annotation_pixmap)
pen = QPen(
self.pen_color,
self.pen_width,
self.polyline_pen_color,
self.polyline_pen_width,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin,
@@ -512,7 +512,9 @@ class AnnotationCanvasWidget(QWidget):
if len(simplified) >= 2:
# Store polyline and redraw all annotations
self._add_polyline(simplified, self.pen_color, self.pen_width)
self._add_polyline(
simplified, self.polyline_pen_color, self.polyline_pen_width
)
# Convert to normalized coordinates for metadata + signal
normalized_stroke = [
@@ -522,9 +524,9 @@ class AnnotationCanvasWidget(QWidget):
self.all_strokes.append(
{
"points": normalized_stroke,
"color": self.pen_color.name(),
"alpha": self.pen_color.alpha(),
"width": self.pen_width,
"color": self.polyline_pen_color.name(),
"alpha": self.polyline_pen_color.alpha(),
"width": self.polyline_pen_width,
}
)
@@ -541,94 +543,6 @@ class AnnotationCanvasWidget(QWidget):
"""Get all drawn strokes with metadata."""
return self.all_strokes
# def get_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 = []
# fig = plt.figure()
# ax1 = fig.add_subplot(411)
# ax2 = fig.add_subplot(412)
# ax3 = fig.add_subplot(413)
# ax4 = fig.add_subplot(414)
# # Get np.arrays from annotation_pixmap accoriding to the color of the stroke
# qimage = self.annotation_pixmap.toImage()
# arr = np.ndarray(
# (qimage.height(), qimage.width(), 4),
# buffer=qimage.constBits(),
# strides=[qimage.bytesPerLine(), 4, 1],
# dtype=np.uint8,
# )
# print(arr.shape, arr.dtype, arr.min(), arr.max())
# arr = np.sum(arr, axis=2)
# ax1.imshow(arr)
# arr_bin = arr > 0
# ax2.imshow(arr_bin)
# arr_bin = binary_fill_holes(arr_bin)
# ax3.imshow(arr_bin)
# labels, _number_of_features = label(
# arr_bin,
# )
# ax4.imshow(labels)
# objects = find_objects(labels)
# bounding_boxes = np.array(
# [[obj[0].start, obj[0].stop, obj[1].start, obj[1].stop] for obj in objects]
# ) / np.array([arr.shape[0], arr.shape[1]])
# print(objects)
# print(bounding_boxes)
# print(np.array([arr.shape[0], arr.shape[1]]))
# polylines = find_contours(arr_bin, 0.5)
# for pl in polylines:
# ax1.plot(pl[:, 1], pl[:, 0], "k")
# print(arr.shape, arr.dtype, arr.min(), arr.max())
# plt.show()
# return polyline
def get_annotation_parameters(self) -> Optional[List[Dict[str, Any]]]:
"""
Get all annotation parameters including bounding box and polyline.

View File

@@ -1,6 +1,6 @@
"""
Annotation tools widget for controlling annotation parameters.
Includes pen tool, color picker, class selection, and annotation management.
Includes polyline tool, color picker, class selection, and annotation management.
"""
from PySide6.QtWidgets import (
@@ -33,29 +33,29 @@ class AnnotationToolsWidget(QWidget):
Widget for annotation tool controls.
Features:
- Enable/disable pen tool
- Color selection for pen
- Enable/disable polyline tool
- Color selection for polyline pen
- Object class selection
- Add new object classes
- Pen width control
- Clear annotations
Signals:
pen_enabled_changed: Emitted when pen tool is enabled/disabled (bool)
pen_color_changed: Emitted when pen color changes (QColor)
pen_width_changed: Emitted when pen width changes (int)
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)
class_selected: Emitted when object class is selected (dict)
clear_annotations_requested: Emitted when clear button is pressed
"""
pen_enabled_changed = Signal(bool)
pen_color_changed = Signal(QColor)
pen_width_changed = Signal(int)
polyline_enabled_changed = Signal(bool)
polyline_pen_color_changed = Signal(QColor)
polyline_pen_width_changed = Signal(int)
simplify_on_finish_changed = Signal(bool)
simplify_epsilon_changed = Signal(float)
class_selected = Signal(dict)
class_color_changed = Signal()
clear_annotations_requested = Signal()
process_annotations_requested = Signal()
def __init__(self, db_manager: DatabaseManager, parent=None):
"""
@@ -67,7 +67,7 @@ class AnnotationToolsWidget(QWidget):
"""
super().__init__(parent)
self.db_manager = db_manager
self.pen_enabled = False
self.polyline_enabled = False
self.current_color = QColor(255, 0, 0, 128) # Red with 50% alpha
self.current_class = None
@@ -78,40 +78,31 @@ class AnnotationToolsWidget(QWidget):
"""Setup user interface."""
layout = QVBoxLayout()
# Pen Tool Group
pen_group = QGroupBox("Pen Tool")
pen_layout = QVBoxLayout()
# Polyline Tool Group
polyline_group = QGroupBox("Polyline Tool")
polyline_layout = QVBoxLayout()
# Enable/Disable pen
# Enable/Disable polyline tool
button_layout = QHBoxLayout()
self.pen_toggle_btn = QPushButton("Enable Pen")
self.pen_toggle_btn.setCheckable(True)
self.pen_toggle_btn.clicked.connect(self._on_pen_toggle)
button_layout.addWidget(self.pen_toggle_btn)
pen_layout.addLayout(button_layout)
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)
# Pen width control
# Polyline pen width control
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Pen Width:"))
self.pen_width_spin = QSpinBox()
self.pen_width_spin.setMinimum(1)
self.pen_width_spin.setMaximum(20)
self.pen_width_spin.setValue(3)
self.pen_width_spin.valueChanged.connect(self._on_pen_width_changed)
width_layout.addWidget(self.pen_width_spin)
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)
width_layout.addStretch()
pen_layout.addLayout(width_layout)
# Color selection
color_layout = QHBoxLayout()
color_layout.addWidget(QLabel("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()
pen_layout.addLayout(color_layout)
polyline_layout.addLayout(width_layout)
# Simplification controls (RDP)
simplify_layout = QHBoxLayout()
@@ -128,10 +119,10 @@ class AnnotationToolsWidget(QWidget):
self.eps_spin.valueChanged.connect(self._on_eps_change)
simplify_layout.addWidget(self.eps_spin)
simplify_layout.addStretch()
pen_layout.addLayout(simplify_layout)
polyline_layout.addLayout(simplify_layout)
pen_group.setLayout(pen_layout)
layout.addWidget(pen_group)
polyline_group.setLayout(polyline_layout)
layout.addWidget(polyline_group)
# Object Class Group
class_group = QGroupBox("Object Class")
@@ -142,7 +133,7 @@ class AnnotationToolsWidget(QWidget):
self.class_combo.currentIndexChanged.connect(self._on_class_selected)
class_layout.addWidget(self.class_combo)
# Add class button
# Add / manage classes
class_button_layout = QHBoxLayout()
self.add_class_btn = QPushButton("Add New Class")
self.add_class_btn.clicked.connect(self._on_add_class)
@@ -153,6 +144,17 @@ class AnnotationToolsWidget(QWidget):
class_button_layout.addWidget(self.refresh_classes_btn)
class_layout.addLayout(class_button_layout)
# 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)
# Selected class info
self.class_info_label = QLabel("No class selected")
self.class_info_label.setWordWrap(True)
@@ -168,13 +170,6 @@ 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.clear_btn = QPushButton("Clear All Annotations")
self.clear_btn.clicked.connect(self._on_clear_annotations)
actions_layout.addWidget(self.clear_btn)
@@ -206,7 +201,7 @@ class AnnotationToolsWidget(QWidget):
# Clear and repopulate combo box
self.class_combo.clear()
self.class_combo.addItem("-- Select Class --", None)
self.class_combo.addItem("-- Select Class / Show All --", None)
for cls in classes:
self.class_combo.addItem(cls["class_name"], cls)
@@ -219,26 +214,26 @@ class AnnotationToolsWidget(QWidget):
self, "Error", f"Failed to load object classes:\n{str(e)}"
)
def _on_pen_toggle(self, checked: bool):
"""Handle pen tool enable/disable."""
self.pen_enabled = checked
def _on_polyline_toggle(self, checked: bool):
"""Handle polyline tool enable/disable."""
self.polyline_enabled = checked
if checked:
self.pen_toggle_btn.setText("Disable Pen")
self.pen_toggle_btn.setStyleSheet(
self.polyline_toggle_btn.setText("Start Drawing Polyline")
self.polyline_toggle_btn.setStyleSheet(
"QPushButton { background-color: #4CAF50; }"
)
else:
self.pen_toggle_btn.setText("Enable Pen")
self.pen_toggle_btn.setStyleSheet("")
self.polyline_toggle_btn.setText("Stop drawing Polyline")
self.polyline_toggle_btn.setStyleSheet("")
self.pen_enabled_changed.emit(self.pen_enabled)
logger.debug(f"Pen tool {'enabled' if checked else 'disabled'}")
self.polyline_enabled_changed.emit(self.polyline_enabled)
logger.debug(f"Polyline tool {'enabled' if checked else 'disabled'}")
def _on_pen_width_changed(self, width: int):
"""Handle pen width changes."""
self.pen_width_changed.emit(width)
logger.debug(f"Pen width changed to {width}")
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}")
def _on_simplify_toggle(self, state: int):
"""Handle simplify-on-finish checkbox toggle."""
@@ -253,24 +248,75 @@ class AnnotationToolsWidget(QWidget):
logger.debug(f"Simplification epsilon changed to {epsilon}")
def _on_color_picker(self):
"""Open color picker dialog with alpha support."""
"""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()))
color = QColorDialog.getColor(
self.current_color,
base_color,
self,
"Select Pen Color",
QColorDialog.ShowAlphaChannel, # Enable alpha channel selection
"Select Class Color",
QColorDialog.ShowAlphaChannel, # Allow alpha in UI, but store RGB in DB
)
if color.isValid():
self.current_color = color
self._update_color_button()
self.pen_color_changed.emit(color)
logger.debug(
f"Pen color changed to {color.name()} with alpha {color.alpha()}"
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
)
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()
def _on_class_selected(self, index: int):
"""Handle object class selection."""
"""Handle object class selection (including '-- Select Class --')."""
class_data = self.class_combo.currentData()
if class_data:
@@ -285,20 +331,23 @@ class AnnotationToolsWidget(QWidget):
self.class_info_label.setText(info_text)
# Update pen color to match class color with semi-transparency
# Update polyline pen color to match class color with semi-transparency
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()
self.pen_color_changed.emit(class_color)
self.polyline_pen_color_changed.emit(class_color)
self.class_selected.emit(class_data)
logger.debug(f"Selected class: {class_data['class_name']}")
else:
# "-- Select Class --" chosen: clear current class and show all annotations
self.current_class = None
self.class_info_label.setText("No class selected")
self.class_selected.emit(None)
logger.debug("Class selection cleared: showing annotations for all classes")
def _on_add_class(self):
"""Handle adding a new object class."""
@@ -376,31 +425,18 @@ 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 get_current_class(self) -> Optional[Dict]:
"""Get currently selected object class."""
return self.current_class
def get_pen_color(self) -> QColor:
"""Get current pen color."""
def get_polyline_pen_color(self) -> QColor:
"""Get current polyline pen color."""
return self.current_color
def get_pen_width(self) -> int:
"""Get current pen width."""
return self.pen_width_spin.value()
def get_polyline_pen_width(self) -> int:
"""Get current polyline pen width."""
return self.polyline_pen_width_spin.value()
def is_pen_enabled(self) -> bool:
"""Check if pen tool is enabled."""
return self.pen_enabled
def is_polyline_enabled(self) -> bool:
"""Check if polyline tool is enabled."""
return self.polyline_enabled