5 Commits

4 changed files with 916 additions and 454 deletions

View File

@@ -706,6 +706,25 @@ class DatabaseManager:
finally: finally:
conn.close() conn.close()
def delete_annotation(self, annotation_id: int) -> bool:
"""
Delete a manual annotation by ID.
Args:
annotation_id: ID of the annotation to delete
Returns:
True if an annotation was deleted, False otherwise.
"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM annotations WHERE id = ?", (annotation_id,))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
# ==================== Object Class Operations ==================== # ==================== Object Class Operations ====================
def get_object_classes(self) -> List[Dict]: def get_object_classes(self) -> List[Dict]:

View File

@@ -38,6 +38,9 @@ class AnnotationTab(QWidget):
self.current_image = None self.current_image = None
self.current_image_path = None self.current_image_path = None
self.current_image_id = None self.current_image_id = None
self.current_annotations = []
# IDs of annotations currently selected on the canvas (multi-select)
self.selected_annotation_ids = []
self._setup_ui() self._setup_ui()
@@ -61,6 +64,8 @@ class AnnotationTab(QWidget):
self.annotation_canvas = AnnotationCanvasWidget() self.annotation_canvas = AnnotationCanvasWidget()
self.annotation_canvas.zoom_changed.connect(self._on_zoom_changed) self.annotation_canvas.zoom_changed.connect(self._on_zoom_changed)
self.annotation_canvas.annotation_drawn.connect(self._on_annotation_drawn) self.annotation_canvas.annotation_drawn.connect(self._on_annotation_drawn)
# Selection of existing polylines (when tool is not in drawing mode)
self.annotation_canvas.annotation_selected.connect(self._on_annotation_selected)
canvas_layout.addWidget(self.annotation_canvas) canvas_layout.addWidget(self.annotation_canvas)
canvas_group.setLayout(canvas_layout) canvas_group.setLayout(canvas_layout)
@@ -80,24 +85,35 @@ class AnnotationTab(QWidget):
# Annotation tools section # Annotation tools section
self.annotation_tools = AnnotationToolsWidget(self.db_manager) self.annotation_tools = AnnotationToolsWidget(self.db_manager)
self.annotation_tools.pen_enabled_changed.connect( self.annotation_tools.polyline_enabled_changed.connect(
self.annotation_canvas.set_pen_enabled self.annotation_canvas.set_polyline_enabled
) )
self.annotation_tools.pen_color_changed.connect( self.annotation_tools.polyline_pen_color_changed.connect(
self.annotation_canvas.set_pen_color self.annotation_canvas.set_polyline_pen_color
) )
self.annotation_tools.pen_width_changed.connect( self.annotation_tools.polyline_pen_width_changed.connect(
self.annotation_canvas.set_pen_width self.annotation_canvas.set_polyline_pen_width
) )
# Show / hide bounding boxes
self.annotation_tools.show_bboxes_changed.connect(
self.annotation_canvas.set_show_bboxes
)
# 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
)
# Class selection and class-color changes
self.annotation_tools.class_selected.connect(self._on_class_selected) 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.annotation_tools.clear_annotations_requested.connect(
self._on_clear_annotations self._on_clear_annotations
) )
self.annotation_tools.process_annotations_requested.connect( # Delete selected annotation on canvas
self._on_process_annotations self.annotation_tools.delete_selected_annotation_requested.connect(
) self._on_delete_selected_annotation
self.annotation_tools.show_annotations_requested.connect(
self._on_show_annotations
) )
self.right_splitter.addWidget(self.annotation_tools) self.right_splitter.addWidget(self.annotation_tools)
@@ -180,6 +196,9 @@ class AnnotationTab(QWidget):
# Display image using the AnnotationCanvasWidget # Display image using the AnnotationCanvasWidget
self.annotation_canvas.load_image(self.current_image) self.annotation_canvas.load_image(self.current_image)
# Load and display any existing annotations for this image
self._load_annotations_for_current_image()
# Update info label # Update info label
self._update_image_info() self._update_image_info()
@@ -217,7 +236,22 @@ class AnnotationTab(QWidget):
self._update_image_info() self._update_image_info()
def _on_annotation_drawn(self, points: list): def _on_annotation_drawn(self, points: list):
"""Handle when an annotation stroke is drawn.""" """
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
current_class = self.annotation_tools.get_current_class() current_class = self.annotation_tools.get_current_class()
if not current_class: if not current_class:
@@ -229,153 +263,260 @@ class AnnotationTab(QWidget):
) )
return return
logger.info( if not points:
f"Annotation drawn with {len(points)} points for class: {current_class['class_name']}" logger.warning("Annotation drawn with no points, ignoring")
) return
# Future: Save annotation to database or export
def _on_class_selected(self, class_data: dict): # points are [(x_norm, y_norm), ...]
"""Handle when an object class is selected.""" xs = [p[0] for p in points]
logger.debug(f"Object class selected: {class_data['class_name']}") 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)}")
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}")
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}")
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'
)
# Changing the class filter invalidates any previous selection
self.selected_annotation_ids = []
self.annotation_tools.set_has_selected_annotation(False)
# Whenever the selection changes, update which annotations are visible
self._redraw_annotations_for_current_filter()
def _on_clear_annotations(self): def _on_clear_annotations(self):
"""Handle clearing all annotations.""" """Handle clearing all annotations."""
self.annotation_canvas.clear_annotations() self.annotation_canvas.clear_annotations()
# 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)
logger.info("Cleared all annotations") logger.info("Cleared all annotations")
def _on_process_annotations(self): def _on_delete_selected_annotation(self):
"""Process annotations and save to database.""" """Handle deleting the currently selected annotation(s) (if any)."""
# Check if we have an image loaded if not self.selected_annotation_ids:
if not self.current_image or not self.current_image_id: QMessageBox.information(
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, self,
"No Class Selected", "No Selection",
"Please select an object class before processing annotations.", "No annotation is currently selected.",
) )
return return
# Compute annotation parameters asbounding boxes and polylines from annotations count = len(self.selected_annotation_ids)
parameters = self.annotation_canvas.get_annotation_parameters() if count == 1:
if not parameters: question = "Are you sure you want to delete the selected annotation?"
QMessageBox.warning( title = "Delete Annotation"
self, else:
"No Annotations", question = (
"Please draw some annotations before processing.", f"Are you sure you want to delete the {count} selected annotations?"
) )
return title = "Delete Annotations"
# polyline = self.annotation_canvas.get_annotation_polyline()
for param in parameters:
bounds = param["bbox"]
polyline = param["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"Bounding box: ({bounds[0]:.3f}, {bounds[1]:.3f}) to ({bounds[2]:.3f}, {bounds[3]:.3f})\n"
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)}",
# )
except Exception as e:
logger.error(f"Failed to save annotation: {e}")
QMessageBox.critical(
self, "Error", f"Failed to save annotation:\n{str(e)}"
)
# Optionally clear annotations after saving
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"Clear Annotations", title,
"Do you want to clear the annotations to start a new one?", question,
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes, QMessageBox.No,
) )
if reply != QMessageBox.Yes:
return
if reply == QMessageBox.Yes: failed_ids = []
self.annotation_canvas.clear_annotations() try:
logger.info("Cleared annotations after saving") 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)
def _on_show_annotations(self): if failed_ids:
"""Load and display saved annotations from database.""" QMessageBox.warning(
# Check if we have an image loaded self,
if not self.current_image or not self.current_image_id: "Partial Failure",
QMessageBox.warning( "Some annotations could not be deleted:\n"
self, "No Image", "Please load an image to view its annotations." + ", ".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)}",
) )
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 = []
self.annotation_canvas.clear_annotations()
self.selected_annotation_ids = []
self.annotation_tools.set_has_selected_annotation(False)
return return
try: try:
# Clear current annotations self.current_annotations = self.db_manager.get_annotations_for_image(
self.annotation_canvas.clear_annotations()
# Retrieve annotations from database
annotations = self.db_manager.get_annotations_for_image(
self.current_image_id self.current_image_id
) )
# New annotations loaded; reset any selection
if not annotations: self.selected_annotation_ids = []
QMessageBox.information( self.annotation_tools.set_has_selected_annotation(False)
self, "No Annotations", "No saved annotations found for this image." self._redraw_annotations_for_current_filter()
)
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)
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} saved annotations from database")
QMessageBox.information(
self,
"Annotations Loaded",
f"Successfully loaded and displayed {drawn_count} annotation(s).",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to load annotations: {e}") logger.error(
QMessageBox.critical( f"Failed to load annotations for image {self.current_image_id}: {e}"
self, "Error", f"Failed to load annotations:\n{str(e)}"
) )
QMessageBox.critical(
self,
"Error",
f"Failed to load annotations for this image:\n{str(e)}",
)
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")
self.annotation_canvas.draw_saved_polyline(
polyline,
color,
width=3,
annotation_id=ann["id"],
)
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}'}"
)
def _restore_state(self): def _restore_state(self):
"""Restore splitter positions from settings.""" """Restore splitter positions from settings."""

