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
@@ -19,6 +19,37 @@ def get_nonzero_labels(image):
19
19
  return [int(x) for x in labels]
20
20
 
21
21
 
22
+ def convert_semantic_to_instance_labels(image, connectivity=None):
23
+ """
24
+ Convert semantic labels (where all objects have the same value) to instance labels.
25
+
26
+ Args:
27
+ image: Label image that may contain semantic labels
28
+ connectivity: Connectivity for connected component analysis (1, 2, or None for full)
29
+
30
+ Returns:
31
+ Image with instance labels (each connected component gets unique label)
32
+ """
33
+ if image is None or np.all(image == 0):
34
+ return image
35
+
36
+ # Get unique non-zero values
37
+ unique_labels = np.unique(image[image != 0])
38
+
39
+ # Quick check: if there's only one unique non-zero value, it's definitely semantic
40
+ # Otherwise, apply connected components to the entire mask at once (much faster)
41
+ if len(unique_labels) == 1:
42
+ # Single semantic label - just label connected components of the binary mask
43
+ mask = image > 0
44
+ return measure.label(mask, connectivity=connectivity)
45
+ else:
46
+ # Multiple labels - could be instance or semantic
47
+ # Apply connected components to entire non-zero region at once
48
+ # This is MUCH faster than iterating over each label value
49
+ mask = image > 0
50
+ return measure.label(mask, connectivity=connectivity)
51
+
52
+
22
53
  def count_unique_nonzero(array, mask):
23
54
  """Count unique non-zero values in array where mask is True."""
24
55
  unique_vals = np.unique(array[mask])
