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.
- phasor_handler/__init__.py +9 -0
- phasor_handler/app.py +249 -0
- phasor_handler/img/icons/chevron-down.svg +3 -0
- phasor_handler/img/icons/chevron-up.svg +3 -0
- phasor_handler/img/logo.ico +0 -0
- phasor_handler/models/dir_manager.py +100 -0
- phasor_handler/scripts/contrast.py +131 -0
- phasor_handler/scripts/convert.py +155 -0
- phasor_handler/scripts/meta_reader.py +467 -0
- phasor_handler/scripts/plot.py +110 -0
- phasor_handler/scripts/register.py +86 -0
- phasor_handler/themes/__init__.py +8 -0
- phasor_handler/themes/dark_theme.py +330 -0
- phasor_handler/tools/__init__.py +1 -0
- phasor_handler/tools/check_stylesheet.py +15 -0
- phasor_handler/tools/misc.py +20 -0
- phasor_handler/widgets/__init__.py +5 -0
- phasor_handler/widgets/analysis/components/__init__.py +9 -0
- phasor_handler/widgets/analysis/components/bnc.py +426 -0
- phasor_handler/widgets/analysis/components/circle_roi.py +850 -0
- phasor_handler/widgets/analysis/components/image_view.py +667 -0
- phasor_handler/widgets/analysis/components/meta_info.py +481 -0
- phasor_handler/widgets/analysis/components/roi_list.py +659 -0
- phasor_handler/widgets/analysis/components/trace_plot.py +621 -0
- phasor_handler/widgets/analysis/view.py +1735 -0
- phasor_handler/widgets/conversion/view.py +83 -0
- phasor_handler/widgets/registration/view.py +110 -0
- phasor_handler/workers/__init__.py +2 -0
- phasor_handler/workers/analysis_worker.py +0 -0
- phasor_handler/workers/histogram_worker.py +55 -0
- phasor_handler/workers/registration_worker.py +242 -0
- phasor_handler-2.2.1.dist-info/METADATA +134 -0
- phasor_handler-2.2.1.dist-info/RECORD +37 -0
- phasor_handler-2.2.1.dist-info/WHEEL +5 -0
- phasor_handler-2.2.1.dist-info/entry_points.txt +5 -0
- phasor_handler-2.2.1.dist-info/licenses/LICENSE.md +21 -0
- 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
|