phasor-handler 2.2.4__tar.gz → 2.3.0__tar.gz

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.
Files changed (49) hide show
  1. {phasor_handler-2.2.4/phasor_handler.egg-info → phasor_handler-2.3.0}/PKG-INFO +1 -1
  2. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/environment.yml +0 -1
  3. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/__init__.py +1 -1
  4. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/app.py +25 -4
  5. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/__init__.py +2 -1
  6. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/components/circle_roi.py +307 -26
  7. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/components/meta_info.py +4 -1
  8. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/components/roi_list.py +177 -15
  9. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/components/trace_plot.py +2 -2
  10. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/view.py +108 -9
  11. phasor_handler-2.3.0/phasor_handler/widgets/secondlevel/view.py +659 -0
  12. phasor_handler-2.3.0/phasor_handler/workers/__init__.py +4 -0
  13. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/workers/conversion_worker.py +4 -1
  14. phasor_handler-2.3.0/phasor_handler/workers/secondlevel_worker.py +148 -0
  15. {phasor_handler-2.2.4 → phasor_handler-2.3.0/phasor_handler.egg-info}/PKG-INFO +1 -1
  16. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler.egg-info/SOURCES.txt +3 -1
  17. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/pyproject.toml +1 -1
  18. phasor_handler-2.2.4/phasor_handler/workers/__init__.py +0 -3
  19. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/CONTRIBUTING.md +0 -0
  20. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/LICENSE.md +0 -0
  21. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/MANIFEST.in +0 -0
  22. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/README.md +0 -0
  23. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/img/icons/chevron-down.svg +0 -0
  24. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/img/icons/chevron-up.svg +0 -0
  25. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/img/logo.ico +0 -0
  26. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/models/dir_manager.py +0 -0
  27. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/scripts/contrast.py +0 -0
  28. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/scripts/convert.py +0 -0
  29. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/scripts/meta_reader.py +0 -0
  30. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/scripts/plot.py +0 -0
  31. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/scripts/register.py +0 -0
  32. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/themes/__init__.py +0 -0
  33. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/themes/dark_theme.py +0 -0
  34. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/tools/__init__.py +0 -0
  35. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/tools/check_stylesheet.py +0 -0
  36. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/tools/misc.py +0 -0
  37. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/components/__init__.py +0 -0
  38. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/components/bnc.py +0 -0
  39. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/analysis/components/image_view.py +0 -0
  40. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/conversion/view.py +0 -0
  41. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/widgets/registration/view.py +0 -0
  42. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/workers/analysis_worker.py +0 -0
  43. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/workers/histogram_worker.py +0 -0
  44. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler/workers/registration_worker.py +0 -0
  45. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler.egg-info/dependency_links.txt +0 -0
  46. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler.egg-info/entry_points.txt +0 -0
  47. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler.egg-info/requires.txt +0 -0
  48. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/phasor_handler.egg-info/top_level.txt +0 -0
  49. {phasor_handler-2.2.4 → phasor_handler-2.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phasor-handler
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: A PyQt6 GUI toolbox for processing two-photon phasor imaging data
5
5
  Author-email: Josia Shemuel <joshemuel@users.noreply.github.com>
6
6
  License: MIT License
@@ -216,4 +216,3 @@ dependencies:
216
216
  - xarray==2024.7.0
217
217
  - xmltodict==0.14.2
218
218
  - xsdata==24.3.1
219
- prefix: C:\Users\user\anaconda3\envs\suite2p
@@ -4,6 +4,6 @@ Phasor Handler - Two-Photon Phasor Imaging Data Processor
4
4
  A PyQt6 GUI toolbox for processing two-photon phasor imaging data.
5
5
  """
6
6
 
7
- __version__ = "2.2.0"
7
+ __version__ = "2.3.0"
8
8
  __author__ = "Josia Shemuel"
9
9
  __all__ = ["app", "widgets", "workers", "models", "scripts", "tools", "themes"]
@@ -13,7 +13,7 @@ from PyQt6.QtGui import QFileSystemModel, QIcon
13
13
  from PyQt6.QtCore import Qt
14
14
  from PyQt6.QtCore import QThread
15
15
 
16
- from .widgets import ConversionWidget, RegistrationWidget, AnalysisWidget
16
+ from .widgets import ConversionWidget, RegistrationWidget, AnalysisWidget, SecondLevelWidget
17
17
  from .workers import RegistrationWorker, ConversionWorker
18
18
  from .models.dir_manager import DirManager
19
19
  from .themes import apply_dark_theme
@@ -37,7 +37,8 @@ class MainWindow(QMainWindow):
37
37
  self.tabs.addTab(ConversionWidget(self), "Conversion")
38
38
  self.tabs.addTab(RegistrationWidget(self), "Registration")
39
39
  # AnalysisWidget exposes compatible attributes on the main window
40
- self.tabs.addTab(AnalysisWidget(self), "Analysis")
40
+ self.tabs.addTab(AnalysisWidget(self), "First Level")
41
+ self.tabs.addTab(SecondLevelWidget(self), "Second Level")
41
42
  self.tabs.currentChanged.connect(self.on_tab_changed)
42
43
  self.setCentralWidget(self.tabs)
43
44
 
@@ -130,17 +131,37 @@ class MainWindow(QMainWindow):
130
131
  self.analysis_list_widget.addItem(item)
131
132
 
132
133
  def on_tab_changed(self, idx):
133
- # When switching to analysis tab, refresh its directory list
134
+ # When switching to first level tab, refresh its directory list
134
135
  tab_text = self.tabs.tabText(idx)
135
- if tab_text == "Analysis":
136
+ if tab_text == "First Level":
136
137
  if hasattr(self, 'analysis_list_widget'):
138
+ # Capture current selection to persist it
139
+ current_path = None
140
+ selected_items = self.analysis_list_widget.selectedItems()
141
+ if selected_items:
142
+ current_path = selected_items[0].data(Qt.ItemDataRole.UserRole)
143
+
144
+ # Block signals to prevent unnecessary clearing/reloading of the image
145
+ self.analysis_list_widget.blockSignals(True)
137
146
  self.analysis_list_widget.clear()
147
+
138
148
  from PyQt6.QtWidgets import QListWidgetItem
139
149
  for full_path, display_name in self.dir_manager.get_display_names():
140
150
  item = QListWidgetItem(display_name)
141
151
  item.setToolTip(full_path)
142
152
  item.setData(Qt.ItemDataRole.UserRole, full_path)
143
153
  self.analysis_list_widget.addItem(item)
154
+
155
+ # Restore selection if it matches
156
+ if current_path is not None and full_path == current_path:
157
+ item.setSelected(True)
158
+ self.analysis_list_widget.setCurrentItem(item)
159
+
160
+ self.analysis_list_widget.blockSignals(False)
161
+ elif tab_text == "Second Level":
162
+ # Trigger refresh of second level plots when tab is shown
163
+ if hasattr(self, 'second_level_widget'):
164
+ self.second_level_widget.refresh_plots()
144
165
 
145
166
  def run_conversion_script(self):
146
167
  if not self.selected_dirs:
@@ -1,5 +1,6 @@
1
1
  from .analysis.view import AnalysisWidget
2
2
  from .conversion.view import ConversionWidget
3
3
  from .registration.view import RegistrationWidget
4
+ from .secondlevel.view import SecondLevelWidget
4
5
 
5
- __all__ = ["AnalysisWidget", "ConversionWidget", "RegistrationWidget"]
6
+ __all__ = ["AnalysisWidget", "ConversionWidget", "RegistrationWidget", "SecondLevelWidget"]
@@ -21,6 +21,7 @@ class CircleRoiTool(QObject):
21
21
  roiChanged = pyqtSignal(tuple) # (x0, y0, x1, y1) in image coords (during drag)
22
22
  roiFinalized = pyqtSignal(tuple) # (x0, y0, x1, y1) in image coords (on release)
23
23
  roiSelected = pyqtSignal(int) # index of ROI selected by right-click
24
+ roiSelectionToggled = pyqtSignal(int) # index of ROI to toggle selection for (Shift+Click)
24
25
  roiDrawingStarted = pyqtSignal() # emitted when user starts drawing a new ROI
25
26
 
26
27
  def __init__(self, label: QLabel, parent=None):
@@ -77,6 +78,11 @@ class CircleRoiTool(QObject):
77
78
  self._freehand_points_origin = None # List of original freehand points for transformations
78
79
  # whether to show the small mode text in the overlay (can be toggled by view)
79
80
  self._show_mode_text = True
81
+
82
+ # Multi-selection support for moving multiple ROIs simultaneously
83
+ self._selected_roi_indices = [] # List of indices of selected ROIs
84
+ self._multi_roi_origins = None # Dict storing original positions during multi-ROI drag
85
+ self._multi_roi_drag_offset = None # QPointF storing current drag offset
80
86
 
81
87
  def set_draw_rect(self, rect: QRect):
82
88
  """Rectangle where the scaled pixmap is drawn inside the label."""
@@ -146,6 +152,95 @@ class CircleRoiTool(QObject):
146
152
  if self._base_pixmap is not None:
147
153
  self._paint_overlay()
148
154
 
155
+ def finalize_multi_roi_movement(self):
156
+ """Finalize the multi-ROI movement by applying the offset to saved ROIs."""
157
+ if self._multi_roi_origins is not None and self._multi_roi_drag_offset is not None:
158
+ dx = self._multi_roi_drag_offset.x()
159
+ dy = self._multi_roi_drag_offset.y()
160
+
161
+ # Apply changes to all selected ROIs
162
+ for idx, origin in self._multi_roi_origins.items():
163
+ if 0 <= idx < len(self._saved_rois):
164
+ roi = self._saved_rois[idx]
165
+ ox, oy, ow, oh = origin['bbox']
166
+ new_left = ox + dx
167
+ new_top = oy + dy
168
+
169
+ # Convert back to image coordinates
170
+ if self._draw_rect and self._img_w and self._img_h:
171
+ scale_x = self._img_w / float(self._draw_rect.width())
172
+ scale_y = self._img_h / float(self._draw_rect.height())
173
+
174
+ img_x0 = (new_left - self._draw_rect.left()) * scale_x
175
+ img_y0 = (new_top - self._draw_rect.top()) * scale_y
176
+ img_x1 = img_x0 + (ow * scale_x)
177
+ img_y1 = img_y0 + (oh * scale_y)
178
+
179
+ # Update the ROI's xyxy coordinates
180
+ roi['xyxy'] = (img_x0, img_y0, img_x1, img_y1)
181
+
182
+ # If this ROI has freehand points, translate them too
183
+ if origin.get('freehand_points'):
184
+ original_points = origin['freehand_points']
185
+ translated_points = []
186
+ for pt in original_points:
187
+ # Points are stored in image coordinates
188
+ # Convert to label coordinates, translate, convert back
189
+ label_x = self._draw_rect.left() + (pt[0] / scale_x)
190
+ label_y = self._draw_rect.top() + (pt[1] / scale_y)
191
+ new_label_x = label_x + dx
192
+ new_label_y = label_y + dy
193
+ new_img_x = (new_label_x - self._draw_rect.left()) * scale_x
194
+ new_img_y = (new_label_y - self._draw_rect.top()) * scale_y
195
+ translated_points.append((new_img_x, new_img_y))
196
+ roi['points'] = translated_points
197
+
198
+ # Clear the origin state
199
+ self._multi_roi_origins = None
200
+ self._multi_roi_drag_offset = None
201
+ self._paint_overlay()
202
+ print(f"DEBUG: Multi-ROI movement finalized for {len(self._selected_roi_indices)} ROIs")
203
+ return True
204
+ return False
205
+
206
+ def revert_multi_roi_movement(self):
207
+ """Revert the multi-ROI movement back to original positions."""
208
+ if self._multi_roi_origins is not None and len(self._multi_roi_origins) > 0:
209
+ # Restore original positions for all moved ROIs
210
+ for idx, origin in self._multi_roi_origins.items():
211
+ if 0 <= idx < len(self._saved_rois):
212
+ roi = self._saved_rois[idx]
213
+ ox, oy, ow, oh = origin['bbox']
214
+
215
+ # Convert original bbox back to image coordinates
216
+ if self._draw_rect and self._img_w and self._img_h:
217
+ scale_x = self._img_w / float(self._draw_rect.width())
218
+ scale_y = self._img_h / float(self._draw_rect.height())
219
+
220
+ img_x0 = (ox - self._draw_rect.left()) * scale_x
221
+ img_y0 = (oy - self._draw_rect.top()) * scale_y
222
+ img_x1 = img_x0 + (ow * scale_x)
223
+ img_y1 = img_y0 + (oh * scale_y)
224
+
225
+ # Restore original xyxy
226
+ roi['xyxy'] = (img_x0, img_y0, img_x1, img_y1)
227
+
228
+ # Restore original freehand points if they existed
229
+ if origin.get('freehand_points'):
230
+ roi['points'] = origin['freehand_points']
231
+
232
+ # Clear the origin state
233
+ self._multi_roi_origins = None
234
+ self._multi_roi_drag_offset = None
235
+ self._paint_overlay()
236
+ print(f"DEBUG: Multi-ROI movement reverted for {len(self._selected_roi_indices)} ROIs")
237
+ return True
238
+ return False
239
+
240
+ def is_multi_roi_preview_active(self):
241
+ """Check if there's a pending multi-ROI movement preview."""
242
+ return self._multi_roi_origins is not None and len(self._multi_roi_origins) > 0
243
+
149
244
  def _get_bbox_center(self):
150
245
  """Get the center point of the current bbox."""
151
246
  if self._bbox is None:
@@ -272,8 +367,22 @@ class CircleRoiTool(QObject):
272
367
  else:
273
368
  print("Draw an ROI first before switching interaction modes")
274
369
  return True
370
+ elif event.key() == Qt.Key.Key_R:
371
+ # Finalize multi-ROI movement if active
372
+ if self._multi_roi_drag_offset is not None and self._multi_roi_origins:
373
+ self.finalize_multi_roi_movement()
374
+ # End the drag operation
375
+ self._dragging = False
376
+ self._mode = None
377
+ self._translate_anchor = None
378
+ return True
275
379
 
276
380
  if et == event.Type.MouseButtonPress:
381
+ # Check for pending multi-ROI preview and cancel it if clicking elsewhere to start new drawing
382
+ if self._multi_roi_origins is not None:
383
+ print("DEBUG: Cancelling multi-ROI preview due to new Left Click")
384
+ self.revert_multi_roi_movement()
385
+
277
386
  if event.button() == Qt.MouseButton.LeftButton and self._in_draw_rect(event.position()):
278
387
  # Emit signal that ROI drawing has started
279
388
  self.roiDrawingStarted.emit()
@@ -315,6 +424,51 @@ class CircleRoiTool(QObject):
315
424
  if event.button() == Qt.MouseButton.RightButton and self._mode != 'draw':
316
425
  p = event.position() # QPointF
317
426
 
427
+ # Check if we clicked on an ROI
428
+ clicked_roi_index = self._find_roi_at_point(p)
429
+
430
+ # Check for pending multi-ROI preview
431
+ if self._multi_roi_origins is not None:
432
+ # If we click right button again, we might want to cancel the previous preview
433
+ # UNLESS we are clicking on the ghost? But ghosts aren't clickable objects yet.
434
+ # User said "reset translate from original", so cancel is appropriate.
435
+ print("DEBUG: Cancelling multi-ROI preview due to new Right Click")
436
+ self.revert_multi_roi_movement()
437
+
438
+ # Check for Shift+Click (Multi-selection toggle or add)
439
+ # User Requirement: "The Shift button needs to be HOLD DOWN... It's not just a toggle"
440
+ # This implies explicit check for modifier.
441
+ if clicked_roi_index is not None and (event.modifiers() & Qt.KeyboardModifier.ShiftModifier):
442
+ print(f"DEBUG: Shift+RightClick on ROI {clicked_roi_index} - toggling selection")
443
+ self.roiSelectionToggled.emit(clicked_roi_index)
444
+ return True
445
+
446
+ # Check if multiple ROIs are selected AND we clicked on one of them
447
+ if len(self._selected_roi_indices) > 1 and clicked_roi_index is not None and clicked_roi_index in self._selected_roi_indices:
448
+ # Multi-selection mode - move all selected ROIs together
449
+ print(f"DEBUG: Multi-ROI drag started - moving {len(self._selected_roi_indices)} ROIs")
450
+ self._mode = 'translate'
451
+ self._dragging = True
452
+ self._translate_anchor = p
453
+ self._multi_roi_drag_offset = QPointF(0, 0)
454
+
455
+ # Store original positions of all selected ROIs
456
+ self._multi_roi_origins = {}
457
+ for idx in self._selected_roi_indices:
458
+ if 0 <= idx < len(self._saved_rois):
459
+ roi = self._saved_rois[idx]
460
+ xyxy = roi.get('xyxy')
461
+ if xyxy:
462
+ lbbox = self._label_bbox_from_image_xyxy(xyxy)
463
+ if lbbox:
464
+ self._multi_roi_origins[idx] = {
465
+ 'bbox': lbbox,
466
+ 'rotation': roi.get('rotation', 0.0),
467
+ 'freehand_points': roi.get('points', None)
468
+ }
469
+ return True
470
+
471
+ # Single selection mode - original behavior
318
472
  # First, check if we're clicking on any saved ROI
319
473
  roi_index = self._find_roi_at_point(p)
320
474
  if roi_index is not None:
@@ -425,31 +579,47 @@ class CircleRoiTool(QObject):
425
579
  self.roiChanged.emit(xyxy)
426
580
  return True
427
581
 
428
- elif self._mode == 'translate' and self._bbox_origin is not None and self._translate_anchor is not None:
429
- # compute delta and move bbox using float math to avoid size drift
430
- anchor = self._translate_anchor
431
- dx = pos.x() - anchor.x()
432
- dy = pos.y() - anchor.y()
433
- ox, oy, ow, oh = self._bbox_origin
434
- new_left = ox + dx
435
- new_top = oy + dy
436
-
437
- self._bbox = (new_left, new_top, ow, oh)
438
-
439
- # Also translate freehand points if they exist
440
- if self._freehand_points_origin:
441
- self._freehand_points = []
442
- for pt in self._freehand_points_origin:
443
- new_pt = QPointF(pt.x() + dx, pt.y() + dy)
444
- self._freehand_points.append(new_pt)
582
+ elif self._mode == 'translate' and self._translate_anchor is not None:
583
+ # Check if we're in multi-ROI mode
584
+ if self._multi_roi_origins is not None and len(self._multi_roi_origins) > 0:
585
+ # Multi-ROI translation mode - PREVIEW ONLY
586
+ anchor = self._translate_anchor
587
+ dx = pos.x() - anchor.x()
588
+ dy = pos.y() - anchor.y()
445
589
 
446
- # Recalculate bbox from translated points to ensure center alignment
447
- self._update_bbox_from_freehand_points()
590
+ # Update offset
591
+ self._multi_roi_drag_offset = QPointF(dx, dy)
592
+
593
+ # Repaint to show preview
594
+ self._paint_overlay()
595
+ return True
448
596
 
449
- self._paint_overlay()
450
- xyxy = self._current_roi_image_coords()
451
- if xyxy is not None:
452
- self.roiChanged.emit(xyxy)
597
+ # Single ROI translation (original behavior)
598
+ if self._bbox_origin is not None:
599
+ # compute delta and move bbox using float math to avoid size drift
600
+ anchor = self._translate_anchor
601
+ dx = pos.x() - anchor.x()
602
+ dy = pos.y() - anchor.y()
603
+ ox, oy, ow, oh = self._bbox_origin
604
+ new_left = ox + dx
605
+ new_top = oy + dy
606
+
607
+ self._bbox = (new_left, new_top, ow, oh)
608
+
609
+ # Also translate freehand points if they exist
610
+ if self._freehand_points_origin:
611
+ self._freehand_points = []
612
+ for pt in self._freehand_points_origin:
613
+ new_pt = QPointF(pt.x() + dx, pt.y() + dy)
614
+ self._freehand_points.append(new_pt)
615
+
616
+ # Recalculate bbox from translated points to ensure center alignment
617
+ self._update_bbox_from_freehand_points()
618
+
619
+ self._paint_overlay()
620
+ xyxy = self._current_roi_image_coords()
621
+ if xyxy is not None:
622
+ self.roiChanged.emit(xyxy)
453
623
  return True
454
624
 
455
625
  elif self._mode == 'rotate' and self._rotation_anchor is not None:
@@ -539,6 +709,21 @@ class CircleRoiTool(QObject):
539
709
 
540
710
  if self._mode == 'translate' and event.button() == Qt.MouseButton.RightButton:
541
711
  self._dragging = False
712
+
713
+ # Check if we're in multi-ROI mode
714
+ # Check if we're in multi-ROI mode
715
+ if self._multi_roi_origins is not None and len(self._multi_roi_origins) > 0:
716
+ # Mouse released -> KEEP PREVIEW
717
+ self._translate_anchor = None
718
+ self._mode = None
719
+ self._dragging = False # Stop dragging but keep state
720
+
721
+ # Repaint to keep preview visible
722
+ self._paint_overlay()
723
+ print(f"DEBUG: Multi-ROI drag released - preview kept. Press 'R' to commit, click elsewhere to cancel.")
724
+ return True
725
+
726
+ # Single ROI translation finalization (original behavior)
542
727
  # finalize translation
543
728
  self._translate_anchor = None
544
729
  self._bbox_origin = None
@@ -764,7 +949,11 @@ class CircleRoiTool(QObject):
764
949
  lx0, ly0, lw, lh = lbbox
765
950
  px0 = float(lx0) - offset_x
766
951
  py0 = float(ly0) - offset_y
767
- # determine color
952
+
953
+ # Check if this ROI is selected
954
+ is_selected = idx in self._selected_roi_indices
955
+
956
+ # determine color - use brighter/thicker pen for selected ROIs
768
957
  col = saved.get('color')
769
958
  if isinstance(col, QColor):
770
959
  qcol = col
@@ -773,8 +962,16 @@ class CircleRoiTool(QObject):
773
962
  qcol = QColor(int(col[0]), int(col[1]), int(col[2]), int(a))
774
963
  else:
775
964
  qcol = QColor(200, 100, 10, 200)
776
- spen = QPen(qcol)
777
- spen.setWidth(3)
965
+
966
+ # Highlight selected ROIs with thicker, brighter border
967
+ if is_selected:
968
+ # Use a brighter version of the ROI's own color
969
+ spen = QPen(qcol.lighter(150))
970
+ spen.setColor(QColor(spen.color().red(), spen.color().green(), spen.color().blue(), 255)) # Ensure opaque
971
+ spen.setWidth(5)
972
+ else:
973
+ spen = QPen(qcol)
974
+ spen.setWidth(3)
778
975
  painter.setPen(spen)
779
976
 
780
977
  # Check ROI type
@@ -874,6 +1071,75 @@ class CircleRoiTool(QObject):
874
1071
  except Exception:
875
1072
  pass
876
1073
 
1074
+ # Draw Multi-ROI Drag Preview (Ghosts)
1075
+ if self._multi_roi_origins is not None and self._multi_roi_drag_offset is not None:
1076
+ try:
1077
+ dx = self._multi_roi_drag_offset.x()
1078
+ dy = self._multi_roi_drag_offset.y()
1079
+
1080
+ # Use a distinct pen for ghosts
1081
+ ghost_pen = QPen(QColor(255, 255, 0, 200)) # Yellow
1082
+ ghost_pen.setWidth(2)
1083
+ ghost_pen.setStyle(Qt.PenStyle.DashLine)
1084
+ painter.setPen(ghost_pen)
1085
+
1086
+ for idx, origin in self._multi_roi_origins.items():
1087
+ ox, oy, ow, oh = origin['bbox']
1088
+
1089
+ # Apply offset
1090
+ ghost_left = ox + dx
1091
+ ghost_top = oy + dy
1092
+
1093
+ # Draw ghost based on type
1094
+ if 0 <= idx < len(self._saved_rois):
1095
+ roi = self._saved_rois[idx]
1096
+ roi_type = roi.get('type', 'circular')
1097
+ rotation = roi.get('rotation', 0.0)
1098
+
1099
+ if roi_type == 'freehand' and origin.get('freehand_points'):
1100
+ # Draw freehand ghost
1101
+ if self._draw_rect and self._img_w and self._img_h:
1102
+ polygon = QPolygonF()
1103
+ scale_x = self._img_w / float(self._draw_rect.width())
1104
+ scale_y = self._img_h / float(self._draw_rect.height())
1105
+
1106
+ for pt in origin['freehand_points']:
1107
+ # Image -> Label
1108
+ lx = self._draw_rect.left() + (pt[0] / scale_x)
1109
+ ly = self._draw_rect.top() + (pt[1] / scale_y)
1110
+ # Apply drag offset
1111
+ lx += dx
1112
+ ly += dy
1113
+ # Label -> Pixmap
1114
+ px = lx - offset_x
1115
+ py = ly - offset_y
1116
+ polygon.append(QPointF(px, py))
1117
+ painter.drawPolygon(polygon)
1118
+ else:
1119
+ # Draw Rect/Ellipse ghost
1120
+ px = ghost_left - offset_x
1121
+ py = ghost_top - offset_y
1122
+
1123
+ if rotation != 0.0:
1124
+ center_x = px + ow / 2.0
1125
+ center_y = py + oh / 2.0
1126
+ painter.save()
1127
+ painter.translate(center_x, center_y)
1128
+ painter.rotate(math.degrees(rotation))
1129
+ if roi_type == 'rectangular':
1130
+ painter.drawRect(int(round(-ow/2)), int(round(-oh/2)), int(round(ow)), int(round(oh)))
1131
+ else:
1132
+ painter.drawEllipse(int(round(-ow/2)), int(round(-oh/2)), int(round(ow)), int(round(oh)))
1133
+ painter.restore()
1134
+ else:
1135
+ if roi_type == 'rectangular':
1136
+ painter.drawRect(int(round(px)), int(round(py)), int(round(ow)), int(round(oh)))
1137
+ else:
1138
+ painter.drawEllipse(int(round(px)), int(round(py)), int(round(ow)), int(round(oh)))
1139
+
1140
+ except Exception as e:
1141
+ print(f"Error drawing multi-ROI preview: {e}")
1142
+
877
1143
  # Draw stimulus ROIs with distinctive styling (if visible)
878
1144
  if getattr(self, '_show_stim_rois', True):
879
1145
  try:
@@ -937,6 +1203,21 @@ class CircleRoiTool(QObject):
937
1203
  font.setBold(True)
938
1204
  painter.setFont(font)
939
1205
  painter.drawText(10, 25, mode_text)
1206
+
1207
+ # Draw multi-selection indicator when multiple ROIs are selected
1208
+ if len(self._selected_roi_indices) > 1 and getattr(self, '_show_mode_text', True):
1209
+ # Check if we're in preview mode (after drag, before finalize)
1210
+ if self._multi_roi_origins is not None and len(self._multi_roi_origins) > 0:
1211
+ multi_text = f"Preview: {len(self._selected_roi_indices)} ROIs moved - Press 'R' to finalize, Escape to revert"
1212
+ painter.setPen(QPen(QColor(255, 165, 0, 255))) # Orange for preview
1213
+ else:
1214
+ multi_text = f"Selected: {len(self._selected_roi_indices)} ROIs (right-click drag to move all)"
1215
+ painter.setPen(QPen(QColor(255, 255, 0, 255))) # Bright yellow
1216
+ font = QFont()
1217
+ font.setPointSize(11)
1218
+ font.setBold(True)
1219
+ painter.setFont(font)
1220
+ painter.drawText(10, 50, multi_text)
940
1221
 
941
1222
  painter.end()
942
1223
  self._label.setPixmap(overlay)
@@ -291,7 +291,7 @@ class MetadataViewer(QDialog):
291
291
  self.add_info_row(self.stim_layout, row, "Camera Frame Rate (Hz)", str(camera_framerate))
292
292
  else:
293
293
  # 3i: Show stimulation information
294
- stim_keys = ['stimulation_timeframes', 'stimulation_ms', 'duty_cycle', 'stimulated_roi_location', 'stimulated_rois']
294
+ stim_keys = ['stimulation_timeframes', 'stimulation_ms', 'duty_cycle', 'stimulated_roi_location', 'stimulated_rois', 'stimulated_roi_powers']
295
295
  for key in stim_keys:
296
296
  if key in self.metadata:
297
297
  value = self.metadata[key]
@@ -315,6 +315,9 @@ class MetadataViewer(QDialog):
315
315
  else:
316
316
  display_value = ', '.join([', '.join(map(str, x)) for x in value])
317
317
  key = "Stimulated ROIs"
318
+ elif key == 'stimulated_roi_powers':
319
+ display_value = ', '.join(map(str, value))
320
+ key = "Stimulated ROI Powers"
318
321
  else:
319
322
  display_value = str(value)
320
323
  self.add_info_row(self.stim_layout, row, key.replace('_', ' '),