lazylabel-gui 1.2.1__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ """Single view mode handler."""
2
+
3
+ from PyQt6.QtCore import QPointF, Qt
4
+ from PyQt6.QtGui import QBrush, QColor, QPen, QPolygonF
5
+ from PyQt6.QtWidgets import QGraphicsEllipseItem
6
+
7
+ from ...utils import mask_to_pixmap
8
+ from ..hoverable_pixelmap_item import HoverablePixmapItem
9
+ from ..hoverable_polygon_item import HoverablePolygonItem
10
+ from .base_mode import BaseModeHandler
11
+
12
+
13
+ class SingleViewModeHandler(BaseModeHandler):
14
+ """Handler for single view mode operations."""
15
+
16
+ def handle_ai_click(self, pos, event):
17
+ """Handle AI mode click in single view."""
18
+ # Implementation moved from main_window._add_point
19
+ positive = event.button() == Qt.MouseButton.LeftButton
20
+
21
+ # Check if SAM model is updating
22
+ if self.main_window.sam_is_updating:
23
+ self.main_window._show_warning_notification(
24
+ "AI model is updating, please wait..."
25
+ )
26
+ return
27
+
28
+ # Ensure SAM model is updated
29
+ self.main_window._ensure_sam_updated()
30
+
31
+ # Check again if model is now updating
32
+ if self.main_window.sam_is_updating:
33
+ self.main_window._show_warning_notification(
34
+ "AI model is loading, please wait..."
35
+ )
36
+ return
37
+
38
+ # Transform coordinates and add point
39
+ sam_x, sam_y = self.main_window._transform_display_coords_to_sam_coords(pos)
40
+
41
+ point_list = (
42
+ self.main_window.positive_points
43
+ if positive
44
+ else self.main_window.negative_points
45
+ )
46
+ point_list.append([sam_x, sam_y])
47
+
48
+ # Add visual point
49
+ point_color = (
50
+ QColor(Qt.GlobalColor.green) if positive else QColor(Qt.GlobalColor.red)
51
+ )
52
+ point_color.setAlpha(150)
53
+ point_diameter = self.main_window.point_radius * 2
54
+
55
+ point_item = QGraphicsEllipseItem(
56
+ pos.x() - self.main_window.point_radius,
57
+ pos.y() - self.main_window.point_radius,
58
+ point_diameter,
59
+ point_diameter,
60
+ )
61
+ point_item.setBrush(QBrush(point_color))
62
+ point_item.setPen(QPen(Qt.PenStyle.NoPen))
63
+ self.main_window.viewer.scene().addItem(point_item)
64
+ self.main_window.point_items.append(point_item)
65
+
66
+ # Record the action for undo
67
+ self.main_window.action_history.append(
68
+ {
69
+ "type": "add_point",
70
+ "point_type": "positive" if positive else "negative",
71
+ "point_coords": [int(pos.x()), int(pos.y())],
72
+ "sam_coords": [sam_x, sam_y],
73
+ "point_item": point_item,
74
+ "viewer_mode": "single",
75
+ }
76
+ )
77
+ # Clear redo history when a new action is performed
78
+ self.main_window.redo_history.clear()
79
+
80
+ # Generate prediction
81
+ self.main_window._update_segmentation()
82
+
83
+ def handle_polygon_click(self, pos):
84
+ """Handle polygon mode click in single view."""
85
+ # Check if clicking near first point to close polygon
86
+ if self.main_window.polygon_points and len(self.main_window.polygon_points) > 2:
87
+ first_point = self.main_window.polygon_points[0]
88
+ distance_squared = (pos.x() - first_point.x()) ** 2 + (
89
+ pos.y() - first_point.y()
90
+ ) ** 2
91
+ if distance_squared < self.main_window.polygon_join_threshold**2:
92
+ self._finalize_polygon()
93
+ return
94
+
95
+ # Add point to polygon
96
+ self.main_window.polygon_points.append(pos)
97
+
98
+ # Add visual point
99
+ point_item = QGraphicsEllipseItem(pos.x() - 3, pos.y() - 3, 6, 6)
100
+ point_item.setBrush(QBrush(QColor(0, 255, 255))) # Cyan
101
+ point_item.setPen(QPen(Qt.PenStyle.NoPen))
102
+ self.main_window.viewer.scene().addItem(point_item)
103
+ self.main_window.polygon_preview_items.append(point_item)
104
+
105
+ def handle_bbox_start(self, pos):
106
+ """Handle bbox mode start in single view."""
107
+ from PyQt6.QtWidgets import QGraphicsRectItem
108
+
109
+ self.main_window.drag_start_pos = pos
110
+
111
+ # Create rubber band rectangle
112
+ self.main_window.rubber_band_rect = QGraphicsRectItem()
113
+ self.main_window.rubber_band_rect.setPen(
114
+ QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine)
115
+ )
116
+ self.main_window.viewer.scene().addItem(self.main_window.rubber_band_rect)
117
+
118
+ def handle_bbox_drag(self, pos):
119
+ """Handle bbox mode drag in single view."""
120
+ if (
121
+ hasattr(self.main_window, "drag_start_pos")
122
+ and self.main_window.drag_start_pos
123
+ and hasattr(self.main_window, "rubber_band_rect")
124
+ and self.main_window.rubber_band_rect
125
+ ):
126
+ from PyQt6.QtCore import QRectF
127
+
128
+ # Update rubber band rectangle
129
+ rect = QRectF(self.main_window.drag_start_pos, pos).normalized()
130
+ self.main_window.rubber_band_rect.setRect(rect)
131
+
132
+ def handle_bbox_complete(self, pos):
133
+ """Handle bbox mode completion in single view."""
134
+ # Implementation from main_window._scene_mouse_release bbox handling
135
+ if (
136
+ hasattr(self.main_window, "rubber_band_rect")
137
+ and self.main_window.rubber_band_rect
138
+ ):
139
+ # Remove rubber band
140
+ self.main_window.viewer.scene().removeItem(
141
+ self.main_window.rubber_band_rect
142
+ )
143
+ self.main_window.rubber_band_rect = None
144
+
145
+ # Create polygon from bbox
146
+ start_pos = self.main_window.drag_start_pos
147
+ from PyQt6.QtCore import QRectF
148
+
149
+ rect = QRectF(start_pos, pos).normalized()
150
+ if rect.width() > 10 and rect.height() > 10:
151
+ # Convert to polygon
152
+ vertices = [
153
+ [rect.left(), rect.top()],
154
+ [rect.right(), rect.top()],
155
+ [rect.right(), rect.bottom()],
156
+ [rect.left(), rect.bottom()],
157
+ ]
158
+
159
+ new_segment = {
160
+ "vertices": vertices,
161
+ "type": "Polygon",
162
+ "mask": None,
163
+ }
164
+
165
+ self.segment_manager.add_segment(new_segment)
166
+
167
+ # Record action for undo
168
+ self.main_window.action_history.append(
169
+ {
170
+ "type": "add_segment",
171
+ "segment_index": len(self.segment_manager.segments) - 1,
172
+ }
173
+ )
174
+ self.main_window.redo_history.clear()
175
+
176
+ self.main_window._update_all_lists()
177
+
178
+ def display_all_segments(self):
179
+ """Display all segments in single view."""
180
+ # Clear existing segment items
181
+ for _i, items in self.main_window.segment_items.items():
182
+ for item in items:
183
+ if item.scene():
184
+ self.main_window.viewer.scene().removeItem(item)
185
+ self.main_window.segment_items.clear()
186
+ self.main_window._clear_edit_handles()
187
+
188
+ # Display segments from segment manager
189
+ for i, segment in enumerate(self.segment_manager.segments):
190
+ self.main_window.segment_items[i] = []
191
+ class_id = segment.get("class_id")
192
+ base_color = self.main_window._get_color_for_class(class_id)
193
+
194
+ if segment["type"] == "Polygon" and segment.get("vertices"):
195
+ # Convert stored list of lists back to QPointF objects
196
+ qpoints = [QPointF(p[0], p[1]) for p in segment["vertices"]]
197
+
198
+ poly_item = HoverablePolygonItem(QPolygonF(qpoints))
199
+ default_brush = QBrush(
200
+ QColor(base_color.red(), base_color.green(), base_color.blue(), 70)
201
+ )
202
+ hover_brush = QBrush(
203
+ QColor(base_color.red(), base_color.green(), base_color.blue(), 170)
204
+ )
205
+ poly_item.set_brushes(default_brush, hover_brush)
206
+ poly_item.setPen(QPen(Qt.GlobalColor.transparent))
207
+ self.main_window.viewer.scene().addItem(poly_item)
208
+ self.main_window.segment_items[i].append(poly_item)
209
+
210
+ elif segment.get("mask") is not None:
211
+ default_pixmap = mask_to_pixmap(
212
+ segment["mask"], base_color.getRgb()[:3], alpha=70
213
+ )
214
+ hover_pixmap = mask_to_pixmap(
215
+ segment["mask"], base_color.getRgb()[:3], alpha=170
216
+ )
217
+ pixmap_item = HoverablePixmapItem()
218
+ pixmap_item.set_pixmaps(default_pixmap, hover_pixmap)
219
+ self.main_window.viewer.scene().addItem(pixmap_item)
220
+ pixmap_item.setZValue(i + 1)
221
+ self.main_window.segment_items[i].append(pixmap_item)
222
+
223
+ def clear_all_points(self):
224
+ """Clear all temporary points in single view."""
225
+ if (
226
+ hasattr(self.main_window, "rubber_band_line")
227
+ and self.main_window.rubber_band_line
228
+ ):
229
+ self.main_window.viewer.scene().removeItem(
230
+ self.main_window.rubber_band_line
231
+ )
232
+ self.main_window.rubber_band_line = None
233
+
234
+ self.main_window.positive_points.clear()
235
+ self.main_window.negative_points.clear()
236
+
237
+ for item in self.main_window.point_items:
238
+ self.main_window.viewer.scene().removeItem(item)
239
+ self.main_window.point_items.clear()
240
+
241
+ self.main_window.polygon_points.clear()
242
+ for item in self.main_window.polygon_preview_items:
243
+ self.main_window.viewer.scene().removeItem(item)
244
+ self.main_window.polygon_preview_items.clear()
245
+
246
+ # Clear polygon lasso lines
247
+ if (
248
+ hasattr(self.main_window, "polygon_lasso_lines")
249
+ and self.main_window.polygon_lasso_lines
250
+ ):
251
+ for line in self.main_window.polygon_lasso_lines:
252
+ if line.scene():
253
+ self.main_window.viewer.scene().removeItem(line)
254
+ self.main_window.polygon_lasso_lines.clear()
255
+
256
+ if (
257
+ hasattr(self.main_window, "preview_mask_item")
258
+ and self.main_window.preview_mask_item
259
+ ):
260
+ self.main_window.viewer.scene().removeItem(
261
+ self.main_window.preview_mask_item
262
+ )
263
+ self.main_window.preview_mask_item = None
264
+
265
+ def _finalize_polygon(self):
266
+ """Finalize polygon drawing in single view."""
267
+ if len(self.main_window.polygon_points) < 3:
268
+ return
269
+
270
+ # Clear lasso lines when finalizing
271
+ if (
272
+ hasattr(self.main_window, "polygon_lasso_lines")
273
+ and self.main_window.polygon_lasso_lines
274
+ ):
275
+ for line in self.main_window.polygon_lasso_lines:
276
+ if line.scene():
277
+ self.main_window.viewer.scene().removeItem(line)
278
+ self.main_window.polygon_lasso_lines.clear()
279
+
280
+ new_segment = {
281
+ "vertices": [[p.x(), p.y()] for p in self.main_window.polygon_points],
282
+ "type": "Polygon",
283
+ "mask": None,
284
+ }
285
+
286
+ self.segment_manager.add_segment(new_segment)
287
+
288
+ # Record action for undo
289
+ self.main_window.action_history.append(
290
+ {
291
+ "type": "add_segment",
292
+ "segment_index": len(self.segment_manager.segments) - 1,
293
+ }
294
+ )
295
+ self.main_window.redo_history.clear()
296
+
297
+ self.main_window.polygon_points.clear()
298
+ self.clear_all_points()
299
+ self.main_window._update_all_lists()
@@ -1,11 +1,15 @@
1
1
  import cv2
