celldetective 1.5.0b1__py3-none-any.whl → 1.5.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 (35) hide show
  1. celldetective/_version.py +1 -1
  2. celldetective/gui/InitWindow.py +51 -12
  3. celldetective/gui/base/components.py +22 -1
  4. celldetective/gui/base_annotator.py +20 -9
  5. celldetective/gui/control_panel.py +21 -16
  6. celldetective/gui/event_annotator.py +51 -1060
  7. celldetective/gui/gui_utils.py +14 -5
  8. celldetective/gui/interactions_block.py +55 -25
  9. celldetective/gui/interactive_timeseries_viewer.py +11 -1
  10. celldetective/gui/measure_annotator.py +1064 -0
  11. celldetective/gui/plot_measurements.py +2 -4
  12. celldetective/gui/plot_signals_ui.py +3 -4
  13. celldetective/gui/process_block.py +298 -72
  14. celldetective/gui/viewers/base_viewer.py +134 -3
  15. celldetective/gui/viewers/contour_viewer.py +4 -4
  16. celldetective/gui/workers.py +25 -10
  17. celldetective/measure.py +3 -0
  18. celldetective/napari/utils.py +29 -19
  19. celldetective/processes/load_table.py +55 -0
  20. celldetective/processes/measure_cells.py +107 -81
  21. celldetective/processes/track_cells.py +39 -39
  22. celldetective/segmentation.py +1 -1
  23. celldetective/tracking.py +9 -0
  24. celldetective/utils/data_loaders.py +21 -1
  25. celldetective/utils/image_loaders.py +3 -0
  26. celldetective/utils/masks.py +1 -1
  27. celldetective/utils/maths.py +14 -1
  28. {celldetective-1.5.0b1.dist-info → celldetective-1.5.0b3.dist-info}/METADATA +1 -1
  29. {celldetective-1.5.0b1.dist-info → celldetective-1.5.0b3.dist-info}/RECORD +35 -32
  30. tests/gui/test_enhancements.py +351 -0
  31. tests/test_notebooks.py +2 -1
  32. {celldetective-1.5.0b1.dist-info → celldetective-1.5.0b3.dist-info}/WHEEL +0 -0
  33. {celldetective-1.5.0b1.dist-info → celldetective-1.5.0b3.dist-info}/entry_points.txt +0 -0
  34. {celldetective-1.5.0b1.dist-info → celldetective-1.5.0b3.dist-info}/licenses/LICENSE +0 -0
  35. {celldetective-1.5.0b1.dist-info → celldetective-1.5.0b3.dist-info}/top_level.txt +0 -0
@@ -263,8 +263,7 @@ class ConfigMeasurementsPlot(CelldetectiveWidget):
263
263
  def ask_for_feature(self):
264
264
 
265
265
  cols = np.array(list(self.df.columns))
266
- is_number = np.vectorize(lambda x: np.issubdtype(x, np.number))
267
- feats = cols[is_number(self.df.dtypes)]
266
+ feats = [c for c in cols if pd.api.types.is_numeric_dtype(self.df[c])]
268
267
 
269
268
  self.feature_choice_widget = CelldetectiveWidget()
270
269
  self.feature_choice_widget.setWindowTitle("Select numeric feature")
@@ -286,8 +285,7 @@ class ConfigMeasurementsPlot(CelldetectiveWidget):
286
285
  def ask_for_features(self):
287
286
 
288
287
  cols = np.array(list(self.df.columns))
289
- is_number = np.vectorize(lambda x: np.issubdtype(x, np.number))
290
- feats = cols[is_number(self.df.dtypes)]
288
+ feats = [c for c in cols if pd.api.types.is_numeric_dtype(self.df[c])]
291
289
 
292
290
  self.feature_choice_widget = CelldetectiveWidget()
293
291
  self.feature_choice_widget.setWindowTitle("Select numeric feature")
@@ -21,6 +21,7 @@ from celldetective.utils.data_cleaning import extract_cols_from_table_list
21
21
  from celldetective.utils.parsing import _extract_labels_from_config
22
22
  from celldetective.utils.data_loaders import load_experiment_tables
23
23
  from celldetective.signals import mean_signal
24
+ import pandas as pd
24
25
  import numpy as np
25
26
  import os
26
27
  import matplotlib.pyplot as plt
@@ -377,8 +378,7 @@ class ConfigSignalPlot(CelldetectiveWidget):
377
378
  def ask_for_feature(self):
378
379
 
