senoquant 1.0.0b2__py3-none-any.whl → 1.0.0b4__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 (57) hide show
  1. senoquant/__init__.py +6 -2
  2. senoquant/_reader.py +1 -1
  3. senoquant/_widget.py +9 -1
  4. senoquant/reader/core.py +201 -18
  5. senoquant/tabs/__init__.py +2 -0
  6. senoquant/tabs/batch/backend.py +76 -27
  7. senoquant/tabs/batch/frontend.py +127 -25
  8. senoquant/tabs/quantification/features/marker/dialog.py +26 -6
  9. senoquant/tabs/quantification/features/marker/export.py +97 -24
  10. senoquant/tabs/quantification/features/marker/rows.py +2 -2
  11. senoquant/tabs/quantification/features/spots/dialog.py +41 -11
  12. senoquant/tabs/quantification/features/spots/export.py +163 -10
  13. senoquant/tabs/quantification/frontend.py +2 -2
  14. senoquant/tabs/segmentation/frontend.py +46 -9
  15. senoquant/tabs/segmentation/models/cpsam/model.py +1 -1
  16. senoquant/tabs/segmentation/models/default_2d/model.py +22 -77
  17. senoquant/tabs/segmentation/models/default_3d/model.py +8 -74
  18. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +0 -0
  19. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +13 -13
  20. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/stardist_libs.py +171 -0
  21. senoquant/tabs/spots/frontend.py +96 -5
  22. senoquant/tabs/spots/models/rmp/details.json +3 -9
  23. senoquant/tabs/spots/models/rmp/model.py +341 -266
  24. senoquant/tabs/spots/models/ufish/details.json +32 -0
  25. senoquant/tabs/spots/models/ufish/model.py +327 -0
  26. senoquant/tabs/spots/ufish_utils/__init__.py +13 -0
  27. senoquant/tabs/spots/ufish_utils/core.py +387 -0
  28. senoquant/tabs/visualization/__init__.py +1 -0
  29. senoquant/tabs/visualization/backend.py +306 -0
  30. senoquant/tabs/visualization/frontend.py +1113 -0
  31. senoquant/tabs/visualization/plots/__init__.py +80 -0
  32. senoquant/tabs/visualization/plots/base.py +152 -0
  33. senoquant/tabs/visualization/plots/double_expression.py +187 -0
  34. senoquant/tabs/visualization/plots/spatialplot.py +156 -0
  35. senoquant/tabs/visualization/plots/umap.py +140 -0
  36. senoquant/utils.py +1 -1
  37. senoquant-1.0.0b4.dist-info/METADATA +162 -0
  38. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/RECORD +53 -30
  39. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/top_level.txt +1 -0
  40. ufish/__init__.py +1 -0
  41. ufish/api.py +778 -0
  42. ufish/model/__init__.py +0 -0
  43. ufish/model/loss.py +62 -0
  44. ufish/model/network/__init__.py +0 -0
  45. ufish/model/network/spot_learn.py +50 -0
  46. ufish/model/network/ufish_net.py +204 -0
  47. ufish/model/train.py +175 -0
  48. ufish/utils/__init__.py +0 -0
  49. ufish/utils/img.py +418 -0
  50. ufish/utils/log.py +8 -0
  51. ufish/utils/spot_calling.py +115 -0
  52. senoquant/tabs/spots/models/udwt/details.json +0 -103
  53. senoquant/tabs/spots/models/udwt/model.py +0 -482
  54. senoquant-1.0.0b2.dist-info/METADATA +0 -193
  55. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/WHEEL +0 -0
  56. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/entry_points.txt +0 -0
  57. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/licenses/LICENSE +0 -0
@@ -94,7 +94,7 @@ class BatchTab(QWidget):
94
94
  backend : BatchBackend or None, optional
95
95
  Backend instance used to execute batch runs.
96
96
  napari_viewer : object or None, optional