2
2
  import numpy as np
3
- from PyQt6.QtCore import QRectF, Qt
3
+ from PyQt6.QtCore import QRectF, Qt, pyqtSignal
4
4
  from PyQt6.QtGui import QCursor, QImage, QPixmap
5
5
  from PyQt6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView
6
6
 
7
7
 
8
8
  class PhotoViewer(QGraphicsView):
9
+ # Signals for multi-view synchronization
10
+ zoom_changed = pyqtSignal(float) # Emits zoom factor
11
+ view_changed = pyqtSignal() # Emits when view (pan/zoom) changes
12
+
9
13
  def __init__(self, parent=None):
10
14
  super().__init__(parent)
11
15
  self._scene = QGraphicsScene(self)
@@ -18,6 +22,7 @@ class PhotoViewer(QGraphicsView):
18
22
  self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
19
23
  self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
20
24
  self.setDragMode(QGraphicsView.DragMode.NoDrag)
25
+ self.setMouseTracking(True) # Enable mouse tracking for hover events
21
26
 
22
27
  self._original_image = None
23
28
  self._adjusted_pixmap = None
@@ -41,6 +46,10 @@ class PhotoViewer(QGraphicsView):
41
46
  if pixmap and not pixmap.isNull():
42
47
  self._original_image = pixmap.toImage()
