@@ -57,30 +57,18 @@ def convert_semantic_to_instance_labels(image, connectivity=None):
5757 # Get unique non-zero values
5858 unique_labels = np .unique (image [image != 0 ])
5959
60- # If there's only one unique non-zero value, it's likely semantic
61- # But even with multiple values, we should check if they're truly instance labels
62- # For safety, we'll convert all non-zero regions to instance labels
63-
64- output = np .zeros_like (image )
65- current_label = 1
66-
67- for semantic_label in unique_labels :
68- # Get mask for this semantic label
69- mask = image == semantic_label
70-
71- # Find connected components
72- labeled_components = measure .label (mask , connectivity = connectivity )
73-
74- # Relabel to avoid conflicts
75- unique_components = np .unique (
76- labeled_components [labeled_components != 0 ]
77- )
78- for component_id in unique_components :
79- component_mask = labeled_components == component_id
80- output [component_mask ] = current_label
81- current_label += 1
82-
83- return output
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 )
8472
8573
8674def longest_common_substring (s1 , s2 ):
@@ -184,6 +172,7 @@ def __init__(
184172 save_images = True ,
185173 convert_to_instances_c2 = False ,
186174 convert_to_instances_c3 = False ,
175+ count_c2_positive_for_c3 = False ,
187176 ):
188177 super ().__init__ ()
189178 self .file_pairs = file_pairs
@@ -199,6 +188,7 @@ def __init__(
199188 self .save_images = save_images
200189 self .convert_to_instances_c2 = convert_to_instances_c2
201190 self .convert_to_instances_c3 = convert_to_instances_c3
191+ self .count_c2_positive_for_c3 = count_c2_positive_for_c3
202192 self .stop_requested = False
203193 self .thread_count = max (1 , (os .cpu_count () or 4 ) - 1 ) # Default value
204194
@@ -267,6 +257,16 @@ def run(self):
267257 f"{ self .channel_names [2 ]} _not_in_{ self .channel_names [1 ]} _but_in_{ self .channel_names [0 ]} _size" ,
268258 ]
269259 )
260+
261+ # Add positive counting columns if requested
262+ if self .count_c2_positive_for_c3 :
263+ header .extend (
264+ [
265+ f"{ self .channel_names [1 ]} _in_{ self .channel_names [0 ]} _positive_for_{ self .channel_names [2 ]} _count" ,
266+ f"{ self .channel_names [1 ]} _in_{ self .channel_names [0 ]} _negative_for_{ self .channel_names [2 ]} _count" ,
267+ f"{ self .channel_names [1 ]} _in_{ self .channel_names [0 ]} _percent_positive_for_{ self .channel_names [2 ]} " ,
268+ ]
269+ )
270270 elif (
271271 self .channel2_is_labels and not self .channel3_is_labels
272272 ):
@@ -500,15 +500,65 @@ def process_colocalization(
500500 idx += 2
501501
502502 if image_c3 is not None :
503- result_dict ["ch3_in_ch2_in_ch1_count" ] = row [idx ]
504- result_dict ["ch3_not_in_ch2_but_in_ch1_count" ] = row [idx + 1 ]
505- idx += 2
506-
507- if self .get_sizes :
508- result_dict ["ch3_in_ch2_in_ch1_size" ] = row [idx ]
509- result_dict ["ch3_not_in_ch2_but_in_ch1_size" ] = row [
503+ # Map CSV row columns to result_dict depending on channel modes
504+ if self .channel2_is_labels and self .channel3_is_labels :
505+ # Both ch2 and ch3 are labels: two counts (in c2 & not in c2)
506+ result_dict ["ch3_in_ch2_in_ch1_count" ] = row [idx ]
507+ result_dict ["ch3_not_in_ch2_but_in_ch1_count" ] = row [
510508 idx + 1
511509 ]
510+ idx += 2
511+
512+ if self .get_sizes :
513+ result_dict ["ch3_in_ch2_in_ch1_size" ] = row [idx ]
514+ result_dict ["ch3_not_in_ch2_but_in_ch1_size" ] = row [
515+ idx + 1
516+ ]
517+ elif self .channel2_is_labels and not self .channel3_is_labels :
518+ # ch2 labels, ch3 intensity: many intensity stats were appended
519+ # Map the first group of intensity stats to ch3_in_ch2_in_ch1_* keys
520+ result_dict ["ch3_in_ch2_in_ch1_mean" ] = row [idx ]
521+ result_dict ["ch3_in_ch2_in_ch1_median" ] = row [idx + 1 ]
522+ result_dict ["ch3_in_ch2_in_ch1_std" ] = row [idx + 2 ]
523+ result_dict ["ch3_in_ch2_in_ch1_max" ] = row [idx + 3 ]
524+ result_dict ["ch3_not_in_ch2_but_in_ch1_mean" ] = row [
525+ idx + 4
526+ ]
527+ result_dict ["ch3_not_in_ch2_but_in_ch1_median" ] = row [
528+ idx + 5
529+ ]
530+ result_dict ["ch3_not_in_ch2_but_in_ch1_std" ] = row [idx + 6 ]
531+ result_dict ["ch3_not_in_ch2_but_in_ch1_max" ] = row [idx + 7 ]
532+ idx += 8
533+
534+ # If positive counting (intensity mode) appended extra columns
535+ if self .count_positive :
536+ result_dict ["ch2_in_ch1_positive_for_ch3_count" ] = row [
537+ idx
538+ ]
539+ result_dict ["ch2_in_ch1_negative_for_ch3_count" ] = row [
540+ idx + 1
541+ ]
542+ result_dict ["ch2_in_ch1_percent_positive_for_ch3" ] = (
543+ row [idx + 2 ]
544+ )
545+ result_dict ["ch3_threshold_used" ] = row [idx + 3 ]
546+ idx += 4
547+ elif not self .channel2_is_labels and self .channel3_is_labels :
548+ # ch2 intensity, ch3 labels: single count (ch3 in ch1)
549+ result_dict ["ch3_in_ch1_count" ] = row [idx ]
550+ idx += 1
551+
552+ if self .get_sizes :
553+ result_dict ["ch3_in_ch1_size" ] = row [idx ]
554+ idx += 1
555+ else :
556+ # Both channels are intensity: map intensity stats
557+ result_dict ["ch3_in_ch1_mean" ] = row [idx ]
558+ result_dict ["ch3_in_ch1_median" ] = row [idx + 1 ]
559+ result_dict ["ch3_in_ch1_std" ] = row [idx + 2 ]
560+ result_dict ["ch3_in_ch1_max" ] = row [idx + 3 ]
561+ idx += 4
512562
513563 results .append (result_dict )
514564
@@ -613,6 +663,19 @@ def process_single_roi(
613663 row .extend (
614664 [c3_in_c2_in_c1_size , c3_not_in_c2_but_in_c1_size ]
615665 )
666+
667+ # Count C2 objects positive for C3 if requested
668+ if self .count_c2_positive_for_c3 :
669+ positive_counts = self .count_c2_positive_for_c3_labels (
670+ image_c2 , image_c3 , mask_roi
671+ )
672+ row .extend (
673+ [
674+ positive_counts ["c2_positive_for_c3_count" ],
675+ positive_counts ["c2_negative_for_c3_count" ],
676+ positive_counts ["c2_percent_positive_for_c3" ],
677+ ]
678+ )
616679 elif self .channel2_is_labels and not self .channel3_is_labels :
617680 # Ch2 is labels, Ch3 is intensity
618681 mask_c2 = image_c2 != 0
@@ -935,6 +998,57 @@ def count_positive_objects(
935998 "threshold_used" : threshold ,
936999 }
9371000
1001+ def count_c2_positive_for_c3_labels (self , image_c2 , image_c3 , mask_roi ):
1002+ """
1003+ Count Channel 2 objects that contain at least one Channel 3 object (label-based).
1004+
1005+ Args:
1006+ image_c2: Label image of Channel 2 (e.g., nuclei)
1007+ image_c3: Label image of Channel 3 (e.g., Ki67 spots)
1008+ mask_roi: Boolean mask for the ROI from Channel 1
1009+
1010+ Returns:
1011+ dict: Dictionary with positive/negative counts and percentage
1012+ """
1013+ # Get all unique Channel 2 objects in the ROI
1014+ c2_in_roi = image_c2 * mask_roi
1015+ c2_labels = np .unique (c2_in_roi )
1016+ c2_labels = c2_labels [c2_labels != 0 ] # Remove background
1017+
1018+ if len (c2_labels ) == 0 :
1019+ return {
1020+ "total_c2_objects" : 0 ,
1021+ "c2_positive_for_c3_count" : 0 ,
1022+ "c2_negative_for_c3_count" : 0 ,
1023+ "c2_percent_positive_for_c3" : 0.0 ,
1024+ }
1025+
1026+ # Count how many C2 objects contain at least one C3 object
1027+ positive_count = 0
1028+ for c2_label in c2_labels :
1029+ # Get mask for this specific Channel 2 object
1030+ mask_c2_obj = (image_c2 == c2_label ) & mask_roi
1031+
1032+ # Check if any C3 objects overlap with this C2 object
1033+ c3_in_c2 = image_c3 [mask_c2_obj ]
1034+ c3_labels_in_c2 = np .unique (c3_in_c2 [c3_in_c2 != 0 ])
1035+
1036+ if len (c3_labels_in_c2 ) > 0 :
1037+ positive_count += 1
1038+
1039+ total_count = int (len (c2_labels ))
1040+ negative_count = total_count - positive_count
1041+ percent_positive = (
1042+ (positive_count / total_count * 100 ) if total_count > 0 else 0.0
1043+ )
1044+
1045+ return {
1046+ "total_c2_objects" : total_count ,
1047+ "c2_positive_for_c3_count" : positive_count ,
1048+ "c2_negative_for_c3_count" : negative_count ,
1049+ "c2_percent_positive_for_c3" : percent_positive ,
1050+ }
1051+
9381052 def stop (self ):
9391053 """Request worker to stop processing"""
9401054 self .stop_requested = True
@@ -1248,6 +1362,24 @@ def __init__(
12481362 ) # Disabled until ch3 folder is set
12491363 options_layout .addRow (self .convert_c3_checkbox )
12501364
1365+ # Count C2 positive for C3 (both labels)
1366+ self .count_c2_positive_checkbox = QCheckBox (
1367+ "Count C2 Objects Positive for C3 (both labels)"
1368+ )
1369+ self .count_c2_positive_checkbox .setChecked (False )
1370+ self .count_c2_positive_checkbox .setEnabled (False )
1371+ self .count_c2_positive_checkbox .setToolTip (
1372+ "When both C2 and C3 are labels, count how many C2 objects contain\n "
1373+ "at least one C3 object (binary: positive/negative per C2 object)."
1374+ )
1375+ self .ch3_is_labels_checkbox .toggled .connect (
1376+ self .update_c2_positive_state
1377+ )
1378+ self .ch2_is_labels_checkbox .toggled .connect (
1379+ self .update_c2_positive_state
1380+ )
1381+ options_layout .addRow (self .count_c2_positive_checkbox )
1382+
12511383 # Positive counting option (only for intensity mode)
12521384 self .count_positive_checkbox = QCheckBox (
12531385 "Count Positive Objects (Ch3 signal in Ch2)"
@@ -1413,6 +1545,7 @@ def update_ch3_controls(self):
14131545
14141546 # Update positive counting state based on ch3 availability
14151547 self .update_positive_counting_state ()
1548+ self .update_c2_positive_state ()
14161549
14171550 def browse_output (self ):
14181551 """Browse for output folder"""
@@ -1458,6 +1591,23 @@ def on_count_positive_changed(self, checked):
14581591 self .threshold_absolute .setEnabled (checked )
14591592 self .threshold_value_input .setEnabled (checked )
14601593
1594+ def update_c2_positive_state (self ):
1595+ """Update the state of C2 positive for C3 counting based on ch2 and ch3 modes"""
1596+ # Get folder and mode states
1597+ ch3_folder = self .ch3_folder .text ().strip ()
1598+ has_ch3 = bool (ch3_folder and os .path .isdir (ch3_folder ))
1599+
1600+ # C2 positive counting only works when BOTH ch2 and ch3 are labels
1601+ ch2_is_labels = self .ch2_is_labels_checkbox .isChecked ()
1602+ ch3_is_labels = self .ch3_is_labels_checkbox .isChecked ()
1603+
1604+ # Enable only when ch3 exists AND both are labels
1605+ can_count_c2_positive = has_ch3 and ch2_is_labels and ch3_is_labels
1606+ self .count_c2_positive_checkbox .setEnabled (can_count_c2_positive )
1607+
1608+ if not can_count_c2_positive :
1609+ self .count_c2_positive_checkbox .setChecked (False )
1610+
14611611 def find_matching_files (self ):
14621612 """Find matching files across channels using the updated grouping function."""
14631613 # Get channel folders and patterns
@@ -1641,6 +1791,7 @@ def start_analysis(self):
16411791 # Get conversion settings
16421792 convert_to_instances_c2 = self .convert_c2_checkbox .isChecked ()
16431793 convert_to_instances_c3 = self .convert_c3_checkbox .isChecked ()
1794+ count_c2_positive_for_c3 = self .count_c2_positive_checkbox .isChecked ()
16441795
16451796 # Create worker thread
16461797 self .worker = ColocalizationWorker (
@@ -1657,6 +1808,7 @@ def start_analysis(self):
16571808 save_images ,
16581809 convert_to_instances_c2 ,
16591810 convert_to_instances_c3 ,
1811+ count_c2_positive_for_c3 ,
16601812 )
16611813
16621814 # Set thread count
0 commit comments