97
- Napari viewer instance for populating layer choices.
97
+ napari viewer instance for populating layer choices.
98
98
  """
99
99
  super().__init__()
100
100
  self._viewer = napari_viewer
@@ -123,6 +123,11 @@ class BatchTab(QWidget):
123
123
  self._spot_min_size_spin: QSpinBox | None = None
124
124
  self._spot_max_size_spin: QSpinBox | None = None
125
125
  self._add_spot_button: QPushButton | None = None
126
+ self._cyto_nuclear_optional = False
127
+ self._cyto_nuclear_from_labels = False
128
+ self._cyto_requires_cyto_channel = True
129
+ self._cyto_supports_nuclear_input = False
130
+ self._refreshing_channel_choices = False
126
131
  self._config_viewer = BatchViewer()
127
132
 
128
133
  layout = QVBoxLayout()
@@ -281,6 +286,9 @@ class BatchTab(QWidget):
281
286
  self._nuclear_model_combo.currentTextChanged.connect(
282
287
  lambda _text: self._update_nuclear_settings()
283
288
  )
289
+ self._nuclear_channel_combo.currentTextChanged.connect(
290
+ self._on_nuclear_channel_changed
291
+ )
284
292
 
285
293
  cyto_layout = QFormLayout()
286
294
  cyto_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
@@ -293,7 +301,6 @@ class BatchTab(QWidget):
293
301
  self._cyto_channel_combo = QComboBox()
294
302
  self._cyto_nuclear_combo = QComboBox()
295
303
  self._cyto_nuclear_label = QLabel("Nuclear channel")
296
- self._cyto_nuclear_optional = False
297
304
  cyto_layout.addRow(self._cyto_enabled)
298
305
  cyto_layout.addRow("Cytoplasmic model", self._cyto_model_combo)
299
306
  cyto_layout.addRow("Cytoplasmic channel", self._cyto_channel_combo)
@@ -593,6 +600,9 @@ class BatchTab(QWidget):
593
600
 
594
601
  def _refresh_channel_choices(self) -> None:
595
602
  """Refresh combo boxes that depend on channel mapping."""
603
+ if self._refreshing_channel_choices:
604
+ return
605
+ self._refreshing_channel_choices = True
596
606
  names = [config.name for config in self._channel_configs]
597
607
 
598
608
  def populate_combo(
@@ -600,13 +610,16 @@ class BatchTab(QWidget):
600
610
  *,
601
611
  include_none: bool = False,
602
612
  none_label: str = "(none)",
613
+ explicit_items: list[str] | None = None,
603
614
  ) -> None:
604
615
  current = combo.currentText()
605
616
  combo.clear()
606
617
  items: list[str] = []
607
618
  if include_none:
608
619
  items.append(none_label)
609
- if names:
620
+ if explicit_items is not None:
621
+ items.extend(explicit_items)
622
+ elif names:
610
623
  items.extend(names)
611
624
  elif not include_none:
612
625
  items.append("0")
@@ -618,15 +631,40 @@ class BatchTab(QWidget):
618
631
  if index != -1:
619
632
  combo.setCurrentIndex(index)
620
633
 
621
- if getattr(self, "_nuclear_channel_combo", None) is not None:
622
- populate_combo(self._nuclear_channel_combo)
623
- if getattr(self, "_cyto_channel_combo", None) is not None:
624
- populate_combo(self._cyto_channel_combo)
625
- if getattr(self, "_cyto_nuclear_combo", None) is not None:
626
- populate_combo(
627
- self._cyto_nuclear_combo,
628
- include_none=self._cyto_nuclear_optional,
629
- )
634
+ try:
635
+ if getattr(self, "_nuclear_channel_combo", None) is not None:
636
+ populate_combo(self._nuclear_channel_combo)
637
+ if getattr(self, "_cyto_channel_combo", None) is not None:
638
+ populate_combo(self._cyto_channel_combo)
639
+ if getattr(self, "_cyto_nuclear_combo", None) is not None:
640
+ if self._cyto_nuclear_from_labels:
641
+ label_options = self._cyto_nuclear_label_options()
642
+ populate_combo(
643
+ self._cyto_nuclear_combo,
644
+ explicit_items=label_options,
645
+ none_label="(no nuclear labels)",
646
+ )
647
+ else:
648
+ populate_combo(
649
+ self._cyto_nuclear_combo,
650
+ include_none=self._cyto_nuclear_optional,
651
+ )
652
+ finally:
653
+ self._refreshing_channel_choices = False
654
+
655
+ def _cyto_nuclear_label_options(self) -> list[str]:
656
+ """Return available nuclear label names for nuclear-only cyto models."""
657
+ if getattr(self, "_nuclear_enabled", None) is None:
658
+ return []
659
+ if not self._nuclear_enabled.isChecked():
660
+ return []
661
+ nuclear_model = self._nuclear_model_combo.currentText().strip()
662
+ nuclear_channel = self._nuclear_channel_combo.currentText().strip()
663
+ if not nuclear_model or nuclear_model.startswith("("):
664
+ return []
665
+ if not nuclear_channel or nuclear_channel.startswith("("):
666
+ return []
667
+ return [f"{nuclear_channel}_{nuclear_model}_nuc_labels"]
630
668
 
631
669
  def _refresh_config_viewer(self) -> None:
632
670
  """Refresh the quantification preview viewer shim."""
@@ -639,18 +677,22 @@ class BatchTab(QWidget):
639
677
  nuclear_channel = self._nuclear_channel_combo.currentText()
640
678
  if nuclear_model and nuclear_channel and not nuclear_model.startswith("("):
641
679
  label_name = f"{nuclear_channel}_{nuclear_model}_nuc_labels"
642
- layers.append(Labels(None, label_name))
680
+ layers.append(Labels(None, label_name, metadata={"task": "nuclear"}))
643
681
  if getattr(self, "_cyto_enabled", None) is not None and self._cyto_enabled.isChecked():
644
682
  cyto_model = self._cyto_model_combo.currentText()
645
683
  cyto_channel = self._cyto_channel_combo.currentText()
646
684
  if cyto_model and cyto_channel and not cyto_model.startswith("("):
647
685
  label_name = f"{cyto_channel}_{cyto_model}_cyto_labels"
648
- layers.append(Labels(None, label_name))
686
+ layers.append(
687
+ Labels(None, label_name, metadata={"task": "cytoplasmic"})
688
+ )
649
689
  if getattr(self, "_spots_enabled", None) is not None and self._spots_enabled.isChecked():
650
690
  spot_detector = self._spot_detector_combo.currentText()
651
691
  if spot_detector and not spot_detector.startswith("("):
652
692
  for label_name in _spot_label_names(self._spot_channel_rows, spot_detector):
653
- layers.append(Labels(None, label_name))
693
+ layers.append(
694
+ Labels(None, label_name, metadata={"task": "spots"})
695
+ )
654
696
  self._config_viewer.set_layers(layers)
655
697
 
656
698
  def _update_nuclear_settings(self) -> None:
@@ -659,6 +701,8 @@ class BatchTab(QWidget):
659
701
  self._nuclear_settings_widgets.clear()
660
702
  self._nuclear_settings_meta.clear()
661
703
  if not model_name or model_name.startswith("("):
704
+ self._refresh_channel_choices()
705
+ self._refresh_config_viewer()
662
706
  return
663
707
  model = self._segmentation_backend.get_model(model_name)
664
708
  settings = model.list_settings()
@@ -667,17 +711,23 @@ class BatchTab(QWidget):
667
711
  item.get("key", item.get("label", "")): item for item in settings
668
712
  }
669
713
  self._nuclear_settings_values = _defaults_from_settings(settings)
714
+ self._refresh_channel_choices()
715
+ self._refresh_config_viewer()
670
716
 
671
717
  def _update_cyto_settings(self) -> None:
672
718
  """Refresh cytoplasmic model settings from the selected model."""
673
719
  model_name = self._cyto_model_combo.currentText()
674
720
  self._cyto_settings_widgets.clear()
675
721
  self._cyto_settings_meta.clear()
722
+ self._cyto_nuclear_optional = False
723
+ self._cyto_nuclear_from_labels = False
724
+ self._cyto_requires_cyto_channel = True
725
+ self._cyto_supports_nuclear_input = False
676
726
  if not model_name or model_name.startswith("("):
677
- self._cyto_nuclear_combo.setEnabled(False)
678
727
  if hasattr(self, "_cyto_nuclear_label"):
679
728
  self._cyto_nuclear_label.setText("Nuclear channel")
680
- self._cyto_nuclear_optional = False
729
+ self._refresh_channel_choices()
730
+ self._update_processing_state()
681
731
  return
682
732
  model = self._segmentation_backend.get_model(model_name)
683
733
  settings = model.list_settings()
@@ -687,20 +737,30 @@ class BatchTab(QWidget):
687
737
  }
688
738
  self._cyto_settings_values = _defaults_from_settings(settings)
689
739
  modes = model.cytoplasmic_input_modes()
690
- supports_nuclear = "nuclear+cytoplasmic" in modes
691
- if supports_nuclear:
740
+ if modes == ["nuclear"]:
741
+ if hasattr(self, "_cyto_nuclear_label"):
742
+ self._cyto_nuclear_label.setText("Nuclear labels layer")
743
+ self._cyto_nuclear_from_labels = True
744
+ self._cyto_requires_cyto_channel = False
745
+ self._cyto_supports_nuclear_input = True
746
+ elif "nuclear+cytoplasmic" in modes:
692
747
  optional = model.cytoplasmic_nuclear_optional()
693
748
  suffix = "optional" if optional else "required"
694
749
  if hasattr(self, "_cyto_nuclear_label"):
695
750
  self._cyto_nuclear_label.setText(f"Nuclear channel ({suffix})")
696
- self._cyto_nuclear_combo.setEnabled(True)
697
751
  self._cyto_nuclear_optional = optional
752
+ self._cyto_supports_nuclear_input = True
698
753
  else:
699
754
  if hasattr(self, "_cyto_nuclear_label"):
700
755
  self._cyto_nuclear_label.setText("Nuclear channel")
701
- self._cyto_nuclear_combo.setEnabled(False)
702
- self._cyto_nuclear_optional = False
703
756
  self._refresh_channel_choices()
757
+ self._update_processing_state()
758
+
759
+ def _on_nuclear_channel_changed(self, _text: str) -> None:
760
+ """Refresh dependent combos when the nuclear channel changes."""
761
+ if self._cyto_nuclear_from_labels:
762
+ self._refresh_channel_choices()
763
+ self._refresh_config_viewer()
704
764
 
705
765
  def _update_spot_settings(self) -> None:
706
766
  """Refresh spot detector settings from the selected detector."""
@@ -828,12 +888,18 @@ class BatchTab(QWidget):
828
888
  nuclear_enabled = self._nuclear_enabled.isChecked()
829
889
  cyto_enabled = self._cyto_enabled.isChecked()
830
890
  spot_enabled = self._spots_enabled.isChecked()
891
+ if self._cyto_nuclear_from_labels:
892
+ self._refresh_channel_choices()
831
893
  self._nuclear_model_combo.setEnabled(nuclear_enabled)
832
894
  self._nuclear_channel_combo.setEnabled(nuclear_enabled)
833
895
  self._nuclear_settings_button.setEnabled(nuclear_enabled)
834
896
  self._cyto_model_combo.setEnabled(cyto_enabled)
835
- self._cyto_channel_combo.setEnabled(cyto_enabled)
836
- self._cyto_nuclear_combo.setEnabled(cyto_enabled)
897
+ self._cyto_channel_combo.setEnabled(
898
+ cyto_enabled and self._cyto_requires_cyto_channel
899
+ )
900
+ self._cyto_nuclear_combo.setEnabled(
901
+ cyto_enabled and self._cyto_supports_nuclear_input
902
+ )
837
903
  self._cyto_settings_button.setEnabled(cyto_enabled)
838
904
  self._spot_detector_combo.setEnabled(spot_enabled)
839
905
  self._spot_settings_button.setEnabled(spot_enabled)
@@ -882,10 +948,33 @@ class BatchTab(QWidget):
882
948
  spot_detector = None
883
949
 
884
950
  cyto_model = None
951
+ cyto_model_obj = None
885
952
  if self._cyto_enabled.isChecked() and self._cyto_model_combo.isEnabled():
886
953
  cyto_model = self._cyto_model_combo.currentText().strip()
887
954
  if cyto_model.startswith("("):
888
955
  cyto_model = None
956
+ elif cyto_model:
957
+ cyto_model_obj = self._segmentation_backend.get_model(cyto_model)
958
+
959
+ if cyto_model_obj is not None and self._cyto_requires_nuclear(cyto_model_obj):
960
+ cyto_nuclear_choice = self._cyto_nuclear_combo.currentText().strip()
961
+ if (
962
+ not cyto_nuclear_choice
963
+ or cyto_nuclear_choice == "(none)"
964
+ or cyto_nuclear_choice == "(no nuclear labels)"
965
+ ):
966
+ self._notify("Select the required cytoplasmic nuclear input.")
967
+ return
968
+
969
+ if (
970
+ cyto_model_obj.cytoplasmic_input_modes() == ["nuclear"]
971
+ and not nuclear_model
972
+ ):
973
+ self._notify(
974
+ "Selected cytoplasmic model requires nuclear labels. "
975
+ "Enable nuclear segmentation first."
976
+ )
977
+ return
889
978
 
890
979
  quant_features = (
891
980
  list(self._quant_tab._feature_configs)
@@ -1004,7 +1093,8 @@ class BatchTab(QWidget):
1004
1093
  channel=self._cyto_channel_combo.currentText(),
1005
1094
  nuclear_channel=(
1006
1095
  ""
1007
- if self._cyto_nuclear_combo.currentText().strip() == "(none)"
1096
+ if self._cyto_nuclear_combo.currentText().strip()
1097
+ in {"(none)", "(no nuclear labels)"}
1008
1098
  else self._cyto_nuclear_combo.currentText()
1009
1099
  ),
1010
1100
  settings=cyto_settings,
@@ -1053,6 +1143,8 @@ class BatchTab(QWidget):
1053
1143
  if not job.cytoplasmic.nuclear_channel:
1054
1144
  if self._cyto_nuclear_combo.findText("(none)") != -1:
1055
1145
  self._set_combo_value(self._cyto_nuclear_combo, "(none)")
1146
+ elif self._cyto_nuclear_combo.findText("(no nuclear labels)") != -1:
1147
+ self._set_combo_value(self._cyto_nuclear_combo, "(no nuclear labels)")
1056
1148
  else:
1057
1149
  self._set_combo_value(
1058
1150
  self._cyto_nuclear_combo, job.cytoplasmic.nuclear_channel
@@ -1078,6 +1170,16 @@ class BatchTab(QWidget):
1078
1170
  self._refresh_spot_channel_choices()
1079
1171
  self._refresh_config_viewer()
1080
1172
 
1173
+ @staticmethod
1174
+ def _cyto_requires_nuclear(model) -> bool:
1175
+ """Return whether the selected cytoplasmic model requires nuclear input."""
1176
+ modes = model.cytoplasmic_input_modes()
1177
+ if modes == ["nuclear"]:
1178
+ return True
1179
+ if "nuclear+cytoplasmic" not in modes:
1180
+ return False
1181
+ return not model.cytoplasmic_nuclear_optional()
1182
+
1081
1183
  def _save_profile(self) -> None:
1082
1184
  """Save the current configuration to a JSON profile."""
1083
1185
  path, _ = QFileDialog.getSaveFileName(
@@ -193,28 +193,48 @@ class MarkerChannelsDialog(QDialog):
193
193
  return
194
194
  for layer in viewer.layers:
195
195
  if layer.__class__.__name__ == "Labels":
196
- layer_name = layer.name
197
196
  # Only show cellular labels (nuclear/cytoplasmic), exclude spot labels
198
- if self._is_cellular_label(layer_name):
199
- combo.addItem(layer_name)
197
+ if self._is_cellular_label(layer):
198
+ combo.addItem(layer.name)
200
199
  if current:
201
200
  index = combo.findText(current)
202
201
  if index != -1:
203
202
  combo.setCurrentIndex(index)
204
203
 
205
- def _is_cellular_label(self, layer_name: str) -> bool:
204
+ def _layer_task(self, layer: object) -> str | None:
205
+ """Return normalized segmentation task from layer metadata."""
206
+ metadata = getattr(layer, "metadata", None)
207
+ if not isinstance(metadata, dict):
208
+ return None
209
+ task = metadata.get("task")
210
+ if not isinstance(task, str):
211
+ return None
212
+ normalized = task.strip().lower()
213
+ return normalized or None
214
+
215
+ def _is_cellular_label(self, layer: object | str) -> bool:
206
216
  """Check if a label layer is a cellular segmentation.
