senoquant 1.0.0b2__py3-none-any.whl → 1.0.0b3__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 (47) hide show
  1. senoquant/__init__.py +6 -2
  2. senoquant/_reader.py +1 -1
  3. senoquant/reader/core.py +201 -18
  4. senoquant/tabs/batch/backend.py +18 -3
  5. senoquant/tabs/batch/frontend.py +8 -4
  6. senoquant/tabs/quantification/features/marker/dialog.py +26 -6
  7. senoquant/tabs/quantification/features/marker/export.py +97 -24
  8. senoquant/tabs/quantification/features/marker/rows.py +2 -2
  9. senoquant/tabs/quantification/features/spots/dialog.py +41 -11
  10. senoquant/tabs/quantification/features/spots/export.py +163 -10
  11. senoquant/tabs/quantification/frontend.py +2 -2
  12. senoquant/tabs/segmentation/frontend.py +46 -9
  13. senoquant/tabs/segmentation/models/cpsam/model.py +1 -1
  14. senoquant/tabs/segmentation/models/default_2d/model.py +22 -77
  15. senoquant/tabs/segmentation/models/default_3d/model.py +8 -74
  16. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +0 -0
  17. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +13 -13
  18. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/stardist_libs.py +171 -0
  19. senoquant/tabs/spots/frontend.py +42 -5
  20. senoquant/tabs/spots/models/ufish/details.json +17 -0
  21. senoquant/tabs/spots/models/ufish/model.py +129 -0
  22. senoquant/tabs/spots/ufish_utils/__init__.py +13 -0
  23. senoquant/tabs/spots/ufish_utils/core.py +357 -0
  24. senoquant/utils.py +1 -1
  25. senoquant-1.0.0b3.dist-info/METADATA +161 -0
  26. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/RECORD +41 -28
  27. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/top_level.txt +1 -0
  28. ufish/__init__.py +1 -0
  29. ufish/api.py +778 -0
  30. ufish/model/__init__.py +0 -0
  31. ufish/model/loss.py +62 -0
  32. ufish/model/network/__init__.py +0 -0
  33. ufish/model/network/spot_learn.py +50 -0
  34. ufish/model/network/ufish_net.py +204 -0
  35. ufish/model/train.py +175 -0
  36. ufish/utils/__init__.py +0 -0
  37. ufish/utils/img.py +418 -0
  38. ufish/utils/log.py +8 -0
  39. ufish/utils/spot_calling.py +115 -0
  40. senoquant/tabs/spots/models/rmp/details.json +0 -61
  41. senoquant/tabs/spots/models/rmp/model.py +0 -499
  42. senoquant/tabs/spots/models/udwt/details.json +0 -103
  43. senoquant/tabs/spots/models/udwt/model.py +0 -482
  44. senoquant-1.0.0b2.dist-info/METADATA +0 -193
  45. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/WHEEL +0 -0
  46. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/entry_points.txt +0 -0
  47. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/licenses/LICENSE +0 -0
senoquant/__init__.py CHANGED
@@ -1,6 +1,10 @@
1
1
  """SenoQuant napari plugin package."""
2
2
 
3
- from ._widget import SenoQuantWidget
3
+ try:
4
+ from importlib.metadata import version
5
+ __version__ = version("senoquant")
6
+ except Exception:
7
+ __version__ = "1.0.0b3" # Fallback for development
4
8
 
5
- __version__ = "1.0.0b2"
9
+ from ._widget import SenoQuantWidget
6
10
  __all__ = ["SenoQuantWidget"]