379
380
  cols = np.array(list(self.df.columns))
380
- is_number = np.vectorize(lambda x: np.issubdtype(x, np.number))
381
- feats = cols[is_number(self.df.dtypes)]
381
+ feats = [c for c in cols if pd.api.types.is_numeric_dtype(self.df[c])]
382
382
 
383
383
  self.feature_choice_widget = CelldetectiveWidget()
384
384
  self.feature_choice_widget.setWindowTitle("Select numeric feature")
@@ -400,8 +400,7 @@ class ConfigSignalPlot(CelldetectiveWidget):
400
400
  def ask_for_features(self):
401
401
 
402
402
  cols = np.array(list(self.df.columns))
403
- is_number = np.vectorize(lambda x: np.issubdtype(x, np.number))
404
- feats = cols[is_number(self.df.dtypes)]
403
+ feats = [c for c in cols if pd.api.types.is_numeric_dtype(self.df[c])]
405
404
 
406
405
  self.feature_choice_widget = CelldetectiveWidget()
407
406
  self.feature_choice_widget.setWindowTitle("Select numeric feature")
@@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (
9
9
  QHBoxLayout,
10
10
  QCheckBox,
11
11
  QMessageBox,
12
+ QApplication,
12
13
  )
13
14
  from PyQt5.QtCore import Qt, QSize, QTimer, QThread, pyqtSignal
14
15
  from superqt.fonticon import icon
@@ -30,13 +31,14 @@ from celldetective.gui.base.components import (
30
31
  CelldetectiveWidget,
31
32
  CelldetectiveProgressDialog,
32
33
  QHSeperationLine,
34
+ HoverButton,
33
35
  )
34
36
 
35
37
  import numpy as np
36
38
  from glob import glob
37
39
  from celldetective import get_logger
38
40
 
39
- logger = get_logger()
41
+ logger = get_logger("celldetective")
40
42
 
41
43
 
42
44
  class NapariLoaderThread(QThread):
@@ -345,11 +347,12 @@ class ProcessPanel(QFrame, Styles):
345
347
  self.refresh_signal_models()
346
348
  # self.to_disable.append(self.cell_models_list)
347
349
 
348
- self.train_signal_model_btn = QPushButton("TRAIN")
350
+ self.train_signal_model_btn = HoverButton(
351
+ "TRAIN", MDI6.redo_variant, "black", "white"
352
+ )
349
353
  self.train_signal_model_btn.setToolTip(
350
354
  "Train or retrain an event detection model\non newly annotated data."
351
355
  )
352
- self.train_signal_model_btn.setIcon(icon(MDI6.redo_variant, color="black"))
353
356
  self.train_signal_model_btn.setIconSize(QSize(20, 20))
354
357
  self.train_signal_model_btn.setStyleSheet(self.button_style_sheet_3)
355
358
  model_zoo_layout.addWidget(self.train_signal_model_btn, 5)
@@ -557,8 +560,7 @@ class ProcessPanel(QFrame, Styles):
557
560
  self.seg_model_list.setGeometry(50, 50, 200, 30)
558
561
  self.init_seg_model_list()
559
562
 
560
- self.upload_model_btn = QPushButton("UPLOAD")
561
- self.upload_model_btn.setIcon(icon(MDI6.upload, color="black"))
563
+ self.upload_model_btn = HoverButton("UPLOAD", MDI6.upload, "black", "white")
562
564
  self.upload_model_btn.setIconSize(QSize(20, 20))
563
565
  self.upload_model_btn.setStyleSheet(self.button_style_sheet_3)
