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 = None
self.current_image_path = None self.current_image_path = None
self.current_image_id = None self.current_image_id = None
self.current_annotations = []
self._setup_ui() self._setup_ui()
@@ -89,6 +90,13 @@ class AnnotationTab(QWidget):
self.annotation_tools.pen_width_changed.connect( self.annotation_tools.pen_width_changed.connect(
self.annotation_canvas.set_pen_width 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.class_selected.connect(self._on_class_selected)
self.annotation_tools.clear_annotations_requested.connect( self.annotation_tools.clear_annotations_requested.connect(
self._on_clear_annotations self._on_clear_annotations
@@ -96,9 +104,6 @@ class AnnotationTab(QWidget):
self.annotation_tools.process_annotations_requested.connect( self.annotation_tools.process_annotations_requested.connect(
self._on_process_annotations self._on_process_annotations
) )
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)
# Image loading section # Image loading section
@@ -180,6 +185,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 +225,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,14 +252,58 @@ 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
# 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): def _on_class_selected(self, class_data: dict):
"""Handle when an object class is selected.""" """Handle when an object class is selected."""
logger.debug(f"Object class selected: {class_data['class_name']}") 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): def _on_clear_annotations(self):
"""Handle clearing all annotations.""" """Handle clearing all annotations."""
@@ -244,138 +311,92 @@ class AnnotationTab(QWidget):
logger.info("Cleared all annotations") logger.info("Cleared all annotations")
def _on_process_annotations(self): 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: 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( QMessageBox.warning(
self, self,
"No Class Selected", "No Image",
"Please select an object class before processing annotations.", "Please load an image before working with annotations.",
) )
return return
# Compute annotation parameters asbounding boxes and polylines from annotations QMessageBox.information(
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(
self, self,
"Clear Annotations", "Annotations Already Saved",
"Do you want to clear the annotations to start a new one?", "Annotations are saved automatically as you draw. "
QMessageBox.Yes | QMessageBox.No, "There is no separate processing step required.",
QMessageBox.Yes,
) )
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() 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 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
) )
self._redraw_annotations_for_current_filter()
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).",
)
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)
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

@@ -4,6 +4,7 @@ Supports pen 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,18 +20,95 @@ 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 pen tool.
@@ -68,8 +146,19 @@ class AnnotationCanvasWidget(QWidget):
self.pen_enabled = False self.pen_enabled = False
self.pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha self.pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha
self.pen_width = 3 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() self._setup_ui()
@@ -128,6 +217,8 @@ 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.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)
@@ -300,6 +391,46 @@ 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
):
"""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): def mousePressEvent(self, event: QMouseEvent):
"""Handle mouse press events for drawing.""" """Handle mouse press events for drawing."""
if not self.pen_enabled or self.annotation_pixmap is None: if not self.pen_enabled or self.annotation_pixmap is None:
@@ -313,7 +444,7 @@ class AnnotationCanvasWidget(QWidget):
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]))]
def mouseMoveEvent(self, event: QMouseEvent): def mouseMoveEvent(self, event: QMouseEvent):
"""Handle mouse move events for drawing.""" """Handle mouse move events for drawing."""
@@ -330,18 +461,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.pen_color,
self.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 +498,42 @@ 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.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 = [] self.current_stroke = []
@@ -464,61 +629,54 @@ class AnnotationCanvasWidget(QWidget):
# return polyline # 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. 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[1].start / h, obj[0].start / w, obj[1].stop / h, obj[0].stop / w]
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
@@ -548,36 +706,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)
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}"
) )

View File

@@ -12,6 +12,8 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QComboBox, QComboBox,
QSpinBox, QSpinBox,
QDoubleSpinBox,
QCheckBox,
QColorDialog, QColorDialog,
QInputDialog, QInputDialog,
QMessageBox, QMessageBox,
@@ -49,10 +51,11 @@ class AnnotationToolsWidget(QWidget):
pen_enabled_changed = Signal(bool) pen_enabled_changed = Signal(bool)
pen_color_changed = Signal(QColor) pen_color_changed = Signal(QColor)
pen_width_changed = Signal(int) pen_width_changed = Signal(int)
simplify_on_finish_changed = Signal(bool)
simplify_epsilon_changed = Signal(float)
class_selected = Signal(dict) class_selected = Signal(dict)
clear_annotations_requested = Signal() clear_annotations_requested = Signal()
process_annotations_requested = Signal() process_annotations_requested = Signal()
show_annotations_requested = Signal()
def __init__(self, db_manager: DatabaseManager, parent=None): def __init__(self, db_manager: DatabaseManager, parent=None):
""" """
@@ -110,6 +113,23 @@ class AnnotationToolsWidget(QWidget):
color_layout.addStretch() color_layout.addStretch()
pen_layout.addLayout(color_layout) 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) pen_group.setLayout(pen_layout)
layout.addWidget(pen_group) layout.addWidget(pen_group)
@@ -155,13 +175,6 @@ class AnnotationToolsWidget(QWidget):
) )
actions_layout.addWidget(self.process_btn) 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)
@@ -227,6 +240,18 @@ class AnnotationToolsWidget(QWidget):
self.pen_width_changed.emit(width) self.pen_width_changed.emit(width)
logger.debug(f"Pen width changed to {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): def _on_color_picker(self):
"""Open color picker dialog with alpha support.""" """Open color picker dialog with alpha support."""
color = QColorDialog.getColor( color = QColorDialog.getColor(
@@ -364,11 +389,6 @@ class AnnotationToolsWidget(QWidget):
self.process_annotations_requested.emit() self.process_annotations_requested.emit()
logger.debug("Process annotations requested") 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]: 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