senoquant/_reader.py CHANGED
@@ -1,4 +1,4 @@
1
- """Napari reader entrypoint for SenoQuant."""
1
+ """napari reader entrypoint for SenoQuant."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
senoquant/reader/core.py CHANGED
@@ -75,7 +75,7 @@ def _read_senoquant(path: str) -> Iterable[tuple]:
75
75
  Returns
76
76
  -------
77
77
  iterable of tuple
78
- Napari layer tuples of the form ``(data, metadata, layer_type)``.
78
+ napari layer tuples of the form ``(data, metadata, layer_type)``.
79
79
 
80
80
  Notes
81
81
  -----
@@ -91,25 +91,208 @@ def _read_senoquant(path: str) -> Iterable[tuple]:
91
91
 
92
92
  base_name = Path(path).name
93
93
  image = _open_bioimage(path)
94
- layers: list[tuple] = []
95
- colormap_cycle = _colormap_cycle()
96
- scenes = image.scenes
97
-
98
- for scene_idx, scene_id in enumerate(scenes):
99
- image.set_scene(scene_id)
100
- layers.extend(
101
- _iter_channel_layers(
102
- image,
103
- base_name=base_name,
104
- scene_id=scene_id,
105
- scene_idx=scene_idx,
106
- total_scenes=len(scenes),
107
- path=path,
108
- colormap_cycle=colormap_cycle,
94
+ try:
95
+ layers: list[tuple] = []
96
+ colormap_cycle = _colormap_cycle()
97
+ scenes = list(getattr(image, "scenes", []) or [])
98
+ selected_scene_indices = _select_scene_indices(path, scenes)
99
+ if not selected_scene_indices:
100
+ return layers
101
+
102
+ for scene_idx in selected_scene_indices:
103
+ scene_id = scenes[scene_idx]
104
+ image.set_scene(scene_id)
105
+ layers.extend(
106
+ _iter_channel_layers(
107
+ image,
108
+ base_name=base_name,
109
+ scene_id=scene_id,
110
+ scene_idx=scene_idx,
111
+ total_scenes=len(scenes),
112
+ path=path,
113
+ colormap_cycle=colormap_cycle,
114
+ )
109
115
  )
116
+
117
+ return layers
118
+ finally:
119
+ if hasattr(image, "close"):
120
+ try:
121
+ image.close()
122
+ except Exception:
123
+ pass
124
+
125
+
126
+ def _select_scene_indices(path: str, scenes: list[str]) -> list[int]:
127
+ """Return scene indices selected by the user for loading."""
128
+ if not scenes:
129
+ return []
130
+ if len(scenes) == 1:
131
+ return [0]
132
+
133
+ selected = _prompt_scene_selection(path, scenes)
134
+ if selected is None:
135
+ return []
136
+ return selected
137
+
138
+
139
+ def _prompt_scene_selection(path: str, scenes: list[str]) -> list[int] | None:
140
+ """Show a scene-selection dialog and return selected indices.
141
+
142
+ Returns
143
+ -------
144
+ list[int] or None
145
+ Selected scene indices. Returns ``None`` when the dialog is cancelled.
146
+ If Qt is not available, all scenes are selected.
147
+ """
148
+ try:
149
+ from qtpy.QtCore import Qt
150
+ from qtpy.QtWidgets import (
151
+ QApplication,
152
+ QDialog,
153
+ QDialogButtonBox,
154
+ QHBoxLayout,
155
+ QLabel,
156
+ QListWidget,
157
+ QListWidgetItem,
158
+ QMessageBox,
159
+ QPushButton,
160
+ QVBoxLayout,
161
+ )
162
+ except Exception:
163
+ return list(range(len(scenes)))
164
+
165
+ app = QApplication.instance()
166
+ if app is None:
167
+ return list(range(len(scenes)))
168
+
169
+ dialog = QDialog(_napari_dialog_parent(app))
170
+ dialog.setWindowTitle("Select scenes to load")
171
+ dialog.setMinimumWidth(520)
172
+ _apply_napari_dialog_theme(dialog, app)
173
+
174
+ layout = QVBoxLayout(dialog)
175
+ layout.addWidget(QLabel(f"File: {Path(path).name}"))
176
+ layout.addWidget(QLabel(f"Select scenes to load ({len(scenes)} total):"))
177
+
178
+ scene_list = QListWidget(dialog)
179
+ for index, scene_id in enumerate(scenes):
180
+ scene_name = str(scene_id).strip() or f"Scene {index}"
181
+ item = QListWidgetItem(f"{index}: {scene_name}")
182
+ item.setData(Qt.UserRole, index)
183
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
184
+ item.setCheckState(Qt.Checked)
185
+ scene_list.addItem(item)
186
+ scene_list.setMinimumHeight(300)
187
+ layout.addWidget(scene_list)
188
+
189
+ controls = QHBoxLayout()
190
+ select_all_button = QPushButton("Select all")
191
+ clear_all_button = QPushButton("Clear all")
192
+ controls.addWidget(select_all_button)
193
+ controls.addWidget(clear_all_button)
194
+ controls.addStretch(1)
195
+ layout.addLayout(controls)
196
+
197
+ select_all_button.clicked.connect(
198
+ lambda: _set_scene_checks(scene_list, Qt.Checked)
199
+ )
200
+ clear_all_button.clicked.connect(lambda: _set_scene_checks(scene_list, Qt.Unchecked))
201
+
202
+ buttons = QDialogButtonBox(
203
+ QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog
204
+ )
205
+ layout.addWidget(buttons)
206
+
207
+ def _accept_if_valid() -> None:
208
+ checked = _checked_scene_indices(scene_list)
209
+ if checked:
210
+ dialog.accept()
211
+ return
212
+ QMessageBox.warning(
213
+ dialog,
214
+ "No scenes selected",
215
+ "Select at least one scene to load.",
110
216
  )
111
217
 
112
- return layers
218
+ buttons.accepted.connect(_accept_if_valid)
219
+ buttons.rejected.connect(dialog.reject)
220
+
221
+ if dialog.exec() != QDialog.Accepted:
222
+ return None
223
+ return _checked_scene_indices(scene_list)
224
+
225
+
226
+ def _napari_dialog_parent(app):
227
+ """Return a good parent window for napari-linked dialogs."""
228
+ try:
229
+ import napari
230
+
231
+ viewer = napari.current_viewer()
232
+ except Exception:
233
+ viewer = None
234
+ if viewer is not None:
235
+ window = getattr(viewer, "window", None)
236
+ qt_window = getattr(window, "_qt_window", None)
237
+ if qt_window is not None:
238
+ return qt_window
239
+ return app.activeWindow()
240
+
241
+
242
+ def _apply_napari_dialog_theme(dialog, app) -> None:
243
+ """Apply napari/app stylesheet so popup theming is consistent."""
244
+ stylesheet = ""
245
+ try:
246
+ import napari
247
+
248
+ viewer = napari.current_viewer()
249
+ theme_id = getattr(viewer, "theme", None) if viewer is not None else None
250
+ if theme_id:
251
+ try:
252
+ from napari.qt import get_stylesheet
253
+
254
+ stylesheet = str(get_stylesheet(theme_id) or "")
255
+ except Exception:
256
+ stylesheet = ""
257
+ except Exception:
258
+ stylesheet = ""
259
+
260
+ if not stylesheet:
261
+ try:
262
+ stylesheet = str(app.styleSheet() or "")
263
+ except Exception:
264
+ stylesheet = ""
265
+ if not stylesheet:
266
+ try:
267
+ parent = dialog.parentWidget()
268
+ stylesheet = str(parent.styleSheet() if parent is not None else "")
269
+ except Exception:
270
+ stylesheet = ""
271
+
272
+ if stylesheet:
273
+ dialog.setStyleSheet(stylesheet)
274
+
275
+
276
+ def _set_scene_checks(scene_list, state) -> None:
277
+ """Set check state for all scene list items."""
278
+ for row in range(scene_list.count()):
279
+ item = scene_list.item(row)
280
+ if item is not None:
281
+ item.setCheckState(state)
282
+
283
+
284
+ def _checked_scene_indices(scene_list) -> list[int]:
285
+ """Return checked scene indices from a QListWidget."""
286
+ from qtpy.QtCore import Qt
287
+
288
+ selected: list[int] = []
289
+ for row in range(scene_list.count()):
290
+ item = scene_list.item(row)
291
+ if item is None:
292
+ continue
293
+ if item.checkState() == Qt.Checked:
294
+ selected.append(int(item.data(Qt.UserRole)))
295
+ return selected
113
296
 
114
297
 
115
298
  def _open_bioimage(path: str):
@@ -304,7 +487,7 @@ def _iter_channel_layers(
304
487
  Returns
305
488
  -------
306
489
  list of tuple
307
- Napari layer tuples for each channel.
490
+ napari layer tuples for each channel.
308
491
  """
