:orphan: Source: bactoscoop/image.py =========================== .. raw:: html
  1# -*- coding: utf-8 -*-
     2"""
     3Created on Wed Apr  5 09:45:53 2023
     4
     5@author: Bart Steemans. Govers Lab.
     6"""
     7from .features import Features
     8from . import utilities as u
     9from .logging_utils import tqdm_if_verbose
    10import numpy as np
    11import pandas as pd
    12import warnings
    13import cv2 as cv
    14import logging
    15
    16warnings.filterwarnings("ignore")
    17bactoscoop_logger = logging.getLogger("logger")
    18
    19
    20class Image:
    21
    22    def __init__(
    23        self,
    24        image,
    25        image_name,
    26        frame,
    27        mask=None,
    28        processed_mesh_dataframe=None,
    29        px=0.065,
    30    ):
    31        """
    32        Initialize an instance of your class.
    33
    34        Parameters
    35        ----------
    36        image : phase contrast image
    37            The phase contrast image associated with this instance.
    38        image_name : str
    39            The name or identifier of the image.
    40        frame : int
    41            The frame number
    42        mask : mask, optional
    43            The segmented image.
    44        processed_mesh_dataframe : pd.DataFrame, optional
    45            A processed mesh dataframe if available. (default is None)
    46        px : float, optional
    47        The pixel size in micrometers. (default is 0.065)
    48        """
    49
    50        self.mask = mask
    51        self.cells = []  # List of cell objects
    52        self.px = px  # micron per pixel
    53        self.image_name = image_name
    54        self.frame = frame
    55        self.image = image
    56        self.channels = None  # Dictionary of the color channels
    57
    58        self.joined_mask = None  # Mask images after cell joining
    59        self.processing_mask = None  # Optional filtered mask used only for processing
    60        self.prefilter_summary = None
    61        self.mesh_dataframe = (
    62            None  # Resulting dataframe containing the meshes of the cells
    63        )
    64        self.processed_mesh_dataframe = processed_mesh_dataframe
    65
    66        self.im_interp2d = None  # 2D interpolation of the phase contrast image
    67        self.chann_interp2d = {}  # Cached interpolators of background-corrected channels
    68        self.bg_channels = (
    69            {}
    70        )  # Dictionary of the 2D interpolations of the different color channels
    71        self.inverted_image = None  # Placeholder for the inverted phase contrast image
    72        self.feature_errors = []
    73        self.object_detection_errors = []
    74        self.shift_errors = []
    75
    76    def calculate_features(
    77        self,
    78        method,
    79        channel,
    80        all_data,
    81        use_shifted_contours,
    82        shift_signal,
    83        max_mesh_size,
    84        retain_contour_on_object_mesh_failure_channels=None,
    85    ):
    86        """
    87        Calculate features for cells based on the specified method and channel.
    88
    89        This method calculates features for individual cells in the image based on the provided method
    90        and channel. It also allows for adding profiling data if required.
    91
    92        Parameters
    93        ----------
    94        method : str
    95            The method used to calculate features for cells.
    96            The methods are: 'morphological', 'profiling', 'svm', 'objects', 'phase'. (To be expanded)
    97        channel : str
    98            The channel for which features are calculated.
    99        add_profiling_data : bool
   100            Whether to include profiling data in the feature calculations.
   101
   102        Returns
   103        -------
   104        pd.DataFrame
   105            A DataFrame containing the calculated features for the cells.
   106        """
   107        features = Features(self)
   108        if retain_contour_on_object_mesh_failure_channels is None:
   109            retained_channels = frozenset()
   110        elif isinstance(retain_contour_on_object_mesh_failure_channels, str):
   111            raise TypeError(
   112                "retain_contour_on_object_mesh_failure_channels must be a list "
   113                "of channel names, not a string."
   114            )
   115        else:
   116            retained_channels = frozenset(
   117                retain_contour_on_object_mesh_failure_channels
   118            )
   119        if not all(isinstance(name, str) for name in retained_channels):
   120            raise TypeError(
   121                "retain_contour_on_object_mesh_failure_channels must contain "
   122                "only channel names."
   123            )
   124        result_df = None
   125        self.feature_errors = []
   126
   127        # First, filter out cells that should be discarded by discard_large_meshes
   128        filtered_cells = []
   129        for cell in self.cells:
   130            cell.discard = False
   131            features.discard_large_meshes(cell, max_mesh_size)
   132
   133            if not cell.discard:
   134                filtered_cells.append(cell)
   135
   136        # Update self.cells to only contain non-discarded cells
   137        self.cells = filtered_cells
   138
   139        # Now, process the remaining cells
   140        for cell in self.cells:
   141            feature_calculation_method = f"{method}"
   142
   143            if hasattr(features, feature_calculation_method):
   144                try:
   145                    calculation_method = getattr(features, feature_calculation_method)
   146
   147                    if method == "objects":
   148                        calculation_method(
   149                            cell, channel, all_data, use_shifted_contours,
   150                            shift_signal,
   151                            retain_contour_on_object_mesh_failure=(
   152                                channel in retained_channels
   153                            ),
   154                        )
   155                    else:
   156                        calculation_method(
   157                            cell, channel, all_data, use_shifted_contours,
   158                            shift_signal,
   159                        )
   160
   161                except Exception as e:
   162                    cell.discard = True
   163                    self.feature_errors.append(
   164                        {
   165                            "cell_id": cell.cell_id,
   166                            "error_type": type(e).__name__,
   167                            "error": str(e),
   168                        }
   169                    )
   170            if feature_calculation_method == "svm":
   171
   172                filtered_cells = []
   173                for cell in self.cells:
   174                    if not cell.discard:
   175                        filtered_cells.append(cell)
   176
   177                # Update self.cells to only contain non-discarded cells
   178                self.cells = filtered_cells
   179        result_df = self.features_to_dataframe(method)
   180        return result_df
   181
   182    def features_to_dataframe(self, method):
   183        """
   184        Convert cell features stored in a dictionary to a pandas DataFrame.
   185
   186        This method takes a dictionary of cell features, specified by the method name, and converts it into a pandas DataFrame.
   187        Additionally, it adds information features to the DataFrame.
   188
   189        Parameters
   190        ----------
   191        method : str
   192            The name of the method used for feature calculation.
   193
   194        Returns
   195        -------
   196        pd.DataFrame
   197            A DataFrame containing cell features.
   198        """
   199        features_df = self.dataframe_from_dict(f"{method}_features")
   200
   201        self.add_info_feature_df(features_df)
   202
   203        return features_df
   204
   205    def dataframe_from_dict(self, feature_dictionary):
   206        """
   207        Convert a feature dictionary to a pandas DataFrame.
   208
   209        This method takes a feature dictionary, which maps cell IDs to feature values, and converts it into a pandas DataFrame.
   210
   211        Parameters
   212        ----------
   213        feature_dictionary : str
   214            The name of the feature dictionary to convert.
   215
   216        Returns
   217        -------
   218        pd.DataFrame
   219            A DataFrame containing cell features.
   220        """
   221        data = {
   222            cell.cell_id: getattr(cell, feature_dictionary)
   223            for cell in self.cells
   224            if not cell.discard
   225        }
   226
   227        df = pd.DataFrame.from_dict(data, orient="index")
   228
   229        return df
   230
   231    def add_info_feature_df(self, dataframe):
   232        """
   233        Add information columns to a feature DataFrame.
   234
   235        This method adds information columns, including 'image_name', 'cell_id', and 'frame',
   236        to an existing DataFrame.
   237
   238        Parameters
   239        ----------
   240        dataframe : pd.DataFrame
   241            The DataFrame to which information columns will be added.
   242        """
   243
   244        dataframe.insert(0, "image_name", self.image_name)
   245        dataframe.insert(
   246            0, "cell_id", [cell.cell_id for cell in self.cells if not cell.discard]
   247        )
   248        dataframe.insert(0, "frame", self.frame)
   249
   250    def _active_mask(self):
   251        return self.processing_mask if self.processing_mask is not None else self.mask
   252
   253    def apply_neighbor_prefilter(self, max_neighbors=None, connectivity=1):
   254        self.processing_mask = None
   255        self.prefilter_summary = None
   256
   257        if max_neighbors is None:
   258            return None
   259
   260        if self.mask is None:
   261            raise ValueError("Mask image is not loaded")
   262
   263        filtered_mask, stats = u.filter_labels_by_neighbor_count(
   264            self.mask,
   265            max_neighbors=max_neighbors,
   266            connectivity=connectivity,
   267        )
   268
   269        if stats["removed_label_count"] > 0:
   270            self.processing_mask = filtered_mask
   271
   272        self.prefilter_summary = stats
   273        bactoscoop_logger.info(
   274            "Neighbor prefilter on %s kept %s/%s labels (removed %s with > %s neighbors)",
   275            self.image_name,
   276            stats["kept_label_count"],
   277            stats["total_label_count"],
   278            stats["removed_label_count"],
   279            stats["max_neighbors"],
   280        )
   281        return stats
   282
   283    def join_split_pipeline(
   284        self,
   285        join_thresh=4,
   286        split_thresh=0.3,
   287        CD_width=False,
   288        smoothing=0.1,
   289        neighbor_filter_max_neighbors=None,
   290        neighbor_filter_connectivity=1,
   291        max_daughter_cell_mesh_rows=800,
   292    ):
   293        """
   294        Perform a pipeline for joining and splitting cells.
   295
   296        This method is used within the batch_mesh_process() method of ImageCollection. It involves joining cells based on
   297        a specified threshold, converting masks to mesh data, splitting cells based on a specified threshold,
   298        and creating cell objects.
   299
   300        Parameters
   301        ----------
   302        join_thresh : int, optional
   303            The threshold for joining cells in pixels. (default is 4)
   304        split_thresh : float, optional
   305            The threshold for splitting cells. (default is 0.3)
   306        max_daughter_cell_mesh_rows : int, optional
   307            Maximum rebuilt daughter mesh rows (default 800, minimum 4).
   308            Oversized resampling is rejected before its dense allocations.
   309
   310        Returns
   311        -------
   312        None
   313        """
   314        max_daughter_cell_mesh_rows = u._validate_max_daughter_cell_mesh_rows(
   315            max_daughter_cell_mesh_rows
   316        )
   317        self.apply_neighbor_prefilter(
   318            max_neighbors=neighbor_filter_max_neighbors,
   319            connectivity=neighbor_filter_connectivity,
   320        )
   321        self.join_cells(join_thresh, smoothing)
   322        self.mask2mesh(smoothing)
   323        self.split_cells(
   324            split_thresh, CD_width,
   325            max_daughter_cell_mesh_rows=max_daughter_cell_mesh_rows,
   326        )
   327        self.create_cell_object()
   328
   329    def create_cell_object(self, verbose=True):
   330        """
   331        Create Cell objects from mesh data.
   332
   333        This method creates Cell objects based on mesh data contained in a DataFrame. The Cell objects are
   334        initialized with contour, mesh, and midline data, and they are added to the list of cells in the Image object.
   335
   336        Parameters
   337        ----------
   338        verbose : bool, optional
   339            Whether to print a message indicating the source of the Cell objects (processed or unprocessed meshes). (default is True)
   340
   341        Returns
   342        -------
   343        None
   344        """
   345        self.cells = []
   346
   347        if self.processed_mesh_dataframe is None:
   348            try:
   349                dataframe = self.mesh_dataframe
   350                message_prefix = "Cell objects are created from unprocessed meshes"
   351            except AttributeError:
   352                bactoscoop_logger.info(
   353                    "No meshes from the masks. Either process the masks or load in mesh data from a .pkl file."
   354                )
   355                return
   356        else:
   357            dataframe = self.processed_mesh_dataframe
   358            message_prefix = "Cell objects are created from processed meshes"
   359
   360        for i, row in dataframe.iterrows():
   361            if "cell_id" not in row or pd.isna(row["cell_id"]):
   362                cell_ID = i
   363            else:
   364                cell_ID = row["cell_id"]
   365            contours = np.array(row["contour"])
   366            meshes = np.array(row["mesh"])
   367            midlines = np.array(row["midline"])
   368            cell = Cell(contours, meshes, midlines, self.image.shape, cell_ID)
   369            self.add_cell(cell)
   370
   371        num_cells_created = len(self.cells)
   372        message = f"{message_prefix}. {num_cells_created} Cell objects are created."
   373
   374        if verbose:
   375            print(message)
   376
   377    def add_cell(self, cell):
   378        """
   379        Add a Cell object to the list of cells in the Image object.
   380        """
   381        self.cells.append(cell)
   382
   383    def get_inverted_image(self):
   384        """
   385        Calculate the inverted image for phase contrast.
   386
   387        This method computes the inverted image for phase contrast imaging. It subtracts the maximum pixel value
   388        from the original image and does a background subtraction (bgr = mean(background intensity)) to obtain the inverted image.
   389
   390        """
   391        inverted_image = np.subtract(np.max(self.image), self.image)
   392        bgr = u.median_background(inverted_image, self.mask)  #
   393        self.inverted_image = np.maximum(inverted_image - bgr, 0)
   394        self.im_interp2d = None
   395
   396    def get_bg_channel(self, channel):
   397        _, bg_matrix = u.construct_bg_function(
   398            self.channels[channel], self.mask
   399        )
   400        self.bg_channels[channel] = np.maximum(self.channels[channel] - bg_matrix, 0)
   401        self.chann_interp2d.pop(channel, None)
   402
   403    def get_inverted_interp2d(self):
   404        if self.inverted_image is None:
   405            self.get_inverted_image()
   406        if self.im_interp2d is None:
   407            self.im_interp2d = u.interp2d(self.inverted_image)
   408        return self.im_interp2d
   409
   410    def get_channel_interp2d(self, channel):
   411        if channel not in self.bg_channels:
   412            self.get_bg_channel(channel)
   413        if channel not in self.chann_interp2d:
   414            self.chann_interp2d[channel] = u.interp2d(self.bg_channels[channel])
   415        return self.chann_interp2d[channel]
   416
   417    def channel_interp2d(self):
   418        """
   419        Interpolate channel images using 2D spline interpolation.
   420
   421        This method interpolates the channel images using 2D spline interpolation and stores the interpolated images
   422        in the 'chann_interp2d' dictionary.
   423        """
   424        for channel in self.channels:
   425            self.get_channel_interp2d(channel)
   426
   427    def join_cells(self, thresh=None, smoothing=0.1):
   428        """
   429        Join cells based on pole-to-pole distance.
   430
   431        This method attempts to join adjacent cells within the image based on the distance between their poles. It identifies cell pairs
   432        with pole-to-pole distances below a specified threshold and merges them into a single cell. The joined cell IDs are updated in the
   433        'joined_mask' array.
   434
   435        Parameters
   436        ----------
   437        thresh : float, optional
   438            The distance threshold for joining cells. Cells with pole-to-pole distances below this value will be merged. If None, no
   439            threshold is applied, and all cells will be considered for joining. (default is None)
   440
   441        """
   442
   443        mask = self._active_mask()
   444        if mask is None:
   445            raise ValueError("Mask image is not loaded")
   446        bactoscoop_logger.info(">>> Joining cells ...")
   447
   448        pole_1 = []
   449        pole_2 = []
   450        cell_ID = []
   451        cell_labels = np.unique(mask)
   452        cell_labels = cell_labels[cell_labels != 0]
   453
   454        for cell in tqdm_if_verbose(cell_labels):
   455            temp_mask = np.where(mask == cell, 1, 0)
   456            temp_mask = cv.convertScaleAbs(temp_mask)
   457
   458            _, cropped_mask, _, x, y = u.crop_image(mask_to_crop=temp_mask)
   459
   460            cell_contours = u.get_object_contours(cropped_mask, smoothing=smoothing)
   461            for cn in cell_contours:
   462                try:
   463                    skeleton = u.extract_skeleton(cropped_mask)
   464                except Exception:
   465                    skeleton = []
   466                    cell_ID.append(cell)
   467                    continue
   468                if np.any(skeleton):
   469                    extended_skeleton, pole1, pole2 = u.extend_skeleton(
   470                        skeleton, cn, find_pole1=True, find_pole2=True
   471                    )
   472                    pole1 += np.array([y, x])
   473                    pole2 += np.array([y, x])
   474                    if pole1.shape == (1, 2) and pole2.shape == (1, 2):
   475                        pole_1.append(pole1)
   476                        pole_2.append(pole2)
   477                    else:
   478                        cell_ID.append(cell)
   479                else:
   480                    cell_ID.append(cell)
   481
   482        if pole_1 == []:
   483            cell_pairs = []
   484        else:
   485            pole_1 = np.vstack(np.array(pole_1))
   486            pole_2 = np.vstack(np.array(pole_2))
   487
   488            cell_pairs = u.get_cell_pairs(pole_1, pole_2, cell_ID, thresh)
   489
   490        if not np.any(cell_pairs):
   491            self.joined_mask = np.copy(mask)
   492        else:
   493            # Create a copy of the original masks array to store the new masks
   494            new_masks = np.copy(mask)
   495            dsu = {}
   496            for mask in np.unique(cell_pairs):
   497                dsu[mask + 1] = mask + 1
   498            # Merge masks that belong to the same group
   499            for mask1, mask2 in cell_pairs:
   500                root1 = mask1 + 1
   501                while root1 != dsu[root1]:
   502                    root1 = dsu[root1]
   503                root2 = mask2 + 1
   504                while root2 != dsu[root2]:
   505                    root2 = dsu[root2]
   506                if root1 != root2:
   507                    dsu[root1] = root2
   508
   509            # Update the masks with the merged values
   510            for mask, root in dsu.items():
   511                new_masks[new_masks == mask] = root
   512            unique_masks = np.unique(new_masks)
   513            # Create a dictionary that maps unique mask values to new mask values starting from 1
   514            mask_dict = {mask: i for i, mask in enumerate(unique_masks)}
   515
   516            # Use the dictionary to relabel the new_masks array and the new_labels array
   517            new_masks_relabel = np.vectorize(mask_dict.get)(new_masks)
   518            self.joined_mask = new_masks_relabel
   519
   520    def mask2mesh(self, smoothing=0.1):
   521        """
   522        Create meshes from cell masks.
   523
   524        This method processes cell masks to create corresponding meshes. It generates contours and skeletons for each cell in the mask,
   525        extends the skeleton, and calculates additional geometric properties, such as length and width. Based on these properties, it
   526        straightens the cell contour and creates a mesh representation. The resulting mesh data, including contour, mesh, and midline,
   527        is stored in a DataFrame and associated with the image frame.
   528
   529        This method is based on the cell profile creation of the MOMIA package (doi: 10.1016/j.celrep.2021.110154)
   530
   531        """
   532        if self.joined_mask is not None:
   533            mask = self.joined_mask
   534            bactoscoop_logger.info(">>> Creating meshes from joined masks ...")
   535        else:
   536            mask = self._active_mask()
   537            bactoscoop_logger.info(">>> Creating meshes from raw masks ...")
   538        # loop through all cells in the frame
   539        self.mesh_dataframe = u.get_cellular_mesh(mask, smoothing=smoothing)
   540        self.mesh_dataframe["frame"] = self.frame
   541        self.mesh_dataframe["image_name"] = self.image_name
   542
   543    def split_cells(self, thresh=0.3, CD_width=False, max_daughter_cell_mesh_rows=800):
   544        """
   545        Split cells based on a constriction degree threshold.
   546
   547        This method separates cells within the image based on a given constriction degree threshold. It calculates the constriction degree
   548        for each cell and, if it exceeds the threshold, splits the cell into two separate cells. The new mesh data, including contour, mesh,
   549        and midline, is stored in a DataFrame and associated with the image frame.
   550
   551        This method is based on the splitting algorithm in the Oufti software package (doi: 10.1111/mmi.13264).
   552
   553        Parameters
   554        ----------
   555        thresh : float, optional
   556            The constriction degree threshold for splitting cells. Cells with a constriction degree greater than this threshold will be split.
   557            The default is 0.3.
   558
   559        max_daughter_cell_mesh_rows : int, optional
   560            Maximum final mesh rows per daughter (default 800, minimum 4).
   561            Reject oversized predicted row counts before reconstruction, then
   562            require both final daughters to have 4..limit rows. If either fails,
   563            neither daughter nor the parent enters the processed dataframe.
   564
   565        """
   566        max_daughter_cell_mesh_rows = u._validate_max_daughter_cell_mesh_rows(
   567            max_daughter_cell_mesh_rows
   568        )
   569        self.get_inverted_image()
   570        im_interp2d = u.interp2d(self.inverted_image)
   571
   572        bactoscoop_logger.info(">>> Splitting cells ...")
   573
   574        new_meshes = []
   575        new_contours = []
   576        new_midlines = []
   577
   578        for row in tqdm_if_verbose(self.mesh_dataframe.itertuples(index=False)):
   579
   580            x1 = row.mesh[:, 0]
   581            y1 = row.mesh[:, 1]
   582            x2 = row.mesh[:, 2]
   583            y2 = row.mesh[:, 3]
   584            contour = row.contour
   585            midline = row.midline
   586
   587            complete_mesh = np.array(np.column_stack((x1, y1, x2, y2)))
   588
   589            step_length_px = u.get_step_length(x1, y1, x2, y2, self.px)
   590
   591            width_px = u.get_width(x1, y1, x2, y2) * self.px
   592            if CD_width:
   593                weighted_intprofile = width_px
   594            else:
   595                intprofile = u.measure_smoothened_intensity(
   596                    midline, im_interp2d, width=5
   597                )
   598                weighted_intprofile = u.get_weighted_intprofile(intprofile, width_px)
   599
   600            constrDegree, relPos, constrDegree_abs, ctpos, _= (
   601                u.constr_degree_single_cell_min(
   602                    weighted_intprofile,
   603                    step_length_px,
   604                    upper_limit=0.95,
   605                    lower_limit=0.05,
   606                )
   607            )
   608
   609            if 0.95 > constrDegree > thresh:
   610                try:
   611                    x, y, left, right = u.split_point(x1, y1, x2, y2, ctpos)
   612
   613                    mesh1rec = np.concatenate(
   614                        (complete_mesh[: (ctpos - 2)], right, np.array([[x, y, x, y]]))
   615                    )
   616                    mesh2rec = np.concatenate(
   617                        (np.array([[x, y, x, y]]), left, complete_mesh[(ctpos + 2) :])
   618                    )
   619
   620                    mesh1, contour1, midline1 = u.split_mesh2mesh(
   621                        mesh1rec[:, 0], mesh1rec[:, 1], mesh1rec[:, 2], mesh1rec[:, 3],
   622                        max_daughter_cell_mesh_rows=max_daughter_cell_mesh_rows,
   623                    )
   624                    mesh2, contour2, midline2 = u.split_mesh2mesh(
   625                        mesh2rec[:, 0], mesh2rec[:, 1], mesh2rec[:, 2], mesh2rec[:, 3],
   626                        max_daughter_cell_mesh_rows=max_daughter_cell_mesh_rows,
   627                    )
   628                except Exception as e:
   629                    bactoscoop_logger.debug(f"{e}")
   630                    continue
   631                if all(4 <= len(daughter) <= max_daughter_cell_mesh_rows for daughter in (mesh1, mesh2)):
   632                    new_meshes.append(mesh1)
   633                    new_meshes.append(mesh2)
   634
   635                    new_contours.append(contour1)
   636                    new_contours.append(contour2)
   637
   638                    new_midlines.append(midline1)
   639                    new_midlines.append(midline2)
   640                else:
   641                    bactoscoop_logger.debug(
   642                        "Splitting resulted in a daughter mesh outside 4-%s rows",
   643                        max_daughter_cell_mesh_rows,
   644                    )
   645
   646            else:
   647
   648                new_meshes.append(complete_mesh)
   649                new_contours.append(contour)
   650                new_midlines.append(midline)
   651        self.processed_mesh_dataframe = pd.DataFrame(
   652            {"mesh": new_meshes, "contour": new_contours, "midline": new_midlines}
   653        )
   654        self.processed_mesh_dataframe["frame"] = self.frame
   655        self.processed_mesh_dataframe["image_name"] = self.image_name
   656
   657    def object_detection(
   658        self,
   659        channels=None,
   660        smoothing=0.1,
   661        align=False,
   662        log_sigma=3,
   663        kernel_width=4,
   664        min_overlap_ratio=0.01,
   665        max_external_ratio=0.1,
   666    ):
   667        """
   668        Detect subcellular objects within cell contours in specified channels.
   669
   670        This method performs object detection within the contours of cells for the specified channels. It uses a Laplacian of Gaussian (LoG)
   671        filter to detect subcellular objects and returns their contours. The detected objects are stored in a DataFrame, including the frame,
   672        cell ID, channel name, and object contours.
   673
   674        Parameters
   675        ----------
   676        channels : List of str, optional
   677            The list of channel names for which to perform object detection.
   678
   679        log_sigma : int, optional
   680            The sigma parameter of the LoG filter used in object detection. Increasing this parameter may detect larger objects.
   681            The default is 3.
   682
   683        kernel_width : int, optional
   684            The kernel width used for dilating object masks. Increasing this value results in larger masks.
   685            The default is 4.
   686
   687        min_overlap_ratio : float, optional
   688            The minimum overlap ratio between detected objects and cell contours to keep the object. Objects with lower overlap are discarded.
   689            The default is 0.01.
   690
   691        max_external_ratio : float, optional
   692            The maximum ratio an object is allowed to lie outside the cell contour. Objects exceeding this ratio are discarded.
   693            The default is 0.1.
   694
   695        Returns
   696        -------
   697        pd.DataFrame
   698            A DataFrame containing information about the detected objects, including frame, cell ID, channel name, and object contours.
   699        """
   700
   701        all_objects = []
   702        self.object_detection_errors = []
   703
   704        for channel in channels:
   705            # A repeat attempt replaces this channel, even when it fails or
   706            # detects no objects. Results for other channels remain intact.
   707            for cell in self.cells:
   708                cell.object_meshdata.pop(channel, None)
   709
   710            if channel in self.channels:
   711
   712                for cell in self.cells:
   713                    if align:
   714                        img = self.image
   715                    else:
   716                        img = None
   717                    try:
   718                        meshdata = u.get_object_mesh(
   719                            cell.contour,
   720                            self.channels[channel],
   721                            img,
   722                            cell.cell_id,
   723                            smoothing,
   724                            log_sigma,
   725                            kernel_width,
   726                            min_overlap_ratio,
   727                            max_external_ratio,
   728                        )
   729                        cell.object_meshdata[channel] = meshdata
   730                        for mesh_error in meshdata.get("mesh_construction_errors", []):
   731                            self.object_detection_errors.append(
   732                                {
   733                                    "cell_id": cell.cell_id,
   734                                    "channel": channel,
   735                                    "error_type": mesh_error["error_type"],
   736                                    "error": (
   737                                        f"Object {mesh_error['object_index']} mesh construction "
   738                                        f"failed: {mesh_error['error']}"
   739                                    ),
   740                                    "recovered": True,
   741                                }
   742                            )
   743
   744                        if cell.object_meshdata[channel]["object_contour"]:
   745                            all_objects.append(
   746                                {
   747                                    "frame": self.frame,
   748                                    "cell_id": cell.cell_id,
   749                                    "cell_mesh": cell.mesh,
   750                                    "channel": channel,
   751                                    "object_contours": cell.object_meshdata[channel][
   752                                        "object_contour"
   753                                    ],
   754                                    "object_mesh": cell.object_meshdata[channel][
   755                                        "object_mesh"
   756                                    ],
   757                                    "object_midline": cell.object_meshdata[channel][
   758                                        "object_midline"
   759                                    ],
   760                                }
   761                            )
   762
   763                    except IndexError as e:
   764                        cell.object_meshdata.pop(channel, None)
   765                        self.object_detection_errors.append(
   766                            {
   767                                "cell_id": cell.cell_id,
   768                                "channel": channel,
   769                                "error_type": type(e).__name__,
   770                                "error": str(e),
   771                            }
   772                        )
   773
   774        detections_df = pd.DataFrame(all_objects)
   775
   776        return detections_df
   777
   778    def shift_correction(
   779        self,
   780        shifted_channel,
   781        log_sigma,
   782        kernel_width,
   783        min_overlap_ratio,
   784        max_external_ratio,
   785        phase_log_sigma,
   786        phase_closing_level,
   787        signal_closing_level,
   788        max_shift_correction,
   789    ):
   790        self.shift_errors = []
   791        if not self.cells:
   792            bactoscoop_logger.warning(
   793                f"No cells available for shift correction in frame {self.frame}."
   794            )
   795            return 0.0
   796
   797        for channel in shifted_channel:
   798            if channel not in self.channels:
   799                raise ValueError(
   800                    f"'{shifted_channel}' is not present in the channels attribute."
   801                )
   802            failed_attempts = 0
   803            for cell in self.cells:
   804                try:
   805                    cell.shifted_contour, cell.shifted_mesh, cell.shifted_midline = (
   806                        u.shift_contour(
   807                            self.image,
   808                            self.channels[channel],
   809                            cell.contour,
   810                            cell.mesh,
   811                            cell.midline,
   812                            log_sigma,
   813                            kernel_width,
   814                            min_overlap_ratio,
   815                            max_external_ratio,
   816                            phase_log_sigma,
   817                            phase_closing_level,
   818                            signal_closing_level,
   819                            max_shift_correction,
   820                        )
   821                    )
   822                except Exception as e:
   823                    self.shift_errors.append(
   824                        {
   825                            "cell_id": cell.cell_id,
   826                            "channel": channel,
   827                            "error_type": type(e).__name__,
   828                            "error": str(e),
   829                        }
   830                    )
   831                    cell.shifted_contour = None
   832                    cell.shifted_mesh = None
   833                    cell.shifted_midline = None
   834                if cell.shifted_contour is None:
   835                    failed_attempts += 1
   836        return (len(self.cells) - failed_attempts) / len(self.cells)
   837
   838
   839class Cell:
   840    """
   841    Class representing a single cell in an image.
   842
   843    This class represents a single cell within an image, characterized by its contour, mesh, and midline data. It is identified by a unique cell ID.
   844
   845    Attributes
   846    ----------
   847    discard : bool
   848        A flag to indicate whether the cell should be discarded during analysis.
   849
   850    contour : ndarray
   851        The contour data of the cell.
   852
   853    mesh : ndarray
   854        The mesh data (4xN).
   855
   856    midline : ndarray
   857        The midline data describing the cell's central axis (2xN).
   858
   859    cell_id : int
   860        A unique identifier for the cell.
   861
   862    x1, y1, x2, y2 : ndarray
   863        Arrays representing the coordinates of the cell's mesh.
   864
   865    profile_mesh : None
   866        Placeholder for profile mesh data.
   867
   868    object_contours : dict
   869        A dictionary to store detected subcellular object contours in different channels.
   870
   871    morphological_features : dict
   872        Dictionary to store morphological features of the cell.
   873
   874    phase_features : dict
   875        Dictionary to store phase contrast features of the cell.
   876
   877    objects_features : dict
   878        Dictionary to store features related to subcellular objects within the cell.
   879
   880    svm_features : dict
   881        Dictionary to store support vector machine (SVM) features.
   882
   883    profiling_features : dict
   884        Dictionary to store profiling features.
   885
   886    profiling_data : dict
   887        Dictionary to store data related to the profiling of the cell.
   888    """
   889
   890    def __init__(self, contour, mesh, midline, shape, cell_id):
   891
   892        self.discard = False
   893        self.contour = contour
   894        self.mesh = mesh
   895        self.midline = midline
   896        self.cell_id = cell_id
   897
   898        self.shifted_contour = None
   899        self.shifted_mesh = None
   900        self.shifted_midline = None
   901
   902        self.x1 = self.mesh[:, 0]
   903        self.y1 = self.mesh[:, 1]
   904        self.x2 = self.mesh[:, 2]
   905        self.y2 = self.mesh[:, 3]
   906
   907        self.profile_mesh = None
   908
   909        self.object_meshdata = {}
   910
   911        self.morphological_features = {}
   912        self.phase_features = {}
   913        self.objects_features = {}
   914        self.svm_features = {}
   915        self.profiling_features = {}
   916        self.signal_correlation_features = {}
   917        self.profiling_data = {}