207
217
 
208
218
  Parameters
209
219
  ----------
210
- layer_name : str
211
- Name of the labels layer.
220
+ layer : object or str
221
+ Labels layer object or labels layer name.
212
222
 
213
223
  Returns
214
224
  -------
215
225
  bool
216
226
  True if the layer is a cellular label (nuclear or cytoplasmic).
217
227
  """
228
+ if isinstance(layer, str):
229
+ layer_name = layer
230
+ task = None
231
+ else:
232
+ layer_name = str(getattr(layer, "name", ""))
233
+ task = self._layer_task(layer)
234
+ if task in {"nuclear", "cytoplasmic"}:
235
+ return True
236
+ if task is not None:
237
+ return False
218
238
  return layer_name.endswith("_nuc_labels") or layer_name.endswith("_cyto_labels")
219
239
 
220
240
  def _refresh_image_combo(self, combo: QComboBox) -> None:
@@ -41,7 +41,7 @@ def export_marker(
41
41
  temp_dir : Path
42
42
  Temporary directory where outputs should be written.
43
43
  viewer : object, optional
44
- Napari viewer instance used to resolve layers by name.
44
+ napari viewer instance used to resolve layers by name.
45
45
  export_format : str, optional
46
46
  File format for exports (``"csv"`` or ``"xlsx"``).
47
47
  enable_thresholds : bool, optional
@@ -76,6 +76,23 @@ def export_marker(
76
76
  if metadata_path is not None:
77
77
  outputs.append(metadata_path)
78
78
 
79
+ all_segmentations: dict[str, tuple[np.ndarray, np.ndarray]] = {}
80
+ for segmentation in data.segmentations:
81
+ seg_name = segmentation.label.strip()
82
+ if not seg_name:
83
+ continue
84
+ seg_layer = _find_layer(viewer, seg_name, "Labels")
85
+ if seg_layer is None:
86
+ continue
87
+ seg_labels = layer_data_asarray(seg_layer)
88
+ if seg_labels.size == 0:
89
+ continue
90
+ seg_label_ids, _seg_centroids = _compute_centroids(seg_labels)
91
+ if seg_label_ids.size == 0:
92
+ continue
93
+ all_segmentations[seg_name] = (seg_labels, seg_label_ids)
94
+ cross_map = _build_cross_segmentation_map(all_segmentations)
95
+
79
96
  for index, segmentation in enumerate(data.segmentations, start=0):
80
97
  label_name = segmentation.label.strip()
81
98
  if not label_name:
@@ -103,7 +120,7 @@ def export_marker(
103
120
  break
104
121
  rows = _initialize_rows(label_ids, centroids, pixel_sizes)
105
122
  _add_roi_columns(rows, labels, label_ids, viewer, data.rois, label_name)
106
- morph_columns = add_morphology_columns(
123
+ _morph_columns = add_morphology_columns(
107
124
  rows, labels, label_ids, pixel_sizes
108
125
  )
109
126
 
@@ -115,11 +132,12 @@ def export_marker(
115
132
  metadata = getattr(first_channel_layer, "metadata", {})
116
133
  file_path = metadata.get("path")
117
134
 
118
- # Determine segmentation type from label name or config
119
- seg_type = getattr(segmentation, "task", "nuclear")
120
- ref_columns = _add_reference_columns(
135
+ # Determine segmentation type from labels metadata with suffix fallback.
136
+ seg_type = _segmentation_type_from_layer(labels_layer, label_name)
137
+ _ref_columns = _add_reference_columns(
121
138
  rows, labels, label_ids, file_path, seg_type
122
139
  )
140
+ _add_cross_reference_column(rows, label_name, label_ids, cross_map)
123
141
 
124
142
  header = list(rows[0].keys()) if rows else []
125
143
 
@@ -207,7 +225,7 @@ def _find_layer(viewer, name: str, layer_type: str):
207
225
  Parameters
208
226
  ----------
209
227
  viewer : object
210
- Napari viewer instance containing layers.
228
+ napari viewer instance containing layers.
211
229
  name : str
212
230
  Layer name to locate.
213
231
  layer_type : str
@@ -224,6 +242,40 @@ def _find_layer(viewer, name: str, layer_type: str):
224
242
  return None
225
243
 
226
244
 
245
+ def _segmentation_type_from_layer(layer: object, label_name: str) -> str:
246
+ """Return segmentation type from layer metadata or legacy suffixes.
247
+
248
+ Parameters
249
+ ----------
250
+ layer : object
251
+ Labels layer object.
252
+ label_name : str
253
+ Labels layer name used for fallback suffix parsing.
254
+
255
+ Returns
256
+ -------
257
+ str
258
+ Segmentation type ("nuclear" or "cytoplasmic").
259
+
260
+ Notes
261
+ -----
262
+ Metadata value ``metadata["task"]`` is authoritative when valid.
263
+ Legacy layer-name suffixes are used as fallback for older layers.
264
+ """
265
+ metadata = getattr(layer, "metadata", None)
266
+ if isinstance(metadata, dict):
267
+ task = metadata.get("task")
268
+ if isinstance(task, str):
269
+ normalized = task.strip().lower()
270
+ if normalized in {"nuclear", "cytoplasmic"}:
271
+ return normalized
272
+ if label_name.endswith("_cyto_labels"):
273
+ return "cytoplasmic"
274
+ if label_name.endswith("_nuc_labels"):
275
+ return "nuclear"
276
+ return "nuclear"
277
+
278
+
227
279
  def _compute_centroids(labels: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
228
280
  """Compute centroid coordinates for each non-zero label.