309
492
  dims = getattr(image, "dims", None)
310
493
  axes_present = _axes_present(image)
@@ -343,7 +343,9 @@ class BatchBackend:
343
343
  output_format,
344
344
  )
345
345
  labels_data[label_name] = masks
346
- labels_meta[label_name] = metadata
346
+ labels_meta[label_name] = _with_task_metadata(
347
+ metadata, "nuclear"
348
+ )
347
349
  item_result.outputs[label_name] = out_path
348
350
 
349
351
  if cyto_model:
@@ -393,7 +395,9 @@ class BatchBackend:
393
395
  output_format,
394
396
  )
395
397
  labels_data[label_name] = masks
396
- labels_meta[label_name] = cyto_meta
398
+ labels_meta[label_name] = _with_task_metadata(
399
+ cyto_meta, "cytoplasmic"
400
+ )
397
401
  item_result.outputs[label_name] = out_path
398
402
 
399
403
  if spot_detector:
@@ -434,7 +438,9 @@ class BatchBackend:
434
438
  output_format,
435
439
  )
436
440
  labels_data[label_name] = mask
437
- labels_meta[label_name] = spot_meta
441
+ labels_meta[label_name] = _with_task_metadata(
442
+ spot_meta, "spots"
443
+ )
438
444
  item_result.outputs[label_name] = out_path