564
566
  self.upload_model_btn.setToolTip(
@@ -568,11 +570,10 @@ class ProcessPanel(QFrame, Styles):
568
570
  self.upload_model_btn.clicked.connect(self.upload_segmentation_model)
569
571
  # self.to_disable.append(self.upload_tc_model)
570
572
 
571
- self.train_btn = QPushButton("TRAIN")
573
+ self.train_btn = HoverButton("TRAIN", MDI6.redo_variant, "black", "white")
572
574
  self.train_btn.setToolTip(
573
575
  "Train or retrain a segmentation model\non newly annotated data."
574
576
  )
575
- self.train_btn.setIcon(icon(MDI6.redo_variant, color="black"))
576
577
  self.train_btn.setIconSize(QSize(20, 20))
577
578
  self.train_btn.setStyleSheet(self.button_style_sheet_3)
578
579
  self.train_btn.clicked.connect(self.open_segmentation_model_config_ui)
@@ -819,58 +820,117 @@ class ProcessPanel(QFrame, Styles):
819
820
 
820
821
  def check_signals(self):
821
822
  from celldetective.gui.event_annotator import EventAnnotator, StackLoaderThread
823
+ from celldetective.utils.experiment import interpret_wells_and_positions
822
824
 
823
- test = self.parent_window.locate_selected_position()
824
- if test:
825
- self.event_annotator = EventAnnotator(self, lazy_load=True)
825
+ self.well_option = self.parent_window.well_list.getSelectedIndices()
826
+ self.position_option = self.parent_window.position_list.getSelectedIndices()
826
827
 
827
- if not getattr(self.event_annotator, "proceed", True):
828
- return
828
+ # Count selected positions
829
+ well_indices, position_indices = interpret_wells_and_positions(
830
+ self.exp_dir, self.well_option, self.position_option
831
+ )
832
+ total_positions = 0
833
+ from celldetective.utils.experiment import (
834
+ get_positions_in_well,
835
+ get_experiment_wells,
836
+ )
829
837
 
830
- self.signal_loader = StackLoaderThread(self.event_annotator)
838
+ wells = get_experiment_wells(self.exp_dir)
839
+ for widx in well_indices:
840
+ positions = get_positions_in_well(wells[widx])
841
+ if position_indices is not None:
842
+ total_positions += len(position_indices)
843
+ else:
844
+ total_positions += len(positions)
831
845
 
832
- self.signal_progress = CelldetectiveProgressDialog(
833
- "Loading data...", "Cancel", 0, 100, self, window_title="Please wait"
834
- )
846
+ if total_positions == 1:
847
+ test = self.parent_window.locate_selected_position()
848
+ if test:
849
+ self.event_annotator = EventAnnotator(self, lazy_load=True)
835
850
 
836
- self.signal_progress.setValue(0)
851
+ if not getattr(self.event_annotator, "proceed", True):
852
+ return
837
853
 
838
- self.signal_loader.progress.connect(self.signal_progress.setValue)
839
- self.signal_loader.status_update.connect(self.signal_progress.setLabelText)
840
- self.signal_progress.canceled.connect(self.signal_loader.stop)
854
+ self.signal_loader = StackLoaderThread(self.event_annotator)
841
855
 
842
- def on_finished():
843
- self.signal_progress.blockSignals(True)
844
- self.signal_progress.close()
845
- if not self.signal_loader._is_cancelled:
846
- try:
847
- self.event_annotator.finalize_init()
848
- self.event_annotator.show()
856
+ self.signal_progress = CelldetectiveProgressDialog(
857
+ "Loading data...",
858
+ "Cancel",
859
+ 0,
860
+ 100,
861
+ self,
862
+ window_title="Please wait",
863
+ )
864
+
865
+ self.signal_progress.setValue(0)
866
+
867
+ self.signal_loader.progress.connect(self.signal_progress.setValue)
868
+ self.signal_loader.status_update.connect(
869
+ self.signal_progress.setLabelText
870
+ )
871
+ self.signal_progress.canceled.connect(self.signal_loader.stop)
872
+
873
+ def on_finished():
874
+ self.signal_progress.blockSignals(True)
875
+ self.signal_progress.close()
876
+ if not self.signal_loader._is_cancelled:
849
877
  try:
850
- QTimer.singleShot(
851
- 100,
852
- lambda: self.event_annotator.resize(
853
- self.event_annotator.width() + 1,
854
- self.event_annotator.height() + 1,
855
- ),
856
- )
857
- except:
858
- pass
859
- except Exception as e:
860
- print(f"Error finalizing annotator: {e}")
861
- else:
862
- self.event_annotator.close()
878
+ self.event_annotator.finalize_init()
879
+ self.event_annotator.show()
880
+ try:
881
+ QTimer.singleShot(
882
+ 100,
883
+ lambda: self.event_annotator.resize(
884
+ self.event_annotator.width() + 1,
885
+ self.event_annotator.height() + 1,
886
+ ),
887
+ )
888
+ except:
889
+ pass
890
+ except Exception as e:
891
+ print(f"Error finalizing annotator: {e}")
892
+ else:
893
+ self.event_annotator.close()
863
894
 
864
- self.signal_loader.finished.connect(on_finished)
865
- self.signal_loader.start()
895
+ self.signal_loader.finished.connect(on_finished)
896
+ self.signal_loader.start()
897
+ else:
898
+ # Multi position explorer: redirect to TableUI with progress bar
899
+ self.view_table_ui()
866
900
 
867
901
  def check_measurements(self):
868
- from celldetective.gui.event_annotator import MeasureAnnotator
902
+ from celldetective.gui.measure_annotator import MeasureAnnotator
903
+ from celldetective.utils.experiment import interpret_wells_and_positions
869
904
 
870
- test = self.parent_window.locate_selected_position()
871
- if test:
872
- self.measure_annotator = MeasureAnnotator(self)
873
- self.measure_annotator.show()
905
+ self.well_option = self.parent_window.well_list.getSelectedIndices()
906
+ self.position_option = self.parent_window.position_list.getSelectedIndices()
907
+
908
+ # Count selected positions
909
+ well_indices, position_indices = interpret_wells_and_positions(
910
+ self.exp_dir, self.well_option, self.position_option
911
+ )
912
+ total_positions = 0
913
+ from celldetective.utils.experiment import (
914
+ get_positions_in_well,
915
+ get_experiment_wells,
916
+ )
917
+
918
+ wells = get_experiment_wells(self.exp_dir)
919
+ for widx in well_indices:
920
+ positions = get_positions_in_well(wells[widx])
921
+ if position_indices is not None:
922
+ total_positions += len(position_indices)
923
+ else:
924
+ total_positions += len(positions)
925
+
926
+ if total_positions == 1:
927
+ test = self.parent_window.locate_selected_position()
928
+ if test:
929
+ self.measure_annotator = MeasureAnnotator(self)
930
+ self.measure_annotator.show()
931
+ else:
932
+ # Multi position explorer: redirect to TableUI with progress bar
933
+ self.view_table_ui()
874
934
 
875
935
  def enable_segmentation_model_list(self):
876
936
  if self.segment_action.isChecked():
@@ -1493,6 +1553,9 @@ class ProcessPanel(QFrame, Styles):
1493
1553
  window_title="Preparing the napari viewer...",
1494
1554
  )
