Source: bactoscoop/image.pyΒΆ

  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 = {}