phasor-handler 2.2.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.
Files changed (37) hide show
  1. phasor_handler/__init__.py +9 -0
  2. phasor_handler/app.py +249 -0
  3. phasor_handler/img/icons/chevron-down.svg +3 -0
  4. phasor_handler/img/icons/chevron-up.svg +3 -0
  5. phasor_handler/img/logo.ico +0 -0
  6. phasor_handler/models/dir_manager.py +100 -0
  7. phasor_handler/scripts/contrast.py +131 -0
  8. phasor_handler/scripts/convert.py +155 -0
  9. phasor_handler/scripts/meta_reader.py +467 -0
  10. phasor_handler/scripts/plot.py +110 -0
  11. phasor_handler/scripts/register.py +86 -0
  12. phasor_handler/themes/__init__.py +8 -0
  13. phasor_handler/themes/dark_theme.py +330 -0
  14. phasor_handler/tools/__init__.py +1 -0
  15. phasor_handler/tools/check_stylesheet.py +15 -0
  16. phasor_handler/tools/misc.py +20 -0
  17. phasor_handler/widgets/__init__.py +5 -0
  18. phasor_handler/widgets/analysis/components/__init__.py +9 -0
  19. phasor_handler/widgets/analysis/components/bnc.py +426 -0
  20. phasor_handler/widgets/analysis/components/circle_roi.py +850 -0
  21. phasor_handler/widgets/analysis/components/image_view.py +667 -0
  22. phasor_handler/widgets/analysis/components/meta_info.py +481 -0
  23. phasor_handler/widgets/analysis/components/roi_list.py +659 -0
  24. phasor_handler/widgets/analysis/components/trace_plot.py +621 -0
  25. phasor_handler/widgets/analysis/view.py +1735 -0
  26. phasor_handler/widgets/conversion/view.py +83 -0
  27. phasor_handler/widgets/registration/view.py +110 -0
  28. phasor_handler/workers/__init__.py +2 -0
  29. phasor_handler/workers/analysis_worker.py +0 -0
  30. phasor_handler/workers/histogram_worker.py +55 -0
  31. phasor_handler/workers/registration_worker.py +242 -0
  32. phasor_handler-2.2.1.dist-info/METADATA +134 -0
  33. phasor_handler-2.2.1.dist-info/RECORD +37 -0
  34. phasor_handler-2.2.1.dist-info/WHEEL +5 -0
  35. phasor_handler-2.2.1.dist-info/entry_points.txt +5 -0
  36. phasor_handler-2.2.1.dist-info/licenses/LICENSE.md +21 -0
  37. phasor_handler-2.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,667 @@
