lazylabel-gui 1.1.7__py3-none-any.whl → 1.1.9__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,500 @@
1
+ """
2
+ Channel Threshold Widget for LazyLabel.
3
+
4
+ This widget provides channel-based thresholding with multi-indicator sliders.
5
+ Users can add multiple threshold points by double-clicking on sliders,
6
+ creating pixel remapping with multiple value ranges.
7
+ """
8
+
9
+ import numpy as np
10
+ from PyQt6.QtCore import QRect, Qt, pyqtSignal
11
+ from PyQt6.QtGui import QBrush, QColor, QFont, QPainter, QPen
12
+ from PyQt6.QtWidgets import (
13
+ QCheckBox,
14
+ QHBoxLayout,
15
+ QLabel,
16
+ QSizePolicy,
17
+ QVBoxLayout,
18
+ QWidget,
19
+ )
20
+
21
+
22
+ class MultiIndicatorSlider(QWidget):
23
+ """Custom slider widget with multiple draggable indicators."""
24
+
25
+ valueChanged = pyqtSignal(list) # Emits list of indicator positions
26
+ dragStarted = pyqtSignal() # Emitted when user starts dragging
27
+ dragFinished = pyqtSignal() # Emitted when user finishes dragging
28
+
29
+ def __init__(self, channel_name="Channel", minimum=0, maximum=256, parent=None):
30
+ super().__init__(parent)
31
+ self.channel_name = channel_name
32
+ self.minimum = minimum
33
+ self.maximum = maximum
34
+ self.indicators = [] # Start with no indicators
35
+ self.dragging_index = -1
36
+ self.drag_offset = 0
37
+ self.is_dragging = False # Track if currently dragging
38
+
39
+ self.setMinimumHeight(60)
40
+ self.setFixedHeight(60)
41
+ self.setMinimumWidth(200)
42
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
43
+
44
+ # Colors for the channel
45
+ self.channel_colors = {
46
+ "Red": QColor(255, 100, 100),
47
+ "Green": QColor(100, 255, 100),
48
+ "Blue": QColor(100, 100, 255),
49
+ "Gray": QColor(200, 200, 200),
50
+ "Channel": QColor(150, 150, 150),
51
+ }
52
+
53
+ def get_channel_color(self):
54
+ """Get color for this channel."""
55
+ return self.channel_colors.get(self.channel_name, QColor(150, 150, 150))
56
+
57
+ def get_slider_rect(self):
58
+ """Get the slider track rectangle."""
59
+ margin = 20
60
+ return QRect(margin, 25, self.width() - 2 * margin, 10)
61
+
62
+ def value_to_x(self, value):
63
+ """Convert value to x coordinate."""
64
+ slider_rect = self.get_slider_rect()
65
+ ratio = (value - self.minimum) / (self.maximum - self.minimum)
66
+ return slider_rect.left() + int(ratio * slider_rect.width())
67
+
68
+ def x_to_value(self, x):
69
+ """Convert x coordinate to value."""
70
+ slider_rect = self.get_slider_rect()
71
+ ratio = (x - slider_rect.left()) / slider_rect.width()
72
+ ratio = max(0, min(1, ratio)) # Clamp to [0, 1]
73
+ return int(self.minimum + ratio * (self.maximum - self.minimum))
74
+
75
+ def paintEvent(self, event):
76
+ """Paint the slider."""
77
+ painter = QPainter(self)
78
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
79
+
80
+ # Draw channel label
81
+ font = QFont()
82
+ font.setPointSize(9)
83
+ painter.setFont(font)
84
+ painter.setPen(QPen(QColor(255, 255, 255)))
85
+ painter.drawText(5, 15, f"{self.channel_name}")
86
+
87
+ # Draw slider track
88
+ slider_rect = self.get_slider_rect()
89
+ painter.setPen(QPen(QColor(100, 100, 100), 2))
90
+ painter.setBrush(QBrush(QColor(50, 50, 50)))
91
+ painter.drawRoundedRect(slider_rect, 5, 5)
92
+
93
+ # Draw value segments
94
+ channel_color = self.get_channel_color()
95
+ sorted_indicators = sorted(self.indicators)
96
+
97
+ # Handle case with no indicators - draw single segment
98
+ if not sorted_indicators:
99
+ # Single segment covering entire slider
100
+ segment_rect = QRect(
101
+ slider_rect.left(),
102
+ slider_rect.top(),
103
+ slider_rect.width(),
104
+ slider_rect.height(),
105
+ )
106
+ # Use low alpha for inactive state
107
+ segment_color = QColor(channel_color)
108
+ segment_color.setAlpha(50)
109
+ painter.setBrush(QBrush(segment_color))
110
+ painter.setPen(QPen(Qt.GlobalColor.transparent))
111
+ painter.drawRoundedRect(segment_rect, 5, 5)
112
+ else:
113
+ # Draw segments between indicators
114
+ for i in range(len(sorted_indicators) + 1):
115
+ start_val = self.minimum if i == 0 else sorted_indicators[i - 1]
116
+ end_val = (
117
+ self.maximum
118
+ if i == len(sorted_indicators)
119
+ else sorted_indicators[i]
120
+ )
121
+
122
+ start_x = self.value_to_x(start_val)
123
+ end_x = self.value_to_x(end_val)
124
+
125
+ # Calculate segment value (evenly distributed)
126
+ segment_value = (
127
+ i / len(sorted_indicators) if len(sorted_indicators) > 0 else 0
128
+ )
129
+ alpha = int(50 + segment_value * 150) # 50-200 alpha range
130
+
131
+ segment_color = QColor(channel_color)
132
+ segment_color.setAlpha(alpha)
133
+
134
+ segment_rect = QRect(
135
+ start_x, slider_rect.top(), end_x - start_x, slider_rect.height()
136
+ )
137
+ painter.setBrush(QBrush(segment_color))
138
+ painter.setPen(QPen(Qt.GlobalColor.transparent))
139
+ painter.drawRoundedRect(segment_rect, 5, 5)
140
+
141
+ # Draw indicators
142
+ for i, value in enumerate(self.indicators):
143
+ x = self.value_to_x(value)
144
+
145
+ # Indicator handle
146
+ handle_rect = QRect(
147
+ x - 6, slider_rect.top() - 3, 12, slider_rect.height() + 6
148
+ )
149
+
150
+ # Highlight if dragging
151
+ if i == self.dragging_index:
152
+ painter.setBrush(QBrush(QColor(255, 255, 100)))
153
+ painter.setPen(QPen(QColor(200, 200, 50), 2))
154
+ else:
155
+ painter.setBrush(QBrush(QColor(255, 255, 255)))
156
+ painter.setPen(QPen(QColor(150, 150, 150), 1))
157
+
158
+ painter.drawRoundedRect(handle_rect, 3, 3)
159
+
160
+ # Value label
161
+ painter.setPen(QPen(QColor(255, 255, 255)))
162
+ painter.drawText(x - 15, slider_rect.bottom() + 15, f"{value}")
163
+
164
+ def mousePressEvent(self, event):
165
+ """Handle mouse press events."""
166
+ if event.button() == Qt.MouseButton.LeftButton:
167
+ slider_rect = self.get_slider_rect()
168
+
169
+ # Check if clicking on an existing indicator
170
+ for i, value in enumerate(self.indicators):
171
+ x = self.value_to_x(value)
172
+ handle_rect = QRect(
173
+ x - 6, slider_rect.top() - 3, 12, slider_rect.height() + 6
174
+ )
175
+
176
+ if handle_rect.contains(event.pos()):
177
+ self.dragging_index = i
178
+ self.drag_offset = event.pos().x() - x
179
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
180
+ self.is_dragging = True
181
+ self.dragStarted.emit()
182
+ return
183
+
184
+ def mouseDoubleClickEvent(self, event):
185
+ """Handle double-click to add new indicator."""
186
+ if event.button() == Qt.MouseButton.LeftButton:
187
+ slider_rect = self.get_slider_rect()
188
+
189
+ if slider_rect.contains(event.pos()):
190
+ new_value = self.x_to_value(event.pos().x())
191
+
192
+ # Don't add if too close to existing indicator
193
+ min_distance = 10
194
+ for existing_value in self.indicators:
195
+ if abs(new_value - existing_value) < min_distance:
196
+ return
197
+
198
+ self.indicators.append(new_value)
199
+ self.indicators.sort()
200
+ self.valueChanged.emit(self.indicators[:])
201
+ self.update()
202
+
203
+ def mouseMoveEvent(self, event):
204
+ """Handle mouse move events for dragging."""
205
+ if self.dragging_index >= 0:
206
+ new_x = event.pos().x() - self.drag_offset
207
+ new_value = self.x_to_value(new_x)
208
+
209
+ # Clamp value
210
+ new_value = max(self.minimum, min(self.maximum, new_value))
211
+
212
+ # Update indicator
213
+ self.indicators[self.dragging_index] = new_value
214
+ self.valueChanged.emit(self.indicators[:])
215
+ self.update()
216
+
217
+ def mouseReleaseEvent(self, event):
218
+ """Handle mouse release events."""
219
+ if event.button() == Qt.MouseButton.LeftButton:
220
+ self.dragging_index = -1
221
+ self.setCursor(Qt.CursorShape.ArrowCursor)
222
+ self.is_dragging = False
223
+ self.dragFinished.emit()
224
+
225
+ def contextMenuEvent(self, event):
226
+ """Handle right-click to remove indicator."""
227
+ slider_rect = self.get_slider_rect()
228
+
229
+ # Only allow removal if more than 1 indicator
230
+ if len(self.indicators) <= 1:
231
+ return
232
+
233
+ # Check if right-clicking on an indicator
234
+ for i, value in enumerate(self.indicators):
235
+ x = self.value_to_x(value)
236
+ handle_rect = QRect(
237
+ x - 6, slider_rect.top() - 3, 12, slider_rect.height() + 6
238
+ )
239
+
240
+ if handle_rect.contains(event.pos()):
241
+ self.indicators.pop(i)
242
+ self.valueChanged.emit(self.indicators[:])
243
+ self.update()
244
+ return
245
+
246
+ def reset(self):
247
+ """Reset to no indicators."""
248
+ self.indicators = []
249
+ self.valueChanged.emit(self.indicators[:])
250
+ self.update()
251
+
252
+ def get_indicators(self):
253
+ """Get current indicator values."""
254
+ return self.indicators[:]
255
+
256
+ def set_indicators(self, indicators):
257
+ """Set indicator values."""
258
+ self.indicators = indicators[:]
259
+ self.valueChanged.emit(self.indicators[:])
260
+ self.update()
261
+
262
+
263
+ class ChannelSliderWidget(QWidget):
264
+ """Combined widget with checkbox and slider for a single channel."""
265
+
266
+ valueChanged = pyqtSignal() # Emitted when checkbox or slider changes
267
+ dragStarted = pyqtSignal() # Emitted when slider drag starts
268
+ dragFinished = pyqtSignal() # Emitted when slider drag finishes
269
+
270
+ def __init__(self, channel_name, parent=None):
271
+ super().__init__(parent)
272
+ self.channel_name = channel_name
273
+ self.setupUI()
274
+
275
+ def setupUI(self):
276
+ """Set up the UI for the combined widget."""
277
+ layout = QHBoxLayout(self)
278
+ layout.setContentsMargins(0, 0, 0, 0)
279
+ layout.setSpacing(5)
280
+
281
+ # Checkbox to enable/disable
282
+ self.checkbox = QCheckBox()
283
+ self.checkbox.setChecked(False)
284
+ self.checkbox.toggled.connect(self._on_checkbox_toggled)
285
+ layout.addWidget(self.checkbox)
286
+
287
+ # Slider
288
+ self.slider = MultiIndicatorSlider(self.channel_name, 0, 256, self)
289
+ self.slider.valueChanged.connect(self._on_slider_changed)
290
+ self.slider.dragStarted.connect(self.dragStarted.emit) # Forward drag signals
291
+ self.slider.dragFinished.connect(self.dragFinished.emit)
292
+ self.slider.setEnabled(False) # Start disabled
293
+ layout.addWidget(self.slider)
294
+
295
+ def _on_checkbox_toggled(self, checked):
296
+ """Handle checkbox toggle."""
297
+ self.slider.setEnabled(checked)
298
+ if not checked:
299
+ # Reset slider when unchecked
300
+ self.slider.reset()
301
+ self.valueChanged.emit()
302
+
303
+ def _on_slider_changed(self):
304
+ """Handle slider value change."""
305
+ if self.checkbox.isChecked():
306
+ self.valueChanged.emit()
307
+
308
+ def is_enabled(self):
309
+ """Check if this channel is enabled."""
310
+ return self.checkbox.isChecked()
311
+
312
+ def get_indicators(self):
313
+ """Get current indicator values if enabled."""
314
+ if self.is_enabled():
315
+ return self.slider.get_indicators()
316
+ return []
317
+
318
+ def reset(self):
319
+ """Reset this channel."""
320
+ self.checkbox.setChecked(False)
321
+ self.slider.reset()
322
+
323
+
324
+ class ChannelThresholdWidget(QWidget):
325
+ """Widget for channel-based thresholding with multi-indicator sliders."""
326
+
327
+ thresholdChanged = pyqtSignal() # Emitted when any threshold changes
328
+ dragStarted = pyqtSignal() # Emitted when any slider drag starts
329
+ dragFinished = pyqtSignal() # Emitted when any slider drag finishes
330
+
331
+ def __init__(self, parent=None):
332
+ super().__init__(parent)
333
+ self.current_image_channels = 0 # 0 = no image, 1 = grayscale, 3 = RGB
334
+ self.sliders = {} # Dictionary of channel name -> slider
335
+
336
+ self.setupUI()
337
+
338
+ def setupUI(self):
339
+ """Set up the user interface."""
340
+ layout = QVBoxLayout(self)
341
+ layout.setContentsMargins(5, 5, 5, 5)
342
+ layout.setSpacing(5)
343
+
344
+ # Title
345
+ title_label = QLabel("Channel Thresholding")
346
+ title_label.setStyleSheet("font-weight: bold; font-size: 11px;")
347
+ layout.addWidget(title_label)
348
+
349
+ # Sliders container
350
+ self.sliders_layout = QVBoxLayout()
351
+ layout.addLayout(self.sliders_layout)
352
+
353
+ # Instructions
354
+ instructions = QLabel(
355
+ "✓ Check to enable • Double-click to add threshold • Right-click to remove"
356
+ )
357
+ instructions.setStyleSheet("color: #888; font-size: 9px;")
358
+ instructions.setWordWrap(True)
359
+ layout.addWidget(instructions)
360
+
361
+ layout.addStretch()
362
+
363
+ def update_for_image(self, image_array):
364
+ """Update widget based on loaded image."""
365
+ if image_array is None:
366
+ self._clear_sliders()
367
+ self.current_image_channels = 0
368
+ return
369
+
370
+ # Determine number of channels
371
+ if len(image_array.shape) == 2:
372
+ # Grayscale
373
+ self.current_image_channels = 1
374
+ channels = ["Gray"]
375
+ elif len(image_array.shape) == 3 and image_array.shape[2] == 3:
376
+ # RGB
377
+ self.current_image_channels = 3
378
+ channels = ["Red", "Green", "Blue"]
379
+ else:
380
+ # Unsupported format
381
+ self._clear_sliders()
382
+ self.current_image_channels = 0
383
+ return
384
+
385
+ # Create sliders for each channel
386
+ self._create_sliders(channels)
387
+
388
+ def _create_sliders(self, channel_names):
389
+ """Create sliders for the specified channels."""
390
+ # Clear existing sliders
391
+ self._clear_sliders()
392
+
393
+ # Create new combined slider widgets
394
+ for channel_name in channel_names:
395
+ slider_widget = ChannelSliderWidget(channel_name, self)
396
+ slider_widget.valueChanged.connect(self._on_slider_changed)
397
+ slider_widget.dragStarted.connect(
398
+ self.dragStarted.emit
399
+ ) # Forward drag signals
400
+ slider_widget.dragFinished.connect(self.dragFinished.emit)
401
+ self.sliders[channel_name] = slider_widget
402
+ self.sliders_layout.addWidget(slider_widget)
403
+
404
+ def _clear_sliders(self):
405
+ """Clear all sliders."""
406
+ for slider in self.sliders.values():
407
+ self.sliders_layout.removeWidget(slider)
408
+ slider.deleteLater()
409
+ self.sliders.clear()
410
+
411
+ def _on_slider_changed(self):
412
+ """Handle slider value change."""
413
+ self.thresholdChanged.emit()
414
+
415
+ def get_threshold_settings(self):
416
+ """Get current threshold settings for all channels."""
417
+ settings = {}
418
+ for channel_name, slider_widget in self.sliders.items():
419
+ settings[channel_name] = slider_widget.get_indicators()
420
+ return settings
421
+
422
+ def apply_thresholding(self, image_array):
423
+ """Apply thresholding to image array and return modified array."""
424
+ if image_array is None or not self.sliders:
425
+ return image_array
426
+
427
+ # Create a copy to modify
428
+ result = image_array.copy().astype(np.float32)
429
+
430
+ if self.current_image_channels == 1:
431
+ # Grayscale image
432
+ if "Gray" in self.sliders:
433
+ result = self._apply_channel_thresholding(
434
+ result, self.sliders["Gray"].get_indicators()
435
+ )
436
+ elif self.current_image_channels == 3:
437
+ # RGB image
438
+ channel_names = ["Red", "Green", "Blue"]
439
+ for i, channel_name in enumerate(channel_names):
440
+ if channel_name in self.sliders:
441
+ result[:, :, i] = self._apply_channel_thresholding(
442
+ result[:, :, i], self.sliders[channel_name].get_indicators()
443
+ )
444
+
445
+ # Convert back to uint8
446
+ return np.clip(result, 0, 255).astype(np.uint8)
447
+
448
+ def _apply_channel_thresholding(self, channel_data, indicators):
449
+ """Apply thresholding to a single channel."""
450
+ if not indicators:
451
+ return channel_data
452
+
453
+ # Sort indicators
454
+ sorted_indicators = sorted(indicators)
455
+
456
+ # Create output array
457
+ result = np.zeros_like(channel_data)
458
+
459
+ # Number of segments = number of indicators + 1
460
+ num_segments = len(sorted_indicators) + 1
461
+
462
+ # Apply thresholding for each segment
463
+ for i in range(num_segments):
464
+ # Determine segment bounds
465
+ if i == 0:
466
+ # First segment: 0 to first indicator
467
+ mask = channel_data < sorted_indicators[0]
468
+ segment_value = 0
469
+ elif i == num_segments - 1:
470
+ # Last segment: last indicator to max
471
+ mask = channel_data >= sorted_indicators[-1]
472
+ segment_value = 255
473
+ else:
474
+ # Middle segments
475
+ mask = (channel_data >= sorted_indicators[i - 1]) & (
476
+ channel_data < sorted_indicators[i]
477
+ )
478
+ # Evenly distribute values between segments
479
+ segment_value = int((i / (num_segments - 1)) * 255)
480
+
481
+ result[mask] = segment_value
482
+
483
+ return result
484
+
485
+ def has_active_thresholding(self):
486
+ """Check if any channel has active thresholding (indicators present)."""
487
+ for slider_widget in self.sliders.values():
488
+ if slider_widget.get_indicators():
489
+ return True
490
+ return False
491
+
492
+ def get_threshold_params(self):
493
+ """Get current threshold parameters for caching."""
494
+ params = {}
495
+ for channel_name, slider_widget in self.sliders.items():
496
+ params[channel_name] = {
497
+ "indicators": sorted(slider_widget.get_indicators()),
498
+ "enabled": slider_widget.is_enabled(),
499
+ }
500
+ return params