1495
1555
 
1556
+ self.napari_progress.setAutoClose(False)
1557
+ self.napari_progress.setAutoReset(False)
1558
+
1496
1559
  self.napari_progress.setValue(0)
1497
1560
  self.napari_loader.progress.connect(self.napari_progress.setValue)
1498
1561
  self.napari_loader.status.connect(self.napari_progress.setLabelText)
@@ -1502,13 +1565,15 @@ class ProcessPanel(QFrame, Styles):
1502
1565
  from celldetective.napari.utils import launch_napari_viewer
1503
1566
 
1504
1567
  self.napari_progress.blockSignals(True)
1505
- self.napari_progress.close()
1568
+ # self.napari_progress.close()
1506
1569
  if self.napari_loader._is_cancelled:
1507
1570
  logger.info("Task was cancelled...")
1571
+ self.napari_progress.close()
1508
1572
  return
1509
1573
 
1510
1574
  if isinstance(result, Exception):
1511
1575
  logger.error(f"napari loading error: {result}")
1576
+ self.napari_progress.close()
1512
1577
  msgBox = QMessageBox()
1513
1578
  msgBox.setIcon(QMessageBox.Warning)
1514
1579
  msgBox.setText(str(result))
@@ -1519,13 +1584,33 @@ class ProcessPanel(QFrame, Styles):
1519
1584
 
1520
1585
  if result:
1521
1586
  logger.info("Launching the napari viewer with tracks...")
1587
+ self.napari_progress.setLabelText("Initializing Napari viewer...")
1588
+ self.napari_progress.setRange(0, 0)
1589
+ QApplication.processEvents()
1590
+
1591
+ def progress_cb(msg):
1592
+ if isinstance(msg, str):
1593
+ self.napari_progress.setLabelText(msg)
1594
+ QApplication.processEvents()
1595
+
1596
+ if "flush_memory" in result:
1597
+ result.pop("flush_memory")
1598
+
1522
1599
  try:
1523
- launch_napari_viewer(**result)
1600
+ launch_napari_viewer(
1601
+ **result,
1602
+ block=False,
1603
+ flush_memory=False,
1604
+ progress_callback=progress_cb,
1605
+ )
1524
1606
  logger.info("napari viewer was closed...")
1525
1607
  except Exception as e:
1526
1608
  logger.error(f"Failed to launch Napari: {e}")
1527
1609
  QMessageBox.warning(self, "Error", f"Failed to launch Napari: {e}")