View File

@@ -1,9 +1,10 @@
""" """
Annotation canvas widget for drawing annotations on images. 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 import numpy as np
import math
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea
from PySide6.QtGui import ( from PySide6.QtGui import (
@@ -19,25 +20,102 @@ from PySide6.QtGui import (
from PySide6.QtCore import Qt, QEvent, Signal, QPoint from PySide6.QtCore import Qt, QEvent, Signal, QPoint
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from scipy.ndimage import binary_dilation, label, binary_fill_holes, find_objects
from skimage.measure import find_contours
from src.utils.image import Image, ImageLoadError from src.utils.image import Image, ImageLoadError
from src.utils.logger import get_logger from src.utils.logger import get_logger
# For debugging visualization
import pylab as plt
logger = get_logger(__name__) logger = get_logger(__name__)
def perpendicular_distance(
point: Tuple[float, float],
start: Tuple[float, float],
end: Tuple[float, float],
) -> float:
"""Perpendicular distance from `point` to the line defined by `start`->`end`."""
(x, y), (x1, y1), (x2, y2) = point, start, end
dx = x2 - x1
dy = y2 - y1
if dx == 0.0 and dy == 0.0:
return math.hypot(x - x1, y - y1)
num = abs(dy * x - dx * y + x2 * y1 - y2 * x1)
den = math.hypot(dx, dy)
return num / den
def rdp(points: List[Tuple[float, float]], epsilon: float) -> List[Tuple[float, float]]:
"""
Recursive Ramer-Douglas-Peucker (RDP) polyline simplification.
Args:
points: List of (x, y) points.
epsilon: Maximum allowed perpendicular distance in pixels.
Returns:
Simplified list of (x, y) points including first and last.
"""
if len(points) <= 2:
return list(points)
start = points[0]
end = points[-1]
max_dist = -1.0
index = -1
for i in range(1, len(points) - 1):
d = perpendicular_distance(points[i], start, end)
if d > max_dist:
max_dist = d
index = i
if max_dist > epsilon:
# Recursive split
left = rdp(points[: index + 1], epsilon)
right = rdp(points[index:], epsilon)
# Concatenate but avoid duplicate at split point
return left[:-1] + right
# Keep only start and end
return [start, end]
def simplify_polyline(
points: List[Tuple[float, float]], epsilon: float
) -> List[Tuple[float, float]]:
"""
Simplify a polyline with RDP while preserving closure semantics.
If the polyline is closed (first == last), the duplicate last point is removed
before simplification and then re-added after simplification.
"""
if not points:
return []
pts = [(float(x), float(y)) for x, y in points]
closed = False
if len(pts) >= 2 and pts[0] == pts[-1]:
closed = True
pts = pts[:-1] # remove duplicate last for simplification
if len(pts) <= 2:
simplified = list(pts)
else:
simplified = rdp(pts, epsilon)
if closed and simplified:
if simplified[0] != simplified[-1]:
simplified.append(simplified[0])
return simplified
class AnnotationCanvasWidget(QWidget): 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: Features:
- Display images with zoom functionality - Display images with zoom functionality
- Pen tool for drawing annotations - Polyline tool for drawing annotations
- Configurable pen color and width - Configurable pen color and width
- Mouse-based drawing interface - Mouse-based drawing interface
- Zoom in/out with mouse wheel and keyboard - Zoom in/out with mouse wheel and keyboard
@@ -49,6 +127,9 @@ class AnnotationCanvasWidget(QWidget):
zoom_changed = Signal(float) zoom_changed = Signal(float)
annotation_drawn = Signal(list) # List of (x, y) points in normalized coordinates annotation_drawn = Signal(list) # List of (x, y) points in normalized coordinates
# Emitted when the user selects an existing polyline on the canvas.
# Carries the associated annotation_id (int) or None if selection is cleared
annotation_selected = Signal(object)
def __init__(self, parent=None): def __init__(self, parent=None):
"""Initialize the annotation canvas widget.""" """Initialize the annotation canvas widget."""
@@ -63,13 +144,33 @@ class AnnotationCanvasWidget(QWidget):
self.zoom_step = 0.1 self.zoom_step = 0.1
self.zoom_wheel_step = 0.15 self.zoom_wheel_step = 0.15
# Drawing state # Drawing / interaction state
self.is_drawing = False self.is_drawing = False
self.pen_enabled = False self.polyline_enabled = False
self.pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha self.polyline_pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha
self.pen_width = 3 self.polyline_pen_width = 3
self.current_stroke = [] # Points in current stroke self.show_bboxes: bool = True # Control visibility of bounding boxes
self.all_strokes = [] # All completed strokes
# Current stroke and stored polylines (in image coordinates, pixel units)
self.current_stroke: List[Tuple[float, float]] = []
self.polylines: List[List[Tuple[float, float]]] = []
self.stroke_meta: List[Dict[str, Any]] = [] # per-polyline style (color, width)
# Optional DB annotation_id for each stored polyline (None for temporary / unsaved)
self.polyline_annotation_ids: List[Optional[int]] = []
# Indices in self.polylines of the currently selected polylines (multi-select)
self.selected_polyline_indices: List[int] = []
# Stored bounding boxes in normalized coordinates (x_min, y_min, x_max, y_max)
self.bboxes: List[List[float]] = []
self.bbox_meta: List[Dict[str, Any]] = [] # per-bbox style (color, width)
# Legacy collection of strokes in normalized coordinates (kept for API compatibility)
self.all_strokes: List[dict] = []
# RDP simplification parameters (in pixels)
self.simplify_on_finish: bool = True
self.simplify_epsilon: float = 2.0
self.sample_threshold: float = 2.0 # minimum movement to sample a new point
self._setup_ui() self._setup_ui()
@@ -128,6 +229,12 @@ class AnnotationCanvasWidget(QWidget):
"""Clear all drawn annotations.""" """Clear all drawn annotations."""
self.all_strokes = [] self.all_strokes = []
self.current_stroke = [] self.current_stroke = []
self.polylines = []
self.stroke_meta = []
self.polyline_annotation_ids = []
self.selected_polyline_indices = []
self.bboxes = []
self.bbox_meta = []
self.is_drawing = False self.is_drawing = False
if self.annotation_pixmap: if self.annotation_pixmap:
self.annotation_pixmap.fill(Qt.transparent) self.annotation_pixmap.fill(Qt.transparent)
@@ -218,21 +325,21 @@ class AnnotationCanvasWidget(QWidget):
"""Update display after drawing.""" """Update display after drawing."""
self._apply_zoom() self._apply_zoom()
def set_pen_enabled(self, enabled: bool): def set_polyline_enabled(self, enabled: bool):
"""Enable or disable pen tool.""" """Enable or disable polyline tool."""
self.pen_enabled = enabled self.polyline_enabled = enabled
if enabled: if enabled:
self.canvas_label.setCursor(Qt.CrossCursor) self.canvas_label.setCursor(Qt.CrossCursor)
else: else:
self.canvas_label.setCursor(Qt.ArrowCursor) self.canvas_label.setCursor(Qt.ArrowCursor)
def set_pen_color(self, color: QColor): def set_polyline_pen_color(self, color: QColor):
"""Set pen color.""" """Set polyline pen color."""
self.pen_color = color self.polyline_pen_color = color
def set_pen_width(self, width: int): def set_polyline_pen_width(self, width: int):
"""Set pen width.""" """Set polyline pen width."""
self.pen_width = max(1, width) self.polyline_pen_width = max(1, width)
def get_zoom_percentage(self) -> int: def get_zoom_percentage(self) -> int:
"""Get current zoom level as percentage.""" """Get current zoom level as percentage."""
@@ -291,6 +398,41 @@ class AnnotationCanvasWidget(QWidget):
return (int(x), int(y)) return (int(x), int(y))
return None return None
def _find_polyline_at(
self, img_x: float, img_y: float, threshold_px: float = 5.0
) -> Optional[int]:
"""
Find index of polyline whose geometry is within threshold_px of (img_x, img_y).
Returns the index in self.polylines, or None if none is close enough.
"""
best_index: Optional[int] = None
best_dist: float = float("inf")
for idx, polyline in enumerate(self.polylines):
if len(polyline) < 2:
continue
# Quick bounding-box check to skip obviously distant polylines
xs = [p[0] for p in polyline]
ys = [p[1] for p in polyline]
if img_x < min(xs) - threshold_px or img_x > max(xs) + threshold_px:
continue
if img_y < min(ys) - threshold_px or img_y > max(ys) + threshold_px:
continue
# Precise distance to all segments
for (x1, y1), (x2, y2) in zip(polyline[:-1], polyline[1:]):
d = perpendicular_distance(
(img_x, img_y), (float(x1), float(y1)), (float(x2), float(y2))
)
if d < best_dist:
best_dist = d
best_index = idx
if best_index is not None and best_dist <= threshold_px:
return best_index
return None
def _image_to_normalized_coords(self, x: int, y: int) -> Tuple[float, float]: def _image_to_normalized_coords(self, x: int, y: int) -> Tuple[float, float]:
"""Convert image coordinates to normalized coordinates (0-1).""" """Convert image coordinates to normalized coordinates (0-1)."""
if self.original_pixmap is None: if self.original_pixmap is None:
@@ -300,26 +442,156 @@ class AnnotationCanvasWidget(QWidget):
norm_y = y / self.original_pixmap.height() norm_y = y / self.original_pixmap.height()
return (norm_x, norm_y) return (norm_x, norm_y)
def _add_polyline(
self,
img_points: List[Tuple[float, float]],
color: QColor,
width: int,
annotation_id: Optional[int] = None,
):
"""Store a polyline in image coordinates and redraw annotations."""
if not img_points or len(img_points) < 2:
return
# Ensure all points are tuples of floats
normalized_points = [(float(x), float(y)) for x, y in img_points]
self.polylines.append(normalized_points)
self.stroke_meta.append({"color": QColor(color), "width": int(width)})
self.polyline_annotation_ids.append(annotation_id)
self._redraw_annotations()
def _redraw_annotations(self):
"""Redraw all stored polylines and (optionally) bounding boxes onto the annotation pixmap."""
if self.annotation_pixmap is None:
return
# Clear existing overlay
self.annotation_pixmap.fill(Qt.transparent)
painter = QPainter(self.annotation_pixmap)
# Draw polylines
for idx, (polyline, meta) in enumerate(zip(self.polylines, self.stroke_meta)):
pen_color: QColor = meta.get("color", self.polyline_pen_color)
width: int = meta.get("width", self.polyline_pen_width)
if idx in self.selected_polyline_indices:
# Highlight selected polylines in a distinct color / width
highlight_color = QColor(255, 255, 0, 200) # yellow, semi-opaque
pen = QPen(
highlight_color,
width + 1,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin,
)
else:
pen = QPen(
pen_color,
width,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin,
)
painter.setPen(pen)
for (x1, y1), (x2, y2) in zip(polyline[:-1], polyline[1:]):
painter.drawLine(int(x1), int(y1), int(x2), int(y2))
# Draw bounding boxes (dashed) if enabled
if self.show_bboxes and self.original_pixmap is not None and self.bboxes:
img_width = float(self.original_pixmap.width())
img_height = float(self.original_pixmap.height())
for bbox, meta in zip(self.bboxes, self.bbox_meta):
if len(bbox) != 4:
continue
x_min_norm, y_min_norm, x_max_norm, y_max_norm = bbox
x_min = int(x_min_norm * img_width)
y_min = int(y_min_norm * img_height)
x_max = int(x_max_norm * img_width)
y_max = int(y_max_norm * img_height)
rect_width = x_max - x_min
rect_height = y_max - y_min
pen_color: QColor = meta.get("color", QColor(255, 0, 0, 128))
width: int = meta.get("width", self.polyline_pen_width)
pen = QPen(
pen_color,
width,
Qt.DashLine,
Qt.SquareCap,
Qt.MiterJoin,
)
painter.setPen(pen)
painter.drawRect(x_min, y_min, rect_width, rect_height)
painter.end()
self._update_display()
def mousePressEvent(self, event: QMouseEvent): def mousePressEvent(self, event: QMouseEvent):
"""Handle mouse press events for drawing.""" """Handle mouse press events for drawing and selecting polylines."""
if not self.pen_enabled or self.annotation_pixmap is None: if self.annotation_pixmap is None:
super().mousePressEvent(event) super().mousePressEvent(event)
return return
if event.button() == Qt.LeftButton: # Map click to image coordinates
# Get accurate position using global coordinates label_pos = self.canvas_label.mapFromGlobal(event.globalPos())
label_pos = self.canvas_label.mapFromGlobal(event.globalPos()) img_coords = self._canvas_to_image_coords(label_pos)
img_coords = self._canvas_to_image_coords(label_pos)
# Left button + drawing tool enabled -> start a new stroke
if event.button() == Qt.LeftButton and self.polyline_enabled:
if img_coords: if img_coords:
self.is_drawing = True self.is_drawing = True
self.current_stroke = [img_coords] self.current_stroke = [(float(img_coords[0]), float(img_coords[1]))]
return
# Left button + drawing tool disabled -> attempt selection of existing polyline
if event.button() == Qt.LeftButton and not self.polyline_enabled:
if img_coords:
idx = self._find_polyline_at(float(img_coords[0]), float(img_coords[1]))
if idx is not None:
if event.modifiers() & Qt.ShiftModifier:
# Multi-select mode: add to current selection (if not already selected)
if idx not in self.selected_polyline_indices:
self.selected_polyline_indices.append(idx)
else:
# Single-select mode: replace current selection
self.selected_polyline_indices = [idx]
# Build list of selected annotation IDs (ignore None entries)
selected_ids: List[int] = []
for sel_idx in self.selected_polyline_indices:
if 0 <= sel_idx < len(self.polyline_annotation_ids):
ann_id = self.polyline_annotation_ids[sel_idx]
if isinstance(ann_id, int):
selected_ids.append(ann_id)
if selected_ids:
self.annotation_selected.emit(selected_ids)
else:
# No valid DB-backed annotations in selection
self.annotation_selected.emit(None)
else:
# Clicked on empty space -> clear selection
self.selected_polyline_indices = []
self.annotation_selected.emit(None)
self._redraw_annotations()
return
# Fallback for other buttons / cases
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent): def mouseMoveEvent(self, event: QMouseEvent):
"""Handle mouse move events for drawing.""" """Handle mouse move events for drawing."""
if ( if (
not self.is_drawing not self.is_drawing
or not self.pen_enabled or not self.polyline_enabled
or self.annotation_pixmap is None or self.annotation_pixmap is None
): ):
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
@@ -330,18 +602,33 @@ class AnnotationCanvasWidget(QWidget):
img_coords = self._canvas_to_image_coords(label_pos) img_coords = self._canvas_to_image_coords(label_pos)
if img_coords and len(self.current_stroke) > 0: if img_coords and len(self.current_stroke) > 0:
# Draw line from last point to current point last_point = self.current_stroke[-1]
dx = img_coords[0] - last_point[0]
dy = img_coords[1] - last_point[1]
# Only sample a new point if we moved enough pixels
if math.hypot(dx, dy) < self.sample_threshold:
return
# Draw line from last point to current point for interactive feedback
painter = QPainter(self.annotation_pixmap) painter = QPainter(self.annotation_pixmap)
pen = QPen( pen = QPen(
self.pen_color, self.pen_width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin self.polyline_pen_color,
self.polyline_pen_width,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin,
) )
painter.setPen(pen) painter.setPen(pen)
painter.drawLine(
last_point = self.current_stroke[-1] int(last_point[0]),
painter.drawLine(last_point[0], last_point[1], img_coords[0], img_coords[1]) int(last_point[1]),
int(img_coords[0]),
int(img_coords[1]),
)
painter.end() painter.end()
self.current_stroke.append(img_coords) self.current_stroke.append((float(img_coords[0]), float(img_coords[1])))
self._update_display() self._update_display()
def mouseReleaseEvent(self, event: QMouseEvent): def mouseReleaseEvent(self, event: QMouseEvent):
@@ -352,23 +639,44 @@ class AnnotationCanvasWidget(QWidget):
self.is_drawing = False self.is_drawing = False
if len(self.current_stroke) > 1: if len(self.current_stroke) > 1 and self.original_pixmap is not None:
# Convert to normalized coordinates and save stroke # Ensure the stroke is closed by connecting end -> start
normalized_stroke = [ raw_points = list(self.current_stroke)
self._image_to_normalized_coords(x, y) for x, y in self.current_stroke if raw_points[0] != raw_points[-1]:
] raw_points.append(raw_points[0])
self.all_strokes.append(
{
"points": normalized_stroke,
"color": self.pen_color.name(),
"alpha": self.pen_color.alpha(),
"width": self.pen_width,
}
)
# Emit signal with normalized coordinates # Optional RDP simplification (in image pixel space)
self.annotation_drawn.emit(normalized_stroke) if self.simplify_on_finish:
logger.debug(f"Completed stroke with {len(normalized_stroke)} points") simplified = simplify_polyline(raw_points, self.simplify_epsilon)
else:
simplified = raw_points
if len(simplified) >= 2:
# Store polyline and redraw all annotations
self._add_polyline(
simplified, self.polyline_pen_color, self.polyline_pen_width
)
# Convert to normalized coordinates for metadata + signal
normalized_stroke = [
self._image_to_normalized_coords(int(x), int(y))
for (x, y) in simplified
]
self.all_strokes.append(
{
"points": normalized_stroke,
"color": self.polyline_pen_color.name(),
"alpha": self.polyline_pen_color.alpha(),
"width": self.polyline_pen_width,
}
)
# Emit signal with normalized coordinates
self.annotation_drawn.emit(normalized_stroke)
logger.debug(
f"Completed stroke with {len(simplified)} points "
f"(normalized len={len(normalized_stroke)})"
)
self.current_stroke = [] self.current_stroke = []
@@ -376,152 +684,61 @@ class AnnotationCanvasWidget(QWidget):
"""Get all drawn strokes with metadata.""" """Get all drawn strokes with metadata."""
return self.all_strokes return self.all_strokes
# def get_annotation_bounds(self) -> Optional[Tuple[float, float, float, float]]: def get_annotation_parameters(self) -> Optional[List[Dict[str, Any]]]:
# """
# 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) -> Dict[str, Any]:
""" """
Get all annotation parameters including bounding box and polyline. Get all annotation parameters including bounding box and polyline.
Returns: Returns:
Dictionary containing: List of dictionaries, each containing:
- 'bbox': Bounding box coordinates (x_min, y_min, x_max, y_max) - 'bbox': [x_min, y_min, x_max, y_max] in normalized image coordinates
- 'polyline': List of [x, y] coordinate pairs - 'polyline': List of [y_norm, x_norm] points describing the polygon
""" """
if self.original_pixmap is None or not self.polylines:
# 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,
)
arr = np.sum(arr, axis=2)
arr_bin = arr > 0
arr_bin = binary_fill_holes(arr_bin)
labels, _number_of_features = label(
arr_bin,
)
if _number_of_features == 0:
return None return None
objects = find_objects(labels) img_width = float(self.original_pixmap.width())
w, h = arr.shape img_height = float(self.original_pixmap.height())
bounding_boxes = [
[obj[0].start / w, obj[1].start / h, obj[0].stop / w, obj[1].stop / h]
for obj in objects
]
polylines = find_contours(arr_bin, 0.5) params: List[Dict[str, Any]] = []
params = []
for i, pl in enumerate(polylines):
# pl is in [row, col] format from find_contours
# We need to normalize: row/height, col/width
# w = height (rows), h = width (cols) from line 510
normalized_polyline = (pl[::-1] / np.array([w, h])).tolist()
logger.debug(f"Polyline {i}: {len(pl)} points") for idx, polyline in enumerate(self.polylines):
logger.debug(f" w={w} (height), h={h} (width)") if len(polyline) < 2:
logger.debug(f" First 3 normalized points: {normalized_polyline[:3]}") continue
xs = [p[0] for p in polyline]
ys = [p[1] for p in polyline]
x_min_norm = min(xs) / img_width
x_max_norm = max(xs) / img_width
y_min_norm = min(ys) / img_height
y_max_norm = max(ys) / img_height
# Store polyline as [y_norm, x_norm] to match DB convention and
# the expectations of draw_saved_polyline().
normalized_polyline = [
[y / img_height, x / img_width] for (x, y) in polyline
]
logger.debug(
f"Polyline {idx}: {len(polyline)} points, "
f"bbox=({x_min_norm:.3f}, {y_min_norm:.3f})-({x_max_norm:.3f}, {y_max_norm:.3f})"
)
params.append( params.append(
{ {
"bbox": bounding_boxes[i], "bbox": [x_min_norm, y_min_norm, x_max_norm, y_max_norm],
"polyline": normalized_polyline, "polyline": normalized_polyline,
} }
) )
return params return params or None
def draw_saved_polyline( def draw_saved_polyline(
self, polyline: List[List[float]], color: str, width: int = 3 self,
polyline: List[List[float]],
color: str,
width: int = 3,
annotation_id: Optional[int] = None,
): ):
""" """
Draw a polyline from database coordinates onto the annotation canvas. Draw a polyline from database coordinates onto the annotation canvas.
@@ -548,36 +765,24 @@ class AnnotationCanvasWidget(QWidget):
logger.debug(f" Image size: {img_width}x{img_height}") logger.debug(f" Image size: {img_width}x{img_height}")
logger.debug(f" First 3 normalized points from DB: {polyline[:3]}") logger.debug(f" First 3 normalized points from DB: {polyline[:3]}")
img_coords = [] img_coords: List[Tuple[float, float]] = []
for y_norm, x_norm in polyline: for y_norm, x_norm in polyline:
x = int(x_norm * img_width) x = float(x_norm * img_width)
y = int(y_norm * img_height) y = float(y_norm * img_height)
img_coords.append((x, y)) img_coords.append((x, y))
logger.debug(f" First 3 pixel coords: {img_coords[:3]}") logger.debug(f" First 3 pixel coords: {img_coords[:3]}")
# Draw polyline on annotation pixmap # Store and redraw using common pipeline
painter = QPainter(self.annotation_pixmap)
pen_color = QColor(color) pen_color = QColor(color)
pen_color.setAlpha(128) # Add semi-transparency pen_color.setAlpha(128) # Add semi-transparency
pen = QPen(pen_color, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) self._add_polyline(img_coords, pen_color, width, annotation_id=annotation_id)
painter.setPen(pen)
# Draw lines between consecutive points # Store in all_strokes for consistency (uses normalized coordinates)
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( self.all_strokes.append(
{"points": polyline, "color": color, "alpha": 128, "width": width} {"points": polyline, "color": color, "alpha": 128, "width": width}
) )
# Update display
self._update_display()
logger.debug( logger.debug(
f"Drew saved polyline with {len(polyline)} points in color {color}" f"Drew saved polyline with {len(polyline)} points in color {color}"
) )
@@ -587,7 +792,7 @@ class AnnotationCanvasWidget(QWidget):
Draw a bounding box from database coordinates onto the annotation canvas. Draw a bounding box from database coordinates onto the annotation canvas.
Args: Args:
bbox: Bounding box as [y_min_norm, x_min_norm, y_max_norm, x_max_norm] bbox: Bounding box as [x_min_norm, y_min_norm, x_max_norm, y_max_norm]
in normalized coordinates (0-1) in normalized coordinates (0-1)
color: Color hex string (e.g., '#FF0000') color: Color hex string (e.g., '#FF0000')
width: Line width in pixels width: Line width in pixels
@@ -602,12 +807,11 @@ class AnnotationCanvasWidget(QWidget):
) )
return return
# Convert normalized coordinates to image coordinates # Convert normalized coordinates to image coordinates (for logging/debug)
# bbox format: [y_min_norm, x_min_norm, y_max_norm, x_max_norm]
img_width = self.original_pixmap.width() img_width = self.original_pixmap.width()
img_height = self.original_pixmap.height() img_height = self.original_pixmap.height()
y_min_norm, x_min_norm, y_max_norm, x_max_norm = bbox x_min_norm, y_min_norm, x_max_norm, y_max_norm = bbox
x_min = int(x_min_norm * img_width) x_min = int(x_min_norm * img_width)
y_min = int(y_min_norm * img_height) y_min = int(y_min_norm * img_height)
x_max = int(x_max_norm * img_width) x_max = int(x_max_norm * img_width)
@@ -617,29 +821,35 @@ class AnnotationCanvasWidget(QWidget):
logger.debug(f" Image size: {img_width}x{img_height}") logger.debug(f" Image size: {img_width}x{img_height}")
logger.debug(f" Pixel coords: ({x_min}, {y_min}) to ({x_max}, {y_max})") logger.debug(f" Pixel coords: ({x_min}, {y_min}) to ({x_max}, {y_max})")
# Draw bounding box on annotation pixmap # Store bounding box (normalized) and its style; actual drawing happens
painter = QPainter(self.annotation_pixmap) # in _redraw_annotations() together with all polylines.
pen_color = QColor(color) pen_color = QColor(color)
pen_color.setAlpha(128) # Add semi-transparency pen_color.setAlpha(128) # Add semi-transparency
pen = QPen(pen_color, width, Qt.SolidLine, Qt.SquareCap, Qt.MiterJoin) self.bboxes.append(
painter.setPen(pen) [float(x_min_norm), float(y_min_norm), float(x_max_norm), float(y_max_norm)]
)
# Draw rectangle self.bbox_meta.append({"color": pen_color, "width": int(width)})
rect_width = x_max - x_min
rect_height = y_max - y_min
painter.drawRect(x_min, y_min, rect_width, rect_height)
painter.end()
# Store in all_strokes for consistency # Store in all_strokes for consistency
self.all_strokes.append( self.all_strokes.append(
{"bbox": bbox, "color": color, "alpha": 128, "width": width} {"bbox": bbox, "color": color, "alpha": 128, "width": width}
) )
# Update display # Redraw overlay (polylines + all bounding boxes)
self._update_display() self._redraw_annotations()
logger.debug(f"Drew saved bounding box in color {color}") logger.debug(f"Drew saved bounding box in color {color}")
def set_show_bboxes(self, show: bool):
"""
Enable or disable drawing of bounding boxes.
Args:
show: If True, draw bounding boxes; if False, hide them.
"""
self.show_bboxes = bool(show)
logger.debug(f"Set show_bboxes to {self.show_bboxes}")
self._redraw_annotations()
def keyPressEvent(self, event: QKeyEvent): def keyPressEvent(self, event: QKeyEvent):
"""Handle keyboard events for zooming.""" """Handle keyboard events for zooming."""
if event.key() in (Qt.Key_Plus, Qt.Key_Equal): if event.key() in (Qt.Key_Plus, Qt.Key_Equal):

View File

@@ -1,6 +1,6 @@
""" """
Annotation tools widget for controlling annotation parameters. 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 ( from PySide6.QtWidgets import (
@@ -12,6 +12,8 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QComboBox, QComboBox,
QSpinBox, QSpinBox,
QDoubleSpinBox,
QCheckBox,
QColorDialog, QColorDialog,
QInputDialog, QInputDialog,
QMessageBox, QMessageBox,
@@ -31,28 +33,33 @@ class AnnotationToolsWidget(QWidget):
Widget for annotation tool controls. Widget for annotation tool controls.
Features: Features:
- Enable/disable pen tool - Enable/disable polyline tool
- Color selection for pen - Color selection for polyline pen
- Object class selection - Object class selection
- Add new object classes - Add new object classes
- Pen width control - Pen width control
- Clear annotations - Clear annotations
Signals: Signals:
pen_enabled_changed: Emitted when pen tool is enabled/disabled (bool) polyline_enabled_changed: Emitted when polyline tool is enabled/disabled (bool)
pen_color_changed: Emitted when pen color changes (QColor) polyline_pen_color_changed: Emitted when polyline pen color changes (QColor)
pen_width_changed: Emitted when pen width changes (int) polyline_pen_width_changed: Emitted when polyline pen width changes (int)
class_selected: Emitted when object class is selected (dict) class_selected: Emitted when object class is selected (dict)
clear_annotations_requested: Emitted when clear button is pressed clear_annotations_requested: Emitted when clear button is pressed
""" """
pen_enabled_changed = Signal(bool) polyline_enabled_changed = Signal(bool)
pen_color_changed = Signal(QColor) polyline_pen_color_changed = Signal(QColor)
pen_width_changed = Signal(int) polyline_pen_width_changed = Signal(int)
simplify_on_finish_changed = Signal(bool)
simplify_epsilon_changed = Signal(float)
# Toggle visibility of bounding boxes on the canvas
show_bboxes_changed = Signal(bool)
class_selected = Signal(dict) class_selected = Signal(dict)
class_color_changed = Signal()
clear_annotations_requested = Signal() clear_annotations_requested = Signal()
process_annotations_requested = Signal() # Request deletion of the currently selected annotation on the canvas
show_annotations_requested = Signal() delete_selected_annotation_requested = Signal()
def __init__(self, db_manager: DatabaseManager, parent=None): def __init__(self, db_manager: DatabaseManager, parent=None):
""" """
@@ -64,7 +71,7 @@ class AnnotationToolsWidget(QWidget):
""" """
super().__init__(parent) super().__init__(parent)
self.db_manager = db_manager 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_color = QColor(255, 0, 0, 128) # Red with 50% alpha
self.current_class = None self.current_class = None
@@ -75,43 +82,51 @@ class AnnotationToolsWidget(QWidget):
"""Setup user interface.""" """Setup user interface."""
layout = QVBoxLayout() layout = QVBoxLayout()
# Pen Tool Group # Polyline Tool Group
pen_group = QGroupBox("Pen Tool") polyline_group = QGroupBox("Polyline Tool")
pen_layout = QVBoxLayout() polyline_layout = QVBoxLayout()
# Enable/Disable pen # Enable/Disable polyline tool
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.pen_toggle_btn = QPushButton("Enable Pen") self.polyline_toggle_btn = QPushButton("Start Drawing Polyline")
self.pen_toggle_btn.setCheckable(True) self.polyline_toggle_btn.setCheckable(True)
self.pen_toggle_btn.clicked.connect(self._on_pen_toggle) self.polyline_toggle_btn.clicked.connect(self._on_polyline_toggle)
button_layout.addWidget(self.pen_toggle_btn) button_layout.addWidget(self.polyline_toggle_btn)
pen_layout.addLayout(button_layout) polyline_layout.addLayout(button_layout)
# Pen width control # Polyline pen width control
width_layout = QHBoxLayout() width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Pen Width:")) width_layout.addWidget(QLabel("Pen Width:"))
self.pen_width_spin = QSpinBox() self.polyline_pen_width_spin = QSpinBox()
self.pen_width_spin.setMinimum(1) self.polyline_pen_width_spin.setMinimum(1)
self.pen_width_spin.setMaximum(20) self.polyline_pen_width_spin.setMaximum(20)
self.pen_width_spin.setValue(3) self.polyline_pen_width_spin.setValue(3)
self.pen_width_spin.valueChanged.connect(self._on_pen_width_changed) self.polyline_pen_width_spin.valueChanged.connect(
width_layout.addWidget(self.pen_width_spin) self._on_polyline_pen_width_changed
)
width_layout.addWidget(self.polyline_pen_width_spin)
width_layout.addStretch() width_layout.addStretch()
pen_layout.addLayout(width_layout) polyline_layout.addLayout(width_layout)
# Color selection # Simplification controls (RDP)
color_layout = QHBoxLayout() simplify_layout = QHBoxLayout()
color_layout.addWidget(QLabel("Color:")) self.simplify_checkbox = QCheckBox("Simplify on finish")
self.color_btn = QPushButton() self.simplify_checkbox.setChecked(True)
self.color_btn.setFixedSize(40, 30) self.simplify_checkbox.stateChanged.connect(self._on_simplify_toggle)
self.color_btn.clicked.connect(self._on_color_picker) simplify_layout.addWidget(self.simplify_checkbox)
self._update_color_button()
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
pen_layout.addLayout(color_layout)
pen_group.setLayout(pen_layout) simplify_layout.addWidget(QLabel("epsilon (px):"))
layout.addWidget(pen_group) 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()
polyline_layout.addLayout(simplify_layout)
polyline_group.setLayout(polyline_layout)
layout.addWidget(polyline_group)
# Object Class Group # Object Class Group
class_group = QGroupBox("Object Class") class_group = QGroupBox("Object Class")
@@ -122,7 +137,7 @@ class AnnotationToolsWidget(QWidget):
self.class_combo.currentIndexChanged.connect(self._on_class_selected) self.class_combo.currentIndexChanged.connect(self._on_class_selected)
class_layout.addWidget(self.class_combo) class_layout.addWidget(self.class_combo)
# Add class button # Add / manage classes
class_button_layout = QHBoxLayout() class_button_layout = QHBoxLayout()
self.add_class_btn = QPushButton("Add New Class") self.add_class_btn = QPushButton("Add New Class")
self.add_class_btn.clicked.connect(self._on_add_class) self.add_class_btn.clicked.connect(self._on_add_class)
@@ -133,6 +148,17 @@ class AnnotationToolsWidget(QWidget):
class_button_layout.addWidget(self.refresh_classes_btn) class_button_layout.addWidget(self.refresh_classes_btn)
class_layout.addLayout(class_button_layout) 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 # Selected class info
self.class_info_label = QLabel("No class selected") self.class_info_label = QLabel("No class selected")
self.class_info_label.setWordWrap(True) self.class_info_label.setWordWrap(True)
@@ -148,24 +174,22 @@ class AnnotationToolsWidget(QWidget):
actions_group = QGroupBox("Actions") actions_group = QGroupBox("Actions")
actions_layout = QVBoxLayout() actions_layout = QVBoxLayout()
self.process_btn = QPushButton("Process Annotations") # Show / hide bounding boxes
self.process_btn.clicked.connect(self._on_process_annotations) self.show_bboxes_checkbox = QCheckBox("Show bounding boxes")
self.process_btn.setStyleSheet( self.show_bboxes_checkbox.setChecked(True)
"QPushButton { background-color: #2196F3; color: white; font-weight: bold; }" self.show_bboxes_checkbox.stateChanged.connect(self._on_show_bboxes_toggle)
) actions_layout.addWidget(self.show_bboxes_checkbox)
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 = QPushButton("Clear All Annotations")
self.clear_btn.clicked.connect(self._on_clear_annotations) self.clear_btn.clicked.connect(self._on_clear_annotations)
actions_layout.addWidget(self.clear_btn) actions_layout.addWidget(self.clear_btn)
# Delete currently selected annotation (enabled when a selection exists)
self.delete_selected_btn = QPushButton("Delete Selected Annotation")
self.delete_selected_btn.clicked.connect(self._on_delete_selected_annotation)
self.delete_selected_btn.setEnabled(False)
actions_layout.addWidget(self.delete_selected_btn)
actions_group.setLayout(actions_layout) actions_group.setLayout(actions_layout)
layout.addWidget(actions_group) layout.addWidget(actions_group)
@@ -193,7 +217,7 @@ class AnnotationToolsWidget(QWidget):
# Clear and repopulate combo box # Clear and repopulate combo box
self.class_combo.clear() self.class_combo.clear()
self.class_combo.addItem("-- Select Class --", None) self.class_combo.addItem("-- Select Class / Show All --", None)
for cls in classes: for cls in classes:
self.class_combo.addItem(cls["class_name"], cls) self.class_combo.addItem(cls["class_name"], cls)
@@ -206,46 +230,115 @@ class AnnotationToolsWidget(QWidget):
self, "Error", f"Failed to load object classes:\n{str(e)}" self, "Error", f"Failed to load object classes:\n{str(e)}"
) )
def _on_pen_toggle(self, checked: bool): def _on_polyline_toggle(self, checked: bool):
"""Handle pen tool enable/disable.""" """Handle polyline tool enable/disable."""
self.pen_enabled = checked self.polyline_enabled = checked
if checked: if checked:
self.pen_toggle_btn.setText("Disable Pen") self.polyline_toggle_btn.setText("Stop Drawing Polyline")
self.pen_toggle_btn.setStyleSheet( self.polyline_toggle_btn.setStyleSheet(
"QPushButton { background-color: #4CAF50; }" "QPushButton { background-color: #4CAF50; }"
) )
else: else:
self.pen_toggle_btn.setText("Enable Pen") self.polyline_toggle_btn.setText("Start Drawing Polyline")
self.pen_toggle_btn.setStyleSheet("") self.polyline_toggle_btn.setStyleSheet("")
self.pen_enabled_changed.emit(self.pen_enabled) self.polyline_enabled_changed.emit(self.polyline_enabled)
logger.debug(f"Pen tool {'enabled' if checked else 'disabled'}") logger.debug(f"Polyline tool {'enabled' if checked else 'disabled'}")
def _on_pen_width_changed(self, width: int): def _on_polyline_pen_width_changed(self, width: int):
"""Handle pen width changes.""" """Handle polyline pen width changes."""
self.pen_width_changed.emit(width) self.polyline_pen_width_changed.emit(width)
logger.debug(f"Pen width changed to {width}") logger.debug(f"Polyline pen width changed to {width}")
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}")
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}")
def _on_color_picker(self): 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( color = QColorDialog.getColor(
self.current_color, base_color,
self, self,
"Select Pen Color", "Select Class Color",
QColorDialog.ShowAlphaChannel, # Enable alpha channel selection QColorDialog.ShowAlphaChannel, # Allow alpha in UI, but store RGB in DB
) )
if color.isValid(): if not color.isValid():
self.current_color = color return
self._update_color_button()
self.pen_color_changed.emit(color) # Normalize to opaque RGB for storage
logger.debug( new_color = QColor(color)
f"Pen color changed to {color.name()} with alpha {color.alpha()}" 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): def _on_class_selected(self, index: int):
"""Handle object class selection.""" """Handle object class selection (including '-- Select Class --')."""
class_data = self.class_combo.currentData() class_data = self.class_combo.currentData()
if class_data: if class_data:
@@ -260,20 +353,23 @@ class AnnotationToolsWidget(QWidget):
self.class_info_label.setText(info_text) 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"]) class_color = QColor(class_data["color"])
if class_color.isValid(): if class_color.isValid():
# Add 50% alpha for semi-transparency # Add 50% alpha for semi-transparency
class_color.setAlpha(128) class_color.setAlpha(128)
self.current_color = class_color self.current_color = class_color
self._update_color_button() 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) self.class_selected.emit(class_data)
logger.debug(f"Selected class: {class_data['class_name']}") logger.debug(f"Selected class: {class_data['class_name']}")
else: else:
# "-- Select Class --" chosen: clear current class and show all annotations
self.current_class = None self.current_class = None
self.class_info_label.setText("No class selected") 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): def _on_add_class(self):
"""Handle adding a new object class.""" """Handle adding a new object class."""
@@ -351,36 +447,32 @@ class AnnotationToolsWidget(QWidget):
self.clear_annotations_requested.emit() self.clear_annotations_requested.emit()
logger.debug("Clear annotations requested") logger.debug("Clear annotations requested")
def _on_process_annotations(self): def _on_delete_selected_annotation(self):
"""Handle process annotations button.""" """Handle delete selected annotation button."""
if not self.current_class: self.delete_selected_annotation_requested.emit()
QMessageBox.warning( logger.debug("Delete selected annotation requested")
self,
"No Class Selected",
"Please select an object class before processing annotations.",
)
return
self.process_annotations_requested.emit() def set_has_selected_annotation(self, has_selection: bool):
logger.debug("Process annotations requested") """
Enable/disable actions that require a selected annotation.
def _on_show_annotations(self): Args:
"""Handle show annotations button.""" has_selection: True if an annotation is currently selected on the canvas.
self.show_annotations_requested.emit() """
logger.debug("Show annotations requested") self.delete_selected_btn.setEnabled(bool(has_selection))
def get_current_class(self) -> Optional[Dict]: def get_current_class(self) -> Optional[Dict]:
"""Get currently selected object class.""" """Get currently selected object class."""
return self.current_class return self.current_class
def get_pen_color(self) -> QColor: def get_polyline_pen_color(self) -> QColor:
"""Get current pen color.""" """Get current polyline pen color."""
return self.current_color return self.current_color
def get_pen_width(self) -> int: def get_polyline_pen_width(self) -> int:
"""Get current pen width.""" """Get current polyline pen width."""
return self.pen_width_spin.value() return self.polyline_pen_width_spin.value()
def is_pen_enabled(self) -> bool: def is_polyline_enabled(self) -> bool:
"""Check if pen tool is enabled.""" """Check if polyline tool is enabled."""
return self.pen_enabled return self.polyline_enabled