Updating
This commit is contained in:
@@ -4,6 +4,7 @@ Supports pen tool with color selection for manual annotation.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea
|
||||
from PySide6.QtGui import (
|
||||
@@ -19,18 +20,95 @@ from PySide6.QtGui import (
|
||||
from PySide6.QtCore import Qt, QEvent, Signal, QPoint
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from scipy.ndimage import binary_dilation, label, binary_fill_holes, find_objects
|
||||
from skimage.measure import find_contours
|
||||
|
||||
from src.utils.image import Image, ImageLoadError
|
||||
from src.utils.logger import get_logger
|
||||
|
||||
# For debugging visualization
|
||||
import pylab as plt
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def perpendicular_distance(
|
||||
point: Tuple[float, float],
|
||||
start: Tuple[float, float],
|
||||
end: Tuple[float, float],
|
||||
) -> float:
|
||||
"""Perpendicular distance from `point` to the line defined by `start`->`end`."""
|
||||
(x, y), (x1, y1), (x2, y2) = point, start, end
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
if dx == 0.0 and dy == 0.0:
|
||||
return math.hypot(x - x1, y - y1)
|
||||
num = abs(dy * x - dx * y + x2 * y1 - y2 * x1)
|
||||
den = math.hypot(dx, dy)
|
||||
return num / den
|
||||
|
||||
|
||||
def rdp(points: List[Tuple[float, float]], epsilon: float) -> List[Tuple[float, float]]:
|
||||
"""
|
||||
Recursive Ramer-Douglas-Peucker (RDP) polyline simplification.
|
||||
|
||||
Args:
|
||||
points: List of (x, y) points.
|
||||
epsilon: Maximum allowed perpendicular distance in pixels.
|
||||
|
||||
Returns:
|
||||
Simplified list of (x, y) points including first and last.
|
||||
"""
|
||||
if len(points) <= 2:
|
||||
return list(points)
|
||||
|
||||
start = points[0]
|
||||
end = points[-1]
|
||||
max_dist = -1.0
|
||||
index = -1
|
||||
|
||||
for i in range(1, len(points) - 1):
|
||||
d = perpendicular_distance(points[i], start, end)
|
||||
if d > max_dist:
|
||||
max_dist = d
|
||||
index = i
|
||||
|
||||
if max_dist > epsilon:
|
||||
# Recursive split
|
||||
left = rdp(points[: index + 1], epsilon)
|
||||
right = rdp(points[index:], epsilon)
|
||||
# Concatenate but avoid duplicate at split point
|
||||
return left[:-1] + right
|
||||
|
||||
# Keep only start and end
|
||||
return [start, end]
|
||||
|
||||
|
||||
def simplify_polyline(
|
||||
points: List[Tuple[float, float]], epsilon: float
|
||||
) -> List[Tuple[float, float]]:
|
||||
"""
|
||||
Simplify a polyline with RDP while preserving closure semantics.
|
||||
|
||||
If the polyline is closed (first == last), the duplicate last point is removed
|
||||
before simplification and then re-added after simplification.
|
||||
"""
|
||||
if not points:
|
||||
return []
|
||||
|
||||
pts = [(float(x), float(y)) for x, y in points]
|
||||
closed = False
|
||||
|
||||
if len(pts) >= 2 and pts[0] == pts[-1]:
|
||||
closed = True
|
||||
pts = pts[:-1] # remove duplicate last for simplification
|
||||
|
||||
if len(pts) <= 2:
|
||||
simplified = list(pts)
|
||||
else:
|
||||
simplified = rdp(pts, epsilon)
|
||||
|
||||
if closed and simplified:
|
||||
if simplified[0] != simplified[-1]:
|
||||
simplified.append(simplified[0])
|
||||
|
||||
return simplified
|
||||
|
||||
|
||||
class AnnotationCanvasWidget(QWidget):
|
||||
"""
|
||||
Widget for displaying images and drawing annotations with pen tool.
|
||||
@@ -68,8 +146,19 @@ class AnnotationCanvasWidget(QWidget):
|
||||
self.pen_enabled = False
|
||||
self.pen_color = QColor(255, 0, 0, 128) # Default red with 50% alpha
|
||||
self.pen_width = 3
|
||||
self.current_stroke = [] # Points in current stroke
|
||||
self.all_strokes = [] # All completed strokes
|
||||
|
||||
# Current stroke and stored polylines (in image coordinates, pixel units)
|
||||
self.current_stroke: List[Tuple[float, float]] = []
|
||||
self.polylines: List[List[Tuple[float, float]]] = []
|
||||
self.stroke_meta: List[Dict[str, Any]] = [] # per-polyline style (color, width)
|
||||
|
||||
# Legacy collection of strokes in normalized coordinates (kept for API compatibility)
|
||||
self.all_strokes: List[dict] = []
|
||||
|
||||
# RDP simplification parameters (in pixels)
|
||||
self.simplify_on_finish: bool = True
|
||||
self.simplify_epsilon: float = 2.0
|
||||
self.sample_threshold: float = 2.0 # minimum movement to sample a new point
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
@@ -128,6 +217,8 @@ class AnnotationCanvasWidget(QWidget):
|
||||
"""Clear all drawn annotations."""
|
||||
self.all_strokes = []
|
||||
self.current_stroke = []
|
||||
self.polylines = []
|
||||
self.stroke_meta = []
|
||||
self.is_drawing = False
|
||||
if self.annotation_pixmap:
|
||||
self.annotation_pixmap.fill(Qt.transparent)
|
||||
@@ -300,6 +391,46 @@ class AnnotationCanvasWidget(QWidget):
|
||||
norm_y = y / self.original_pixmap.height()
|
||||
return (norm_x, norm_y)
|
||||
|
||||
def _add_polyline(
|
||||
self, img_points: List[Tuple[float, float]], color: QColor, width: int
|
||||
):
|
||||
"""Store a polyline in image coordinates and redraw annotations."""
|
||||
if not img_points or len(img_points) < 2:
|
||||
return
|
||||
|
||||
# Ensure all points are tuples of floats
|
||||
normalized_points = [(float(x), float(y)) for x, y in img_points]
|
||||
self.polylines.append(normalized_points)
|
||||
self.stroke_meta.append({"color": QColor(color), "width": int(width)})
|
||||
|
||||
self._redraw_annotations()
|
||||
|
||||
def _redraw_annotations(self):
|
||||
"""Redraw all stored polylines onto the annotation pixmap."""
|
||||
if self.annotation_pixmap is None:
|
||||
return
|
||||
|
||||
# Clear existing overlay
|
||||
self.annotation_pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(self.annotation_pixmap)
|
||||
for polyline, meta in zip(self.polylines, self.stroke_meta):
|
||||
pen_color: QColor = meta.get("color", self.pen_color)
|
||||
width: int = meta.get("width", self.pen_width)
|
||||
pen = QPen(
|
||||
pen_color,
|
||||
width,
|
||||
Qt.SolidLine,
|
||||
Qt.RoundCap,
|
||||
Qt.RoundJoin,
|
||||
)
|
||||
painter.setPen(pen)
|
||||
for (x1, y1), (x2, y2) in zip(polyline[:-1], polyline[1:]):
|
||||
painter.drawLine(int(x1), int(y1), int(x2), int(y2))
|
||||
painter.end()
|
||||
|
||||
self._update_display()
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
"""Handle mouse press events for drawing."""
|
||||
if not self.pen_enabled or self.annotation_pixmap is None:
|
||||
@@ -313,7 +444,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
if img_coords:
|
||||
self.is_drawing = True
|
||||
self.current_stroke = [img_coords]
|
||||
self.current_stroke = [(float(img_coords[0]), float(img_coords[1]))]
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent):
|
||||
"""Handle mouse move events for drawing."""
|
||||
@@ -330,18 +461,33 @@ class AnnotationCanvasWidget(QWidget):
|
||||
img_coords = self._canvas_to_image_coords(label_pos)
|
||||
|
||||
if img_coords and len(self.current_stroke) > 0:
|
||||
# Draw line from last point to current point
|
||||
last_point = self.current_stroke[-1]
|
||||
dx = img_coords[0] - last_point[0]
|
||||
dy = img_coords[1] - last_point[1]
|
||||
|
||||
# Only sample a new point if we moved enough pixels
|
||||
if math.hypot(dx, dy) < self.sample_threshold:
|
||||
return
|
||||
|
||||
# Draw line from last point to current point for interactive feedback
|
||||
painter = QPainter(self.annotation_pixmap)
|
||||
pen = QPen(
|
||||
self.pen_color, self.pen_width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin
|
||||
self.pen_color,
|
||||
self.pen_width,
|
||||
Qt.SolidLine,
|
||||
Qt.RoundCap,
|
||||
Qt.RoundJoin,
|
||||
)
|
||||
painter.setPen(pen)
|
||||
|
||||
last_point = self.current_stroke[-1]
|
||||
painter.drawLine(last_point[0], last_point[1], img_coords[0], img_coords[1])
|
||||
painter.drawLine(
|
||||
int(last_point[0]),
|
||||
int(last_point[1]),
|
||||
int(img_coords[0]),
|
||||
int(img_coords[1]),
|
||||
)
|
||||
painter.end()
|
||||
|
||||
self.current_stroke.append(img_coords)
|
||||
self.current_stroke.append((float(img_coords[0]), float(img_coords[1])))
|
||||
self._update_display()
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent):
|
||||
@@ -352,23 +498,42 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
self.is_drawing = False
|
||||
|
||||
if len(self.current_stroke) > 1:
|
||||
# Convert to normalized coordinates and save stroke
|
||||
normalized_stroke = [
|
||||
self._image_to_normalized_coords(x, y) for x, y in self.current_stroke
|
||||
]
|
||||
self.all_strokes.append(
|
||||
{
|
||||
"points": normalized_stroke,
|
||||
"color": self.pen_color.name(),
|
||||
"alpha": self.pen_color.alpha(),
|
||||
"width": self.pen_width,
|
||||
}
|
||||
)
|
||||
if len(self.current_stroke) > 1 and self.original_pixmap is not None:
|
||||
# Ensure the stroke is closed by connecting end -> start
|
||||
raw_points = list(self.current_stroke)
|
||||
if raw_points[0] != raw_points[-1]:
|
||||
raw_points.append(raw_points[0])
|
||||
|
||||
# Emit signal with normalized coordinates
|
||||
self.annotation_drawn.emit(normalized_stroke)
|
||||
logger.debug(f"Completed stroke with {len(normalized_stroke)} points")
|
||||
# Optional RDP simplification (in image pixel space)
|
||||
if self.simplify_on_finish:
|
||||
simplified = simplify_polyline(raw_points, self.simplify_epsilon)
|
||||
else:
|
||||
simplified = raw_points
|
||||
|
||||
if len(simplified) >= 2:
|
||||
# Store polyline and redraw all annotations
|
||||
self._add_polyline(simplified, self.pen_color, self.pen_width)
|
||||
|
||||
# Convert to normalized coordinates for metadata + signal
|
||||
normalized_stroke = [
|
||||
self._image_to_normalized_coords(int(x), int(y))
|
||||
for (x, y) in simplified
|
||||
]
|
||||
self.all_strokes.append(
|
||||
{
|
||||
"points": normalized_stroke,
|
||||
"color": self.pen_color.name(),
|
||||
"alpha": self.pen_color.alpha(),
|
||||
"width": self.pen_width,
|
||||
}
|
||||
)
|
||||
|
||||
# Emit signal with normalized coordinates
|
||||
self.annotation_drawn.emit(normalized_stroke)
|
||||
logger.debug(
|
||||
f"Completed stroke with {len(simplified)} points "
|
||||
f"(normalized len={len(normalized_stroke)})"
|
||||
)
|
||||
|
||||
self.current_stroke = []
|
||||
|
||||
@@ -464,61 +629,54 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
# return polyline
|
||||
|
||||
def get_annotation_parameters(self) -> Dict[str, Any]:
|
||||
def get_annotation_parameters(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get all annotation parameters including bounding box and polyline.
|
||||
|
||||
Returns:
|
||||
Dictionary containing:
|
||||
- 'bbox': Bounding box coordinates (x_min, y_min, x_max, y_max)
|
||||
- 'polyline': List of [x, y] coordinate pairs
|
||||
List of dictionaries, each containing:
|
||||
- 'bbox': [x_min, y_min, x_max, y_max] in normalized image coordinates
|
||||
- 'polyline': List of [y_norm, x_norm] points describing the polygon
|
||||
"""
|
||||
|
||||
# Get np.arrays from annotation_pixmap accoriding to the color of the stroke
|
||||
qimage = self.annotation_pixmap.toImage()
|
||||
arr = np.ndarray(
|
||||
(qimage.height(), qimage.width(), 4),
|
||||
buffer=qimage.constBits(),
|
||||
strides=[qimage.bytesPerLine(), 4, 1],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
arr = np.sum(arr, axis=2)
|
||||
arr_bin = arr > 0
|
||||
arr_bin = binary_fill_holes(arr_bin)
|
||||
|
||||
labels, _number_of_features = label(
|
||||
arr_bin,
|
||||
)
|
||||
if _number_of_features == 0:
|
||||
if self.original_pixmap is None or not self.polylines:
|
||||
return None
|
||||
|
||||
objects = find_objects(labels)
|
||||
w, h = arr.shape
|
||||
bounding_boxes = [
|
||||
[obj[1].start / h, obj[0].start / w, obj[1].stop / h, obj[0].stop / w]
|
||||
for obj in objects
|
||||
]
|
||||
img_width = float(self.original_pixmap.width())
|
||||
img_height = float(self.original_pixmap.height())
|
||||
|
||||
polylines = find_contours(arr_bin, 0.5)
|
||||
params = []
|
||||
for i, pl in enumerate(polylines):
|
||||
# pl is in [row, col] format from find_contours
|
||||
# We need to normalize: row/height, col/width
|
||||
# w = height (rows), h = width (cols) from line 510
|
||||
normalized_polyline = (pl[::-1] / np.array([w, h])).tolist()
|
||||
params: List[Dict[str, Any]] = []
|
||||
|
||||
logger.debug(f"Polyline {i}: {len(pl)} points")
|
||||
logger.debug(f" w={w} (height), h={h} (width)")
|
||||
logger.debug(f" First 3 normalized points: {normalized_polyline[:3]}")
|
||||
for idx, polyline in enumerate(self.polylines):
|
||||
if len(polyline) < 2:
|
||||
continue
|
||||
|
||||
xs = [p[0] for p in polyline]
|
||||
ys = [p[1] for p in polyline]
|
||||
|
||||
x_min_norm = min(xs) / img_width
|
||||
x_max_norm = max(xs) / img_width
|
||||
y_min_norm = min(ys) / img_height
|
||||
y_max_norm = max(ys) / img_height
|
||||
|
||||
# Store polyline as [y_norm, x_norm] to match DB convention and
|
||||
# the expectations of draw_saved_polyline().
|
||||
normalized_polyline = [
|
||||
[y / img_height, x / img_width] for (x, y) in polyline
|
||||
]
|
||||
|
||||
logger.debug(
|
||||
f"Polyline {idx}: {len(polyline)} points, "
|
||||
f"bbox=({x_min_norm:.3f}, {y_min_norm:.3f})-({x_max_norm:.3f}, {y_max_norm:.3f})"
|
||||
)
|
||||
|
||||
params.append(
|
||||
{
|
||||
"bbox": bounding_boxes[i],
|
||||
"bbox": [x_min_norm, y_min_norm, x_max_norm, y_max_norm],
|
||||
"polyline": normalized_polyline,
|
||||
}
|
||||
)
|
||||
|
||||
return params
|
||||
return params or None
|
||||
|
||||
def draw_saved_polyline(
|
||||
self, polyline: List[List[float]], color: str, width: int = 3
|
||||
@@ -548,36 +706,24 @@ class AnnotationCanvasWidget(QWidget):
|
||||
logger.debug(f" Image size: {img_width}x{img_height}")
|
||||
logger.debug(f" First 3 normalized points from DB: {polyline[:3]}")
|
||||
|
||||
img_coords = []
|
||||
img_coords: List[Tuple[float, float]] = []
|
||||
for y_norm, x_norm in polyline:
|
||||
x = int(x_norm * img_width)
|
||||
y = int(y_norm * img_height)
|
||||
x = float(x_norm * img_width)
|
||||
y = float(y_norm * img_height)
|
||||
img_coords.append((x, y))
|
||||
|
||||
logger.debug(f" First 3 pixel coords: {img_coords[:3]}")
|
||||
|
||||
# Draw polyline on annotation pixmap
|
||||
painter = QPainter(self.annotation_pixmap)
|
||||
# Store and redraw using common pipeline
|
||||
pen_color = QColor(color)
|
||||
pen_color.setAlpha(128) # Add semi-transparency
|
||||
pen = QPen(pen_color, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
self._add_polyline(img_coords, pen_color, width)
|
||||
|
||||
# Draw lines between consecutive points
|
||||
for i in range(len(img_coords) - 1):
|
||||
x1, y1 = img_coords[i]
|
||||
x2, y2 = img_coords[i + 1]
|
||||
painter.drawLine(x1, y1, x2, y2)
|
||||
|
||||
painter.end()
|
||||
|
||||
# Store in all_strokes for consistency
|
||||
# Store in all_strokes for consistency (uses normalized coordinates)
|
||||
self.all_strokes.append(
|
||||
{"points": polyline, "color": color, "alpha": 128, "width": width}
|
||||
)
|
||||
|
||||
# Update display
|
||||
self._update_display()
|
||||
logger.debug(
|
||||
f"Drew saved polyline with {len(polyline)} points in color {color}"
|
||||
)
|
||||
|
||||
@@ -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