43
48
  self._adjusted_pixmap = pixmap
49
+ # Check if _pixmap_item still exists, recreate if deleted
50
+ if self._pixmap_item not in self._scene.items():
51
+ self._pixmap_item = QGraphicsPixmapItem()
52
+ self._scene.addItem(self._pixmap_item)
44
53
  self._pixmap_item.setPixmap(pixmap)
45
54
 
46
55
  # Convert QImage to ARGB32 for consistent processing
@@ -61,12 +70,21 @@ class PhotoViewer(QGraphicsView):
61
70
  self._original_image = None
62
71
  self._adjusted_pixmap = None
63
72
  self._original_image_bgr = None
73
+ # Check if _pixmap_item still exists, recreate if deleted
74
+ if self._pixmap_item not in self._scene.items():
75
+ self._pixmap_item = QGraphicsPixmapItem()
76
+ self._scene.addItem(self._pixmap_item)
64
77
  self._pixmap_item.setPixmap(QPixmap())
65
78
 
66
79
  def set_image_adjustments(self, brightness: float, contrast: float, gamma: float):
67
- if self._original_image_bgr is None:
80
+ if self._original_image_bgr is None or self._original_image is None:
68
81
  return
69
82
 
83
+ # Ensure _pixmap_item exists and is valid
84
+ if self._pixmap_item not in self._scene.items():
85
+ self._pixmap_item = QGraphicsPixmapItem()
86
+ self._scene.addItem(self._pixmap_item)
87
+
70
88
  img_bgr = self._original_image_bgr.copy()
71
89
 
72
90
  # Apply brightness and contrast
@@ -90,7 +108,10 @@ class PhotoViewer(QGraphicsView):
90
108
  adjusted_img.data, w, h, bytes_per_line, QImage.Format.Format_BGR888
91
109
  )
92
110
  self._adjusted_pixmap = QPixmap.fromImage(adjusted_qimage)
93
- self._pixmap_item.setPixmap(self._adjusted_pixmap)
111
+
112
+ # Ensure the pixmap is valid before setting it
113
+ if not self._adjusted_pixmap.isNull():
114
+ self._pixmap_item.setPixmap(self._adjusted_pixmap)
94
115
 
95
116
  def set_cursor(self, cursor_shape):
96
117
  self.viewport().setCursor(QCursor(cursor_shape))
@@ -103,3 +124,10 @@ class PhotoViewer(QGraphicsView):
103
124
  if not self._pixmap_item.pixmap().isNull():
104
125
  factor = 1.25 if event.angleDelta().y() > 0 else 0.8
105
126
  self.scale(factor, factor)
127
+ # Emit zoom signal for multi-view synchronization
128
+ self.zoom_changed.emit(factor)
129
+
130
+ def sync_zoom(self, factor):
131
+ """Synchronize zoom from another viewer."""
132
+ if not self._pixmap_item.pixmap().isNull():
133
+ self.scale(factor, factor)
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test script to check hover functionality.
4
+ Run this before starting LazyLabel to enable debug logging.
5
+ """
6
+
7
+ # First, enable debug logging
8
+ import logging
9
+ import os
10
+ import sys
11
+
12
+ # Add the src directory to path so we can import LazyLabel modules
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
14
+
15
+ from lazylabel.utils.logger import logger
16
+
17
+ # Set logger to DEBUG level
18
+ logger.setLevel(logging.DEBUG)
19
+
20
+ # Add console handler if not already present
21
+ if not logger.handlers:
22
+ console_handler = logging.StreamHandler()
23
+ console_handler.setLevel(logging.DEBUG)
24
+ formatter = logging.Formatter(
25
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
26
+ )
27
+ console_handler.setFormatter(formatter)
28
+ logger.addHandler(console_handler)
29
+
30
+ print("=" * 50)
31
+ print("HOVER DEBUG MODE ENABLED")
32
+ print("=" * 50)
33
+ print("Logger level:", logger.level)
34
+ print("Logger handlers:", len(logger.handlers))
35
+ print()
36
+ print("Now run LazyLabel and test hover functionality:")
37
+ print("1. Load images in multi-view mode")
38
+ print("2. Create some segments (AI or polygon)")
39
+ print("3. Try hovering over segments")
40
+ print("4. Watch the console for debug messages")
41
+ print()
42
+ print("Expected debug messages:")
43
+ print("- HoverablePolygonItem.set_segment_info")
44
+ print("- HoverablePixmapItem.set_segment_info")
45
+ print("- HoverablePolygonItem.hoverEnterEvent")
46
+ print("- HoverablePixmapItem.hoverEnterEvent")
47
+ print("- _trigger_segment_hover called")
48
+ print("=" * 50)
@@ -50,9 +50,9 @@ class AdjustmentsWidget(QWidget):
50
50
  Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
51
51
  )
52
52
 
53
- # Text edit with smaller width
53
+ # Text edit with width to show numbers like 1.00 and -1.00
54
54
  text_edit = QLineEdit(str(default_value))
55
- text_edit.setFixedWidth(35)
55
+ text_edit.setFixedWidth(45) # Increased from 35 to 45
56
56
 
57
57
  # Slider takes remaining space
58
58
  slider = QSlider(Qt.Orientation.Horizontal)
@@ -188,6 +188,17 @@ class BorderCropWidget(QWidget):
188
188
  """Check if crop coordinates are set."""
189
189
  return self.get_crop_coordinates() is not None
190
190
 
191
+ def disable_thresholding_for_multi_view(self):
192
+ """Disable thresholding controls for multi-view mode."""
193
+ # This method is called when entering multi-view mode
194
+ # to handle mixed BW/RGB images
195
+ pass
196
+
197
+ def enable_thresholding(self):
198
+ """Re-enable thresholding controls when exiting multi-view mode."""
199
+ # This method is called when exiting multi-view mode
200
+ pass
201
+
191
202
  def _get_button_style(self):
192
203
  """Get consistent button styling."""
193
204
  return """
