Updating
This commit is contained in:
@@ -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']}"
|
||||
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,
|
||||
)
|
||||
# Future: Save annotation to database or export
|
||||
|
||||
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,117 +311,80 @@ 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."
|
||||
self,
|
||||
"No Image",
|
||||
"Please load an image before working with annotations.",
|
||||
)
|
||||
return
|
||||
|
||||
# Get current class
|
||||
current_class = self.annotation_tools.get_current_class()
|
||||
if not current_class:
|
||||
QMessageBox.warning(
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"No Class Selected",
|
||||
"Please select an object class before processing 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,
|
||||
"Annotations Already Saved",
|
||||
"Annotations are saved automatically as you draw. "
|
||||
"There is no separate processing step required.",
|
||||
)
|
||||
|
||||
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,
|
||||
"Clear Annotations",
|
||||
"Do you want to clear the annotations to start a new one?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
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()
|
||||
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."
|
||||
self._redraw_annotations_for_current_filter()
|
||||
except Exception as 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
|
||||
|
||||
# Draw each annotation's polyline
|
||||
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 annotations:
|
||||
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")
|
||||
|
||||
# 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"]],
|
||||
@@ -363,18 +393,9 @@ class AnnotationTab(QWidget):
|
||||
)
|
||||
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:
|
||||
logger.error(f"Failed to load annotations: {e}")
|
||||
QMessageBox.critical(
|
||||
self, "Error", f"Failed to load annotations:\n{str(e)}"
|
||||
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):
|
||||
|
||||
@@ -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,10 +498,26 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
self.is_drawing = False
|
||||
|
||||
if len(self.current_stroke) > 1:
|
||||
# Convert to normalized coordinates and save stroke
|
||||
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])
|
||||
|
||||
# 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(x, y) for x, y in self.current_stroke
|
||||
self._image_to_normalized_coords(int(x), int(y))
|
||||
for (x, y) in simplified
|
||||
]
|
||||
self.all_strokes.append(
|
||||
{
|
||||
@@ -368,7 +530,10 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
# Emit signal with normalized coordinates
|
||||
self.annotation_drawn.emit(normalized_stroke)
|
||||
logger.debug(f"Completed stroke with {len(normalized_stroke)} points")
|
||||
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())
|
||||
|
||||
params: List[Dict[str, Any]] = []
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
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()
|
||||
|
||||
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]}")
|
||||
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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user