1610
+ finally:
1611
+ self.napari_progress.close()
1528
1612
  else:
1613
+ self.napari_progress.close()
1529
1614
  logger.warning(
1530
1615
  "napari loading returned None (likely no trajectories found)."
1531
1616
  )
@@ -1540,33 +1625,95 @@ class ProcessPanel(QFrame, Styles):
1540
1625
 
1541
1626
  def view_table_ui(self):
1542
1627
  from celldetective.gui.tableUI import TableUI
1628
+ from celldetective.gui.workers import ProgressWindow
1629
+ from celldetective.processes.load_table import TableLoaderProcess
1630
+ from celldetective.utils.experiment import interpret_wells_and_positions
1543
1631
 
1544
1632
  logger.info("Load table...")
1545
- self.load_available_tables()
1546
1633
 
1547
- if self.df is not None:
1548
- plot_mode = "plot_track_signals"
1549
- if "TRACK_ID" not in list(self.df.columns):
1550
- plot_mode = "static"
1551
- self.tab_ui = TableUI(
1552
- self.df,
1553
- f"{self.parent_window.well_list.currentText()}; Position {self.parent_window.position_list.currentText()}",
1634
+ # Prepare args for the process
1635
+ self.well_option = self.parent_window.well_list.getSelectedIndices()
1636
+ self.position_option = self.parent_window.position_list.getSelectedIndices()
1637
+
1638
+ # Count selected positions
1639
+ well_indices, position_indices = interpret_wells_and_positions(
1640
+ self.exp_dir, self.well_option, self.position_option
1641
+ )
1642
+ total_positions = 0
1643
+ from celldetective.utils.experiment import (
1644
+ get_positions_in_well,
1645
+ get_experiment_wells,
1646
+ )
1647
+
1648
+ wells = get_experiment_wells(self.exp_dir)
1649
+ for widx in well_indices:
1650
+ positions = get_positions_in_well(wells[widx])
1651
+ if position_indices is not None:
1652
+ total_positions += len(position_indices)
1653
+ else:
1654
+ total_positions += len(positions)
1655
+
1656
+ def show_table(df):
1657
+ if df is not None:
1658
+ plot_mode = "plot_track_signals"
1659
+ if "TRACK_ID" not in list(df.columns):
1660
+ plot_mode = "static"
1661
+ self.tab_ui = TableUI(
1662
+ df,
1663
+ f"{self.parent_window.well_list.currentText()}; Position {self.parent_window.position_list.currentText()}",
1664
+ population=self.mode,
1665
+ plot_mode=plot_mode,
1666
+ save_inplace_option=True,
1667
+ )
1668
+ self.tab_ui.show()
1669
+ center_window(self.tab_ui)
1670
+ else:
1671
+ logger.info("Table could not be loaded...")
1672
+ msgBox = QMessageBox()
1673
+ msgBox.setIcon(QMessageBox.Warning)
1674
+ msgBox.setText("No table could be loaded...")
1675
+ msgBox.setWindowTitle("Info")
1676
+ msgBox.setStandardButtons(QMessageBox.Ok)
1677
+ msgBox.exec()
1678
+
1679
+ if total_positions == 1:
1680
+ # Synchronous load for single position
1681
+ from celldetective.utils.data_loaders import load_experiment_tables
1682
+
1683
+ df = load_experiment_tables(
1684
+ self.exp_dir,
1554
1685
  population=self.mode,
1555
- plot_mode=plot_mode,
1556
- save_inplace_option=True,
1686
+ well_option=self.well_option,
1687
+ position_option=self.position_option,
1557
1688
  )
1558
- self.tab_ui.show()
1559
- center_window(self.tab_ui)
1689
+ show_table(df)
1560
1690
  else:
1561
- logger.info("Table could not be loaded...")
1562
- msgBox = QMessageBox()
1563
- msgBox.setIcon(QMessageBox.Warning)
1564
- msgBox.setText("No table could be loaded...")
1565
- msgBox.setWindowTitle("Info")
1566
- msgBox.setStandardButtons(QMessageBox.Ok)
1567
- returnValue = msgBox.exec()
1568
- if returnValue == QMessageBox.Ok:
1569
- return None
1691
+ # Asynchronous load for multiple positions
1692
+ process_args = {
1693
+ "experiment": self.exp_dir,
1694
+ "population": self.mode,
1695
+ "well_option": self.well_option,
1696
+ "position_option": self.position_option,
1697
+ "show_frame_progress": False,
1698
+ }
1699
+
1700
+ self.df = None
1701
+
1702
+ def on_table_loaded(df):
1703
+ self.df = df
1704
+ show_table(self.df)
1705
+
1706
+ self.job = ProgressWindow(
1707
+ TableLoaderProcess,
1708
+ parent_window=self,
1709
+ title="Loading tables...",
1710
+ process_args=process_args,
1711
+ position_info=False,
1712
+ well_label="Wells loaded:",
1713
+ pos_label="Positions loaded:",
1714
+ )
1715
+ self.job._ProgressWindow__runner.signals.result.connect(on_table_loaded)
1716
+ self.job.exec_()
1570
1717
 
1571
1718
  def load_available_tables(self):
1572
1719
  """
@@ -1587,8 +1734,87 @@ class ProcessPanel(QFrame, Styles):
1587
1734
  self.signals = []
1588
1735
  if self.df is not None:
1589
1736
  self.signals = list(self.df.columns)
1590
- if self.df is None:
1591
- logger.info("No table could be found for the selected position(s)...")
1737
+ else:
1738
+ logger.info(
1739
+ "No table could be found for the selected position(s)... Anticipating measurements..."
1740
+ )
1741
+
1742
+ from celldetective.utils.experiment import extract_experiment_channels
1743
+
1744
+ channel_names, _ = extract_experiment_channels(self.exp_dir)
1745
+
1746
+ # Standard measurements
1747
+ self.signals = ["area"]
1748
+ for ch in channel_names:
1749
+ self.signals.append(f"{ch}_mean")
1750
+
1751
+ # Anticipate from instructions
1752
+ instr_path = os.path.join(
1753
+ self.exp_dir, "configs", f"measurement_instructions_{self.mode}.json"
1754
+ )
1755
+ if os.path.exists(instr_path):
1756
+ try:
1757
+ with open(instr_path, "r") as f:
1758
+ instr = json.load(f)
1759
+
1760
+ # 1. Features
1761
+ features = instr.get("features", [])
1762
+ if features:
1763
+ for f_name in features:
1764
+ if f_name == "intensity_mean":
1765
+ continue # handled by standard
1766
+ if f_name == "area":
1767
+ continue
1768
+
1769
+ # For other features, skimage/celldetective might suffix them.
1770
+ # If it's a generic feature, skimage usually keeps the name.
1771
+ # If it's multichannel, it might need channel names.
1772
+ # For now, let's keep it simple as requested for intensity_mean and area.
1773
+ pass
1774
+
1775
+ # 2. Isotropic measurements
1776
+ radii = instr.get("intensity_measurement_radii", [])
1777
+ ops = instr.get("isotropic_operations", [])
1778
+ if radii and ops:
1779
+ for r in radii if isinstance(radii, list) else [radii]:
1780
+ for op in ops:
1781
+ for ch in channel_names:
1782
+ if isinstance(r, list):
1783
+ self.signals.append(
1784
+ f"{ch}_ring_{int(min(r))}_{int(max(r))}_{op}"
1785
+ )
1786
+ else:
1787
+ self.signals.append(
1788
+ f"{ch}_circle_{int(r)}_{op}"
1789
+ )
1790
+
1791
+ # 3. Border distances
1792
+ borders = instr.get("border_distances", [])
1793
+ if borders:
1794
+ for b in borders if isinstance(borders, list) else [borders]:
1795
+ # Logic from measure.py for suffix
1796
+ b_str = (
1797
+ str(b)
1798
+ .replace("(", "")
1799
+ .replace(")", "")
1800
+ .replace(", ", "_")
1801
+ .replace(",", "_")
1802
+ )
1803
+ suffix = (
1804
+ f"_slice_{b_str.replace('-', 'm')}px"
1805
+ if ("-" in str(b) or "," in str(b))
1806
+ else f"_edge_{b_str}px"
1807
+ )
1808
+ for ch in channel_names:
1809
+ # In measure_features, it's {ch}_mean{suffix}
1810
+ self.signals.append(f"{ch}_mean{suffix}")
1811
+
1812
+ except Exception as e:
1813
+ logger.warning(f"Could not parse measurement instructions: {e}")
1814
+
1815
+ # Remove duplicates and keep order
1816
+ seen = set()
1817
+ self.signals = [x for x in self.signals if not (x in seen or seen.add(x))]
1592
1818
 
1593
1819
  def set_cellpose_scale(self):
1594
1820