@@ -70,7 +70,12 @@ class MultiIndicatorSlider(QWidget):
70
70
  slider_rect = self.get_slider_rect()
71
71
  ratio = (x - slider_rect.left()) / slider_rect.width()
72
72
  ratio = max(0, min(1, ratio)) # Clamp to [0, 1]
73
- return int(self.minimum + ratio * (self.maximum - self.minimum))
73
+ value = self.minimum + ratio * (self.maximum - self.minimum)
74
+ # Use integer values for channel thresholds (0-255) and intensity sliders, float for frequency sliders (0-10000)
75
+ if self.maximum <= 255 or self.maximum == 256:
76
+ return int(value)
77
+ else:
78
+ return value
74
79
 
75
80
  def paintEvent(self, event):
76
81
  """Paint the slider."""
@@ -138,7 +143,8 @@ class MultiIndicatorSlider(QWidget):
138
143
  painter.setPen(QPen(Qt.GlobalColor.transparent))
139
144
  painter.drawRoundedRect(segment_rect, 5, 5)
140
145
 
141
- # Draw indicators
146
+ # Draw indicators and collect label positions to avoid overlaps
147
+ label_positions = []
142
148
  for i, value in enumerate(self.indicators):
143
149
  x = self.value_to_x(value)
144
150
 
