napari-tmidas 0.2.1__py3-none-any.whl → 0.2.4__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.
- napari_tmidas/__init__.py +35 -5
- napari_tmidas/_crop_anything.py +1458 -499
- napari_tmidas/_env_manager.py +76 -0
- napari_tmidas/_file_conversion.py +1646 -1131
- napari_tmidas/_file_selector.py +1464 -223
- napari_tmidas/_label_inspection.py +83 -8
- napari_tmidas/_processing_worker.py +309 -0
- napari_tmidas/_reader.py +6 -10
- napari_tmidas/_registry.py +15 -14
- napari_tmidas/_roi_colocalization.py +1221 -84
- napari_tmidas/_tests/test_crop_anything.py +123 -0
- napari_tmidas/_tests/test_env_manager.py +89 -0
- napari_tmidas/_tests/test_file_selector.py +90 -0
- napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
- napari_tmidas/_tests/test_init.py +98 -0
- napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
- napari_tmidas/_tests/test_label_inspection.py +86 -0
- napari_tmidas/_tests/test_processing_basic.py +500 -0
- napari_tmidas/_tests/test_processing_worker.py +142 -0
- napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
- napari_tmidas/_tests/test_registry.py +135 -0
- napari_tmidas/_tests/test_scipy_filters.py +168 -0
- napari_tmidas/_tests/test_skimage_filters.py +259 -0
- napari_tmidas/_tests/test_split_channels.py +217 -0
- napari_tmidas/_tests/test_spotiflow.py +87 -0
- napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
- napari_tmidas/_tests/test_ui_utils.py +68 -0
- napari_tmidas/_tests/test_widget.py +30 -0
- napari_tmidas/_tests/test_windows_basic.py +66 -0
- napari_tmidas/_ui_utils.py +57 -0
- napari_tmidas/_version.py +16 -3
- napari_tmidas/_widget.py +41 -4
- napari_tmidas/processing_functions/basic.py +557 -20
- napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
- napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
- napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
- napari_tmidas/processing_functions/colocalization.py +513 -56
- napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
- napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
- napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
- napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
- napari_tmidas/processing_functions/sam2_mp4.py +274 -195
- napari_tmidas/processing_functions/scipy_filters.py +403 -8
- napari_tmidas/processing_functions/skimage_filters.py +424 -212
- napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
- napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
- napari_tmidas/processing_functions/timepoint_merger.py +334 -86
- napari_tmidas/processing_functions/trackastra_tracking.py +24 -5
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +92 -39
- napari_tmidas-0.2.4.dist-info/RECORD +63 -0
- napari_tmidas/_tests/__init__.py +0 -0
- napari_tmidas-0.2.1.dist-info/RECORD +0 -38
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -11,7 +11,6 @@ and can optionally calculate sizes of these overlapping regions.
|
|
|
11
11
|
import concurrent.futures
|
|
12
12
|
|
|
13
13
|
# contextlib is used to suppress exceptions
|
|
14
|
-
import contextlib
|
|
15
14
|
import csv
|
|
16
15
|
import os
|
|
17
16
|
from collections import defaultdict
|
|
@@ -41,6 +40,37 @@ from qtpy.QtWidgets import (
|
|
|
41
40
|
from skimage import measure
|
|
42
41
|
|
|
43
42
|
|
|
43
|
+
def convert_semantic_to_instance_labels(image, connectivity=None):
|
|
44
|
+
"""
|
|
45
|
+
Convert semantic labels (where all objects have the same value) to instance labels.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
image: Label image that may contain semantic labels
|
|
49
|
+
connectivity: Connectivity for connected component analysis (1, 2, or None for full)
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Image with instance labels (each connected component gets unique label)
|
|
53
|
+
"""
|
|
54
|
+
if image is None or np.all(image == 0):
|
|
55
|
+
return image
|
|
56
|
+
|
|
57
|
+
# Get unique non-zero values
|
|
58
|
+
unique_labels = np.unique(image[image != 0])
|
|
59
|
+
|
|
60
|
+
# Quick check: if there's only one unique non-zero value, it's definitely semantic
|
|
61
|
+
# Otherwise, apply connected components to the entire mask at once (much faster)
|
|
62
|
+
if len(unique_labels) == 1:
|
|
63
|
+
# Single semantic label - just label connected components of the binary mask
|
|
64
|
+
mask = image > 0
|
|
65
|
+
return measure.label(mask, connectivity=connectivity)
|
|
66
|
+
else:
|
|
67
|
+
# Multiple labels - could be instance or semantic
|
|
68
|
+
# Apply connected components to entire non-zero region at once
|
|
69
|
+
# This is MUCH faster than iterating over each label value
|
|
70
|
+
mask = image > 0
|
|
71
|
+
return measure.label(mask, connectivity=connectivity)
|
|
72
|
+
|
|
73
|
+
|
|
44
74
|
def longest_common_substring(s1, s2):
|
|
45
75
|
"""Finds the longest common substring between two strings."""
|
|
46
76
|
matcher = SequenceMatcher(None, s1, s2)
|
|
@@ -100,9 +130,9 @@ def group_files_by_common_substring(file_lists, channels):
|
|
|
100
130
|
|
|
101
131
|
# If matches were found for all channels, add them to the group
|
|
102
132
|
if len(matched_files) == len(channels):
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
groups[
|
|
133
|
+
# Use the full common substring as the key (don't strip it yet)
|
|
134
|
+
# This prevents different file pairs from overwriting each other
|
|
135
|
+
groups[common_substring] = {
|
|
106
136
|
channel: file_lists[channel][
|
|
107
137
|
base_files[channel].index(matched_files[channel])
|
|
108
138
|
]
|
|
@@ -134,6 +164,15 @@ class ColocalizationWorker(QThread):
|
|
|
134
164
|
get_sizes=False,
|
|
135
165
|
size_method="median",
|
|
136
166
|
output_folder=None,
|
|
167
|
+
channel2_is_labels=True,
|
|
168
|
+
channel3_is_labels=True,
|
|
169
|
+
count_positive=False,
|
|
170
|
+
threshold_method="percentile",
|
|
171
|
+
threshold_value=75.0,
|
|
172
|
+
save_images=True,
|
|
173
|
+
convert_to_instances_c2=False,
|
|
174
|
+
convert_to_instances_c3=False,
|
|
175
|
+
count_c2_positive_for_c3=False,
|
|
137
176
|
):
|
|
138
177
|
super().__init__()
|
|
139
178
|
self.file_pairs = file_pairs
|
|
@@ -141,6 +180,15 @@ class ColocalizationWorker(QThread):
|
|
|
141
180
|
self.get_sizes = get_sizes
|
|
142
181
|
self.size_method = size_method
|
|
143
182
|
self.output_folder = output_folder
|
|
183
|
+
self.channel2_is_labels = channel2_is_labels
|
|
184
|
+
self.channel3_is_labels = channel3_is_labels
|
|
185
|
+
self.count_positive = count_positive
|
|
186
|
+
self.threshold_method = threshold_method
|
|
187
|
+
self.threshold_value = threshold_value
|
|
188
|
+
self.save_images = save_images
|
|
189
|
+
self.convert_to_instances_c2 = convert_to_instances_c2
|
|
190
|
+
self.convert_to_instances_c3 = convert_to_instances_c3
|
|
191
|
+
self.count_c2_positive_for_c3 = count_c2_positive_for_c3
|
|
144
192
|
self.stop_requested = False
|
|
145
193
|
self.thread_count = max(1, (os.cpu_count() or 4) - 1) # Default value
|
|
146
194
|
|
|
@@ -167,30 +215,137 @@ class ColocalizationWorker(QThread):
|
|
|
167
215
|
header = [
|
|
168
216
|
"Filename",
|
|
169
217
|
f"{self.channel_names[0]}_label_id",
|
|
170
|
-
f"{self.channel_names[1]}_in_{self.channel_names[0]}_count",
|
|
171
218
|
]
|
|
172
219
|
|
|
173
|
-
if
|
|
220
|
+
# Add c2_label_id column if in individual mode
|
|
221
|
+
if (
|
|
222
|
+
self.size_method == "individual"
|
|
223
|
+
and self.channel2_is_labels
|
|
224
|
+
):
|
|
225
|
+
header.append(f"{self.channel_names[1]}_label_id")
|
|
226
|
+
|
|
227
|
+
# Add Channel 2 columns based on whether it's labels or intensity
|
|
228
|
+
if self.channel2_is_labels:
|
|
229
|
+
if self.size_method != "individual":
|
|
230
|
+
# Aggregate mode: add count of C2 labels in C1
|
|
231
|
+
header.append(
|
|
232
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_count"
|
|
233
|
+
)
|
|
234
|
+
# Individual mode: no aggregate C2 statistics needed
|
|
235
|
+
else:
|
|
236
|
+
# Channel 2 is intensity: add intensity statistics
|
|
174
237
|
header.extend(
|
|
175
238
|
[
|
|
176
|
-
f"{self.channel_names[0]}
|
|
177
|
-
f"{self.channel_names[1]}_in_{self.channel_names[0]}
|
|
239
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_mean",
|
|
240
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_median",
|
|
241
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_std",
|
|
242
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_max",
|
|
178
243
|
]
|
|
179
244
|
)
|
|
180
245
|
|
|
246
|
+
if self.get_sizes:
|
|
247
|
+
header.append(f"{self.channel_names[0]}_size")
|
|
248
|
+
if self.channel2_is_labels:
|
|
249
|
+
if self.size_method == "individual":
|
|
250
|
+
# Individual mode: one row per c2 label with its size
|
|
251
|
+
header.append(f"{self.channel_names[1]}_size")
|
|
252
|
+
else:
|
|
253
|
+
header.append(
|
|
254
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_size"
|
|
255
|
+
)
|
|
256
|
+
|
|
181
257
|
if len(self.channel_names) == 3:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
258
|
+
if self.channel2_is_labels and self.channel3_is_labels:
|
|
259
|
+
# Both ch2 and ch3 are labels - original 3-channel label mode
|
|
260
|
+
header.extend(
|
|
261
|
+
[
|
|
262
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_in_{self.channel_names[0]}_count",
|
|
263
|
+
f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_but_in_{self.channel_names[0]}_count",
|
|
264
|
+
]
|
|
265
|
+
)
|
|
188
266
|
|
|
189
|
-
|
|
267
|
+
if self.get_sizes:
|
|
268
|
+
if self.size_method == "individual":
|
|
269
|
+
# Individual mode: c3 size within each c2 label
|
|
270
|
+
header.append(
|
|
271
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_size"
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
header.extend(
|
|
275
|
+
[
|
|
276
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_in_{self.channel_names[0]}_size",
|
|
277
|
+
f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_but_in_{self.channel_names[0]}_size",
|
|
278
|
+
]
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Add positive counting columns if requested
|
|
282
|
+
if self.count_c2_positive_for_c3:
|
|
283
|
+
header.extend(
|
|
284
|
+
[
|
|
285
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_positive_for_{self.channel_names[2]}_count",
|
|
286
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_negative_for_{self.channel_names[2]}_count",
|
|
287
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_percent_positive_for_{self.channel_names[2]}",
|
|
288
|
+
]
|
|
289
|
+
)
|
|
290
|
+
elif (
|
|
291
|
+
self.channel2_is_labels and not self.channel3_is_labels
|
|
292
|
+
):
|
|
293
|
+
# Ch2 is labels, Ch3 is intensity
|
|
294
|
+
if self.size_method == "individual":
|
|
295
|
+
# Individual mode: intensity statistics per c2 label
|
|
296
|
+
header.extend(
|
|
297
|
+
[
|
|
298
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_mean",
|
|
299
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_median",
|
|
300
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_std",
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
# Note: positive counting is not available in individual mode
|
|
304
|
+
# Individual mode provides per-label statistics, not aggregate counts
|
|
305
|
+
else:
|
|
306
|
+
# Aggregate statistics (original behavior)
|
|
307
|
+
header.extend(
|
|
308
|
+
[
|
|
309
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_in_{self.channel_names[0]}_mean",
|
|
310
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_in_{self.channel_names[0]}_median",
|
|
311
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_in_{self.channel_names[0]}_std",
|
|
312
|
+
f"{self.channel_names[2]}_in_{self.channel_names[1]}_in_{self.channel_names[0]}_max",
|
|
313
|
+
f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_but_in_{self.channel_names[0]}_mean",
|
|
314
|
+
f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_in_{self.channel_names[0]}_median",
|
|
315
|
+
f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_but_in_{self.channel_names[0]}_std",
|
|
316
|
+
f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_but_in_{self.channel_names[0]}_max",
|
|
317
|
+
]
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Add positive counting columns if requested (only in aggregate mode)
|
|
321
|
+
if self.count_positive:
|
|
322
|
+
header.extend(
|
|
323
|
+
[
|
|
324
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_positive_for_{self.channel_names[2]}_count",
|
|
325
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_negative_for_{self.channel_names[2]}_count",
|
|
326
|
+
f"{self.channel_names[1]}_in_{self.channel_names[0]}_percent_positive_for_{self.channel_names[2]}",
|
|
327
|
+
f"{self.channel_names[2]}_threshold_used",
|
|
328
|
+
]
|
|
329
|
+
)
|
|
330
|
+
elif (
|
|
331
|
+
not self.channel2_is_labels and self.channel3_is_labels
|
|
332
|
+
):
|
|
333
|
+
# Ch2 is intensity, Ch3 is labels
|
|
334
|
+
header.append(
|
|
335
|
+
f"{self.channel_names[2]}_in_{self.channel_names[0]}_count"
|
|
336
|
+
)
|
|
337
|
+
if self.get_sizes:
|
|
338
|
+
header.append(
|
|
339
|
+
f"{self.channel_names[2]}_in_{self.channel_names[0]}_size"
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
# Both Ch2 and Ch3 are intensity
|
|
190
343
|
header.extend(
|
|
191
344
|
[
|
|
192
|
-
f"{self.channel_names[2]}_in_{self.channel_names[
|
|
193
|
-
f"{self.channel_names[2]}
|
|
345
|
+
f"{self.channel_names[2]}_in_{self.channel_names[0]}_mean",
|
|
346
|
+
f"{self.channel_names[2]}_in_{self.channel_names[0]}_median",
|
|
347
|
+
f"{self.channel_names[2]}_in_{self.channel_names[0]}_std",
|
|
348
|
+
f"{self.channel_names[2]}_in_{self.channel_names[0]}_max",
|
|
194
349
|
]
|
|
195
350
|
)
|
|
196
351
|
|
|
@@ -284,6 +439,17 @@ class ColocalizationWorker(QThread):
|
|
|
284
439
|
image_c2 = tifffile.imread(filepath_c2)
|
|
285
440
|
image_c3 = tifffile.imread(filepath_c3) if filepath_c3 else None
|
|
286
441
|
|
|
442
|
+
# Debugging: Check if images are identical (possible file selection error)
|
|
443
|
+
if image_c3 is not None and np.array_equal(image_c2, image_c3):
|
|
444
|
+
print(
|
|
445
|
+
"WARNING: Channel 2 and Channel 3 contain IDENTICAL data!"
|
|
446
|
+
)
|
|
447
|
+
print(f" C2 file: {os.path.basename(filepath_c2)}")
|
|
448
|
+
print(f" C3 file: {os.path.basename(filepath_c3)}")
|
|
449
|
+
print(
|
|
450
|
+
" This likely indicates the same file was selected for both channels."
|
|
451
|
+
)
|
|
452
|
+
|
|
287
453
|
# Ensure all images have the same shape
|
|
288
454
|
if image_c1.shape != image_c2.shape:
|
|
289
455
|
raise ValueError(
|
|
@@ -318,6 +484,21 @@ class ColocalizationWorker(QThread):
|
|
|
318
484
|
self, filename, image_c1, image_c2, image_c3=None
|
|
319
485
|
):
|
|
320
486
|
"""Process colocalization between channels"""
|
|
487
|
+
# Convert semantic labels to instance labels if requested
|
|
488
|
+
if (
|
|
489
|
+
self.convert_to_instances_c2
|
|
490
|
+
and self.channel2_is_labels
|
|
491
|
+
and image_c2 is not None
|
|
492
|
+
):
|
|
493
|
+
image_c2 = convert_semantic_to_instance_labels(image_c2)
|
|
494
|
+
|
|
495
|
+
if (
|
|
496
|
+
self.convert_to_instances_c3
|
|
497
|
+
and self.channel3_is_labels
|
|
498
|
+
and image_c3 is not None
|
|
499
|
+
):
|
|
500
|
+
image_c3 = convert_semantic_to_instance_labels(image_c3)
|
|
501
|
+
|
|
321
502
|
# Get unique label IDs in image_c1
|
|
322
503
|
label_ids = self.get_nonzero_labels(image_c1)
|
|
323
504
|
|
|
@@ -326,17 +507,48 @@ class ColocalizationWorker(QThread):
|
|
|
326
507
|
if self.get_sizes:
|
|
327
508
|
roi_sizes = self.calculate_all_rois_size(image_c1)
|
|
328
509
|
|
|
510
|
+
# Handle intensity images for channel 2 and 3
|
|
511
|
+
image_c2_intensity = None
|
|
512
|
+
image_c3_intensity = None
|
|
513
|
+
|
|
514
|
+
if not self.channel2_is_labels:
|
|
515
|
+
image_c2_intensity = image_c2
|
|
516
|
+
|
|
517
|
+
if image_c3 is not None and not self.channel3_is_labels:
|
|
518
|
+
image_c3_intensity = image_c3
|
|
519
|
+
|
|
329
520
|
# Process each label
|
|
330
521
|
csv_rows = []
|
|
331
522
|
results = []
|
|
332
523
|
|
|
333
524
|
for label_id in label_ids:
|
|
334
|
-
|
|
335
|
-
filename,
|
|
525
|
+
row_or_rows = self.process_single_roi(
|
|
526
|
+
filename,
|
|
527
|
+
label_id,
|
|
528
|
+
image_c1,
|
|
529
|
+
image_c2,
|
|
530
|
+
image_c3,
|
|
531
|
+
roi_sizes,
|
|
532
|
+
image_c2_intensity,
|
|
533
|
+
image_c3_intensity,
|
|
336
534
|
)
|
|
337
|
-
csv_rows.append(row)
|
|
338
535
|
|
|
339
|
-
#
|
|
536
|
+
# Check if we got multiple rows (individual mode) or single row
|
|
537
|
+
if len(row_or_rows) == 0:
|
|
538
|
+
# No rows returned (e.g., no C2 labels in this C1 ROI in individual mode)
|
|
539
|
+
continue
|
|
540
|
+
elif isinstance(row_or_rows[0], list):
|
|
541
|
+
# Multiple rows returned (one per c2 label)
|
|
542
|
+
for row in row_or_rows:
|
|
543
|
+
csv_rows.append(row)
|
|
544
|
+
# For individual mode, skip creating result_dict (simplified visualization)
|
|
545
|
+
continue
|
|
546
|
+
else:
|
|
547
|
+
# Single row returned
|
|
548
|
+
row = row_or_rows
|
|
549
|
+
csv_rows.append(row)
|
|
550
|
+
|
|
551
|
+
# Extract results as dictionary (only for non-individual mode)
|
|
340
552
|
result_dict = {"label_id": label_id, "ch2_in_ch1_count": row[2]}
|
|
341
553
|
|
|
342
554
|
idx = 3
|
|
@@ -346,15 +558,65 @@ class ColocalizationWorker(QThread):
|
|
|
346
558
|
idx += 2
|
|
347
559
|
|
|
348
560
|
if image_c3 is not None:
|
|
349
|
-
result_dict
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
result_dict["ch3_in_ch2_in_ch1_size"] = row[idx]
|
|
355
|
-
result_dict["ch3_not_in_ch2_but_in_ch1_size"] = row[
|
|
561
|
+
# Map CSV row columns to result_dict depending on channel modes
|
|
562
|
+
if self.channel2_is_labels and self.channel3_is_labels:
|
|
563
|
+
# Both ch2 and ch3 are labels: two counts (in c2 & not in c2)
|
|
564
|
+
result_dict["ch3_in_ch2_in_ch1_count"] = row[idx]
|
|
565
|
+
result_dict["ch3_not_in_ch2_but_in_ch1_count"] = row[
|
|
356
566
|
idx + 1
|
|
357
567
|
]
|
|
568
|
+
idx += 2
|
|
569
|
+
|
|
570
|
+
if self.get_sizes:
|
|
571
|
+
result_dict["ch3_in_ch2_in_ch1_size"] = row[idx]
|
|
572
|
+
result_dict["ch3_not_in_ch2_but_in_ch1_size"] = row[
|
|
573
|
+
idx + 1
|
|
574
|
+
]
|
|
575
|
+
elif self.channel2_is_labels and not self.channel3_is_labels:
|
|
576
|
+
# ch2 labels, ch3 intensity: many intensity stats were appended
|
|
577
|
+
# Map the first group of intensity stats to ch3_in_ch2_in_ch1_* keys
|
|
578
|
+
result_dict["ch3_in_ch2_in_ch1_mean"] = row[idx]
|
|
579
|
+
result_dict["ch3_in_ch2_in_ch1_median"] = row[idx + 1]
|
|
580
|
+
result_dict["ch3_in_ch2_in_ch1_std"] = row[idx + 2]
|
|
581
|
+
result_dict["ch3_in_ch2_in_ch1_max"] = row[idx + 3]
|
|
582
|
+
result_dict["ch3_not_in_ch2_but_in_ch1_mean"] = row[
|
|
583
|
+
idx + 4
|
|
584
|
+
]
|
|
585
|
+
result_dict["ch3_not_in_ch2_but_in_ch1_median"] = row[
|
|
586
|
+
idx + 5
|
|
587
|
+
]
|
|
588
|
+
result_dict["ch3_not_in_ch2_but_in_ch1_std"] = row[idx + 6]
|
|
589
|
+
result_dict["ch3_not_in_ch2_but_in_ch1_max"] = row[idx + 7]
|
|
590
|
+
idx += 8
|
|
591
|
+
|
|
592
|
+
# If positive counting (intensity mode) appended extra columns
|
|
593
|
+
if self.count_positive:
|
|
594
|
+
result_dict["ch2_in_ch1_positive_for_ch3_count"] = row[
|
|
595
|
+
idx
|
|
596
|
+
]
|
|
597
|
+
result_dict["ch2_in_ch1_negative_for_ch3_count"] = row[
|
|
598
|
+
idx + 1
|
|
599
|
+
]
|
|
600
|
+
result_dict["ch2_in_ch1_percent_positive_for_ch3"] = (
|
|
601
|
+
row[idx + 2]
|
|
602
|
+
)
|
|
603
|
+
result_dict["ch3_threshold_used"] = row[idx + 3]
|
|
604
|
+
idx += 4
|
|
605
|
+
elif not self.channel2_is_labels and self.channel3_is_labels:
|
|
606
|
+
# ch2 intensity, ch3 labels: single count (ch3 in ch1)
|
|
607
|
+
result_dict["ch3_in_ch1_count"] = row[idx]
|
|
608
|
+
idx += 1
|
|
609
|
+
|
|
610
|
+
if self.get_sizes:
|
|
611
|
+
result_dict["ch3_in_ch1_size"] = row[idx]
|
|
612
|
+
idx += 1
|
|
613
|
+
else:
|
|
614
|
+
# Both channels are intensity: map intensity stats
|
|
615
|
+
result_dict["ch3_in_ch1_mean"] = row[idx]
|
|
616
|
+
result_dict["ch3_in_ch1_median"] = row[idx + 1]
|
|
617
|
+
result_dict["ch3_in_ch1_std"] = row[idx + 2]
|
|
618
|
+
result_dict["ch3_in_ch1_max"] = row[idx + 3]
|
|
619
|
+
idx += 4
|
|
358
620
|
|
|
359
621
|
results.append(result_dict)
|
|
360
622
|
|
|
@@ -368,66 +630,230 @@ class ColocalizationWorker(QThread):
|
|
|
368
630
|
return output
|
|
369
631
|
|
|
370
632
|
def process_single_roi(
|
|
371
|
-
self,
|
|
633
|
+
self,
|
|
634
|
+
filename,
|
|
635
|
+
label_id,
|
|
636
|
+
image_c1,
|
|
637
|
+
image_c2,
|
|
638
|
+
image_c3,
|
|
639
|
+
roi_sizes,
|
|
640
|
+
image_c2_intensity=None,
|
|
641
|
+
image_c3_intensity=None,
|
|
372
642
|
):
|
|
373
|
-
"""Process a single ROI for colocalization analysis.
|
|
643
|
+
"""Process a single ROI for colocalization analysis.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
list or list of lists: Single row for non-individual mode,
|
|
647
|
+
list of rows (one per c2 label) for individual mode
|
|
648
|
+
"""
|
|
374
649
|
# Create masks once
|
|
375
650
|
mask_roi = image_c1 == label_id
|
|
376
|
-
mask_c2 = image_c2 != 0
|
|
377
651
|
|
|
378
|
-
#
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
652
|
+
# Check if we should create individual rows for each c2 label
|
|
653
|
+
if (
|
|
654
|
+
self.size_method == "individual"
|
|
655
|
+
and self.channel2_is_labels
|
|
656
|
+
and (
|
|
657
|
+
image_c3 is not None
|
|
658
|
+
and not self.channel3_is_labels
|
|
659
|
+
or self.get_sizes
|
|
660
|
+
)
|
|
661
|
+
):
|
|
662
|
+
# Individual mode: return one row per c2 label
|
|
663
|
+
return self._process_individual_c2_labels(
|
|
664
|
+
filename,
|
|
665
|
+
label_id,
|
|
666
|
+
image_c1,
|
|
667
|
+
image_c2,
|
|
668
|
+
image_c3,
|
|
669
|
+
roi_sizes,
|
|
670
|
+
mask_roi,
|
|
671
|
+
image_c2_intensity,
|
|
672
|
+
image_c3_intensity,
|
|
673
|
+
)
|
|
382
674
|
|
|
383
675
|
# Build the result row
|
|
384
|
-
row = [filename, int(label_id)
|
|
676
|
+
row = [filename, int(label_id)]
|
|
677
|
+
|
|
678
|
+
# Handle Channel 2 based on whether it's labels or intensity
|
|
679
|
+
if self.channel2_is_labels:
|
|
680
|
+
mask_c2 = image_c2 != 0
|
|
681
|
+
# Calculate counts
|
|
682
|
+
c2_in_c1_count = self.count_unique_nonzero(
|
|
683
|
+
image_c2, mask_roi & mask_c2
|
|
684
|
+
)
|
|
685
|
+
row.append(c2_in_c1_count)
|
|
686
|
+
else:
|
|
687
|
+
# Channel 2 is intensity - calculate intensity statistics
|
|
688
|
+
intensity_img = (
|
|
689
|
+
image_c2_intensity
|
|
690
|
+
if image_c2_intensity is not None
|
|
691
|
+
else image_c2
|
|
692
|
+
)
|
|
693
|
+
stats_c2 = self.calculate_intensity_stats(intensity_img, mask_roi)
|
|
694
|
+
row.extend(
|
|
695
|
+
[
|
|
696
|
+
stats_c2["mean"],
|
|
697
|
+
stats_c2["median"],
|
|
698
|
+
stats_c2["std"],
|
|
699
|
+
stats_c2["max"],
|
|
700
|
+
]
|
|
701
|
+
)
|
|
385
702
|
|
|
386
703
|
# Add size information if requested
|
|
387
704
|
if self.get_sizes:
|
|
388
705
|
size = roi_sizes.get(int(label_id), 0)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
706
|
+
row.append(size)
|
|
707
|
+
|
|
708
|
+
if self.channel2_is_labels:
|
|
709
|
+
# Calculate aggregate size (non-individual mode handles this)
|
|
710
|
+
c2_in_c1_size = self.calculate_coloc_size(
|
|
711
|
+
image_c1, image_c2, label_id
|
|
712
|
+
)
|
|
713
|
+
row.append(c2_in_c1_size)
|
|
393
714
|
|
|
394
715
|
# Handle third channel if present
|
|
395
716
|
if image_c3 is not None:
|
|
396
|
-
|
|
717
|
+
if self.channel2_is_labels and self.channel3_is_labels:
|
|
718
|
+
# Both ch2 and ch3 are labels - original 3-channel label mode
|
|
719
|
+
mask_c2 = image_c2 != 0
|
|
720
|
+
mask_c3 = image_c3 != 0
|
|
721
|
+
|
|
722
|
+
# Calculate third channel statistics
|
|
723
|
+
c3_in_c2_in_c1_count = self.count_unique_nonzero(
|
|
724
|
+
image_c3, mask_roi & mask_c2 & mask_c3
|
|
725
|
+
)
|
|
726
|
+
c3_not_in_c2_but_in_c1_count = self.count_unique_nonzero(
|
|
727
|
+
image_c3, mask_roi & ~mask_c2 & mask_c3
|
|
728
|
+
)
|
|
397
729
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
)
|
|
402
|
-
c3_not_in_c2_but_in_c1_count = self.count_unique_nonzero(
|
|
403
|
-
image_c3, mask_roi & ~mask_c2 & mask_c3
|
|
404
|
-
)
|
|
730
|
+
row.extend(
|
|
731
|
+
[c3_in_c2_in_c1_count, c3_not_in_c2_but_in_c1_count]
|
|
732
|
+
)
|
|
405
733
|
|
|
406
|
-
|
|
734
|
+
# Add size information for third channel if requested
|
|
735
|
+
if self.get_sizes:
|
|
736
|
+
# Calculate aggregate sizes (non-individual mode)
|
|
737
|
+
c3_in_c2_in_c1_size = self.calculate_coloc_size(
|
|
738
|
+
image_c1,
|
|
739
|
+
image_c2,
|
|
740
|
+
label_id,
|
|
741
|
+
mask_c2=True,
|
|
742
|
+
image_c3=image_c3,
|
|
743
|
+
)
|
|
744
|
+
c3_not_in_c2_but_in_c1_size = self.calculate_coloc_size(
|
|
745
|
+
image_c1,
|
|
746
|
+
image_c2,
|
|
747
|
+
label_id,
|
|
748
|
+
mask_c2=False,
|
|
749
|
+
image_c3=image_c3,
|
|
750
|
+
)
|
|
751
|
+
row.extend(
|
|
752
|
+
[c3_in_c2_in_c1_size, c3_not_in_c2_but_in_c1_size]
|
|
753
|
+
)
|
|
407
754
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
755
|
+
# Count C2 objects positive for C3 if requested
|
|
756
|
+
if self.count_c2_positive_for_c3:
|
|
757
|
+
positive_counts = self.count_c2_positive_for_c3_labels(
|
|
758
|
+
image_c2, image_c3, mask_roi
|
|
759
|
+
)
|
|
760
|
+
row.extend(
|
|
761
|
+
[
|
|
762
|
+
positive_counts["c2_positive_for_c3_count"],
|
|
763
|
+
positive_counts["c2_negative_for_c3_count"],
|
|
764
|
+
positive_counts["c2_percent_positive_for_c3"],
|
|
765
|
+
]
|
|
766
|
+
)
|
|
767
|
+
elif self.channel2_is_labels and not self.channel3_is_labels:
|
|
768
|
+
# Ch2 is labels, Ch3 is intensity
|
|
769
|
+
mask_c2 = image_c2 != 0
|
|
770
|
+
intensity_img = (
|
|
771
|
+
image_c3_intensity
|
|
772
|
+
if image_c3_intensity is not None
|
|
773
|
+
else image_c3
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Calculate aggregate intensity statistics (non-individual mode)
|
|
777
|
+
# Calculate intensity where c2 is present in c1
|
|
778
|
+
mask_c2_in_c1 = mask_roi & mask_c2
|
|
779
|
+
stats_c2_in_c1 = self.calculate_intensity_stats(
|
|
780
|
+
intensity_img, mask_c2_in_c1
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Calculate intensity where c2 is NOT present in c1
|
|
784
|
+
mask_not_c2_in_c1 = mask_roi & ~mask_c2
|
|
785
|
+
stats_not_c2_in_c1 = self.calculate_intensity_stats(
|
|
786
|
+
intensity_img, mask_not_c2_in_c1
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
# Add intensity statistics to row
|
|
790
|
+
row.extend(
|
|
791
|
+
[
|
|
792
|
+
stats_c2_in_c1["mean"],
|
|
793
|
+
stats_c2_in_c1["median"],
|
|
794
|
+
stats_c2_in_c1["std"],
|
|
795
|
+
stats_c2_in_c1["max"],
|
|
796
|
+
stats_not_c2_in_c1["mean"],
|
|
797
|
+
stats_not_c2_in_c1["median"],
|
|
798
|
+
stats_not_c2_in_c1["std"],
|
|
799
|
+
stats_not_c2_in_c1["max"],
|
|
800
|
+
]
|
|
416
801
|
)
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
802
|
+
|
|
803
|
+
# Count positive Channel 2 objects if requested
|
|
804
|
+
if self.count_positive:
|
|
805
|
+
positive_counts = self.count_positive_objects(
|
|
806
|
+
image_c2,
|
|
807
|
+
intensity_img,
|
|
808
|
+
mask_roi,
|
|
809
|
+
self.threshold_method,
|
|
810
|
+
self.threshold_value,
|
|
811
|
+
)
|
|
812
|
+
row.extend(
|
|
813
|
+
[
|
|
814
|
+
positive_counts["positive_c2_objects"],
|
|
815
|
+
positive_counts["negative_c2_objects"],
|
|
816
|
+
positive_counts["percent_positive"],
|
|
817
|
+
positive_counts["threshold_used"],
|
|
818
|
+
]
|
|
819
|
+
)
|
|
820
|
+
elif not self.channel2_is_labels and self.channel3_is_labels:
|
|
821
|
+
# Ch2 is intensity, Ch3 is labels - count Ch3 objects in Ch1
|
|
822
|
+
mask_c3 = image_c3 != 0
|
|
823
|
+
c3_in_c1_count = self.count_unique_nonzero(
|
|
824
|
+
image_c3, mask_roi & mask_c3
|
|
825
|
+
)
|
|
826
|
+
row.append(c3_in_c1_count)
|
|
827
|
+
|
|
828
|
+
if self.get_sizes:
|
|
829
|
+
c3_in_c1_size = self.calculate_coloc_size(
|
|
830
|
+
image_c1, image_c3, label_id
|
|
831
|
+
)
|
|
832
|
+
row.append(c3_in_c1_size)
|
|
833
|
+
else:
|
|
834
|
+
# Both Ch2 and Ch3 are intensity - just add Ch3 stats to Ch1 ROIs
|
|
835
|
+
intensity_img = (
|
|
836
|
+
image_c3_intensity
|
|
837
|
+
if image_c3_intensity is not None
|
|
838
|
+
else image_c3
|
|
839
|
+
)
|
|
840
|
+
stats_c3 = self.calculate_intensity_stats(
|
|
841
|
+
intensity_img, mask_roi
|
|
842
|
+
)
|
|
843
|
+
row.extend(
|
|
844
|
+
[
|
|
845
|
+
stats_c3["mean"],
|
|
846
|
+
stats_c3["median"],
|
|
847
|
+
stats_c3["std"],
|
|
848
|
+
stats_c3["max"],
|
|
849
|
+
]
|
|
423
850
|
)
|
|
424
|
-
row.extend([c3_in_c2_in_c1_size, c3_not_in_c2_but_in_c1_size])
|
|
425
851
|
|
|
426
852
|
return row
|
|
427
853
|
|
|
428
854
|
def save_output_image(self, results, file_pair):
|
|
429
855
|
"""Generate and save visualization of colocalization results"""
|
|
430
|
-
if not self.output_folder:
|
|
856
|
+
if not self.output_folder or not self.save_images:
|
|
431
857
|
return
|
|
432
858
|
|
|
433
859
|
try:
|
|
@@ -435,18 +861,9 @@ class ColocalizationWorker(QThread):
|
|
|
435
861
|
filepath_c1 = file_pair[0] # Channel 1
|
|
436
862
|
image_c1 = tifffile.imread(filepath_c1)
|
|
437
863
|
|
|
438
|
-
#
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
# image_c2 = tifffile.imread(filepath_c2)
|
|
442
|
-
has_c2 = True
|
|
443
|
-
except (FileNotFoundError, IndexError):
|
|
444
|
-
has_c2 = False
|
|
445
|
-
|
|
446
|
-
# Try to load channel 3 if available
|
|
447
|
-
has_c3 = False
|
|
448
|
-
if len(file_pair) > 2:
|
|
449
|
-
contextlib.suppress(FileNotFoundError, IndexError)
|
|
864
|
+
# Check if we have channel 2 and 3
|
|
865
|
+
has_c2 = len(file_pair) > 1
|
|
866
|
+
has_c3 = len(file_pair) > 2
|
|
450
867
|
|
|
451
868
|
# Create output filename
|
|
452
869
|
channels_str = "_".join(self.channel_names)
|
|
@@ -561,6 +978,323 @@ class ColocalizationWorker(QThread):
|
|
|
561
978
|
|
|
562
979
|
return int(size)
|
|
563
980
|
|
|
981
|
+
def calculate_intensity_stats(self, intensity_image, mask):
|
|
982
|
+
"""
|
|
983
|
+
Calculate intensity statistics for a masked region.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
intensity_image: Raw intensity image
|
|
987
|
+
mask: Boolean mask defining the region
|
|
988
|
+
|
|
989
|
+
Returns:
|
|
990
|
+
dict: Dictionary with mean, median, std, max, min intensity
|
|
991
|
+
"""
|
|
992
|
+
# Get intensity values within the mask
|
|
993
|
+
intensity_values = intensity_image[mask]
|
|
994
|
+
|
|
995
|
+
if len(intensity_values) == 0:
|
|
996
|
+
return {
|
|
997
|
+
"mean": 0.0,
|
|
998
|
+
"median": 0.0,
|
|
999
|
+
"std": 0.0,
|
|
1000
|
+
"max": 0.0,
|
|
1001
|
+
"min": 0.0,
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
stats = {
|
|
1005
|
+
"mean": float(np.mean(intensity_values)),
|
|
1006
|
+
"median": float(np.median(intensity_values)),
|
|
1007
|
+
"std": float(np.std(intensity_values)),
|
|
1008
|
+
"max": float(np.max(intensity_values)),
|
|
1009
|
+
"min": float(np.min(intensity_values)),
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return stats
|
|
1013
|
+
|
|
1014
|
+
def _process_individual_c2_labels(
|
|
1015
|
+
self,
|
|
1016
|
+
filename,
|
|
1017
|
+
c1_label_id,
|
|
1018
|
+
image_c1,
|
|
1019
|
+
image_c2,
|
|
1020
|
+
image_c3,
|
|
1021
|
+
roi_sizes,
|
|
1022
|
+
mask_roi,
|
|
1023
|
+
image_c2_intensity,
|
|
1024
|
+
image_c3_intensity,
|
|
1025
|
+
):
|
|
1026
|
+
"""
|
|
1027
|
+
Process each C2 label individually, returning one row per C2 label.
|
|
1028
|
+
|
|
1029
|
+
Returns:
|
|
1030
|
+
list of lists: One row per C2 label within the C1 ROI
|
|
1031
|
+
"""
|
|
1032
|
+
# Get all unique Channel 2 objects in the ROI
|
|
1033
|
+
c2_in_roi = image_c2 * mask_roi
|
|
1034
|
+
c2_labels = np.unique(c2_in_roi)
|
|
1035
|
+
c2_labels = c2_labels[c2_labels != 0] # Remove background
|
|
1036
|
+
|
|
1037
|
+
if len(c2_labels) == 0:
|
|
1038
|
+
# No c2 labels in this ROI, return empty list (no rows)
|
|
1039
|
+
return []
|
|
1040
|
+
|
|
1041
|
+
rows = []
|
|
1042
|
+
c1_size = (
|
|
1043
|
+
roi_sizes.get(int(c1_label_id), 0) if self.get_sizes else None
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
for c2_label in sorted(c2_labels):
|
|
1047
|
+
# Start row with filename, c1_label_id, c2_label_id
|
|
1048
|
+
row = [filename, int(c1_label_id), int(c2_label)]
|
|
1049
|
+
|
|
1050
|
+
# Get mask for this specific Channel 2 object within the ROI
|
|
1051
|
+
mask_c2_obj = (image_c2 == c2_label) & mask_roi
|
|
1052
|
+
|
|
1053
|
+
# Add size information if requested
|
|
1054
|
+
if self.get_sizes:
|
|
1055
|
+
row.append(c1_size) # C1 ROI size (same for all c2 labels)
|
|
1056
|
+
|
|
1057
|
+
# C2 label size
|
|
1058
|
+
c2_size = int(np.count_nonzero(mask_c2_obj))
|
|
1059
|
+
row.append(c2_size)
|
|
1060
|
+
|
|
1061
|
+
# Handle C3 channel based on its type
|
|
1062
|
+
if image_c3 is not None:
|
|
1063
|
+
if self.channel3_is_labels:
|
|
1064
|
+
# C3 is labels: count unique C3 labels in this C2 label
|
|
1065
|
+
mask_c3 = image_c3 != 0
|
|
1066
|
+
mask_c3_in_c2 = mask_c2_obj & mask_c3
|
|
1067
|
+
c3_count = self.count_unique_nonzero(
|
|
1068
|
+
image_c3, mask_c3_in_c2
|
|
1069
|
+
)
|
|
1070
|
+
row.append(c3_count)
|
|
1071
|
+
|
|
1072
|
+
# Add C3 size if requested
|
|
1073
|
+
if self.get_sizes:
|
|
1074
|
+
c3_size = int(np.count_nonzero(mask_c3_in_c2))
|
|
1075
|
+
row.append(c3_size)
|
|
1076
|
+
else:
|
|
1077
|
+
# C3 is intensity: calculate intensity statistics in this C2 label
|
|
1078
|
+
intensity_img = (
|
|
1079
|
+
image_c3_intensity
|
|
1080
|
+
if image_c3_intensity is not None
|
|
1081
|
+
else image_c3
|
|
1082
|
+
)
|
|
1083
|
+
intensity_in_obj = intensity_img[mask_c2_obj]
|
|
1084
|
+
|
|
1085
|
+
if len(intensity_in_obj) > 0:
|
|
1086
|
+
c3_mean = float(np.mean(intensity_in_obj))
|
|
1087
|
+
c3_median = float(np.median(intensity_in_obj))
|
|
1088
|
+
c3_std = float(np.std(intensity_in_obj))
|
|
1089
|
+
else:
|
|
1090
|
+
c3_mean = 0.0
|
|
1091
|
+
c3_median = 0.0
|
|
1092
|
+
c3_std = 0.0
|
|
1093
|
+
|
|
1094
|
+
row.extend([c3_mean, c3_median, c3_std])
|
|
1095
|
+
|
|
1096
|
+
rows.append(row)
|
|
1097
|
+
|
|
1098
|
+
return rows
|
|
1099
|
+
|
|
1100
|
+
def calculate_individual_c2_intensities(
|
|
1101
|
+
self, image_c2, intensity_c3, mask_roi
|
|
1102
|
+
):
|
|
1103
|
+
"""
|
|
1104
|
+
Calculate individual Channel 3 intensity values for each Channel 2 label.
|
|
1105
|
+
|
|
1106
|
+
Args:
|
|
1107
|
+
image_c2: Label image of Channel 2 (e.g., nuclei)
|
|
1108
|
+
intensity_c3: Intensity image of Channel 3
|
|
1109
|
+
mask_roi: Boolean mask for the ROI from Channel 1
|
|
1110
|
+
|
|
1111
|
+
Returns:
|
|
1112
|
+
dict: Dictionary mapping c2_label_id -> intensity value (mean of c3 in that c2 label)
|
|
1113
|
+
"""
|
|
1114
|
+
# Get all unique Channel 2 objects in the ROI
|
|
1115
|
+
c2_in_roi = image_c2 * mask_roi
|
|
1116
|
+
c2_labels = np.unique(c2_in_roi)
|
|
1117
|
+
c2_labels = c2_labels[c2_labels != 0] # Remove background
|
|
1118
|
+
|
|
1119
|
+
individual_values = {}
|
|
1120
|
+
for c2_label in c2_labels:
|
|
1121
|
+
# Get mask for this specific Channel 2 object within the ROI
|
|
1122
|
+
mask_c2_obj = (image_c2 == c2_label) & mask_roi
|
|
1123
|
+
|
|
1124
|
+
# Get intensity values of Channel 3 in this Channel 2 object
|
|
1125
|
+
intensity_in_obj = intensity_c3[mask_c2_obj]
|
|
1126
|
+
|
|
1127
|
+
if len(intensity_in_obj) > 0:
|
|
1128
|
+
# Use mean intensity as the representative value for this c2 label
|
|
1129
|
+
individual_values[int(c2_label)] = float(
|
|
1130
|
+
np.mean(intensity_in_obj)
|
|
1131
|
+
)
|
|
1132
|
+
else:
|
|
1133
|
+
individual_values[int(c2_label)] = 0.0
|
|
1134
|
+
|
|
1135
|
+
return individual_values
|
|
1136
|
+
|
|
1137
|
+
def calculate_individual_c2_sizes(self, image_c2, mask_roi, image_c3=None):
|
|
1138
|
+
"""
|
|
1139
|
+
Calculate individual sizes for each Channel 2 label within the ROI.
|
|
1140
|
+
|
|
1141
|
+
Args:
|
|
1142
|
+
image_c2: Label image of Channel 2
|
|
1143
|
+
mask_roi: Boolean mask for the ROI from Channel 1
|
|
1144
|
+
image_c3: Optional Channel 3 image (if provided, calculate c3 size within each c2 label)
|
|
1145
|
+
|
|
1146
|
+
Returns:
|
|
1147
|
+
dict: Dictionary mapping c2_label_id -> size (pixel count)
|
|
1148
|
+
"""
|
|
1149
|
+
# Get all unique Channel 2 objects in the ROI
|
|
1150
|
+
c2_in_roi = image_c2 * mask_roi
|
|
1151
|
+
c2_labels = np.unique(c2_in_roi)
|
|
1152
|
+
c2_labels = c2_labels[c2_labels != 0] # Remove background
|
|
1153
|
+
|
|
1154
|
+
individual_sizes = {}
|
|
1155
|
+
for c2_label in c2_labels:
|
|
1156
|
+
# Get mask for this specific Channel 2 object within the ROI
|
|
1157
|
+
mask_c2_obj = (image_c2 == c2_label) & mask_roi
|
|
1158
|
+
|
|
1159
|
+
if image_c3 is not None:
|
|
1160
|
+
# Calculate c3 size within this c2 label
|
|
1161
|
+
mask_with_c3 = mask_c2_obj & (image_c3 != 0)
|
|
1162
|
+
size = int(np.count_nonzero(mask_with_c3))
|
|
1163
|
+
else:
|
|
1164
|
+
# Calculate c2 label size
|
|
1165
|
+
size = int(np.count_nonzero(mask_c2_obj))
|
|
1166
|
+
|
|
1167
|
+
individual_sizes[int(c2_label)] = size
|
|
1168
|
+
|
|
1169
|
+
return individual_sizes
|
|
1170
|
+
|
|
1171
|
+
def count_positive_objects(
|
|
1172
|
+
self,
|
|
1173
|
+
image_c2,
|
|
1174
|
+
intensity_c3,
|
|
1175
|
+
mask_roi,
|
|
1176
|
+
threshold_method="percentile",
|
|
1177
|
+
threshold_value=75.0,
|
|
1178
|
+
):
|
|
1179
|
+
"""
|
|
1180
|
+
Count Channel 2 objects that are positive for Channel 3 signal.
|
|
1181
|
+
|
|
1182
|
+
Args:
|
|
1183
|
+
image_c2: Label image of Channel 2 (e.g., nuclei)
|
|
1184
|
+
intensity_c3: Intensity image of Channel 3 (e.g., KI67)
|
|
1185
|
+
mask_roi: Boolean mask for the ROI from Channel 1
|
|
1186
|
+
threshold_method: 'percentile' or 'absolute'
|
|
1187
|
+
threshold_value: Threshold value (0-100 for percentile, or absolute intensity)
|
|
1188
|
+
|
|
1189
|
+
Returns:
|
|
1190
|
+
dict: Dictionary with counts and threshold info
|
|
1191
|
+
"""
|
|
1192
|
+
# Get all unique Channel 2 objects in the ROI
|
|
1193
|
+
c2_in_roi = image_c2 * mask_roi
|
|
1194
|
+
c2_labels = np.unique(c2_in_roi)
|
|
1195
|
+
c2_labels = c2_labels[c2_labels != 0] # Remove background
|
|
1196
|
+
|
|
1197
|
+
if len(c2_labels) == 0:
|
|
1198
|
+
return {
|
|
1199
|
+
"total_c2_objects": 0,
|
|
1200
|
+
"positive_c2_objects": 0,
|
|
1201
|
+
"negative_c2_objects": 0,
|
|
1202
|
+
"percent_positive": 0.0,
|
|
1203
|
+
"threshold_used": 0.0,
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
# Calculate threshold
|
|
1207
|
+
if threshold_method == "percentile":
|
|
1208
|
+
# Calculate threshold from all Channel 3 intensity values within ROI where Channel 2 exists
|
|
1209
|
+
mask_c2_in_roi = c2_in_roi > 0
|
|
1210
|
+
intensity_in_c2 = intensity_c3[mask_c2_in_roi]
|
|
1211
|
+
if len(intensity_in_c2) > 0:
|
|
1212
|
+
threshold = float(
|
|
1213
|
+
np.percentile(intensity_in_c2, threshold_value)
|
|
1214
|
+
)
|
|
1215
|
+
else:
|
|
1216
|
+
threshold = 0.0
|
|
1217
|
+
else: # absolute
|
|
1218
|
+
threshold = threshold_value
|
|
1219
|
+
|
|
1220
|
+
# Count positive objects
|
|
1221
|
+
positive_count = 0
|
|
1222
|
+
for label_id in c2_labels:
|
|
1223
|
+
# Get mask for this specific Channel 2 object
|
|
1224
|
+
mask_c2_obj = (image_c2 == label_id) & mask_roi
|
|
1225
|
+
|
|
1226
|
+
# Get mean intensity of Channel 3 in this Channel 2 object
|
|
1227
|
+
intensity_in_obj = intensity_c3[mask_c2_obj]
|
|
1228
|
+
if len(intensity_in_obj) > 0:
|
|
1229
|
+
mean_intensity = float(np.mean(intensity_in_obj))
|
|
1230
|
+
if mean_intensity >= threshold:
|
|
1231
|
+
positive_count += 1
|
|
1232
|
+
|
|
1233
|
+
total_count = int(len(c2_labels))
|
|
1234
|
+
negative_count = total_count - positive_count
|
|
1235
|
+
percent_positive = (
|
|
1236
|
+
(positive_count / total_count * 100) if total_count > 0 else 0.0
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
return {
|
|
1240
|
+
"total_c2_objects": total_count,
|
|
1241
|
+
"positive_c2_objects": positive_count,
|
|
1242
|
+
"negative_c2_objects": negative_count,
|
|
1243
|
+
"percent_positive": percent_positive,
|
|
1244
|
+
"threshold_used": threshold,
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
def count_c2_positive_for_c3_labels(self, image_c2, image_c3, mask_roi):
|
|
1248
|
+
"""
|
|
1249
|
+
Count Channel 2 objects that contain at least one Channel 3 object (label-based).
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
image_c2: Label image of Channel 2 (e.g., nuclei)
|
|
1253
|
+
image_c3: Label image of Channel 3 (e.g., Ki67 spots)
|
|
1254
|
+
mask_roi: Boolean mask for the ROI from Channel 1
|
|
1255
|
+
|
|
1256
|
+
Returns:
|
|
1257
|
+
dict: Dictionary with positive/negative counts and percentage
|
|
1258
|
+
"""
|
|
1259
|
+
# Get all unique Channel 2 objects in the ROI
|
|
1260
|
+
c2_in_roi = image_c2 * mask_roi
|
|
1261
|
+
c2_labels = np.unique(c2_in_roi)
|
|
1262
|
+
c2_labels = c2_labels[c2_labels != 0] # Remove background
|
|
1263
|
+
|
|
1264
|
+
if len(c2_labels) == 0:
|
|
1265
|
+
return {
|
|
1266
|
+
"total_c2_objects": 0,
|
|
1267
|
+
"c2_positive_for_c3_count": 0,
|
|
1268
|
+
"c2_negative_for_c3_count": 0,
|
|
1269
|
+
"c2_percent_positive_for_c3": 0.0,
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
# Count how many C2 objects contain at least one C3 object
|
|
1273
|
+
positive_count = 0
|
|
1274
|
+
for c2_label in c2_labels:
|
|
1275
|
+
# Get mask for this specific Channel 2 object
|
|
1276
|
+
mask_c2_obj = (image_c2 == c2_label) & mask_roi
|
|
1277
|
+
|
|
1278
|
+
# Check if any C3 objects overlap with this C2 object
|
|
1279
|
+
c3_in_c2 = image_c3[mask_c2_obj]
|
|
1280
|
+
c3_labels_in_c2 = np.unique(c3_in_c2[c3_in_c2 != 0])
|
|
1281
|
+
|
|
1282
|
+
if len(c3_labels_in_c2) > 0:
|
|
1283
|
+
positive_count += 1
|
|
1284
|
+
|
|
1285
|
+
total_count = int(len(c2_labels))
|
|
1286
|
+
negative_count = total_count - positive_count
|
|
1287
|
+
percent_positive = (
|
|
1288
|
+
(positive_count / total_count * 100) if total_count > 0 else 0.0
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
return {
|
|
1292
|
+
"total_c2_objects": total_count,
|
|
1293
|
+
"c2_positive_for_c3_count": positive_count,
|
|
1294
|
+
"c2_negative_for_c3_count": negative_count,
|
|
1295
|
+
"c2_percent_positive_for_c3": percent_positive,
|
|
1296
|
+
}
|
|
1297
|
+
|
|
564
1298
|
def stop(self):
|
|
565
1299
|
"""Request worker to stop processing"""
|
|
566
1300
|
self.stop_requested = True
|
|
@@ -726,6 +1460,12 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
726
1460
|
self.ch1_folder = QLineEdit()
|
|
727
1461
|
self.ch1_pattern = QLineEdit()
|
|
728
1462
|
self.ch1_pattern.setPlaceholderText("*_labels.tif")
|
|
1463
|
+
self.ch1_pattern.setToolTip(
|
|
1464
|
+
"Glob pattern for matching files. Wildcards:\n"
|
|
1465
|
+
"* = any characters (e.g., *_labels.tif)\n"
|
|
1466
|
+
"? = single character (e.g., *_labels?.tif)\n"
|
|
1467
|
+
"[seq] = character in sequence (e.g., *_labels[0-9]*.tif for _labels1, _labels23, etc.)"
|
|
1468
|
+
)
|
|
729
1469
|
self.ch1_browse = QPushButton("Browse...")
|
|
730
1470
|
self.ch1_browse.clicked.connect(lambda: self.browse_folder(0))
|
|
731
1471
|
|
|
@@ -741,6 +1481,12 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
741
1481
|
self.ch2_folder = QLineEdit()
|
|
742
1482
|
self.ch2_pattern = QLineEdit()
|
|
743
1483
|
self.ch2_pattern.setPlaceholderText("*_labels.tif")
|
|
1484
|
+
self.ch2_pattern.setToolTip(
|
|
1485
|
+
"Glob pattern for matching files. Wildcards:\n"
|
|
1486
|
+
"* = any characters (e.g., *_labels.tif)\n"
|
|
1487
|
+
"? = single character (e.g., *_labels?.tif)\n"
|
|
1488
|
+
"[seq] = character in sequence (e.g., *_labels[0-9]*.tif for _labels1, _labels23, etc.)"
|
|
1489
|
+
)
|
|
744
1490
|
self.ch2_browse = QPushButton("Browse...")
|
|
745
1491
|
self.ch2_browse.clicked.connect(lambda: self.browse_folder(1))
|
|
746
1492
|
|
|
@@ -754,8 +1500,15 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
754
1500
|
# Channel 3 (optional)
|
|
755
1501
|
self.ch3_label = QLabel("Channel 3 (Optional):")
|
|
756
1502
|
self.ch3_folder = QLineEdit()
|
|
1503
|
+
self.ch3_folder.textChanged.connect(lambda: self.update_ch3_controls())
|
|
757
1504
|
self.ch3_pattern = QLineEdit()
|
|
758
1505
|
self.ch3_pattern.setPlaceholderText("*_labels.tif")
|
|
1506
|
+
self.ch3_pattern.setToolTip(
|
|
1507
|
+
"Glob pattern for matching files. Wildcards:\n"
|
|
1508
|
+
"* = any characters (e.g., *_labels.tif or *.tif for intensity)\n"
|
|
1509
|
+
"? = single character (e.g., *_labels?.tif)\n"
|
|
1510
|
+
"[seq] = character in sequence (e.g., *_labels[0-9]*.tif for _labels1, _labels23, etc.)"
|
|
1511
|
+
)
|
|
759
1512
|
self.ch3_browse = QPushButton("Browse...")
|
|
760
1513
|
self.ch3_browse.clicked.connect(lambda: self.browse_folder(2))
|
|
761
1514
|
|
|
@@ -771,26 +1524,61 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
771
1524
|
|
|
772
1525
|
# Get sizes option
|
|
773
1526
|
self.get_sizes_checkbox = QCheckBox("Calculate Region Sizes")
|
|
1527
|
+
self.get_sizes_checkbox.toggled.connect(self._on_get_sizes_changed)
|
|
774
1528
|
options_layout.addRow(self.get_sizes_checkbox)
|
|
775
1529
|
|
|
1530
|
+
# Save images option
|
|
1531
|
+
self.save_images_checkbox = QCheckBox("Save Output Images")
|
|
1532
|
+
self.save_images_checkbox.setChecked(
|
|
1533
|
+
False
|
|
1534
|
+
) # Default to not saving images
|
|
1535
|
+
self.save_images_checkbox.setToolTip(
|
|
1536
|
+
"Save visualization images showing colocalization results.\n"
|
|
1537
|
+
"Uncheck to only generate CSV output (faster)."
|
|
1538
|
+
)
|
|
1539
|
+
options_layout.addRow(self.save_images_checkbox)
|
|
1540
|
+
|
|
776
1541
|
# Size calculation method
|
|
777
1542
|
self.size_method_layout = QHBoxLayout()
|
|
778
1543
|
self.size_method_label = QLabel("Size Calculation Method:")
|
|
779
1544
|
self.size_method_median = QCheckBox("Median")
|
|
780
1545
|
self.size_method_median.setChecked(True)
|
|
1546
|
+
self.size_method_median.setToolTip(
|
|
1547
|
+
"Aggregate mode: One row per C1 ROI\n"
|
|
1548
|
+
"Size = median size of C2 objects in this C1 ROI"
|
|
1549
|
+
)
|
|
781
1550
|
self.size_method_sum = QCheckBox("Sum")
|
|
1551
|
+
self.size_method_sum.setToolTip(
|
|
1552
|
+
"Aggregate mode: One row per C1 ROI\n"
|
|
1553
|
+
"Size = total size of all C2 objects in this C1 ROI"
|
|
1554
|
+
)
|
|
1555
|
+
self.size_method_individual = QCheckBox("Individual")
|
|
1556
|
+
self.size_method_individual.setToolTip(
|
|
1557
|
+
"Individual mode: One row per C2 object (not per C1 ROI)\n"
|
|
1558
|
+
"Each C2 label gets its own row with individual stats.\n"
|
|
1559
|
+
"\n"
|
|
1560
|
+
"Note: Positive counting is disabled in Individual mode.\n"
|
|
1561
|
+
"You get per-object C3 mean/median/std values instead."
|
|
1562
|
+
)
|
|
782
1563
|
|
|
783
1564
|
# Connect to make them mutually exclusive
|
|
784
1565
|
self.size_method_median.toggled.connect(
|
|
785
1566
|
lambda checked: (
|
|
786
|
-
self.
|
|
1567
|
+
self._update_size_method_checkboxes("median", checked)
|
|
787
1568
|
if checked
|
|
788
1569
|
else None
|
|
789
1570
|
)
|
|
790
1571
|
)
|
|
791
1572
|
self.size_method_sum.toggled.connect(
|
|
792
1573
|
lambda checked: (
|
|
793
|
-
self.
|
|
1574
|
+
self._update_size_method_checkboxes("sum", checked)
|
|
1575
|
+
if checked
|
|
1576
|
+
else None
|
|
1577
|
+
)
|
|
1578
|
+
)
|
|
1579
|
+
self.size_method_individual.toggled.connect(
|
|
1580
|
+
lambda checked: (
|
|
1581
|
+
self._update_size_method_checkboxes("individual", checked)
|
|
794
1582
|
if checked
|
|
795
1583
|
else None
|
|
796
1584
|
)
|
|
@@ -799,8 +1587,158 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
799
1587
|
self.size_method_layout.addWidget(self.size_method_label)
|
|
800
1588
|
self.size_method_layout.addWidget(self.size_method_median)
|
|
801
1589
|
self.size_method_layout.addWidget(self.size_method_sum)
|
|
1590
|
+
self.size_method_layout.addWidget(self.size_method_individual)
|
|
802
1591
|
options_layout.addRow(self.size_method_layout)
|
|
803
1592
|
|
|
1593
|
+
# Initially disable size method controls
|
|
1594
|
+
self._update_size_method_controls_state()
|
|
1595
|
+
|
|
1596
|
+
# Channel 2 mode selection
|
|
1597
|
+
self.ch2_is_labels_checkbox = QCheckBox(
|
|
1598
|
+
"Channel 2 is Labels (uncheck for intensity)"
|
|
1599
|
+
)
|
|
1600
|
+
self.ch2_is_labels_checkbox.setChecked(True)
|
|
1601
|
+
self.ch2_is_labels_checkbox.setToolTip(
|
|
1602
|
+
"Check: C2 contains labeled objects (e.g., nuclei segmentation)\n"
|
|
1603
|
+
" → Counts C2 objects in C1 ROIs\n"
|
|
1604
|
+
"\n"
|
|
1605
|
+
"Uncheck: C2 contains intensity values (e.g., fluorescence)\n"
|
|
1606
|
+
" → Calculates C2 intensity statistics in C1 ROIs"
|
|
1607
|
+
)
|
|
1608
|
+
self.ch2_is_labels_checkbox.toggled.connect(self.on_ch2_mode_changed)
|
|
1609
|
+
options_layout.addRow(self.ch2_is_labels_checkbox)
|
|
1610
|
+
|
|
1611
|
+
# Channel 3 mode selection
|
|
1612
|
+
self.ch3_is_labels_checkbox = QCheckBox(
|
|
1613
|
+
"Channel 3 is Labels (uncheck for intensity)"
|
|
1614
|
+
)
|
|
1615
|
+
self.ch3_is_labels_checkbox.setChecked(True)
|
|
1616
|
+
self.ch3_is_labels_checkbox.setEnabled(
|
|
1617
|
+
False
|
|
1618
|
+
) # Disabled until ch3 folder is set
|
|
1619
|
+
self.ch3_is_labels_checkbox.setToolTip(
|
|
1620
|
+
"COMMON MODES:\n"
|
|
1621
|
+
"\n"
|
|
1622
|
+
"C2=Labels + C3=Intensity (UNCHECKED):\n"
|
|
1623
|
+
" → Measures C3 intensity within C2 objects\n"
|
|
1624
|
+
" → Also measures C3 where C2 doesn't exist\n"
|
|
1625
|
+
" → Use Individual mode for per-C2-object stats\n"
|
|
1626
|
+
" → Use Aggregate + Count Positive to threshold C2 objects\n"
|
|
1627
|
+
"\n"
|
|
1628
|
+
"C2=Labels + C3=Labels (CHECKED):\n"
|
|
1629
|
+
" → Counts C3 objects within C2 objects\n"
|
|
1630
|
+
" → Counts C3 objects outside C2 but in C1\n"
|
|
1631
|
+
"\n"
|
|
1632
|
+
"C2=Intensity + C3=Intensity (UNCHECKED):\n"
|
|
1633
|
+
" → Measures both C2 and C3 intensity in C1 ROIs"
|
|
1634
|
+
)
|
|
1635
|
+
self.ch3_is_labels_checkbox.toggled.connect(self.on_ch3_mode_changed)
|
|
1636
|
+
options_layout.addRow(self.ch3_is_labels_checkbox)
|
|
1637
|
+
|
|
1638
|
+
# Semantic to instance label conversion options
|
|
1639
|
+
self.convert_c2_checkbox = QCheckBox(
|
|
1640
|
+
"Convert C2 Semantic to Instance Labels"
|
|
1641
|
+
)
|
|
1642
|
+
self.convert_c2_checkbox.setChecked(False)
|
|
1643
|
+
self.convert_c2_checkbox.setToolTip(
|
|
1644
|
+
"Enable if C2 contains semantic labels (all objects have same value).\n"
|
|
1645
|
+
"This will find connected components and assign unique labels to each object."
|
|
1646
|
+
)
|
|
1647
|
+
options_layout.addRow(self.convert_c2_checkbox)
|
|
1648
|
+
|
|
1649
|
+
self.convert_c3_checkbox = QCheckBox(
|
|
1650
|
+
"Convert C3 Semantic to Instance Labels"
|
|
1651
|
+
)
|
|
1652
|
+
self.convert_c3_checkbox.setChecked(False)
|
|
1653
|
+
self.convert_c3_checkbox.setToolTip(
|
|
1654
|
+
"Enable if C3 contains semantic labels (all objects have same value).\n"
|
|
1655
|
+
"This will find connected components and assign unique labels to each object."
|
|
1656
|
+
)
|
|
1657
|
+
self.convert_c3_checkbox.setEnabled(
|
|
1658
|
+
False
|
|
1659
|
+
) # Disabled until ch3 folder is set
|
|
1660
|
+
options_layout.addRow(self.convert_c3_checkbox)
|
|
1661
|
+
|
|
1662
|
+
# Count C2 positive for C3 (both labels)
|
|
1663
|
+
self.count_c2_positive_checkbox = QCheckBox(
|
|
1664
|
+
"Count C2 Objects Positive for C3 (both labels)"
|
|
1665
|
+
)
|
|
1666
|
+
self.count_c2_positive_checkbox.setChecked(False)
|
|
1667
|
+
self.count_c2_positive_checkbox.setEnabled(False)
|
|
1668
|
+
self.count_c2_positive_checkbox.setToolTip(
|
|
1669
|
+
"When both C2 and C3 are labels, count how many C2 objects contain\n"
|
|
1670
|
+
"at least one C3 object (binary: positive/negative per C2 object)."
|
|
1671
|
+
)
|
|
1672
|
+
self.ch3_is_labels_checkbox.toggled.connect(
|
|
1673
|
+
self.update_c2_positive_state
|
|
1674
|
+
)
|
|
1675
|
+
self.ch2_is_labels_checkbox.toggled.connect(
|
|
1676
|
+
self.update_c2_positive_state
|
|
1677
|
+
)
|
|
1678
|
+
options_layout.addRow(self.count_c2_positive_checkbox)
|
|
1679
|
+
|
|
1680
|
+
# Positive counting option (only for intensity mode)
|
|
1681
|
+
self.count_positive_checkbox = QCheckBox(
|
|
1682
|
+
"Count Positive C2 Objects (Aggregate mode only)"
|
|
1683
|
+
)
|
|
1684
|
+
self.count_positive_checkbox.setEnabled(False) # Disabled initially
|
|
1685
|
+
self.count_positive_checkbox.setToolTip(
|
|
1686
|
+
"Available when: C2 is labels AND C3 is intensity AND aggregate mode\n"
|
|
1687
|
+
"\n"
|
|
1688
|
+
"Counts how many C2 objects are 'positive' for C3 signal based on\n"
|
|
1689
|
+
"mean C3 intensity within each C2 object vs. threshold.\n"
|
|
1690
|
+
"\n"
|
|
1691
|
+
"Outputs: positive_count, negative_count, percent_positive, threshold\n"
|
|
1692
|
+
"\n"
|
|
1693
|
+
"Note: Individual mode provides per-object C3 stats instead of counts."
|
|
1694
|
+
)
|
|
1695
|
+
self.count_positive_checkbox.toggled.connect(
|
|
1696
|
+
self.on_count_positive_changed
|
|
1697
|
+
)
|
|
1698
|
+
options_layout.addRow(self.count_positive_checkbox)
|
|
1699
|
+
|
|
1700
|
+
# Threshold method selection
|
|
1701
|
+
self.threshold_layout = QHBoxLayout()
|
|
1702
|
+
self.threshold_label = QLabel("Threshold Method:")
|
|
1703
|
+
self.threshold_percentile = QCheckBox("Percentile")
|
|
1704
|
+
self.threshold_percentile.setChecked(True)
|
|
1705
|
+
self.threshold_absolute = QCheckBox("Absolute")
|
|
1706
|
+
self.threshold_percentile.setEnabled(False)
|
|
1707
|
+
self.threshold_absolute.setEnabled(False)
|
|
1708
|
+
|
|
1709
|
+
# Connect to make them mutually exclusive
|
|
1710
|
+
self.threshold_percentile.toggled.connect(
|
|
1711
|
+
lambda checked: (
|
|
1712
|
+
self.threshold_absolute.setChecked(not checked)
|
|
1713
|
+
if checked
|
|
1714
|
+
else None
|
|
1715
|
+
)
|
|
1716
|
+
)
|
|
1717
|
+
self.threshold_absolute.toggled.connect(
|
|
1718
|
+
lambda checked: (
|
|
1719
|
+
self.threshold_percentile.setChecked(not checked)
|
|
1720
|
+
if checked
|
|
1721
|
+
else None
|
|
1722
|
+
)
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
self.threshold_layout.addWidget(self.threshold_label)
|
|
1726
|
+
self.threshold_layout.addWidget(self.threshold_percentile)
|
|
1727
|
+
self.threshold_layout.addWidget(self.threshold_absolute)
|
|
1728
|
+
options_layout.addRow(self.threshold_layout)
|
|
1729
|
+
|
|
1730
|
+
# Threshold value input
|
|
1731
|
+
self.threshold_value_layout = QHBoxLayout()
|
|
1732
|
+
self.threshold_value_label = QLabel("Threshold Value:")
|
|
1733
|
+
self.threshold_value_input = QLineEdit("75.0")
|
|
1734
|
+
self.threshold_value_input.setPlaceholderText(
|
|
1735
|
+
"e.g., 75 for 75th percentile"
|
|
1736
|
+
)
|
|
1737
|
+
self.threshold_value_input.setEnabled(False)
|
|
1738
|
+
self.threshold_value_layout.addWidget(self.threshold_value_label)
|
|
1739
|
+
self.threshold_value_layout.addWidget(self.threshold_value_input)
|
|
1740
|
+
options_layout.addRow(self.threshold_value_layout)
|
|
1741
|
+
|
|
804
1742
|
layout.addLayout(options_layout)
|
|
805
1743
|
|
|
806
1744
|
# Output folder selection
|
|
@@ -898,6 +1836,27 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
898
1836
|
self.ch2_folder.setText(folder)
|
|
899
1837
|
elif channel_index == 2:
|
|
900
1838
|
self.ch3_folder.setText(folder)
|
|
1839
|
+
# Enable ch3 checkbox when folder is set
|
|
1840
|
+
self.update_ch3_controls()
|
|
1841
|
+
|
|
1842
|
+
def update_ch3_controls(self):
|
|
1843
|
+
"""Enable/disable channel 3 controls based on whether folder is set"""
|
|
1844
|
+
ch3_folder = self.ch3_folder.text().strip()
|
|
1845
|
+
# Expand user path (~/...) and normalize the path
|
|
1846
|
+
if ch3_folder:
|
|
1847
|
+
ch3_folder = os.path.expanduser(ch3_folder)
|
|
1848
|
+
ch3_folder = os.path.abspath(ch3_folder)
|
|
1849
|
+
has_ch3 = bool(ch3_folder and os.path.isdir(ch3_folder))
|
|
1850
|
+
|
|
1851
|
+
# Enable/disable ch3 checkbox
|
|
1852
|
+
self.ch3_is_labels_checkbox.setEnabled(has_ch3)
|
|
1853
|
+
|
|
1854
|
+
# Enable/disable ch3 semantic conversion checkbox
|
|
1855
|
+
self.convert_c3_checkbox.setEnabled(has_ch3)
|
|
1856
|
+
|
|
1857
|
+
# Update positive counting state based on ch3 availability
|
|
1858
|
+
self.update_positive_counting_state()
|
|
1859
|
+
self.update_c2_positive_state()
|
|
901
1860
|
|
|
902
1861
|
def browse_output(self):
|
|
903
1862
|
"""Browse for output folder"""
|
|
@@ -910,6 +1869,82 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
910
1869
|
if folder:
|
|
911
1870
|
self.output_folder.setText(folder)
|
|
912
1871
|
|
|
1872
|
+
def _update_size_method_checkboxes(self, selected, checked):
|
|
1873
|
+
"""Make size method checkboxes mutually exclusive"""
|
|
1874
|
+
if not checked:
|
|
1875
|
+
return
|
|
1876
|
+
if selected == "median":
|
|
1877
|
+
self.size_method_sum.setChecked(False)
|
|
1878
|
+
self.size_method_individual.setChecked(False)
|
|
1879
|
+
elif selected == "sum":
|
|
1880
|
+
self.size_method_median.setChecked(False)
|
|
1881
|
+
self.size_method_individual.setChecked(False)
|
|
1882
|
+
elif selected == "individual":
|
|
1883
|
+
self.size_method_median.setChecked(False)
|
|
1884
|
+
self.size_method_sum.setChecked(False)
|
|
1885
|
+
|
|
1886
|
+
def _on_get_sizes_changed(self, checked):
|
|
1887
|
+
"""Enable/disable size method controls based on get_sizes checkbox"""
|
|
1888
|
+
self._update_size_method_controls_state()
|
|
1889
|
+
|
|
1890
|
+
def _update_size_method_controls_state(self):
|
|
1891
|
+
"""Update the enabled state of size method controls"""
|
|
1892
|
+
enabled = self.get_sizes_checkbox.isChecked()
|
|
1893
|
+
self.size_method_label.setEnabled(enabled)
|
|
1894
|
+
self.size_method_median.setEnabled(enabled)
|
|
1895
|
+
self.size_method_sum.setEnabled(enabled)
|
|
1896
|
+
self.size_method_individual.setEnabled(enabled)
|
|
1897
|
+
|
|
1898
|
+
def on_ch2_mode_changed(self, checked):
|
|
1899
|
+
"""Handle channel 2 mode change (labels vs intensity)"""
|
|
1900
|
+
# When ch2 is intensity, positive counting doesn't apply
|
|
1901
|
+
# Positive counting only works when ch2 is labels and ch3 is intensity
|
|
1902
|
+
self.update_positive_counting_state()
|
|
1903
|
+
|
|
1904
|
+
def on_ch3_mode_changed(self, checked):
|
|
1905
|
+
"""Handle channel 3 mode change (labels vs intensity)"""
|
|
1906
|
+
# Enable/disable positive counting based on mode
|
|
1907
|
+
# Positive counting only available when ch2 is labels and ch3 is intensity
|
|
1908
|
+
self.update_positive_counting_state()
|
|
1909
|
+
|
|
1910
|
+
def update_positive_counting_state(self):
|
|
1911
|
+
"""Update the state of positive counting controls based on ch2 and ch3 modes"""
|
|
1912
|
+
# Positive counting only works when ch2 is labels and ch3 is intensity
|
|
1913
|
+
ch2_is_labels = self.ch2_is_labels_checkbox.isChecked()
|
|
1914
|
+
ch3_is_labels = self.ch3_is_labels_checkbox.isChecked()
|
|
1915
|
+
|
|
1916
|
+
# Enable positive counting only when ch2 is labels AND ch3 is intensity
|
|
1917
|
+
can_count_positive = ch2_is_labels and not ch3_is_labels
|
|
1918
|
+
self.count_positive_checkbox.setEnabled(can_count_positive)
|
|
1919
|
+
|
|
1920
|
+
if not can_count_positive:
|
|
1921
|
+
# Disable positive counting if conditions aren't met
|
|
1922
|
+
self.count_positive_checkbox.setChecked(False)
|
|
1923
|
+
|
|
1924
|
+
def on_count_positive_changed(self, checked):
|
|
1925
|
+
"""Handle positive counting checkbox change"""
|
|
1926
|
+
# Enable/disable threshold controls
|
|
1927
|
+
self.threshold_percentile.setEnabled(checked)
|
|
1928
|
+
self.threshold_absolute.setEnabled(checked)
|
|
1929
|
+
self.threshold_value_input.setEnabled(checked)
|
|
1930
|
+
|
|
1931
|
+
def update_c2_positive_state(self):
|
|
1932
|
+
"""Update the state of C2 positive for C3 counting based on ch2 and ch3 modes"""
|
|
1933
|
+
# Get folder and mode states
|
|
1934
|
+
ch3_folder = self.ch3_folder.text().strip()
|
|
1935
|
+
has_ch3 = bool(ch3_folder and os.path.isdir(ch3_folder))
|
|
1936
|
+
|
|
1937
|
+
# C2 positive counting only works when BOTH ch2 and ch3 are labels
|
|
1938
|
+
ch2_is_labels = self.ch2_is_labels_checkbox.isChecked()
|
|
1939
|
+
ch3_is_labels = self.ch3_is_labels_checkbox.isChecked()
|
|
1940
|
+
|
|
1941
|
+
# Enable only when ch3 exists AND both are labels
|
|
1942
|
+
can_count_c2_positive = has_ch3 and ch2_is_labels and ch3_is_labels
|
|
1943
|
+
self.count_c2_positive_checkbox.setEnabled(can_count_c2_positive)
|
|
1944
|
+
|
|
1945
|
+
if not can_count_c2_positive:
|
|
1946
|
+
self.count_c2_positive_checkbox.setChecked(False)
|
|
1947
|
+
|
|
913
1948
|
def find_matching_files(self):
|
|
914
1949
|
"""Find matching files across channels using the updated grouping function."""
|
|
915
1950
|
# Get channel folders and patterns
|
|
@@ -922,6 +1957,17 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
922
1957
|
ch3_folder = self.ch3_folder.text().strip()
|
|
923
1958
|
ch3_pattern = self.ch3_pattern.text().strip() or "*_labels.tif"
|
|
924
1959
|
|
|
1960
|
+
# Expand user paths (~/...) and normalize paths
|
|
1961
|
+
if ch1_folder:
|
|
1962
|
+
ch1_folder = os.path.expanduser(ch1_folder)
|
|
1963
|
+
ch1_folder = os.path.abspath(ch1_folder)
|
|
1964
|
+
if ch2_folder:
|
|
1965
|
+
ch2_folder = os.path.expanduser(ch2_folder)
|
|
1966
|
+
ch2_folder = os.path.abspath(ch2_folder)
|
|
1967
|
+
if ch3_folder:
|
|
1968
|
+
ch3_folder = os.path.expanduser(ch3_folder)
|
|
1969
|
+
ch3_folder = os.path.abspath(ch3_folder)
|
|
1970
|
+
|
|
925
1971
|
# Validate required folders
|
|
926
1972
|
if not ch1_folder or not os.path.isdir(ch1_folder):
|
|
927
1973
|
self.status_label.setText(
|
|
@@ -941,12 +1987,36 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
941
1987
|
ch1_files = sorted(glob.glob(os.path.join(ch1_folder, ch1_pattern)))
|
|
942
1988
|
ch2_files = sorted(glob.glob(os.path.join(ch2_folder, ch2_pattern)))
|
|
943
1989
|
|
|
1990
|
+
# Check if files were found
|
|
1991
|
+
if not ch1_files:
|
|
1992
|
+
self.status_label.setText(
|
|
1993
|
+
f"No files matching pattern '{ch1_pattern}' found in Channel 1 folder"
|
|
1994
|
+
)
|
|
1995
|
+
self.match_label.setText("No matching files found")
|
|
1996
|
+
self.analyze_button.setEnabled(False)
|
|
1997
|
+
return
|
|
1998
|
+
|
|
1999
|
+
if not ch2_files:
|
|
2000
|
+
self.status_label.setText(
|
|
2001
|
+
f"No files matching pattern '{ch2_pattern}' found in Channel 2 folder"
|
|
2002
|
+
)
|
|
2003
|
+
self.match_label.setText("No matching files found")
|
|
2004
|
+
self.analyze_button.setEnabled(False)
|
|
2005
|
+
return
|
|
2006
|
+
|
|
944
2007
|
# Check if third channel is provided
|
|
945
2008
|
use_ch3 = bool(ch3_folder and os.path.isdir(ch3_folder))
|
|
946
2009
|
if use_ch3:
|
|
947
2010
|
ch3_files = sorted(
|
|
948
2011
|
glob.glob(os.path.join(ch3_folder, ch3_pattern))
|
|
949
2012
|
)
|
|
2013
|
+
if not ch3_files:
|
|
2014
|
+
self.status_label.setText(
|
|
2015
|
+
f"No files matching pattern '{ch3_pattern}' found in Channel 3 folder"
|
|
2016
|
+
)
|
|
2017
|
+
self.match_label.setText("No matching files found")
|
|
2018
|
+
self.analyze_button.setEnabled(False)
|
|
2019
|
+
return
|
|
950
2020
|
else:
|
|
951
2021
|
ch3_files = []
|
|
952
2022
|
|
|
@@ -997,11 +2067,37 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
997
2067
|
|
|
998
2068
|
# Get settings
|
|
999
2069
|
get_sizes = self.get_sizes_checkbox.isChecked()
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
2070
|
+
save_images = self.save_images_checkbox.isChecked()
|
|
2071
|
+
|
|
2072
|
+
# Determine size method
|
|
2073
|
+
if self.size_method_median.isChecked():
|
|
2074
|
+
size_method = "median"
|
|
2075
|
+
elif self.size_method_sum.isChecked():
|
|
2076
|
+
size_method = "sum"
|
|
2077
|
+
elif self.size_method_individual.isChecked():
|
|
2078
|
+
size_method = "individual"
|
|
2079
|
+
else:
|
|
2080
|
+
size_method = "median" # Default fallback
|
|
2081
|
+
|
|
1003
2082
|
output_folder = self.output_folder.text().strip()
|
|
1004
2083
|
|
|
2084
|
+
# Get new settings for channel mode and positive counting
|
|
2085
|
+
channel2_is_labels = self.ch2_is_labels_checkbox.isChecked()
|
|
2086
|
+
channel3_is_labels = self.ch3_is_labels_checkbox.isChecked()
|
|
2087
|
+
count_positive = self.count_positive_checkbox.isChecked()
|
|
2088
|
+
threshold_method = (
|
|
2089
|
+
"percentile"
|
|
2090
|
+
if self.threshold_percentile.isChecked()
|
|
2091
|
+
else "absolute"
|
|
2092
|
+
)
|
|
2093
|
+
|
|
2094
|
+
# Get threshold value
|
|
2095
|
+
try:
|
|
2096
|
+
threshold_value = float(self.threshold_value_input.text())
|
|
2097
|
+
except ValueError:
|
|
2098
|
+
threshold_value = 75.0 # Default value
|
|
2099
|
+
self.threshold_value_input.setText("75.0")
|
|
2100
|
+
|
|
1005
2101
|
# Create output folder if it doesn't exist and is specified
|
|
1006
2102
|
if output_folder:
|
|
1007
2103
|
try:
|
|
@@ -1032,13 +2128,54 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
1032
2128
|
self.analyze_button.setEnabled(False)
|
|
1033
2129
|
self.cancel_button.setEnabled(True)
|
|
1034
2130
|
|
|
2131
|
+
# Determine actual channel names based on folder names
|
|
2132
|
+
# file_pairs contains tuples of (ch1_file, ch2_file) or (ch1_file, ch2_file, ch3_file)
|
|
2133
|
+
ch1_folder = self.ch1_folder.text().strip()
|
|
2134
|
+
ch2_folder = self.ch2_folder.text().strip()
|
|
2135
|
+
ch3_folder = self.ch3_folder.text().strip()
|
|
2136
|
+
|
|
2137
|
+
# Expand paths for consistent handling
|
|
2138
|
+
if ch1_folder:
|
|
2139
|
+
ch1_folder = os.path.expanduser(ch1_folder)
|
|
2140
|
+
ch1_folder = os.path.abspath(ch1_folder)
|
|
2141
|
+
if ch2_folder:
|
|
2142
|
+
ch2_folder = os.path.expanduser(ch2_folder)
|
|
2143
|
+
ch2_folder = os.path.abspath(ch2_folder)
|
|
2144
|
+
if ch3_folder:
|
|
2145
|
+
ch3_folder = os.path.expanduser(ch3_folder)
|
|
2146
|
+
ch3_folder = os.path.abspath(ch3_folder)
|
|
2147
|
+
|
|
2148
|
+
active_channel_names = [
|
|
2149
|
+
os.path.basename(ch1_folder) if ch1_folder else "CH1",
|
|
2150
|
+
os.path.basename(ch2_folder) if ch2_folder else "CH2",
|
|
2151
|
+
]
|
|
2152
|
+
|
|
2153
|
+
# Only add third channel if it exists
|
|
2154
|
+
num_channels = len(self.file_pairs[0]) if self.file_pairs else 2
|
|
2155
|
+
if num_channels == 3 and ch3_folder:
|
|
2156
|
+
active_channel_names.append(os.path.basename(ch3_folder))
|
|
2157
|
+
|
|
2158
|
+
# Get conversion settings
|
|
2159
|
+
convert_to_instances_c2 = self.convert_c2_checkbox.isChecked()
|
|
2160
|
+
convert_to_instances_c3 = self.convert_c3_checkbox.isChecked()
|
|
2161
|
+
count_c2_positive_for_c3 = self.count_c2_positive_checkbox.isChecked()
|
|
2162
|
+
|
|
1035
2163
|
# Create worker thread
|
|
1036
2164
|
self.worker = ColocalizationWorker(
|
|
1037
2165
|
self.file_pairs,
|
|
1038
|
-
|
|
2166
|
+
active_channel_names,
|
|
1039
2167
|
get_sizes,
|
|
1040
2168
|
size_method,
|
|
1041
2169
|
output_folder,
|
|
2170
|
+
channel2_is_labels,
|
|
2171
|
+
channel3_is_labels,
|
|
2172
|
+
count_positive,
|
|
2173
|
+
threshold_method,
|
|
2174
|
+
threshold_value,
|
|
2175
|
+
save_images,
|
|
2176
|
+
convert_to_instances_c2,
|
|
2177
|
+
convert_to_instances_c3,
|
|
2178
|
+
count_c2_positive_for_c3,
|
|
1042
2179
|
)
|
|
1043
2180
|
|
|
1044
2181
|
# Set thread count
|
|
@@ -1061,7 +2198,7 @@ class ColocalizationAnalysisWidget(QWidget):
|
|
|
1061
2198
|
# Create results widget if needed
|
|
1062
2199
|
if not self.results_widget:
|
|
1063
2200
|
self.results_widget = ColocalizationResultsWidget(
|
|
1064
|
-
self.viewer,
|
|
2201
|
+
self.viewer, active_channel_names
|
|
1065
2202
|
)
|
|
1066
2203
|
self.viewer.window.add_dock_widget(
|
|
1067
2204
|
self.results_widget,
|