439
445
 
440
446
  if quantification_features:
@@ -575,6 +581,15 @@ def _resolve_output_dir(
575
581
  return output_dir
576
582
 
577
583
 
584
+ def _with_task_metadata(metadata: dict | None, task: str) -> dict:
585
+ """Return a metadata copy with task type attached."""
586
+ payload: dict[str, object] = {}
587
+ if isinstance(metadata, dict):
588
+ payload.update(metadata)
589
+ payload["task"] = task
590
+ return payload
591
+
592
+
578
593
  def _build_viewer_for_quantification(
579
594
  path: Path,
580
595
  scene_id: str | None,
@@ -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
@@ -639,18 +639,22 @@ class BatchTab(QWidget):
639
639
  nuclear_channel = self._nuclear_channel_combo.currentText()
640
640
  if nuclear_model and nuclear_channel and not nuclear_model.startswith("("):
641
641
  label_name = f"{nuclear_channel}_{nuclear_model}_nuc_labels"
642
- layers.append(Labels(None, label_name))
642
+ layers.append(Labels(None, label_name, metadata={"task": "nuclear"}))
643
643
  if getattr(self, "_cyto_enabled", None) is not None and self._cyto_enabled.isChecked():
644
644
  cyto_model = self._cyto_model_combo.currentText()
645
645
  cyto_channel = self._cyto_channel_combo.currentText()
646
646
  if cyto_model and cyto_channel and not cyto_model.startswith("("):
647
647
  label_name = f"{cyto_channel}_{cyto_model}_cyto_labels"
648
- layers.append(Labels(None, label_name))
648
+ layers.append(
649
+ Labels(None, label_name, metadata={"task": "cytoplasmic"})
650
+ )
649
651
  if getattr(self, "_spots_enabled", None) is not None and self._spots_enabled.isChecked():
650
652
  spot_detector = self._spot_detector_combo.currentText()
651
653
  if spot_detector and not spot_detector.startswith("("):
652
654
  for label_name in _spot_label_names(self._spot_channel_rows, spot_detector):
653
- layers.append(Labels(None, label_name))
655
+ layers.append(
656
+ Labels(None, label_name, metadata={"task": "spots"})
657
+ )
654
658
  self._config_viewer.set_layers(layers)
655
659
 
656
660
  def _update_nuclear_settings(self) -> None:
@@ -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
  -------