@@ -157,9 +163,47 @@ class MultiIndicatorSlider(QWidget):
157
163
 
158
164
  painter.drawRoundedRect(handle_rect, 3, 3)
159
165
 
160
- # Value label
161
- painter.setPen(QPen(QColor(255, 255, 255)))
162
- painter.drawText(x - 15, slider_rect.bottom() + 15, f"{value}")
166
+ # Store label info for non-overlapping positioning
167
+ label_text = f"{int(value)}"
168
+ label_positions.append((x, label_text))
169
+
170
+ # Draw labels with overlap prevention
171
+ self._draw_non_overlapping_labels(painter, slider_rect, label_positions)
172
+
173
+ def _draw_non_overlapping_labels(self, painter, slider_rect, label_positions):
174
+ """Draw labels with spacing to prevent overlaps."""
175
+ if not label_positions:
176
+ return
177
+
178
+ painter.setPen(QPen(QColor(255, 255, 255)))
179
+
180
+ # Sort by x position
181
+ sorted_labels = sorted(label_positions, key=lambda item: item[0])
182
+
183
+ # Minimum spacing between labels (in pixels)
184
+ min_spacing = 30
185
+
186
+ # Adjust positions to prevent overlaps
187
+ adjusted_positions = []
188
+ for i, (x, text) in enumerate(sorted_labels):
189
+ if i == 0:
190
+ adjusted_positions.append((x, text))
191
+ else:
192
+ prev_x = adjusted_positions[-1][0]
193
+ if x - prev_x < min_spacing:
194
+ # Move this label to maintain minimum spacing
195
+ new_x = prev_x + min_spacing
196
+ # But don't go beyond the slider bounds
197
+ slider_right = slider_rect.right() - 15
198
+ if new_x > slider_right:
199
+ new_x = slider_right
200
+ adjusted_positions.append((new_x, text))
201
+ else:
202
+ adjusted_positions.append((x, text))
203
+
204
+ # Draw the adjusted labels
205
+ for x, text in adjusted_positions:
206
+ painter.drawText(int(x - 15), slider_rect.bottom() + 15, text)
163
207
 
164
208
  def mousePressEvent(self, event):
165
209
  """Handle mouse press events."""
@@ -352,7 +396,7 @@ class ChannelThresholdWidget(QWidget):
352
396
 
353
397
  # Instructions
354
398
  instructions = QLabel(
355
- "✓ Check to enable • Double-click to add threshold • Right-click to remove"
399
+ "✓ Check to enable\n• Double-click to add threshold\n• Right-click to remove"
356
400
  )
357
401
  instructions.setStyleSheet("color: #888; font-size: 9px;")
358
402
  instructions.setWordWrap(True)