Updating
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user