229
281
 
@@ -299,7 +351,7 @@ def _pixel_volume(layer, ndim: int) -> float:
299
351
  Parameters
300
352
  ----------
301
353
  layer : object
302
- Napari image layer providing metadata.
354
+ napari image layer providing metadata.
303
355
  ndim : int
304
356
  Dimensionality of the image data.
305
357
 
@@ -348,7 +400,7 @@ def _pixel_sizes(layer, ndim: int) -> np.ndarray | None:
348
400
  Parameters
349
401
  ----------
350
402
  layer : object
351
- Napari image layer providing metadata.
403
+ napari image layer providing metadata.
352
404
  ndim : int
353
405
  Dimensionality of the image data.
354
406
 
@@ -429,7 +481,7 @@ def _add_roi_columns(
429
481
  label_ids : numpy.ndarray
430
482
  Label ids corresponding to the output rows.
431
483
  viewer : object or None
432
- Napari viewer used to resolve shapes layers.
484
+ napari viewer used to resolve shapes layers.
433
485
  rois : sequence of ROIConfig
434
486
  ROI configuration entries to evaluate.
435
487
  label_name : str
@@ -480,7 +532,7 @@ def _shapes_layer_mask(
480
532
  Parameters
481
533
  ----------
482
534
  layer : object
483
- Napari shapes layer instance.
535
+ napari shapes layer instance.
484
536
  shape : tuple of int
485
537
  Target mask shape matching the labels array.
486
538
 
@@ -776,25 +828,46 @@ def _build_cross_segmentation_map(
776
828
  -----
777
829
  This function identifies which labels from different segmentations
778
830
  overlap spatially, enabling cross-referencing between tables.
831
+ The resulting mapping is bidirectional: if ``A:1`` overlaps ``B:2``,
832
+ both keys receive a reference to the other.
779
833
  """
780
834
  cross_map: dict[tuple[str, int], list[tuple[str, int]]] = {}
835
+ valid_ids: dict[str, set[int]] = {}
836
+
837
+ for seg_name, (_labels, label_ids) in all_segmentations.items():
838
+ ids = {int(label_id) for label_id in np.asarray(label_ids, dtype=int)}
839
+ valid_ids[seg_name] = ids
840
+ for label_id in ids:
841
+ cross_map[(seg_name, label_id)] = []
781
842
 
782
843
  seg_names = list(all_segmentations.keys())
783
844
  for i, seg1_name in enumerate(seg_names):
784
- labels1, label_ids1 = all_segmentations[seg1_name]
785
- for label_id1 in label_ids1:
786
- cross_map[(seg1_name, int(label_id1))] = []
787
- # Check overlaps with all other segmentations
788
- for seg2_name in seg_names[i + 1 :]:
789
- labels2, _label_ids2 = all_segmentations[seg2_name]
790
- # Find which labels in seg2 overlap with label_id1
791
- mask1 = labels1 == label_id1
792
- overlapping_labels2 = np.unique(labels2[mask1])
793
- overlapping_labels2 = overlapping_labels2[overlapping_labels2 > 0]
794
- for label_id2 in overlapping_labels2:
795
- cross_map[(seg1_name, int(label_id1))].append(
796
- (seg2_name, int(label_id2)),
797
- )
845
+ labels1, _label_ids1 = all_segmentations[seg1_name]
846
+ # Check overlaps with all other segmentations.
847
+ for seg2_name in seg_names[i + 1 :]:
848
+ labels2, _label_ids2 = all_segmentations[seg2_name]
849
+ if labels1.shape != labels2.shape:
850
+ warnings.warn(
851
+ "Marker export: segmentation shape mismatch for "
852
+ f"'{seg1_name}' vs '{seg2_name}'. "
853
+ "Skipping cross-segmentation overlap mapping for this pair.",
854
+ RuntimeWarning,
855
+ )
856
+ continue
857
+
858
+ mask = (labels1 > 0) & (labels2 > 0)
859
+ if not np.any(mask):
860
+ continue
861
+
862
+ overlap_pairs = np.column_stack((labels1[mask], labels2[mask]))
863
+ unique_pairs = np.unique(overlap_pairs, axis=0)
864
+ for label_id1, label_id2 in unique_pairs:
865
+ id1 = int(label_id1)
866
+ id2 = int(label_id2)
867
+ if id1 not in valid_ids[seg1_name] or id2 not in valid_ids[seg2_name]:
868
+ continue
869
+ cross_map[(seg1_name, id1)].append((seg2_name, id2))
870
+ cross_map[(seg2_name, id2)].append((seg1_name, id1))
798
871
 
799
872
  return cross_map
800
873
 
@@ -593,7 +593,7 @@ class MarkerChannelRow(QGroupBox):
593
593
  slider : QWidget
594
594
  Range slider widget.
595
595
  layer : object
596
- Napari image layer providing intensity bounds.
596
+ napari image layer providing intensity bounds.
597
597
  min_spin : QDoubleSpinBox or None
598
598
  Spin box that displays the minimum threshold value.
599
599
  max_spin : QDoubleSpinBox or None
@@ -628,7 +628,7 @@ class MarkerChannelRow(QGroupBox):
628
628
  Parameters
629
629
  ----------
630
630
  layer : object
631
- Napari image layer providing contrast bounds and data.
631
+ napari image layer providing contrast bounds and data.
632
632
 
633
633
  Returns
634
634
  -------
@@ -216,45 +216,75 @@ class SpotsChannelsDialog(QDialog):
216
216
  return
217
217
  for layer in viewer.layers:
218
218
  if layer.__class__.__name__ == "Labels":
219
- layer_name = layer.name
220
219
  # Filter based on label type
221
- if filter_type == "cellular" and self._is_cellular_label(layer_name):
222
- combo.addItem(layer_name)
223
- elif filter_type == "spots" and self._is_spot_label(layer_name):
224
- combo.addItem(layer_name)
220
+ if filter_type == "cellular" and self._is_cellular_label(layer):
221
+ combo.addItem(layer.name)
222
+ elif filter_type == "spots" and self._is_spot_label(layer):
223
+ combo.addItem(layer.name)
225
224
  if current:
226
225
  index = combo.findText(current)
227
226
  if index != -1:
228
227
  combo.setCurrentIndex(index)
229
228
 
230
- def _is_cellular_label(self, layer_name: str) -> bool:
229
+ def _layer_task(self, layer: object) -> str | None:
230
+ """Return normalized segmentation task from layer metadata."""
231
+ metadata = getattr(layer, "metadata", None)
232
+ if not isinstance(metadata, dict):
233
+ return None
234
+ task = metadata.get("task")
235
+ if not isinstance(task, str):
236
+ return None
237
+ normalized = task.strip().lower()
238
+ return normalized or None
239
+
240
+ def _is_cellular_label(self, layer: object | str) -> bool:
231
241
  """Check if a label layer is a cellular segmentation.
232
242
 
233
243
  Parameters
234
244
  ----------
235
- layer_name : str
236
- Name of the labels layer.
245
+ layer : object or str
246
+ Labels layer object or labels layer name.
237
247
 
238
248
  Returns
239
249
  -------
240
250
  bool
241
251
  True if the layer is a cellular label (nuclear or cytoplasmic).
242
252
  """
253
+ if isinstance(layer, str):
254
+ layer_name = layer
255
+ task = None
256
+ else:
257
+ layer_name = str(getattr(layer, "name", ""))
258
+ task = self._layer_task(layer)
259
+ if task in {"nuclear", "cytoplasmic"}:
260
+ return True
261
+ if task is not None:
262
+ return False
243
263
  return layer_name.endswith("_nuc_labels") or layer_name.endswith("_cyto_labels")
244
264
 
245
- def _is_spot_label(self, layer_name: str) -> bool:
265
+ def _is_spot_label(self, layer: object | str) -> bool:
246
266
  """Check if a label layer is a spot segmentation.
247
267
 
248
268
  Parameters
249
269
  ----------
250
- layer_name : str
251
- Name of the labels layer.
270
+ layer : object or str
271
+ Labels layer object or labels layer name.
252
272
 
253
273
  Returns
254
274
  -------
255
275
  bool
256
276
  True if the layer is a spot label.
257
277
  """
278
+ if isinstance(layer, str):
279
+ layer_name = layer
280
+ task = None
281
+ else:
282
+ layer_name = str(getattr(layer, "name", ""))
283
+ task = self._layer_task(layer)
284
+ if task == "spots":
285
+ return True
286
+ if task is not None:
287
+ return False
258
288
  return layer_name.endswith("_spot_labels")
259
289
 
260
290
  def _refresh_image_combo(self, combo: QComboBox) -> None: