This commit is contained in:
2025-12-09 22:44:23 +02:00
parent 73cb698488
commit dad5c2bf74
3 changed files with 411 additions and 224 deletions

View File

@@ -38,6 +38,7 @@ class AnnotationTab(QWidget):
self.current_image = None
self.current_image_path = None
self.current_image_id = None
self.current_annotations = []
self._setup_ui()
@@ -89,6 +90,13 @@ class AnnotationTab(QWidget):
self.annotation_tools.pen_width_changed.connect(
self.annotation_canvas.set_pen_width
)
# 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
)
self.annotation_tools.class_selected.connect(self._on_class_selected)
self.annotation_tools.clear_annotations_requested.connect(
self._on_clear_annotations
@@ -96,9 +104,6 @@ class AnnotationTab(QWidget):
self.annotation_tools.process_annotations_requested.connect(
self._on_process_annotations
)
self.annotation_tools.show_annotations_requested.connect(
self._on_show_annotations
)
self.right_splitter.addWidget(self.annotation_tools)
# Image loading section
@@ -180,6 +185,9 @@ class AnnotationTab(QWidget):
# Display image using the AnnotationCanvasWidget
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
self._update_image_info()
@@ -217,7 +225,22 @@ class AnnotationTab(QWidget):
self._update_image_info()
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()
if not current_class:
@@ -229,14 +252,58 @@ class AnnotationTab(QWidget):
)
return
logger.info(
f"Annotation drawn with {len(points)} points for class: {current_class['class_name']}"
)
# Future: Save annotation to database or export
if not points:
logger.warning("Annotation drawn with no points, ignoring")
return
# points are [(x_norm, y_norm), ...]
xs = [p[0] for p in points]
ys = [p[1] for p in points]
x_min, x_max = min(xs), max(xs)
y_min, y_max = min(ys), max(ys)
# Store segmentation mask in [y_norm, x_norm] format to match DB
db_polyline = [[float(y), float(x)] for (x, y) in points]
try:
annotation_id = self.db_manager.add_annotation(
image_id=self.current_image_id,
class_id=current_class["id"],
bbox=(x_min, y_min, x_max, y_max),
annotator="manual",
segmentation_mask=db_polyline,
verified=False,
)
logger.info(
f"Saved annotation (ID: {annotation_id}) for class "
f"'{current_class['class_name']}' "
f"Bounding box: ({x_min:.3f}, {y_min:.3f}) to ({x_max:.3f}, {y_max:.3f})\n"
f"with {len(points)} polyline points"
)
# Reload annotations from DB and redraw (respecting current class filter)
self._load_annotations_for_current_image()
except Exception as e:
logger.error(f"Failed to save annotation: {e}")
QMessageBox.critical(self, "Error", f"Failed to save annotation:\n{str(e)}")
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_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
self._redraw_annotations_for_current_filter()
def _on_clear_annotations(self):
"""Handle clearing all annotations."""
@@ -244,138 +311,92 @@ class AnnotationTab(QWidget):
logger.info("Cleared all annotations")
def _on_process_annotations(self):
"""Process annotations and save to database."""
# Check if we have an image loaded
"""
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 processing annotations."
)
return
# Get current class
current_class = self.annotation_tools.get_current_class()
if not current_class:
QMessageBox.warning(
self,
"No Class Selected",
"Please select an object class before processing annotations.",
"No Image",
"Please load an image before working with annotations.",
)
return
# Compute annotation parameters asbounding boxes and polylines from annotations
parameters = self.annotation_canvas.get_annotation_parameters()
if not parameters:
QMessageBox.warning(
self,
"No Annotations",
"Please draw some annotations before processing.",
)
return
# 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(
QMessageBox.information(
self,
"Clear Annotations",
"Do you want to clear the annotations to start a new one?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
"Annotations Already Saved",
"Annotations are saved automatically as you draw. "
"There is no separate processing step required.",
)
if reply == QMessageBox.Yes:
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()
logger.info("Cleared annotations after saving")
def _on_show_annotations(self):
"""Load and display saved annotations from database."""
# Check if we have an image loaded
if not self.current_image or not self.current_image_id:
QMessageBox.warning(
self, "No Image", "Please load an image to view its annotations."
)
return
try:
# Clear current annotations
self.annotation_canvas.clear_annotations()
# Retrieve annotations from database
annotations = self.db_manager.get_annotations_for_image(
self.current_annotations = self.db_manager.get_annotations_for_image(
self.current_image_id
)
if not annotations:
QMessageBox.information(
self, "No Annotations", "No saved annotations found for this image."
)
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).",
)
self._redraw_annotations_for_current_filter()
except Exception as e:
logger.error(f"Failed to load annotations: {e}")
QMessageBox.critical(
self, "Error", f"Failed to load annotations:\n{str(e)}"
logger.error(
f"Failed to load annotations for image {self.current_image_id}: {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)
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):
"""Restore splitter positions from settings."""

View File

@@ -4,6 +4,7 @@ Supports pen tool with color selection for manual annotation.
"""
import numpy as np
import math
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea
from PySide6.QtGui import (
@@ -19,18 +20,95 @@ from PySide6.QtGui import (
from PySide6.QtCore import Qt, QEvent, Signal, QPoint
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.logger import get_logger
# For debugging visualization
import pylab as plt
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):
"""
Widget for displaying images and drawing annotations with pen tool.
@@ -68,8 +146,19 @@ class AnnotationCanvasWidget(QWidget):
self.pen_enabled = False
self.pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha
self.pen_width = 3
self.current_stroke = [] # Points in current stroke
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)
# 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()
@@ -128,6 +217,8 @@ class AnnotationCanvasWidget(QWidget):
"""Clear all drawn annotations."""
self.all_strokes = []
self.current_stroke = []
self.polylines = []
self.stroke_meta = []
self.is_drawing = False
if self.annotation_pixmap:
self.annotation_pixmap.fill(Qt.transparent)
@@ -300,6 +391,46 @@ class AnnotationCanvasWidget(QWidget):
norm_y = y / self.original_pixmap.height()
return (norm_x, norm_y)
def _add_polyline(
self, img_points: List[Tuple[float, float]], color: QColor, width: int
):
"""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._redraw_annotations()
def _redraw_annotations(self):
"""Redraw all stored polylines 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)
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 = 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))
painter.end()
self._update_display()
def mousePressEvent(self, event: QMouseEvent):
"""Handle mouse press events for drawing."""
if not self.pen_enabled or self.annotation_pixmap is None:
@@ -313,7 +444,7 @@ class AnnotationCanvasWidget(QWidget):
if img_coords:
self.is_drawing = True
self.current_stroke = [img_coords]
self.current_stroke = [(float(img_coords[0]), float(img_coords[1]))]
def mouseMoveEvent(self, event: QMouseEvent):
"""Handle mouse move events for drawing."""
@@ -330,18 +461,33 @@ class AnnotationCanvasWidget(QWidget):
img_coords = self._canvas_to_image_coords(label_pos)
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)
pen = QPen(
self.pen_color, self.pen_width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin
self.pen_color,
self.pen_width,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin,
)
painter.setPen(pen)
last_point = self.current_stroke[-1]
painter.drawLine(last_point[0], last_point[1], img_coords[0], img_coords[1])
painter.drawLine(
int(last_point[0]),
int(last_point[1]),
int(img_coords[0]),
int(img_coords[1]),
)
painter.end()
self.current_stroke.append(img_coords)
self.current_stroke.append((float(img_coords[0]), float(img_coords[1])))
self._update_display()
def mouseReleaseEvent(self, event: QMouseEvent):
@@ -352,23 +498,42 @@ class AnnotationCanvasWidget(QWidget):
self.is_drawing = False
if len(self.current_stroke) > 1:
# Convert to normalized coordinates and save stroke
normalized_stroke = [
self._image_to_normalized_coords(x, y) for x, y in self.current_stroke
]
self.all_strokes.append(
{
"points": normalized_stroke,
"color": self.pen_color.name(),
"alpha": self.pen_color.alpha(),
"width": self.pen_width,
}
)
if len(self.current_stroke) > 1 and self.original_pixmap is not None:
# Ensure the stroke is closed by connecting end -> start
raw_points = list(self.current_stroke)
if raw_points[0] != raw_points[-1]:
raw_points.append(raw_points[0])
# Emit signal with normalized coordinates
self.annotation_drawn.emit(normalized_stroke)
logger.debug(f"Completed stroke with {len(normalized_stroke)} points")
# Optional RDP simplification (in image pixel space)
if self.simplify_on_finish:
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.pen_color, self.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.pen_color.name(),
"alpha": self.pen_color.alpha(),
"width": self.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 = []
@@ -464,61 +629,54 @@ class AnnotationCanvasWidget(QWidget):
# return polyline
def get_annotation_parameters(self) -> Dict[str, Any]:
def get_annotation_parameters(self) -> Optional[List[Dict[str, Any]]]:
"""
Get all annotation parameters including bounding box and polyline.
Returns:
Dictionary containing:
- 'bbox': Bounding box coordinates (x_min, y_min, x_max, y_max)
- 'polyline': List of [x, y] coordinate pairs
List of dictionaries, each containing:
- 'bbox': [x_min, y_min, x_max, y_max] in normalized image coordinates
- 'polyline': List of [y_norm, x_norm] points describing the polygon
"""
# 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:
if self.original_pixmap is None or not self.polylines:
return None
objects = find_objects(labels)
w, h = arr.shape
bounding_boxes = [
[obj[1].start / h, obj[0].start / w, obj[1].stop / h, obj[0].stop / w]
for obj in objects
]
img_width = float(self.original_pixmap.width())
img_height = float(self.original_pixmap.height())
polylines = find_contours(arr_bin, 0.5)
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()
params: List[Dict[str, Any]] = []
logger.debug(f"Polyline {i}: {len(pl)} points")
logger.debug(f" w={w} (height), h={h} (width)")
logger.debug(f" First 3 normalized points: {normalized_polyline[:3]}")
for idx, polyline in enumerate(self.polylines):
if len(polyline) < 2:
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(
{
"bbox": bounding_boxes[i],
"bbox": [x_min_norm, y_min_norm, x_max_norm, y_max_norm],
"polyline": normalized_polyline,
}
)
return params
return params or None
def draw_saved_polyline(
self, polyline: List[List[float]], color: str, width: int = 3
@@ -548,36 +706,24 @@ class AnnotationCanvasWidget(QWidget):
logger.debug(f" Image size: {img_width}x{img_height}")
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:
x = int(x_norm * img_width)
y = int(y_norm * img_height)
x = float(x_norm * img_width)
y = float(y_norm * img_height)
img_coords.append((x, y))
logger.debug(f" First 3 pixel coords: {img_coords[:3]}")
# Draw polyline on annotation pixmap
painter = QPainter(self.annotation_pixmap)
# Store and redraw using common pipeline
pen_color = QColor(color)
pen_color.setAlpha(128) # Add semi-transparency
pen = QPen(pen_color, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
painter.setPen(pen)
self._add_polyline(img_coords, pen_color, width)
# Draw lines between consecutive points
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
# Store in all_strokes for consistency (uses normalized coordinates)
self.all_strokes.append(
{"points": polyline, "color": color, "alpha": 128, "width": width}
)
# Update display
self._update_display()
logger.debug(
f"Drew saved polyline with {len(polyline)} points in color {color}"
)

View File

@@ -12,6 +12,8 @@ from PySide6.QtWidgets import (
QPushButton,
QComboBox,
QSpinBox,
QDoubleSpinBox,
QCheckBox,
QColorDialog,
QInputDialog,
QMessageBox,
@@ -49,10 +51,11 @@ class AnnotationToolsWidget(QWidget):
pen_enabled_changed = Signal(bool)
pen_color_changed = Signal(QColor)
pen_width_changed = Signal(int)
simplify_on_finish_changed = Signal(bool)
simplify_epsilon_changed = Signal(float)
class_selected = Signal(dict)
clear_annotations_requested = Signal()
process_annotations_requested = Signal()
show_annotations_requested = Signal()
def __init__(self, db_manager: DatabaseManager, parent=None):
"""
@@ -110,6 +113,23 @@ class AnnotationToolsWidget(QWidget):
color_layout.addStretch()
pen_layout.addLayout(color_layout)
# 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()
pen_layout.addLayout(simplify_layout)
pen_group.setLayout(pen_layout)
layout.addWidget(pen_group)
@@ -155,13 +175,6 @@ class AnnotationToolsWidget(QWidget):
)
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.clicked.connect(self._on_clear_annotations)
actions_layout.addWidget(self.clear_btn)
@@ -227,6 +240,18 @@ class AnnotationToolsWidget(QWidget):
self.pen_width_changed.emit(width)
logger.debug(f"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_color_picker(self):
"""Open color picker dialog with alpha support."""
color = QColorDialog.getColor(
@@ -364,11 +389,6 @@ class AnnotationToolsWidget(QWidget):
self.process_annotations_requested.emit()
logger.debug("Process annotations requested")
def _on_show_annotations(self):
"""Handle show annotations button."""
self.show_annotations_requested.emit()
logger.debug("Show annotations requested")
def get_current_class(self) -> Optional[Dict]:
"""Get currently selected object class."""
return self.current_class