napari-tmidas 0.2.2__py3-none-any.whl → 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. napari_tmidas/__init__.py +35 -5
  2. napari_tmidas/_crop_anything.py +1520 -609
  3. napari_tmidas/_env_manager.py +76 -0
  4. napari_tmidas/_file_conversion.py +1646 -1131
  5. napari_tmidas/_file_selector.py +1455 -216
  6. napari_tmidas/_label_inspection.py +83 -8
  7. napari_tmidas/_processing_worker.py +309 -0
  8. napari_tmidas/_reader.py +6 -10
  9. napari_tmidas/_registry.py +2 -2
  10. napari_tmidas/_roi_colocalization.py +1221 -84
  11. napari_tmidas/_tests/test_crop_anything.py +123 -0
  12. napari_tmidas/_tests/test_env_manager.py +89 -0
  13. napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
  14. napari_tmidas/_tests/test_init.py +98 -0
  15. napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
  16. napari_tmidas/_tests/test_label_inspection.py +86 -0
  17. napari_tmidas/_tests/test_processing_basic.py +500 -0
  18. napari_tmidas/_tests/test_processing_worker.py +142 -0
  19. napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
  20. napari_tmidas/_tests/test_registry.py +70 -2
  21. napari_tmidas/_tests/test_scipy_filters.py +168 -0
  22. napari_tmidas/_tests/test_skimage_filters.py +259 -0
  23. napari_tmidas/_tests/test_split_channels.py +217 -0
  24. napari_tmidas/_tests/test_spotiflow.py +87 -0
  25. napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
  26. napari_tmidas/_tests/test_ui_utils.py +68 -0
  27. napari_tmidas/_tests/test_widget.py +30 -0
  28. napari_tmidas/_tests/test_windows_basic.py +66 -0
  29. napari_tmidas/_ui_utils.py +57 -0
  30. napari_tmidas/_version.py +16 -3
  31. napari_tmidas/_widget.py +41 -4
  32. napari_tmidas/processing_functions/basic.py +557 -20
  33. napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
  34. napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
  35. napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
  36. napari_tmidas/processing_functions/colocalization.py +513 -56
  37. napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
  38. napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
  39. napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
  40. napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
  41. napari_tmidas/processing_functions/sam2_mp4.py +274 -195
  42. napari_tmidas/processing_functions/scipy_filters.py +403 -8
  43. napari_tmidas/processing_functions/skimage_filters.py +424 -212
  44. napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
  45. napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
  46. napari_tmidas/processing_functions/timepoint_merger.py +334 -86
  47. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/METADATA +71 -30
  48. napari_tmidas-0.2.5.dist-info/RECORD +63 -0
  49. napari_tmidas/_tests/__init__.py +0 -0
  50. napari_tmidas-0.2.2.dist-info/RECORD +0 -40
  51. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/WHEEL +0 -0
  52. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/entry_points.txt +0 -0
  53. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/licenses/LICENSE +0 -0
  54. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.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
- # Strip suffixes from the common substring
104
- stripped_common_substring = common_substring.rsplit("_", 1)[0]
105
- groups[stripped_common_substring] = {
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 self.get_sizes:
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]}_size",
177
- f"{self.channel_names[1]}_in_{self.channel_names[0]}_size",
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
- header.extend(
183
- [
184
- f"{self.channel_names[2]}_in_{self.channel_names[1]}_in_{self.channel_names[0]}_count",
185
- f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_but_in_{self.channel_names[0]}_count",
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
- if self.get_sizes:
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[1]}_in_{self.channel_names[0]}_size",
193
- f"{self.channel_names[2]}_not_in_{self.channel_names[1]}_but_in_{self.channel_names[0]}_size",
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
- row = self.process_single_roi(
335
- filename, label_id, image_c1, image_c2, image_c3, roi_sizes
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
- # Extract results as dictionary
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["ch3_in_ch2_in_ch1_count"] = row[idx]
350
- result_dict["ch3_not_in_ch2_but_in_ch1_count"] = row[idx + 1]
351
- idx += 2
352
-
353
- if self.get_sizes:
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, filename, label_id, image_c1, image_c2, image_c3, roi_sizes
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
- # Calculate counts
379
- c2_in_c1_count = self.count_unique_nonzero(
380
- image_c2, mask_roi & mask_c2
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), c2_in_c1_count]
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
- c2_in_c1_size = self.calculate_coloc_size(
390
- image_c1, image_c2, label_id
391
- )
392
- row.extend([size, c2_in_c1_size])
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
- mask_c3 = image_c3 != 0
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
- # Calculate third channel statistics
399
- c3_in_c2_in_c1_count = self.count_unique_nonzero(
400
- image_c3, mask_roi & mask_c2 & mask_c3
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
- row.extend([c3_in_c2_in_c1_count, c3_not_in_c2_but_in_c1_count])
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
- # Add size information for third channel if requested
409
- if self.get_sizes:
410
- c3_in_c2_in_c1_size = self.calculate_coloc_size(
411
- image_c1,
412
- image_c2,
413
- label_id,
414
- mask_c2=True,
415
- image_c3=image_c3,
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
- c3_not_in_c2_but_in_c1_size = self.calculate_coloc_size(
418
- image_c1,
419
- image_c2,
420
- label_id,
421
- mask_c2=False,
422
- image_c3=image_c3,
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
- # Try to load channel 2 as well
439
- try:
440
- # filepath_c2 = file_pair[1] # Channel 2
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.size_method_sum.setChecked(not checked)
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.size_method_median.setChecked(not checked)
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
- size_method = (
1001
- "median" if self.size_method_median.isChecked() else "sum"
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
- self.channel_names,
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, self.channel_names
2201
+ self.viewer, active_channel_names
1065
2202
  )
1066
2203
  self.viewer.window.add_dock_widget(
1067
2204
  self.results_widget,