@@ -61,6 +92,160 @@ def calculate_coloc_size(
61
92
  return int(size)
62
93
 
63
94
 
95
+ def calculate_intensity_stats(intensity_image, mask):
96
+ """
97
+ Calculate intensity statistics for a masked region.
98
+
99
+ Args:
100
+ intensity_image: Raw intensity image
101
+ mask: Boolean mask defining the region
102
+
103
+ Returns:
104
+ dict: Dictionary with mean, median, std, max, min intensity
105
+ """
106
+ # Get intensity values within the mask
107
+ intensity_values = intensity_image[mask]
108
+
109
+ if len(intensity_values) == 0:
110
+ return {"mean": 0.0, "median": 0.0, "std": 0.0, "max": 0.0, "min": 0.0}
111
+
112
+ stats = {
113
+ "mean": float(np.mean(intensity_values)),
114
+ "median": float(np.median(intensity_values)),
115
+ "std": float(np.std(intensity_values)),
116
+ "max": float(np.max(intensity_values)),
117
+ "min": float(np.min(intensity_values)),
118
+ }
119
+
120
+ return stats
121
+
122
+
123
+ def count_c2_positive_for_c3_labels(image_c2, image_c3, mask_roi):
124
+ """
125
+ Count Channel 2 objects that contain at least one Channel 3 object (label-based).
126
+
127
+ Args:
128
+ image_c2: Label image of Channel 2 (e.g., nuclei)
129
+ image_c3: Label image of Channel 3 (e.g., Ki67 spots)
130
+ mask_roi: Boolean mask for the ROI from Channel 1
131
+
132
+ Returns:
133
+ dict: Dictionary with positive/negative counts and percentage
134
+ """
135
+ # Get all unique Channel 2 objects in the ROI
136
+ c2_in_roi = image_c2 * mask_roi
137
+ c2_labels = np.unique(c2_in_roi)
138
+ c2_labels = c2_labels[c2_labels != 0] # Remove background
139
+
140
+ if len(c2_labels) == 0:
141
+ return {
142
+ "total_c2_objects": 0,
143
+ "c2_positive_for_c3_count": 0,
144
+ "c2_negative_for_c3_count": 0,
145
+ "c2_percent_positive_for_c3": 0.0,
146
+ }
147
+
148
+ # Count how many C2 objects contain at least one C3 object
149
+ positive_count = 0
150
+ for c2_label in c2_labels:
151
+ # Get mask for this specific Channel 2 object
152
+ mask_c2_obj = (image_c2 == c2_label) & mask_roi
153
+
154
+ # Check if any C3 objects overlap with this C2 object
155
+ c3_in_c2 = image_c3[mask_c2_obj]
156
+ c3_labels_in_c2 = np.unique(c3_in_c2[c3_in_c2 != 0])
157
+
158
+ if len(c3_labels_in_c2) > 0:
159
+ positive_count += 1
160
+
161
+ total_count = int(len(c2_labels))
162
+ negative_count = total_count - positive_count
163
+ percent_positive = (
164
+ (positive_count / total_count * 100) if total_count > 0 else 0.0
165
+ )
166
+
167
+ return {
168
+ "total_c2_objects": total_count,
169
+ "c2_positive_for_c3_count": positive_count,
170
+ "c2_negative_for_c3_count": negative_count,
171
+ "c2_percent_positive_for_c3": percent_positive,
172
+ }
173
+
174
+
175
+ def count_positive_objects(
176
+ image_c2,
177
+ intensity_c3,
178
+ mask_roi,
179
+ threshold_method="percentile",
180
+ threshold_value=75.0,
181
+ ):
182
+ """
183
+ Count Channel 2 objects that are positive for Channel 3 signal (intensity-based).
184
+
185
+ Args:
186
+ image_c2: Label image of Channel 2 (e.g., nuclei)
187
+ intensity_c3: Intensity image of Channel 3 (e.g., KI67)
188
+ mask_roi: Boolean mask for the ROI from Channel 1
189
+ threshold_method: 'percentile' or 'absolute'
190
+ threshold_value: Threshold value (0-100 for percentile, or absolute intensity)
191
+
192
+ Returns:
193
+ dict: Dictionary with counts and threshold info
194
+ """
195
+ # Get all unique Channel 2 objects in the ROI
196
+ c2_in_roi = image_c2 * mask_roi
197
+ c2_labels = np.unique(c2_in_roi)
198
+ c2_labels = c2_labels[c2_labels != 0] # Remove background
199
+
200
+ if len(c2_labels) == 0:
201
+ return {
202
+ "total_c2_objects": 0,
203
+ "positive_c2_objects": 0,
204
+ "negative_c2_objects": 0,
205
+ "percent_positive": 0.0,
206
+ "threshold_used": 0.0,
207
+ }
208
+
209
+ # Calculate threshold
210
+ if threshold_method == "percentile":
211
+ # Calculate threshold from all Channel 3 intensity values within ROI where Channel 2 exists
212
+ mask_c2_in_roi = c2_in_roi > 0
213
+ intensity_in_c2 = intensity_c3[mask_c2_in_roi]
214
+ if len(intensity_in_c2) > 0:
215
+ threshold = float(np.percentile(intensity_in_c2, threshold_value))
216
+ else:
217
+ threshold = 0.0
218
+ else: # absolute
219
+ threshold = threshold_value
220
+
221
+ # Count positive objects
222
+ positive_count = 0
223
+ for label_id in c2_labels:
224
+ # Get mask for this specific Channel 2 object
225
+ mask_c2_obj = (image_c2 == label_id) & mask_roi
226
+
227
+ # Get mean intensity of Channel 3 in this Channel 2 object
228
+ intensity_in_obj = intensity_c3[mask_c2_obj]
229
+ if len(intensity_in_obj) > 0:
230
+ mean_intensity = float(np.mean(intensity_in_obj))
231
+ if mean_intensity >= threshold:
232
+ positive_count += 1
233
+
234
+ total_count = int(len(c2_labels))
235
+ negative_count = total_count - positive_count
236
+ percent_positive = (
237
+ (positive_count / total_count * 100) if total_count > 0 else 0.0
238
+ )
239
+
240
+ return {
241
+ "total_c2_objects": total_count,
242
+ "positive_c2_objects": positive_count,
243
+ "negative_c2_objects": negative_count,
244
+ "percent_positive": percent_positive,
245
+ "threshold_used": threshold,
246
+ }
247
+
248
+
64
249
  def process_single_roi(
65
250
  label_id,
66
251
  image_c1,
@@ -68,19 +253,73 @@ def process_single_roi(
68
253
  image_c3=None,
69
254
  get_sizes=False,
70
255
  roi_sizes=None,
256
+ channel2_is_labels=True,
257
+ channel3_is_labels=True,
258
+ image_c2_intensity=None,
259
+ image_c3_intensity=None,
260
+ count_positive=False,
261
+ threshold_method="percentile",
262
+ threshold_value=75.0,
263
+ convert_to_instances_c2=False,
264
+ convert_to_instances_c3=False,
265
+ count_c2_positive_for_c3=False,
71
266
  ):
72
- """Process a single ROI for colocalization analysis."""
267
+ """
268
+ Process a single ROI for colocalization analysis.
269
+
270
+ Args:
271
+ label_id: Label ID to process
272
+ image_c1: First channel image (ROI labels)
273
+ image_c2: Second channel image (object labels or intensity)
274
+ image_c3: Third channel image (labels or intensity, optional)
275
+ get_sizes: Whether to calculate size statistics
276
+ roi_sizes: Pre-calculated ROI sizes dict
277
+ channel2_is_labels: If True, treat channel 2 as labels; if False, as intensity
278
+ channel3_is_labels: If True, treat channel 3 as labels; if False, as intensity
279
+ image_c2_intensity: Separate intensity image for channel 2 (optional)
280
+ image_c3_intensity: Separate intensity image for channel 3 (optional)
281
+ count_positive: Count positive objects (only applicable when ch2 is labels and ch3 is intensity)
282
+ threshold_method: 'percentile' or 'absolute' for positive counting
283
+ threshold_value: Threshold value for positive counting
284
+ convert_to_instances_c2: If True, convert semantic labels to instance labels for channel 2
285
+ convert_to_instances_c3: If True, convert semantic labels to instance labels for channel 3
286
+ count_c2_positive_for_c3: Count C2 objects containing at least one C3 object (ch2 and ch3 both labels)
287
+ """
288
+ # Convert semantic labels to instance labels if requested
289
+ if convert_to_instances_c2 and channel2_is_labels and image_c2 is not None:
290
+ image_c2 = convert_semantic_to_instance_labels(image_c2)
291
+
292
+ if convert_to_instances_c3 and channel3_is_labels and image_c3 is not None:
293
+ image_c3 = convert_semantic_to_instance_labels(image_c3)
294
+
73
295
  # Create masks once
74
296
  mask_roi = image_c1 == label_id
75
- mask_c2 = image_c2 != 0
76
-
77
- # Calculate counts
78
- c2_in_c1_count = count_unique_nonzero(image_c2, mask_roi & mask_c2)
79
297
 
80
298
  # Build the result dictionary
81
- result = {"label_id": int(label_id), "ch2_in_ch1_count": c2_in_c1_count}
299
+ result = {"label_id": int(label_id)}
300
+
301
+ # Handle Channel 2 based on whether it's labels or intensity
302
+ if channel2_is_labels:
303
+ mask_c2 = image_c2 != 0
304
+ # Calculate counts
305
+ c2_in_c1_count = count_unique_nonzero(image_c2, mask_roi & mask_c2)
306
+ result["ch2_in_ch1_count"] = c2_in_c1_count
307
+ else:
308
+ # Channel 2 is intensity - calculate intensity statistics
309
+ intensity_img = (
310
+ image_c2_intensity if image_c2_intensity is not None else image_c2
311
+ )
312
+ stats_c2 = calculate_intensity_stats(intensity_img, mask_roi)
313
+ result.update(
314
+ {
315
+ "ch2_in_ch1_mean": stats_c2["mean"],
316
+ "ch2_in_ch1_median": stats_c2["median"],
317
+ "ch2_in_ch1_std": stats_c2["std"],
318
+ "ch2_in_ch1_max": stats_c2["max"],
319
+ }
320
+ )
82
321
 
83
- # Add size information if requested
322
+ # Add size information if requested (only for label-based channels)
84
323
  if get_sizes:
85
324
  if roi_sizes is None:
86
325
  roi_sizes = {}
@@ -89,44 +328,173 @@ def process_single_roi(
89
328
  roi_sizes[label_id] = area
90
329
 
91
330
  size = roi_sizes.get(int(label_id), 0)
92
- c2_in_c1_size = calculate_coloc_size(image_c1, image_c2, label_id)
331
+ result["ch1_size"] = size
93
332
 
94
- result.update({"ch1_size": size, "ch2_in_ch1_size": c2_in_c1_size})
333
+ if channel2_is_labels:
334
+ c2_in_c1_size = calculate_coloc_size(image_c1, image_c2, label_id)
335
+ result["ch2_in_ch1_size"] = c2_in_c1_size
95
336
 
96
337
  # Handle third channel if present
97
338
  if image_c3 is not None:
98
- mask_c3 = image_c3 != 0
99
-
100
- # Calculate third channel statistics
101
- c3_in_c2_in_c1_count = count_unique_nonzero(
102
- image_c3, mask_roi & mask_c2 & mask_c3
103
- )
104
- c3_not_in_c2_but_in_c1_count = count_unique_nonzero(
105
- image_c3, mask_roi & ~mask_c2 & mask_c3
106
- )
107
-
108
- result.update(
109
- {
110
- "ch3_in_ch2_in_ch1_count": c3_in_c2_in_c1_count,
111
- "ch3_not_in_ch2_but_in_ch1_count": c3_not_in_c2_but_in_c1_count,
112
- }
113
- )
114
-
115
- # Add size information for third channel if requested
116
- if get_sizes:
117
- c3_in_c2_in_c1_size = calculate_coloc_size(
118
- image_c1, image_c2, label_id, mask_c2=True, image_c3=image_c3
119
- )
120
- c3_not_in_c2_but_in_c1_size = calculate_coloc_size(
121
- image_c1, image_c2, label_id, mask_c2=False, image_c3=image_c3
339
+ if channel3_is_labels:
340
+ # Original behavior: count objects in channel 3
341
+ mask_c3 = image_c3 != 0
342
+
343
+ if channel2_is_labels:
344
+ # Both ch2 and ch3 are labels - original 3-channel label mode
345
+ mask_c2 = image_c2 != 0
346
+
347
+ # Calculate third channel statistics
348
+ c3_in_c2_in_c1_count = count_unique_nonzero(
349
+ image_c3, mask_roi & mask_c2 & mask_c3
350
+ )
351
+ c3_not_in_c2_but_in_c1_count = count_unique_nonzero(
352
+ image_c3, mask_roi & ~mask_c2 & mask_c3
353
+ )
354
+
355
+ result.update(
356
+ {
357
+ "ch3_in_ch2_in_ch1_count": c3_in_c2_in_c1_count,
358
+ "ch3_not_in_ch2_but_in_ch1_count": c3_not_in_c2_but_in_c1_count,
359
+ }
360
+ )
361
+
362
+ # Count C2 objects positive for C3 if requested
363
+ if count_c2_positive_for_c3:
364
+ positive_counts = count_c2_positive_for_c3_labels(
365
+ image_c2, image_c3, mask_roi
366
+ )
367
+ result.update(
368
+ {
369
+ "c2_in_c1_positive_for_c3_count": positive_counts[
370
+ "c2_positive_for_c3_count"
371
+ ],
372
+ "c2_in_c1_negative_for_c3_count": positive_counts[
373
+ "c2_negative_for_c3_count"
374
+ ],
375
+ "c2_in_c1_percent_positive_for_c3": positive_counts[
376
+ "c2_percent_positive_for_c3"
377
+ ],
378
+ }
379
+ )
380
+
381
+ # Add size information for third channel if requested
382
+ if get_sizes:
383
+ c3_in_c2_in_c1_size = calculate_coloc_size(
384
+ image_c1,
385
+ image_c2,
386
+ label_id,
387
+ mask_c2=True,
388
+ image_c3=image_c3,
389
+ )
390
+ c3_not_in_c2_but_in_c1_size = calculate_coloc_size(
391
+ image_c1,
392
+ image_c2,
393
+ label_id,
394
+ mask_c2=False,
395
+ image_c3=image_c3,
396
+ )
397
+
398
+ result.update(
399
+ {
400
+ "ch3_in_ch2_in_ch1_size": c3_in_c2_in_c1_size,
401
+ "ch3_not_in_ch2_but_in_c1_size": c3_not_in_c2_but_in_c1_size,
402
+ }
403
+ )
404
+ else:
405
+ # Ch2 is intensity, Ch3 is labels - count Ch3 objects in Ch1
406
+ c3_in_c1_count = count_unique_nonzero(
407
+ image_c3, mask_roi & mask_c3
408
+ )
409
+ result["ch3_in_ch1_count"] = c3_in_c1_count
410
+
411
+ if get_sizes:
412
+ c3_in_c1_size = calculate_coloc_size(
413
+ image_c1, image_c3, label_id
414
+ )
415
+ result["ch3_in_ch1_size"] = c3_in_c1_size
416
+ else:
417
+ # Channel 3 is intensity
418
+ intensity_img = (
419
+ image_c3_intensity
420
+ if image_c3_intensity is not None
421
+ else image_c3
122
422
  )
123
423
 
124
- result.update(
125
- {
126
- "ch3_in_ch2_in_ch1_size": c3_in_c2_in_c1_size,
127
- "ch3_not_in_ch2_but_in_ch1_size": c3_not_in_c2_but_in_c1_size,
128
- }
129
- )
424
+ if channel2_is_labels:
425
+ # Ch2 is labels, Ch3 is intensity - original intensity mode
426
+ mask_c2 = image_c2 != 0
427
+
428
+ # Calculate intensity where c2 is present in c1
429
+ mask_c2_in_c1 = mask_roi & mask_c2
430
+ stats_c2_in_c1 = calculate_intensity_stats(
431
+ intensity_img, mask_c2_in_c1
432
+ )
433
+
434
+ # Calculate intensity where c2 is NOT present in c1
435
+ mask_not_c2_in_c1 = mask_roi & ~mask_c2
436
+ stats_not_c2_in_c1 = calculate_intensity_stats(
437
+ intensity_img, mask_not_c2_in_c1
438
+ )
439
+
440
+ # Add intensity statistics to result
441
+ result.update(
442
+ {
443
+ "ch3_in_ch2_in_ch1_mean": stats_c2_in_c1["mean"],
444
+ "ch3_in_ch2_in_ch1_median": stats_c2_in_c1["median"],
445
+ "ch3_in_ch2_in_ch1_std": stats_c2_in_c1["std"],
446
+ "ch3_in_ch2_in_ch1_max": stats_c2_in_c1["max"],
447
+ "ch3_not_in_ch2_but_in_ch1_mean": stats_not_c2_in_c1[
448
+ "mean"
449
+ ],
450
+ "ch3_not_in_ch2_but_in_ch1_median": stats_not_c2_in_c1[
451
+ "median"
452
+ ],
453
+ "ch3_not_in_ch2_but_in_ch1_std": stats_not_c2_in_c1[
454
+ "std"
455
+ ],
456
+ "ch3_not_in_ch2_but_in_ch1_max": stats_not_c2_in_c1[
457
+ "max"
458
+ ],
459
+ }
460
+ )
461
+
462
+ # Count positive Channel 2 objects if requested
463
+ if count_positive:
464
+ positive_counts = count_positive_objects(
465
+ image_c2,
466
+ intensity_img,
467
+ mask_roi,
468
+ threshold_method,
469
+ threshold_value,
470
+ )
471
+ result.update(
472
+ {
473
+ "ch2_in_ch1_positive_for_ch3_count": positive_counts[
474
+ "positive_c2_objects"
475
+ ],
476
+ "ch2_in_ch1_negative_for_ch3_count": positive_counts[
477
+ "negative_c2_objects"
478
+ ],
479
+ "ch2_in_ch1_percent_positive_for_ch3": positive_counts[
480
+ "percent_positive"
481
+ ],
482
+ "ch3_threshold_used": positive_counts[
483
+ "threshold_used"
484
+ ],
485
+ }
486
+ )
487
+ else:
488
+ # Both Ch2 and Ch3 are intensity - just add Ch3 stats to Ch1 ROIs
489
+ stats_c3 = calculate_intensity_stats(intensity_img, mask_roi)
490
+ result.update(
491
+ {
492
+ "ch3_in_ch1_mean": stats_c3["mean"],
493
+ "ch3_in_ch1_median": stats_c3["median"],
494
+ "ch3_in_ch1_std": stats_c3["std"],
495
+ "ch3_in_ch1_max": stats_c3["max"],
496
+ }
497
+ )
130
498
 
131
499
  return result
132
500
 
@@ -145,27 +513,70 @@ def process_single_roi(
145
513
  # "type": str,
146
514
  # "default": "median",
147
515
  # "description": "Method for size calculation (median or sum)",
516
+ # "channel2_is_labels": {
517
+ # "type": bool,
518
+ # "default": True,
519
+ # "description": "Treat channel 2 as labels (True) or intensity (False)",
520
+ # },
521
+ # "channel3_is_labels": {
522
+ # "type": bool,
523
+ # "default": True,
524
+ # "description": "Treat channel 3 as labels (True) or intensity (False)",
525
+ # },
526
+ # "count_positive": {
527
+ # "type": bool,
528
+ # "default": False,
529
+ # "description": "Count positive objects (when one channel is labels and another is intensity)",
530
+ # },
531
+ # "threshold_method": {
532
+ # "type": str,
533
+ # "default": "percentile",
534
+ # "description": "Threshold method: 'percentile' or 'absolute'",
535
+ # },
536
+ # "threshold_value": {
537
+ # "type": float,
538
+ # "default": 75.0,
539
+ # "description": "Threshold value for positive counting",
148
540
  # },
149
541
  # },
150
542
  # )
151
- def roi_colocalization(image, get_sizes=False, size_method="median"):
543
+ def roi_colocalization(
544
+ image,
545
+ get_sizes=False,
546
+ size_method="median",
547
+ channel2_is_labels=True,
548
+ channel3_is_labels=True,
549
+ count_positive=False,
550
+ threshold_method="percentile",
551
+ threshold_value=75.0,
552
+ ):
152
553
  """
153
- Calculate colocalization between channels for a multi-channel label image.
554
+ Calculate colocalization between channels for a multi-channel label/intensity image.
154
555
 
155
556
  This function takes a multi-channel image where each channel contains
156
- labeled objects (segmentation masks). It analyzes how objects in one channel
157
- overlap with objects in the other channels, and returns detailed statistics
158
- about their colocalization relationships.
557
+ labeled objects (segmentation masks) or intensity values. It analyzes how
558
+ objects in one channel overlap with objects in the other channels, and
559
+ returns detailed statistics about their colocalization relationships.
159
560
 
160
561
  Parameters:
161
562
  -----------
162
563
  image : numpy.ndarray
163
564
  Input image array, should have shape corresponding to a multichannel
164
- label image (e.g., [n_channels, height, width]).
565
+ image (e.g., [n_channels, height, width]).
165
566
  get_sizes : bool, optional
166
- Whether to calculate size statistics for overlapping regions.
567
+ Whether to calculate size statistics for overlapping regions (only for label channels).
167
568
  size_method : str, optional
168
569
  Method for calculating size statistics ('median' or 'sum').
570
+ channel2_is_labels : bool, optional
571
+ If True, treat channel 2 as labeled objects. If False, treat as intensity image.
572
+ channel3_is_labels : bool, optional
573
+ If True, treat channel 3 as labeled objects. If False, treat as intensity image.
574
+ count_positive : bool, optional
575
+ Count Channel 2 objects positive for Channel 3 signal (only when channel3_is_labels=False).
576
+ threshold_method : str, optional
577
+ Method for positive threshold: 'percentile' or 'absolute'.
578
+ threshold_value : float, optional
579
+ Threshold value (0-100 for percentile, or absolute intensity value).
169
580
 
170
581
  Returns:
171
582
  --------
@@ -191,6 +602,16 @@ def roi_colocalization(image, get_sizes=False, size_method="median"):
191
602
  image_c1, image_c2 = channels[:2]
192
603
  image_c3 = channels[2] if n_channels > 2 else None
193
604
 
605
+ # Handle intensity images for channel 2 and 3
606
+ image_c2_intensity = None
607
+ image_c3_intensity = None
608
+
609
+ if not channel2_is_labels:
610
+ image_c2_intensity = image_c2
611
+
612
+ if image_c3 is not None and not channel3_is_labels:
613
+ image_c3_intensity = image_c3
614
+
194
615
  # Get unique label IDs in image_c1
195
616
  label_ids = get_nonzero_labels(image_c1)
196
617
 
@@ -206,7 +627,19 @@ def roi_colocalization(image, get_sizes=False, size_method="median"):
206
627
 
207
628
  for label_id in label_ids:
208
629
  result = process_single_roi(
209
- label_id, image_c1, image_c2, image_c3, get_sizes, roi_sizes
630
+ label_id,
631
+ image_c1,
632
+ image_c2,
633
+ image_c3,
634
+ get_sizes,
635
+ roi_sizes,
636
+ channel2_is_labels,
637
+ channel3_is_labels,
638
+ image_c2_intensity,
639
+ image_c3_intensity,
640
+ count_positive,
641
+ threshold_method,
642
+ threshold_value,
210
643
  )
211
644
  results.append(result)
212
645
 
@@ -224,17 +657,41 @@ def roi_colocalization(image, get_sizes=False, size_method="median"):
224
657
  # Fill first channel with original labels
225
658
  output[0] = image_c1
226
659
 
227
- # Fill second channel with ch1 labels where ch2 overlaps
228
- for label_id in label_ids:
229
- mask = (image_c1 == label_id) & (image_c2 != 0)
230
- if np.any(mask):
231
- output[1][mask] = label_id
660
+ # Fill second channel based on whether it's labels or intensity
661
+ if channel2_is_labels:
662
+ # Fill with ch1 labels where ch2 overlaps
663
+ for label_id in label_ids:
664
+ mask = (image_c1 == label_id) & (image_c2 != 0)
665
+ if np.any(mask):
666
+ output[1][mask] = label_id
667
+ else:
668
+ # For intensity-based channel 2, show the intensity values within ch1 ROIs
669
+ for label_id in label_ids:
670
+ mask = image_c1 == label_id
671
+ if np.any(mask):
672
+ output[1][mask] = image_c2[mask]
232
673
 
233
674
  # Fill third channel with ch1 labels where ch3 overlaps (if applicable)
234
675
  if image_c3 is not None and output_channels > 2:
235
- for label_id in label_ids:
236
- mask = (image_c1 == label_id) & (image_c3 != 0)
237
- if np.any(mask):
238
- output[2][mask] = label_id
676
+ if channel3_is_labels:
677
+ # For label-based channel 3, show overlap
678
+ if channel2_is_labels:
679
+ # Ch2 is labels - show ch3 overlap with ch2 in ch1
680
+ for label_id in label_ids:
681
+ mask = (image_c1 == label_id) & (image_c3 != 0)
682
+ if np.any(mask):
683
+ output[2][mask] = label_id
684
+ else:
685
+ # Ch2 is intensity - just show ch3 overlap with ch1
686
+ for label_id in label_ids:
687
+ mask = (image_c1 == label_id) & (image_c3 != 0)
688
+ if np.any(mask):
689
+ output[2][mask] = label_id
690
+ else:
691
+ # For intensity-based channel 3, show the intensity values
692
+ for label_id in label_ids:
693
+ mask = image_c1 == label_id
694
+ if np.any(mask):
695
+ output[2][mask] = image_c3[mask]
239
696
 
240
697
  return output