1
+ """
2
+ Image View Widget for Analysis Tab
3
+
4
+ This module contains the ImageViewWidget that handles image display functionality
5
+ for the analysis tab, including the reg_tif_label, image scaling with aspect ratio
6
+ preservation, and ROI tool integration.
7
+ """
8
+
9
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QSizePolicy
10
+ from PyQt6.QtCore import Qt, QRect, pyqtSignal
11
+ from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QFont, QColor
12
+ import numpy as np
13
+ import os
14
+ import pickle
15
+ import subprocess
16
+ import tifffile
17
+
18
+
19
+ class ImageViewWidget(QWidget):
20
+ """
21
+ Widget that handles image display with aspect ratio preservation and ROI integration.
22
+ """
23
+
24
+ # Signals to communicate with parent
25
+ imageUpdated = pyqtSignal() # Emitted when a new image is displayed
26
+
27
+ def __init__(self, parent=None):
28
+ super().__init__(parent)
29
+ self.setupUI()
30
+
31
+ # Store references for image data
32
+ self._current_image_np = None
33
+ self._current_qimage = None
34
+
35
+ def setupUI(self):
36
+ """Set up the UI components."""
37
+ layout = QVBoxLayout()
38
+ layout.setContentsMargins(0, 0, 0, 0)
39
+
40
+ # Create the main image display label
41
+ self.reg_tif_label = QLabel("Select a directory to view registered images.")
42
+ self.reg_tif_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
43
+ self.reg_tif_label.setMinimumSize(700, 700)
44
+ self.reg_tif_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
45
+
46
+ layout.addWidget(self.reg_tif_label, 1) # Give stretch factor of 1 to make it greedy
47
+ self.setLayout(layout)
48
+
49
+ def get_label(self):
50
+ """Get the internal QLabel for ROI tool integration."""
51
+ return self.reg_tif_label
52
+
53
+ def set_text(self, text):
54
+ """Set text message on the image label."""
55
+ self.reg_tif_label.setText(text)
56
+
57
+ def clear_pixmap(self):
58
+ """Clear the current pixmap and show default text."""
59
+ self.reg_tif_label.setPixmap(QPixmap())
60
+ self.reg_tif_label.setText("Select a directory to view registered images.")
61
+
62
+ def set_loading_message(self, message):
63
+ """Set a loading message."""
64
+ self.reg_tif_label.setText(message)
65
+
66
+ def display_image(self, arr_uint8, show_scale_bar=False, metadata=None):
67
+ """
68
+ Display an image array with proper scaling and aspect ratio preservation.
69
+
70
+ Args:
71
+ arr_uint8: RGBA uint8 array with shape (height, width, 4)
72
+ show_scale_bar: Whether to draw scale bar on the image
73
+ metadata: Experiment metadata for scale bar calculations
74
+ """
75
+ if arr_uint8 is None or arr_uint8.size == 0:
76
+ self.reg_tif_label.setText("Error: Image data is empty or corrupted.")
77
+ return
78
+
79
+ h, w, _ = arr_uint8.shape
80
+ qimg = QImage(arr_uint8.data, w, h, w * 4, QImage.Format.Format_RGBA8888)
81
+ pixmap = QPixmap.fromImage(qimg)
82
+
83
+ # Store a copy of the displayed image for external use (CNB, etc.)
84
+ try:
85
+ if arr_uint8.shape[2] == 4:
86
+ rgb = arr_uint8[..., :3]
87
+ else:
88
+ rgb = arr_uint8
89
+ self._current_image_np = rgb.copy()
90
+ self._current_qimage = qimg.copy()
91
+ except Exception:
92
+ self._current_image_np = None
93
+ self._current_qimage = None
94
+
95
+ # Scale and display the final pixmap with aspect ratio preservation
96
+ base_pix = pixmap.scaled(self.reg_tif_label.size(), Qt.AspectRatioMode.KeepAspectRatio)
97
+
98
+ # Add scale bar if requested
99
+ if show_scale_bar and metadata is not None:
100
+ pixel_size = self.get_pixel_size_from_metadata(metadata)
101
+ if pixel_size is not None:
102
+ base_pix = self.draw_scale_bar(base_pix, pixel_size, w, h)
103
+
104
+ self.reg_tif_label.setFixedSize(base_pix.size())
105
+ self.reg_tif_label.setPixmap(base_pix)
106
+ self.reg_tif_label.setText("")
107
+
108
+ # Emit signal to notify parent that image was updated
109
+ self.imageUpdated.emit()
110
+
111
+ return base_pix
112
+
113
+ def display_image_with_bnc(self, arr_uint8, bnc_settings=None, img=None, img_chan2=None, composite_mode=False, active_channel=1, show_scale_bar=False, metadata=None):
114
+ """
115
+ Display an image with optional brightness/contrast adjustments.
116
+
117
+ Args:
118
+ arr_uint8: RGBA uint8 array with shape (height, width, 4)
119
+ bnc_settings: Optional brightness/contrast settings dict
120
+ img: Original single channel image for BnC processing
121
+ img_chan2: Optional second channel image for BnC processing
122
+ composite_mode: Whether composite mode is active
123
+ active_channel: Which channel is active (1 or 2)
124
+ show_scale_bar: Whether to draw scale bar on the image
125
+ metadata: Experiment metadata for scale bar calculations
126
+ """
127
+ if arr_uint8 is None or arr_uint8.size == 0:
128
+ self.reg_tif_label.setText("Error: Image data is empty or corrupted.")
129
+ return
130
+
131
+ h, w, _ = arr_uint8.shape
132
+ qimg = QImage(arr_uint8.data, w, h, w * 4, QImage.Format.Format_RGBA8888)
133
+ pixmap = QPixmap.fromImage(qimg)
134
+
135
+ # Store a copy of the displayed image
136
+ try:
137
+ if arr_uint8.shape[2] == 4:
138
+ rgb = arr_uint8[..., :3]
139
+ else:
140
+ rgb = arr_uint8
141
+ self._current_image_np = rgb.copy()
142
+ self._current_qimage = qimg.copy()
143
+ except Exception:
144
+ self._current_image_np = None
145
+ self._current_qimage = None
146
+
147
+ # Apply BnC settings if provided and enabled
148
+ if (bnc_settings and bnc_settings.get('enabled', False) and
149
+ img is not None):
150
+ try:
151
+ from .bnc import apply_bnc_to_image, create_qimage_from_array, create_composite_image
152
+
153
+ # Apply BnC to current frame
154
+ if img_chan2 is not None and composite_mode:
155
+ # Composite mode - apply BnC to both channels
156
+ bnc_img = create_composite_image(img, img_chan2, bnc_settings['ch1'], bnc_settings['ch2'])
157
+ else:
158
+ # Single channel mode
159
+ if img_chan2 is not None and active_channel == 2:
160
+ # Channel 2
161
+ bnc_img = apply_bnc_to_image(img_chan2, bnc_settings['ch2']['min'], bnc_settings['ch2']['max'], bnc_settings['ch2']['contrast'])
162
+ # Convert to RGBA grayscale
163
+ if bnc_img.ndim == 2:
164
+ h_bnc, w_bnc = bnc_img.shape
165
+ rgba_bnc = np.zeros((h_bnc, w_bnc, 4), dtype=np.uint8)
166
+ rgba_bnc[..., :3] = bnc_img[..., None]
167
+ rgba_bnc[..., 3] = 255
168
+ bnc_img = rgba_bnc
169
+ else:
170
+ # Channel 1
171
+ bnc_img = apply_bnc_to_image(img, bnc_settings['ch1']['min'], bnc_settings['ch1']['max'], bnc_settings['ch1']['contrast'])
172
+ # Convert to RGBA grayscale
173
+ if bnc_img.ndim == 2:
174
+ h_bnc, w_bnc = bnc_img.shape
175
+ rgba_bnc = np.zeros((h_bnc, w_bnc, 4), dtype=np.uint8)
176
+ rgba_bnc[..., :3] = bnc_img[..., None]
177
+ rgba_bnc[..., 3] = 255
178
+ bnc_img = rgba_bnc
179
+
180
+ # Create new QImage and pixmap with BnC applied
181
+ if bnc_img is not None:
182
+ qimg = create_qimage_from_array(bnc_img)
183
+ pixmap = QPixmap.fromImage(qimg)
184
+
185
+ except Exception as e:
186
+ print(f"DEBUG: Error applying BnC in ImageViewWidget: {e}")
187
+ # Fall back to original pixmap if BnC fails
188
+ pass
189
+
190
+ # Scale and display the final pixmap with aspect ratio preservation
191
+ base_pix = pixmap.scaled(self.reg_tif_label.size(), Qt.AspectRatioMode.KeepAspectRatio)
192
+
193
+ # Add scale bar if requested
194
+ if show_scale_bar and metadata is not None:
195
+ pixel_size = self.get_pixel_size_from_metadata(metadata)
196
+ if pixel_size is not None:
197
+ base_pix = self.draw_scale_bar(base_pix, pixel_size, w, h)
198
+
199
+ self.reg_tif_label.setFixedSize(base_pix.size())
200
+ self.reg_tif_label.setPixmap(base_pix)
201
+ self.reg_tif_label.setText("")
202
+
203
+ # Emit signal to notify parent that image was updated
204
+ self.imageUpdated.emit()
205
+
206
+ return base_pix
207
+
208
+ def get_current_image_data(self):
209
+ """Get the current image data for external processing."""
210
+ return {
211
+ 'numpy_array': self._current_image_np,
212
+ 'qimage': self._current_qimage
213
+ }
214
+
215
+ def resize_for_new_image(self, new_width, new_height):
216
+ """
217
+ Resize the widget to accommodate a new image with different dimensions.
218
+
219
+ Args:
220
+ new_width: Width of the new image
221
+ new_height: Height of the new image
222
+ """
223
+ # Reset the size policy to allow proper resizing
224
+ self.reg_tif_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
225
+
226
+ # Set a reasonable minimum size that accommodates different image sizes
227
+ # Use at least the original minimum size (700x629) or larger if needed
228
+ min_width = max(new_width + 40, 700) # Ensure at least original width or larger
229
+ min_height = max(new_height + 40, 629) # Ensure at least original height or larger
230
+
231
+ # Cap at reasonable maximums to prevent huge widgets
232
+ min_width = min(min_width, 1200)
233
+ min_height = min(min_height, 1000)
234
+
235
+ self.reg_tif_label.setMinimumSize(min_width, min_height)
236
+
237
+ # Force an update of the layout
238
+ self.reg_tif_label.updateGeometry()
239
+ self.update()
240
+
241
+ print(f"DEBUG: Resized widget for image {new_width}x{new_height} -> min size {min_width}x{min_height}")
242
+
243
+ def compute_draw_rect_for_label(self, img_w: int, img_h: int):
244
+ """
245
+ Return the QRect inside the label where the image pixmap will be drawn
246
+ when scaled with aspect ratio preserved.
247
+
248
+ Args:
249
+ img_w: Image width
250
+ img_h: Image height
251
+
252
+ Returns:
253
+ QRect: Rectangle where the image is drawn within the label
254
+ """
255
+ lw, lh = self.reg_tif_label.width(), self.reg_tif_label.height()
256
+ if img_w <= 0 or img_h <= 0 or lw <= 0 or lh <= 0:
257
+ return QRect(0, 0, 0, 0)
258
+
259
+ scale = min(lw / img_w, lh / img_h)
260
+ sw = int(img_w * scale) # scaled width
261
+ sh = int(img_h * scale) # scaled height
262
+ x = (lw - sw) // 2
263
+ y = (lh - sh) // 2
264
+
265
+ return QRect(x, y, sw, sh)
266
+
267
+ def get_label_size(self):
268
+ """Get the current size of the image label."""
269
+ return self.reg_tif_label.size()
270
+
271
+ def set_error_message(self, message):
272
+ """Display an error message."""
273
+ self.reg_tif_label.setPixmap(QPixmap())
274
+ self.reg_tif_label.setText(message)
275
+
276
+ def load_experiment_data(self, directory_path, use_registered=True):
277
+ """
278
+ Load experiment image data from directory.
279
+
280
+ Args:
281
+ directory_path (str): Path to the experiment directory
282
+ use_registered (bool): Whether to prefer registered TIFF files over raw numpy
283
+
284
+ Returns:
285
+ dict: {
286
+ 'tif': numpy array or None,
287
+ 'tif_chan2': numpy array or None,
288
+ 'metadata': dict or None,
289
+ 'nframes': int,
290
+ 'has_registered_tif': bool,
291
+ 'has_raw_numpy': bool,
292
+ 'success': bool,
293
+ 'error': str or None
294
+ }
295
+ """
296
+ result = {
297
+ 'tif': None,
298
+ 'tif_chan2': None,
299
+ 'metadata': None,
300
+ 'nframes': 0,
301
+ 'has_registered_tif': False,
302
+ 'has_raw_numpy': False,
303
+ 'success': False,
304
+ 'error': None
305
+ }
306
+
307
+ try:
308
+ # Define file paths
309
+ reg_tif_path = os.path.join(directory_path, "Ch1-reg.tif")
310
+ reg_tif_chan2_path = os.path.join(directory_path, "Ch2-reg.tif")
311
+ npy_ch0_path = os.path.join(directory_path, "ImageData_Ch0_TP0000000.npy")
312
+ npy_ch1_path = os.path.join(directory_path, "ImageData_Ch1_TP0000000.npy")
313
+ exp_details = os.path.join(directory_path, "experiment_summary.pkl")
314
+ exp_json = os.path.join(directory_path, "experiment_summary.json")
315
+
316
+ # Check what files are available
317
+ result['has_registered_tif'] = os.path.isfile(reg_tif_path)
318
+ result['has_raw_numpy'] = os.path.isfile(npy_ch0_path)
319
+
320
+ # Load image data based on preference and availability
321
+ if use_registered and result['has_registered_tif']:
322
+ self._load_registered_tiffs(reg_tif_path, reg_tif_chan2_path, result)
323
+ elif not use_registered and result['has_raw_numpy']:
324
+ self._load_raw_numpy(npy_ch0_path, npy_ch1_path, result)
325
+ elif use_registered and not result['has_registered_tif'] and result['has_raw_numpy']:
326
+ # Fallback to raw numpy if registered not available
327
+ self._load_raw_numpy(npy_ch0_path, npy_ch1_path, result)
328
+ elif not use_registered and not result['has_raw_numpy'] and result['has_registered_tif']:
329
+ # Fallback to registered if raw not available
330
+ self._load_registered_tiffs(reg_tif_path, reg_tif_chan2_path, result)
331
+ else:
332
+ result['error'] = "No suitable image files found in directory"
333
+ return result
334
+
335
+ # Load metadata
336
+ result['metadata'] = self._load_experiment_metadata(exp_details, exp_json, directory_path)
337
+
338
+ # Calculate number of frames
339
+ if result['tif'] is not None:
340
+ result['nframes'] = result['tif'].shape[0] if result['tif'].ndim == 3 else 1
341
+ # If we have channel 2, limit frames to the minimum of both channels
342
+ if result['tif_chan2'] is not None and result['tif_chan2'].ndim == 3:
343
+ ch2_frames = result['tif_chan2'].shape[0]
344
+ result['nframes'] = min(result['nframes'], ch2_frames)
345
+
346
+ result['success'] = True
347
+ else:
348
+ result['error'] = "Failed to load image data"
349
+
350
+ except Exception as e:
351
+ result['error'] = str(e)
352
+
353
+ return result
354
+
355
+ def _load_registered_tiffs(self, reg_tif_path, reg_tif_chan2_path, result):
356
+ """Load registered TIFF files with robust error handling."""
357
+ self.set_loading_message("Loading registered TIFF files...")
358
+
359
+ try:
360
+ # Load Channel 1
361
+ file_size = os.path.getsize(reg_tif_path) / (1024*1024) # MB
362
+ print(f"DEBUG: TIFF file size: {file_size:.1f} MB at {reg_tif_path}")
363
+
364
+ result['tif'] = self._robust_tiff_load(reg_tif_path, "Ch1")
365
+
366
+ # Load Channel 2 if available
367
+ if os.path.isfile(reg_tif_chan2_path):
368
+ self.set_loading_message("Loading registered TIFF files (Channel 2)...")
369
+ file_size_ch2 = os.path.getsize(reg_tif_chan2_path) / (1024*1024) # MB
370
+ print(f"DEBUG: Ch2 TIFF file size: {file_size_ch2:.1f} MB at {reg_tif_chan2_path}")
371
+
372
+ result['tif_chan2'] = self._robust_tiff_load(reg_tif_chan2_path, "Ch2")
373
+
374
+ except Exception as e:
375
+ raise Exception(f"Failed to load registered TIFF files: {e}")
376
+
377
+ def _load_raw_numpy(self, npy_ch0_path, npy_ch1_path, result):
378
+ """Load raw numpy files."""
379
+ self.set_loading_message("Loading raw numpy files...")
380
+
381
+ try:
382
+ # Load Channel 0 (usually Channel 1 in the UI)
383
+ print(f"DEBUG: Loading raw numpy from {npy_ch0_path}")
384
+ result['tif'] = np.load(npy_ch0_path)
385
+ print(f"DEBUG: Ch0 shape: {result['tif'].shape}, dtype: {result['tif'].dtype}")
386
+
387
+ # Load Channel 1 (usually Channel 2 in the UI) if available
388
+ if os.path.isfile(npy_ch1_path):
389
+ print(f"DEBUG: Loading Ch1 from {npy_ch1_path}")
390
+ result['tif_chan2'] = np.load(npy_ch1_path)
391
+ print(f"DEBUG: Ch1 shape: {result['tif_chan2'].shape}, dtype: {result['tif_chan2'].dtype}")
392
+
393
+ except Exception as e:
394
+ raise Exception(f"Failed to load raw numpy files: {e}")
395
+
396
+ def _robust_tiff_load(self, tiff_path, channel_name):
397
+ """Load TIFF file with multiple fallback methods."""
398
+ # Get page count for validation
399
+ page_count = None
400
+ try:
401
+ with tifffile.TiffFile(tiff_path) as tiff:
402
+ page_count = len(tiff.pages)
403
+ print(f"DEBUG: {channel_name} TIFF file contains {page_count} pages/frames")
404
+ if page_count > 0:
405
+ first_page = tiff.pages[0]
406
+ print(f"DEBUG: {channel_name} first page shape: {first_page.shape}")
407
+ except Exception as page_error:
408
+ print(f"DEBUG: Could not examine {channel_name} TIFF pages: {page_error}")
409
+
410
+ # Try multiple loading methods
411
+ loading_methods = [
412
+ ("tifffile.imread", lambda: tifffile.imread(tiff_path)),
413
+ ("tifffile.imread(memmap=False)", lambda: tifffile.imread(tiff_path, memmap=False)),
414
+ ("page-by-page", lambda: self._load_tiff_page_by_page(tiff_path))
415
+ ]
416
+
417
+ for method_name, load_func in loading_methods:
418
+ try:
419
+ print(f"DEBUG: {channel_name} Method - {method_name}...")
420
+ tif_data = load_func()
421
+ print(f"DEBUG: {channel_name} Method SUCCESS - shape: {tif_data.shape}, dtype: {tif_data.dtype}")
422
+
423
+ # Validate frame count if we have page count
424
+ actual_frames = tif_data.shape[0] if tif_data.ndim >= 3 else 1
425
+ if page_count and actual_frames != page_count:
426
+ print(f"DEBUG: {channel_name} WARNING - Loaded {actual_frames} frames but TIFF has {page_count} pages!")
427
+ if method_name != "page-by-page": # Try next method
428
+ continue
429
+
430
+ print(f"DEBUG: {channel_name} successfully loaded using: {method_name}")
431
+ return tif_data
432
+
433
+ except Exception as method_error:
434
+ print(f"DEBUG: {channel_name} Method {method_name} FAILED: {method_error}")
435
+ continue
436
+
437
+ raise Exception(f"All loading methods failed for {channel_name} TIFF: {tiff_path}")
438
+
439
+ def _load_tiff_page_by_page(self, tiff_path):
440
+ """Load TIFF file page by page as fallback method."""
441
+ with tifffile.TiffFile(tiff_path) as tiff:
442
+ if len(tiff.pages) == 0:
443
+ raise ValueError("No pages found in TIFF file")
444
+
445
+ # Get dimensions from first page
446
+ first_page_array = tiff.pages[0].asarray()
447
+ page_shape = first_page_array.shape
448
+ total_pages = len(tiff.pages)
449
+
450
+ print(f"DEBUG: Creating array for {total_pages} pages of shape {page_shape}")
451
+ tif_data = np.zeros((total_pages,) + page_shape, dtype=first_page_array.dtype)
452
+
453
+ # Load each page
454
+ for i, page in enumerate(tiff.pages):
455
+ tif_data[i] = page.asarray()
456
+ if i % 500 == 0 or i < 5 or i >= total_pages - 3:
457
+ print(f"DEBUG: Loaded page {i}/{total_pages}")
458
+
459
+ return tif_data
460
+
461
+ def _load_experiment_metadata(self, exp_details, exp_json, directory_path):
462
+ """Load experiment metadata from pickle or JSON files."""
463
+ metadata = None
464
+
465
+ # First try to read existing pickle file
466
+ if os.path.isfile(exp_details):
467
+ try:
468
+ print(f"DEBUG: Loading experiment metadata from {exp_details}")
469
+ with open(exp_details, 'rb') as f:
470
+ metadata = pickle.load(f)
471
+ print(f"DEBUG: Loaded experiment metadata type: {type(metadata)}")
472
+ except Exception as e:
473
+ print(f"DEBUG: Failed to load pickle metadata: {e}")
474
+
475
+ # If no metadata exists or failed to load, try JSON
476
+ if metadata is None and os.path.isfile(exp_json):
477
+ try:
478
+ print(f"DEBUG: Loading experiment metadata from {exp_json}")
479
+ import json
480
+ with open(exp_json, 'r') as f:
481
+ metadata = json.load(f)
482
+ print(f"DEBUG: Loaded JSON metadata")
483
+ except Exception as e:
484
+ print(f"DEBUG: Failed to load JSON metadata: {e}")
485
+
486
+ # If still no metadata, try to read from raw files
487
+ if metadata is None:
488
+ try:
489
+ print(f"DEBUG: Attempting to read metadata from raw files in {directory_path}")
490
+ result = subprocess.run([
491
+ 'python', 'scripts/meta_reader.py', '-f', directory_path
492
+ ], capture_output=True, text=True, timeout=30)
493
+
494
+ if result.returncode == 0:
495
+ print("DEBUG: meta_reader.py executed successfully")
496
+ # Try loading the newly created files
497
+ if os.path.isfile(exp_details):
498
+ with open(exp_details, 'rb') as f:
499
+ metadata = pickle.load(f)
500
+ print("DEBUG: Successfully loaded newly created metadata")
501
+ elif os.path.isfile(exp_json):
502
+ import json
503
+ with open(exp_json, 'r') as f:
504
+ metadata = json.load(f)
505
+ print("DEBUG: Successfully loaded newly created JSON metadata")
506
+ else:
507
+ print(f"DEBUG: meta_reader.py failed: {result.stderr}")
508
+ except Exception as e:
509
+ print(f"DEBUG: Failed to run meta_reader.py: {e}")
510
+
511
+ return metadata
512
+
513
+ def clear_experiment(self):
514
+ """Clear all experiment data and reset display."""
515
+ self.clear_pixmap()
516
+ self._current_image_np = None
517
+ self._current_qimage = None
518
+
519
+ def draw_scale_bar(self, pixmap, pixel_size_microns, img_width, img_height):
520
+ """
521
+ Draw a scale bar on the given pixmap.
522
+
523
+ Args:
524
+ pixmap: QPixmap to draw on
525
+ pixel_size_microns: Size of one pixel in microns (from metadata)
526
+ img_width: Original image width in pixels
527
+ img_height: Original image height in pixels
528
+
529
+ Returns:
530
+ QPixmap with scale bar drawn
531
+ """
532
+ if pixel_size_microns is None or pixel_size_microns <= 0:
533
+ print("DEBUG: Invalid pixel size for scale bar")
534
+ return pixmap
535
+
536
+ # Create a copy to draw on
537
+ pixmap_with_scale = QPixmap(pixmap)
538
+ painter = QPainter(pixmap_with_scale)
539
+
540
+ try:
541
+ # Calculate scale factor between original image and displayed pixmap
542
+ scale_factor = min(pixmap.width() / img_width, pixmap.height() / img_height)
543
+
544
+ # Calculate appropriate scale bar length
545
+ # Aim for ~10% of image width, rounded to nice values
546
+ desired_length_pixels = img_width * 0.1
547
+ desired_length_microns = desired_length_pixels * pixel_size_microns
548
+
549
+ # Round to nice values (1, 2, 5, 10, 20, 50, 100, etc.)
550
+ scale_bar_microns = self._round_to_nice_value(desired_length_microns)
551
+ scale_bar_pixels = scale_bar_microns / pixel_size_microns
552
+
553
+ # Scale to displayed pixmap size
554
+ display_scale_bar_pixels = scale_bar_pixels * scale_factor
555
+
556
+ # Position scale bar at bottom right
557
+ margin = 20
558
+ bar_height = 10
559
+ text_height = 15
560
+
561
+ # Ensure scale bar fits within image bounds
562
+ min_margin = 10
563
+ max_bar_width = pixmap.width() - (2 * min_margin)
564
+ if display_scale_bar_pixels > max_bar_width:
565
+ # If calculated scale bar is too long, recalculate with smaller size
566
+ display_scale_bar_pixels = max_bar_width
567
+ scale_bar_pixels = display_scale_bar_pixels / scale_factor
568
+ scale_bar_microns = scale_bar_pixels * pixel_size_microns
569
+
570
+ # Scale bar rectangle
571
+ bar_x = pixmap.width() - display_scale_bar_pixels - margin
572
+ bar_y = pixmap.height() - margin - bar_height - text_height - 5
573
+
574
+ # Ensure minimum margins
575
+ bar_x = max(min_margin, bar_x)
576
+ bar_y = max(min_margin, bar_y)
577
+
578
+ # Draw scale bar
579
+ painter.setPen(QPen(QColor(Qt.GlobalColor.white), 2)) # White, 2px wide
580
+ painter.drawLine(int(bar_x), # Start X
581
+ int(bar_y + bar_height), # Start Y (bottom of where rect would be)
582
+ int(bar_x + display_scale_bar_pixels), # End X
583
+ int(bar_y + bar_height))
584
+
585
+ # Draw text label
586
+ font = QFont()
587
+ font.setPointSize(10)
588
+ font.setBold(True)
589
+ painter.setFont(font)
590
+ painter.setPen(QPen(QColor(Qt.GlobalColor.white), 1)) # Black text
591
+
592
+ # Format label
593
+ if scale_bar_microns >= 1000:
594
+ label = f"{scale_bar_microns/1000:.0f} mm"
595
+ elif scale_bar_microns >= 1:
596
+ label = f"{scale_bar_microns:.0f} μm"
597
+ else:
598
+ label = f"{scale_bar_microns:.1f} μm"
599
+
600
+ text_x = bar_x + (display_scale_bar_pixels / 2) - (len(label) * 3) # Rough centering
601
+ text_y = bar_y + bar_height + text_height
602
+ painter.drawText(int(text_x), int(text_y), label)
603
+
604
+ finally:
605
+ painter.end()
606
+
607
+ return pixmap_with_scale
608
+
609
+ def _round_to_nice_value(self, value):
610
+ """
611
+ Round a value to a nice scale bar length (1, 2, 5, 10, 20, 50, 100, etc.)
612
+ """
613
+ if value <= 0:
614
+ return 1
615
+
616
+ # Find the appropriate order of magnitude
617
+ magnitude = 10 ** np.floor(np.log10(value))
618
+ normalized = value / magnitude
619
+
620
+ # Choose nice values
621
+ if normalized <= 1:
622
+ nice_value = 1
623
+ elif normalized <= 2:
624
+ nice_value = 2
625
+ elif normalized <= 5:
626
+ nice_value = 5
627
+ else:
628
+ nice_value = 10
629
+
630
+ return nice_value * magnitude
631
+
632
+ def get_pixel_size_from_metadata(self, metadata):
633
+ """
634
+ Extract pixel size in microns from experiment metadata.
635
+
636
+ Args:
637
+ metadata: Experiment metadata dict or object
638
+
639
+ Returns:
640
+ float: Pixel size in microns per pixel, or None if not found
641
+ """
642
+ if metadata is None:
643
+ return None
644
+
645
+ try:
646
+ # Try different possible locations for pixel size
647
+ if isinstance(metadata, dict):
648
+ # Check direct key
649
+ if 'pixel_size' in metadata:
650
+ return float(metadata['pixel_size'])
651
+
652
+ # Check nested structure from ImageRecord.yaml
653
+ if ('ImageRecord.yaml' in metadata and
654
+ 'CLensDef70' in metadata['ImageRecord.yaml'] and
655
+ 'mMicronPerPixel' in metadata['ImageRecord.yaml']['CLensDef70']):
656
+ return float(metadata['ImageRecord.yaml']['CLensDef70']['mMicronPerPixel'])
657
+
658
+ # Try as object with attributes
659
+ elif hasattr(metadata, 'pixel_size'):
660
+ return float(metadata.pixel_size)
661
+ elif hasattr(metadata, 'mMicronPerPixel'):
662
+ return float(metadata.mMicronPerPixel)
663
+
664
+ except (KeyError, TypeError, ValueError, AttributeError) as e:
665
+ print(f"DEBUG: Could not extract pixel size from metadata: {e}")
666
+
667
+ return None