Source: bactoscoop/imagecollection.pyΒΆ

   1# -*- coding: utf-8 -*-
   2"""
   3Created on Wed Apr 12 16:47:19 2023
   4
   5@author: Bart Steemans. Govers Lab.
   6"""
   7
   8import os
   9import pickle
  10import cv2 as cv
  11import numpy as np
  12import tifffile
  13from . import utilities as u
  14from .logging_utils import get_bactoscoop_logger, tqdm_if_verbose
  15from .image import Image
  16import pandas as pd
  17from itertools import combinations
  18
  19import logging, sys, datetime, traceback
  20import gc
  21import tempfile
  22
  23
  24bactoscoop_logger = get_bactoscoop_logger()
  25# # Change following to DEBUG to see more information
  26# bactoscoop_logger.setLevel(logging.INFO)
  27
  28
  29class ImageCollection:
  30    """ """
  31
  32    def __init__(
  33        self, image_folder_path=None, px=0.065, error_policy="continue",
  34        log_cell_errors=False,
  35    ):
  36        """Create a collection.
  37
  38        ``error_policy="continue"`` records item-level errors and proceeds with
  39        independent fields. ``error_policy="raise"`` raises on the first one.
  40        Required-stage failures still stop dependent work in either mode.
  41        Cell feature failures are recorded separately and do not make a run
  42        partial in continue mode. Set ``log_cell_errors=True`` to print each one.
  43        """
  44        if error_policy not in {"continue", "raise"}:
  45            raise ValueError("error_policy must be either 'continue' or 'raise'.")
  46        self.error_policy = error_policy
  47        self.log_cell_errors = log_cell_errors
  48        self.processing_errors = []
  49        self.cell_errors = []
  50        self.completed_stages = set()
  51
  52        self.px = px  # microns per pixel
  53        self.images = None
  54        self.masks = None
  55        self.channel_images = {}
  56        self.channel_image_filenames = {}
  57        self.channel_images_by_field = {}
  58        self.inverted_image = None
  59        self.channel_list = None
  60        self.image_filenames = None
  61        self.mask_filenames = None
  62        self.phase_channel = ""
  63        self.segmentation_failed = False
  64
  65        self.image_folder_path = image_folder_path  # Path to the folder to be processed
  66        self.name = (
  67            os.path.basename(os.path.normpath(self.image_folder_path))
  68            if self.image_folder_path is not None
  69            else None
  70        )
  71
  72        self.image_objects = []  # Array that will be populated with objects
  73
  74        self.mesh_df_collection = pd.DataFrame()
  75
  76        self.feature_dataframes = {}
  77        self.merged_features = None
  78        self.mesh_prefilter_stats = pd.DataFrame()
  79
  80    def _require_image_folder_path(self):
  81        if self.image_folder_path is None:
  82            raise ValueError("image_folder_path must be set before loading image data.")
  83
  84    def _require_loaded_images(self):
  85        if not self.image_objects:
  86            raise ValueError(
  87                "No image objects are available. Run create_image_objects() or load mesh data first."
  88            )
  89
  90    def _record_processing_error(
  91        self, stage, error, image_name=None, channel=None, cell_id=None
  92    ):
  93        record = {
  94            "stage": stage,
  95            "image_name": image_name,
  96            "channel": channel,
  97            "cell_id": cell_id,
  98            "error_type": type(error).__name__,
  99            "error": str(error),
 100        }
 101        self.processing_errors.append(record)
 102        bactoscoop_logger.error(
 103            "Processing error at %s (image=%s, channel=%s, cell_id=%s): %s",
 104            stage,
 105            image_name,
 106            channel,
 107            cell_id,
 108            error,
 109        )
 110        if self.error_policy == "raise":
 111            raise error
 112        return record
 113
 114    def _record_cell_error(
 115        self, stage, error, image_name, channel, cell_id, error_type=None
 116    ):
 117        record = {
 118            "stage": stage,
 119            "image_name": image_name,
 120            "channel": channel,
 121            "cell_id": cell_id,
 122            "error_type": error_type or type(error).__name__,
 123            "error": str(error),
 124        }
 125        self.cell_errors.append(record)
 126        if self.log_cell_errors:
 127            bactoscoop_logger.warning(
 128                "Cell processing error at %s (image=%s, channel=%s, cell_id=%s): %s",
 129                stage, image_name, channel, cell_id, error,
 130            )
 131        if self.error_policy == "raise":
 132            raise error
 133        return record
 134
 135    def processing_summary(self):
 136        """Return attempted-stage status and retained image/cell error records."""
 137        if self.processing_errors:
 138            status = "completed_with_errors"
 139        elif self.completed_stages:
 140            status = "completed"
 141        else:
 142            status = "not_started"
 143        return {
 144            "status": status,
 145            "completed_stages": sorted(self.completed_stages),
 146            "error_count": len(self.processing_errors),
 147            "errors": list(self.processing_errors),
 148            "cell_error_count": len(self.cell_errors),
 149            "cell_errors": list(self.cell_errors),
 150        }
 151
 152    def _record_stage_success(self, stage):
 153        self.completed_stages.add(stage)
 154
 155    def _validate_phase_image_state(self):
 156        if self.images is None or self.image_filenames is None:
 157            raise ValueError(
 158                "Phase images are not loaded. Run load_phase_images() before creating image objects."
 159            )
 160        if self.masks is None:
 161            raise ValueError(
 162                "Masks are not loaded. Run load_masks() before creating image objects."
 163            )
 164        if len(self.image_filenames) != len(self.images):
 165            raise ValueError(
 166                f"Loaded {len(self.image_filenames)} image names but {len(self.images)} phase images."
 167            )
 168        if self.mask_filenames is None or len(self.mask_filenames) != len(self.masks):
 169            raise ValueError("Mask arrays and mask filenames are not aligned.")
 170
 171        phase_by_key = {}
 172        for index, filename in enumerate(self.image_filenames):
 173            key = u.image_field_key(filename, suffix=self.phase_channel)
 174            if key in phase_by_key:
 175                self._record_processing_error(
 176                    "phase_mask_matching",
 177                    ValueError(f"Duplicate phase field identity '{key}'."),
 178                    image_name=filename,
 179                )
 180                continue
 181            phase_by_key[key] = (index, filename, self.images[index])
 182
 183        masks_by_key = {}
 184        for index, filename in enumerate(self.mask_filenames):
 185            key = u.image_field_key(
 186                filename, suffix=self.phase_channel, is_mask=True
 187            )
 188            if key in masks_by_key:
 189                self._record_processing_error(
 190                    "phase_mask_matching",
 191                    ValueError(f"Duplicate mask field identity '{key}'."),
 192                    image_name=filename,
 193                )
 194                continue
 195            masks_by_key[key] = (filename, self.masks[index])
 196
 197        matched = []
 198        for key, (frame, image_name, image) in phase_by_key.items():
 199            mask_entry = masks_by_key.get(key)
 200            if mask_entry is None:
 201                self._record_processing_error(
 202                    "phase_mask_matching",
 203                    ValueError(f"No mask matches phase field '{image_name}'."),
 204                    image_name=image_name,
 205                )
 206                continue
 207            mask_name, mask = mask_entry
 208            if image.ndim != 2 or mask.ndim != 2:
 209                self._record_processing_error(
 210                    "phase_mask_matching",
 211                    ValueError(
 212                        f"Phase and mask images must be 2D; got phase shape "
 213                        f"{image.shape} and mask '{mask_name}' shape {mask.shape}."
 214                    ),
 215                    image_name=image_name,
 216                )
 217                continue
 218            if image.shape != mask.shape:
 219                self._record_processing_error(
 220                    "phase_mask_matching",
 221                    ValueError(
 222                        f"Phase shape {image.shape} does not match mask "
 223                        f"'{mask_name}' shape {mask.shape}."
 224                    ),
 225                    image_name=image_name,
 226                )
 227                continue
 228            matched.append((frame, image_name, image, mask))
 229
 230        for key, (mask_name, _) in masks_by_key.items():
 231            if key not in phase_by_key:
 232                self._record_processing_error(
 233                    "phase_mask_matching",
 234                    ValueError(f"No phase image matches mask '{mask_name}'."),
 235                    image_name=mask_name,
 236                )
 237        if not matched:
 238            raise ValueError("No phase images have matching masks and dimensions.")
 239        return matched
 240    def _normalize_channel_list(self, channel_list, argument_name="channel_list"):
 241        if channel_list is None:
 242            raise ValueError(
 243                f"No {argument_name} provided. It should be of format ['channelname1', 'channelname2']."
 244            )
 245        if isinstance(channel_list, str):
 246            raise TypeError(
 247                f"{argument_name} must be an iterable of channel names, not a single string."
 248            )
 249        normalized_channels = list(channel_list)
 250        if not normalized_channels:
 251            raise ValueError(f"{argument_name} must contain at least one channel name.")
 252        return normalized_channels
 253
 254    def _iter_processing_targets(self, object_list, phase_channel):
 255        if isinstance(object_list, Image):
 256            return [object_list]
 257        if object_list is None:
 258            self.create_image_objects(phase_channel=phase_channel)
 259            return self.image_objects
 260        if isinstance(object_list, (list, tuple)):
 261            return list(object_list)
 262        raise TypeError(
 263            "object_list must be None, a single Image instance, or a list/tuple of Image instances."
 264        )
 265
 266    def _get_mesh_dataframe_lookup(self):
 267        """Build an image-name lookup from the current mesh dataframe.
 268
 269        Do not retain this lookup across calls: callers can replace or mutate
 270        ``mesh_df_collection``, and a cached lookup could then return stale
 271        meshes for newly loaded data.
 272        """
 273        if not isinstance(self.mesh_df_collection, pd.DataFrame) or self.mesh_df_collection.empty:
 274            return None
 275        return {
 276            image_name: group.reset_index(drop=True)
 277            for image_name, group in self.mesh_df_collection.groupby(
 278                "image_name", sort=False
 279            )
 280        }
 281
 282
 283    def load_masks(self):
 284        """
 285        Read masks from the ``/masks`` subfolder.
 286
 287        Mutates ``self.masks`` and ``self.mask_filenames``.
 288
 289        """
 290        self._require_image_folder_path()
 291        self.masks, self.mask_filenames = u.read_tiff_folder(
 292            self.image_folder_path + "/masks"
 293        )
 294        self._record_stage_success("load_masks")
 295
 296    def load_phase_images(self, phase_channel=""):
 297        """
 298        This method loads phase contrast images from the specified image folder path, based on the characters before the file-extension,
 299        and stores them in the 'self.images' array.
 300
 301        Parameters
 302        ----------
 303        phase_channel : str, optional
 304            Characters before the file-extension (e.g., "c1", "phase") to look for when using this function. (default is an empty string)
 305
 306        Mutates ``self.images``, ``self.image_filenames`` and ``self.paths``.
 307
 308        """
 309        self._require_image_folder_path()
 310        self.phase_channel = phase_channel or ""
 311        try:
 312            self.images, self.image_filenames, self.paths = u.read_tiff_folder(
 313                self.image_folder_path, phase_channel, include_paths=True
 314            )
 315        except Exception as e:
 316            raise type(e)(
 317                f"Failed to load phase contrast images from '{self.image_folder_path}' "
 318                f"with phase_channel='{phase_channel}': {e}"
 319            ) from e
 320        self._record_stage_success("load_phase_images")
 321
 322    def load_channel_images(self, channel_list):
 323        """
 324        This method loads channel images from the image folder path based on the specified characters before the file-extension and stores them in
 325        the 'self.channel_images' dictionary, where the channel name is used as the key.
 326
 327        Parameters
 328        ----------
 329        channel_list : List of str
 330            Characters before the extension (e.g., ["c2", "c3"]) to look for when using this function.
 331
 332        Mutates ``self.channel_images``.
 333
 334        """
 335        self._require_image_folder_path()
 336        self.channel_list = self._normalize_channel_list(channel_list)
 337        for channel in self.channel_list:
 338            # A failed read must not leave arrays from an earlier folder/load in place.
 339            self.channel_images.pop(channel, None)
 340            self.channel_image_filenames.pop(channel, None)
 341            self.channel_images_by_field.pop(channel, None)
 342            try:
 343                images, filenames = u.read_tiff_folder(
 344                    self.image_folder_path, suffix=channel
 345                )
 346            except Exception as e:
 347                self._record_processing_error(
 348                    "load_channel_images", e, channel=channel
 349                )
 350                continue
 351
 352            self.channel_images[channel] = list(images)
 353            self.channel_image_filenames[channel] = list(filenames)
 354            keyed_images = {}
 355            for filename, image in zip(filenames, images):
 356                key = u.image_field_key(filename, suffix=channel)
 357                if key in keyed_images:
 358                    self._record_processing_error(
 359                        "load_channel_images",
 360                        ValueError(f"Duplicate channel field identity '{key}'."),
 361                        image_name=filename,
 362                        channel=channel,
 363                    )
 364                    continue
 365                keyed_images[key] = image
 366            self.channel_images_by_field[channel] = keyed_images
 367        self._record_stage_success("load_channel_images")
 368
 369    def create_image_objects(self, phase_channel=""):
 370        """
 371        Create ``Image`` objects from the loaded masks and phase images.
 372
 373        Invalid collection-level setup raises immediately. Failures while creating
 374        a single image object are logged and skipped so the rest of the batch can continue.
 375        """
 376        if self.segmentation_failed:
 377            raise RuntimeError(
 378                "Segmentation failed in this collection; refusing to load masks "
 379                "from disk because they may belong to an earlier run."
 380            )
 381        if self.masks is None:
 382            self.load_masks()
 383        if self.images is None or self.image_filenames is None:
 384            self.load_phase_images(phase_channel)
 385
 386        matched_images = self._validate_phase_image_state()
 387
 388        # Feature tables and merged output belong to the previous image/cell set.
 389        self.feature_dataframes = {}
 390        self.merged_features = None
 391        self.image_objects = []
 392        mesh_lookup = self._get_mesh_dataframe_lookup()
 393
 394        for i, image_name, image, mask in matched_images:
 395            try:
 396                mesh_df = mesh_lookup.get(image_name) if mesh_lookup is not None else None
 397
 398                img_obj = self.create_image_object(
 399                    image, image_name, i, mask, mesh_df, self.px
 400                )
 401                self.image_objects.append(img_obj)
 402            except Exception as e:
 403                bactoscoop_logger.debug(traceback.format_exc())
 404                self._record_processing_error(
 405                    "create_image_objects", e, image_name=image_name
 406                )
 407        self._record_stage_success("create_image_objects")
 408
 409    def add_channels(self, img_objects, channel_list, load_data=True):
 410        """
 411        This method loads channel images based on the provided channel list and adds them to the individual image objects.
 412
 413        Parameters
 414        ----------
 415        img_objects : List
 416            List of image objects.
 417        channel_list : List
 418            List of channel names.
 419
 420        """
 421        channel_list = self._normalize_channel_list(channel_list)
 422        if img_objects is None or len(img_objects) == 0:
 423            raise ValueError("img_objects must contain at least one image object.")
 424
 425        # Load requested channels while keeping field identity beside each array.
 426        if load_data:
 427            self.load_channel_images(channel_list)
 428
 429        expected_fields = {
 430            u.image_field_key(obj.image_name, suffix=self.phase_channel)
 431            for obj in img_objects
 432        }
 433        for channel in channel_list:
 434            for extra_field in self.channel_images_by_field.get(channel, {}):
 435                if extra_field not in expected_fields:
 436                    self._record_processing_error(
 437                        "match_channel_image",
 438                        ValueError(
 439                            f"No phase image object matches {channel} field '{extra_field}'."
 440                        ),
 441                        image_name=extra_field,
 442                        channel=channel,
 443                    )
 444
 445        for img_obj in img_objects:
 446            channel_single_image_dict = dict(getattr(img_obj, "channels", None) or {})
 447            field_key = u.image_field_key(
 448                img_obj.image_name, suffix=self.phase_channel
 449            )
 450            for channel in channel_list:
 451                # Never retain an earlier image or derived interpolation for a
 452                # requested channel if its current file is missing or invalid.
 453                channel_single_image_dict.pop(channel, None)
 454                for cache_name in ("bg_channels", "chann_interp2d"):
 455                    cache = getattr(img_obj, cache_name, None)
 456                    if isinstance(cache, dict):
 457                        cache.pop(channel, None)
 458                channel_fields = self.channel_images_by_field.get(channel, {})
 459                channel_image = channel_fields.get(field_key)
 460                if channel_image is None:
 461                    self._record_processing_error(
 462                        "match_channel_image",
 463                        ValueError(
 464                            f"No {channel} image matches field '{img_obj.image_name}'."
 465                        ),
 466                        image_name=img_obj.image_name,
 467                        channel=channel,
 468                    )
 469                    continue
 470                if channel_image.ndim != 2 or img_obj.image.ndim != 2:
 471                    self._record_processing_error(
 472                        "match_channel_image",
 473                        ValueError(
 474                            f"{channel} and phase images must be 2D; got channel "
 475                            f"shape {channel_image.shape} and phase shape {img_obj.image.shape}."
 476                        ),
 477                        image_name=img_obj.image_name,
 478                        channel=channel,
 479                    )
 480                    continue
 481                if channel_image.shape != img_obj.image.shape:
 482                    self._record_processing_error(
 483                        "match_channel_image",
 484                        ValueError(
 485                            f"{channel} image shape {channel_image.shape} does not "
 486                            f"match phase shape {img_obj.image.shape}."
 487                        ),
 488                        image_name=img_obj.image_name,
 489                        channel=channel,
 490                    )
 491                    continue
 492                channel_single_image_dict[channel] = channel_image
 493            img_obj.channels = channel_single_image_dict or None
 494        self._record_stage_success("add_channels")
 495
 496    def get_mesh_dataframe(self, image_name):
 497        """
 498        This method returns the mesh dataframe associated with the specified image name.
 499
 500        Parameters
 501        ----------
 502        image_name : str
 503            The name or identifier of the image.
 504
 505        """
 506
 507        mesh_lookup = self._get_mesh_dataframe_lookup()
 508        if mesh_lookup is None:
 509            return None
 510        return mesh_lookup.get(image_name)
 511
 512    def create_image_object(self, image, image_name, index, mask, mesh_df, px):
 513        """
 514        This method creates a single Image object using the provided image, image name, index, mask, mesh dataframe, and pixel size (px).
 515        If mesh data is present, it also creates associated Cell objects.
 516
 517        Parameters
 518        ----------
 519        image : phase contrast image
 520            The phase contrast image for the Image object.
 521        image_name : str
 522            The name or identifier of the image.
 523        index : int
 524            The index or position of the image.
 525        mask : mask or None
 526            The mask or region of interest associated with the image.
 527        mesh_df : pd.DataFrame or None
 528            The mesh dataframe associated with the image, if available, or None.
 529        px : float
 530        The pixel size in micrometers.
 531        """
 532
 533        img_obj = Image(image, image_name, index, mask, mesh_df, px=px)
 534
 535        if mesh_df is not None:
 536            img_obj.create_cell_object(verbose=False)
 537        return img_obj
 538
 539    # Methods for batch processing of images ----------------------------------------------
 540    def segment_images(self, mask_thresh, minsize, n, model_name="bact_phase_omni"):
 541        self.segmentation_failed = False
 542        self.masks = None
 543        self.mask_filenames = None
 544        try:
 545            from .omni import Omnipose
 546            import torch
 547
 548            omni = Omnipose(self.images, self.paths)
 549            omni.load_models(model_name)
 550
 551            omni.compiled_process(n, mask_thresh, minsize)
 552            torch.cuda.empty_cache()
 553
 554            del omni
 555            gc.collect()
 556            self._record_stage_success("segment_images")
 557            return True
 558        except Exception as e:
 559            self.segmentation_failed = True
 560            bactoscoop_logger.debug(traceback.format_exc())
 561            self._record_processing_error("segment_images", e)
 562            return False
 563
 564    def batch_detect_objects(
 565        self,
 566        channels=None,
 567        reset_channels=True,
 568        align=False,
 569        smoothing=0.1,
 570        log_sigma=3,
 571        kernel_width=4,
 572        min_overlap_ratio=0.01,
 573        max_external_ratio=0.1,
 574    ):
 575        """
 576        Detect objects within the contour of the cell.
 577
 578        Parameters
 579        ----------
 580        channels : List of strings, optional
 581            The channel names of the channels to perform object detection on.
 582
 583        log_sigma : int, optional
 584            The sigma parameter of the Laplacian of Gaussian filter used in object detection. (default is 3)
 585
 586        kernel_width : int, optional
 587            The kernel width of the kernel used for dilating the object masks.
 588            Decreasing results in smaller masks, while increasing results in larger masks. (default is 4)
 589
 590        min_overlap_ratio : float, optional
 591            Minimum overlap between the object and cell required for the object to be kept. (default is 0.01)
 592
 593        max_external_ratio : float, optional
 594            The maximum ratio an object is allowed to lie outside the cell contour. (default is 0.1)
 595
 596        Returns
 597        -------
 598        pd.DataFrame
 599            A DataFrame containing cell_id, object_contours, and frame information.
 600
 601        """
 602        self._require_loaded_images()
 603        channels = self._normalize_channel_list(channels, argument_name="channels")
 604
 605        if reset_channels:
 606            self.channel_images = {}
 607            self.channel_image_filenames = {}
 608            self.channel_images_by_field = {}
 609            for image in self.image_objects:
 610                image.channels = None
 611                for cache_name in ("bg_channels", "chann_interp2d"):
 612                    cache = getattr(image, cache_name, None)
 613                    if isinstance(cache, dict):
 614                        cache.clear()
 615
 616        channels_to_load = [
 617            channel for channel in channels
 618            if channel not in self.channel_images_by_field
 619        ]
 620        if channels_to_load:
 621            self.load_channel_images(channels_to_load)
 622        self.add_channels(self.image_objects, channels, load_data=False)
 623
 624        bactoscoop_logger.info("Detecting objects within cell ...")
 625
 626        dfs = []
 627        for image in tqdm_if_verbose(self.image_objects):
 628            for cell in getattr(image, "cells", []):
 629                for channel in channels:
 630                    cell.object_meshdata.pop(channel, None)
 631            if image.channels is None or any(
 632                channel not in image.channels for channel in channels
 633            ):
 634                # The missing field has already been recorded by add_channels.
 635                continue
 636            try:
 637                detection_df = image.object_detection(
 638                    channels,
 639                    smoothing,
 640                    align,
 641                    log_sigma,
 642                    kernel_width,
 643                    min_overlap_ratio,
 644                    max_external_ratio,
 645                )
 646            except Exception as e:
 647                bactoscoop_logger.debug(traceback.format_exc())
 648                self._record_processing_error(
 649                    "object_detection", e, image_name=image.image_name
 650                )
 651                continue
 652            dfs.append(detection_df)
 653            cell_errors = getattr(image, "object_detection_errors", [])
 654            for cell_error in cell_errors:
 655                self._record_cell_error(
 656                    "cell_object_detection",
 657                    RuntimeError(cell_error["error"]),
 658                    image_name=image.image_name,
 659                    channel=cell_error.get("channel"),
 660                    cell_id=cell_error.get("cell_id"),
 661                    error_type=cell_error.get("error_type"),
 662                )
 663            cell_count = len(getattr(image, "cells", []))
 664            failed_attempts = sum(
 665                not error.get("recovered", False) for error in cell_errors
 666            )
 667            if cell_count and failed_attempts >= cell_count * len(channels):
 668                self._record_processing_error(
 669                    "object_detection",
 670                    ValueError(
 671                        f"All cell object-detection attempts failed for '{image.image_name}'."
 672                    ),
 673                    image_name=image.image_name,
 674                )
 675
 676        self.object_detection_df = (
 677            pd.concat(dfs, axis=0, ignore_index=True) if dfs else pd.DataFrame()
 678        )
 679
 680        if not self.object_detection_df.empty and "channel" in self.object_detection_df:
 681            bactoscoop_logger.info(self.object_detection_df["channel"].value_counts())
 682        else:
 683            bactoscoop_logger.info("No objects were detected.")
 684
 685        self._record_stage_success("batch_detect_objects")
 686        return self.object_detection_df
 687
 688    def batch_process_mesh(
 689        self,
 690        pkl_name=None,
 691        object_list=None,
 692        save_data=True,
 693        phase_channel="",
 694        join_thresh=4,
 695        split_thresh=0.35,
 696        CD_width=False,
 697        smoothing=0.1,
 698        neighbor_filter_max_neighbors=None,
 699        neighbor_filter_connectivity=1,
 700        max_daughter_cell_mesh_rows=800,
 701    ):
 702        """
 703        Process cellular meshes from segmented masks and store the data in Cell objects.
 704
 705        After segmentation, this method can process the resulting masks and create cellular meshes from them.
 706        It first joins cells with a pole-pole distance (px) below the 'join_thresh'.
 707        Subsequently, meshes of all cells are created and cells can be split again if the constriction degree exceeds the 'split_thresh'.
 708        The data is then stored in Cell objects and can be exported in a pickle file.
 709
 710        Parameters
 711        ----------
 712        pkl_name : str, optional
 713            Name of the pickle file to save the mesh data. If None, the file will be saved as 'folder_name_meshdata.pkl'.
 714
 715        object_list : List, optional
 716            Specify a list of Image objects to be processed. If 'object_list' is None, all Image instances in 'image_objects' are processed.
 717
 718        phase_channel : str, optional
 719            Characters before the file-extension (.tif, .tiff) that the algorithm looks for when using this function (e.g., "c1", "phase").
 720
 721        join_thresh : int, optional
 722            The pole-pole distance threshold for joining cells. Cells with a distance below this threshold will have their masks joined. (default is 4)
 723
 724        split_thresh : float, optional
 725            The constriction degree threshold for splitting cells. Cells with a constriction degree exceeding this threshold will be split. (default is 0.35)
 726
 727        max_daughter_cell_mesh_rows : int, optional
 728            Maximum rebuilt daughter mesh rows (default 800, minimum 4).
 729            Oversized resampling is rejected before allocation; both final
 730            daughters must have 4..limit rows. This is separate from the feature
 731            extraction max_mesh_size parameter and does not limit unsplit parents.
 732
 733        """
 734        max_daughter_cell_mesh_rows = u._validate_max_daughter_cell_mesh_rows(
 735            max_daughter_cell_mesh_rows
 736        )
 737        self.mesh_df_collection = pd.DataFrame()
 738        self.mesh_prefilter_stats = pd.DataFrame()
 739        object_list = self._iter_processing_targets(object_list, phase_channel)
 740        mesh_frames = []
 741        prefilter_stats = []
 742
 743        for img_obj in object_list:
 744            bactoscoop_logger.info(f"PROCESSING IMAGE: {img_obj.image_name}")
 745            try:
 746                img_obj.join_split_pipeline(
 747                    join_thresh,
 748                    split_thresh,
 749                    CD_width,
 750                    smoothing,
 751                    neighbor_filter_max_neighbors=neighbor_filter_max_neighbors,
 752                    neighbor_filter_connectivity=neighbor_filter_connectivity,
 753                    max_daughter_cell_mesh_rows=max_daughter_cell_mesh_rows,
 754                )
 755                mesh_df = getattr(img_obj, "processed_mesh_dataframe", None)
 756                if mesh_df is None:
 757                    raise ValueError(
 758                        f"Image '{img_obj.image_name}' did not produce a processed mesh dataframe."
 759                    )
 760                mesh_frames.append(mesh_df)
 761                if getattr(img_obj, "prefilter_summary", None) is not None:
 762                    prefilter_stats.append(
 763                        {
 764                            "image_name": img_obj.image_name,
 765                            **img_obj.prefilter_summary,
 766                        }
 767                    )
 768                img_obj.create_cell_object(verbose=False)
 769            except Exception as e:
 770                bactoscoop_logger.debug(traceback.format_exc())
 771                self._record_processing_error(
 772                    "mesh_processing", e, image_name=img_obj.image_name
 773                )
 774
 775        self.mesh_df_collection = (
 776            pd.concat(mesh_frames, ignore_index=True) if mesh_frames else pd.DataFrame()
 777        )
 778        self.mesh_prefilter_stats = (
 779            pd.DataFrame(prefilter_stats) if prefilter_stats else pd.DataFrame()
 780        )
 781
 782        if save_data:
 783            if pkl_name is not None:
 784                self._to_pickle(pkl_name, self.mesh_df_collection)
 785
 786            self._to_pickle(f"{self.name}_meshdata.pkl", self.mesh_df_collection)
 787
 788        self._record_stage_success("batch_process_mesh")
 789
 790
 791    def batch_load_mesh(self, pkl_name, pkl_path=None, phase_channel=""):
 792        """
 793        Load mesh data from a pickle-file and create image objects from the data.
 794
 795        Parameters
 796        ----------
 797        pkl_name : str
 798            Name of the pickle file where the mesh data is stored (e.g., 'Ecoli_meshdata.pkl').
 799
 800        pkl_path : str, optional
 801            Path to the file where the mesh data is stored. If not provided, the method will look in the image folder path.
 802
 803        phase_channel : str, optional
 804            Characters before the file-extension (.tif, .tiff) that the algorithm looks for when using this function (e.g., "c1", "phase").
 805
 806        """
 807        self.mesh_df_collection = None
 808        if pkl_path is None:
 809            pkl_path = self.image_folder_path
 810
 811        full_path = os.path.join(pkl_path, pkl_name)
 812        if not os.path.isfile(full_path):
 813            raise FileNotFoundError(f"Pickle file not found: {full_path}")
 814
 815        if full_path.lower().endswith(".parquet"):
 816            mesh_df = pd.read_parquet(full_path, engine="pyarrow")
 817            for column in ("contour", "mesh", "midline"):
 818                if column not in mesh_df:
 819                    raise ValueError(f"Mesh Parquet file is missing '{column}'.")
 820                mesh_df[column] = mesh_df[column].map(
 821                    lambda value: np.asarray(value.tolist(), dtype=float)
 822                )
 823            self.mesh_df_collection = mesh_df
 824        else:
 825            with open(full_path, "rb") as f:
 826                self.mesh_df_collection = pickle.load(f)
 827
 828        bactoscoop_logger.info("Meshes loaded from file: %s", pkl_name)
 829        self.create_image_objects(phase_channel=phase_channel)
 830
 831    def batch_mask2mesh(
 832        self,
 833        pkl_name=None,
 834        object_list=None,
 835        save_data=True,
 836        phase_channel="",
 837        smoothing=0.1,
 838    ):
 839        """
 840        Create meshes from masks without the joining or splitting as in the batch_process_mesh method.
 841
 842        Parameters
 843        ----------
 844        pkl_name : str, optional
 845            Name of the pickle file to save the mesh data. If None, the file will be saved as 'folder_name_meshdata.pkl'.
 846        object_list : List, optional
 847            Specify a list of Image objects to be processed. If 'object_list' is None, all Image instances in 'image_objects' are processed.
 848        phase_channel : str, optional
 849            Characters before the file-extension (.tif, .tiff) that the algorithm looks for when using this function (e.g., "c1", "phase").
 850        """
 851        self.mesh_df_collection = pd.DataFrame()
 852        mesh_frames = []
 853
 854        if isinstance(object_list, Image):
 855            print("\n")
 856            bactoscoop_logger.info(f"PROCESSING IMAGE: {object_list.image_name}")
 857
 858            object_list.mask2mesh(smoothing)
 859            mesh_frames.append(getattr(object_list, "mesh_dataframe"))
 860            object_list.create_cell_object(verbose=False)
 861        else:
 862
 863            if object_list is None:
 864                self.create_image_objects(phase_channel=phase_channel)
 865                object_list = self.image_objects
 866
 867            for img_obj in object_list:
 868
 869                bactoscoop_logger.info(f"PROCESSING IMAGE: {img_obj.image_name}")
 870
 871                img_obj.mask2mesh()
 872                mesh_frames.append(getattr(img_obj, "mesh_dataframe"))
 873
 874                img_obj.create_cell_object(verbose=False)
 875
 876        self.mesh_df_collection = (
 877            pd.concat(mesh_frames, ignore_index=True) if mesh_frames else pd.DataFrame()
 878        )
 879
 880        if save_data:
 881            if pkl_name is not None:
 882                self._to_pickle(pkl_name, self.mesh_df_collection)
 883
 884            self._to_pickle(
 885                "{}_meshdata.pkl".format(self.name), self.mesh_df_collection
 886            )
 887
 888    def batch_shift_correction(
 889        self,
 890        shifted_channel,
 891        reset_channels=False,
 892        log_sigma=1.5,
 893        kernel_width=3,
 894        min_overlap_ratio=0.1,
 895        max_external_ratio=1,
 896        phase_log_sigma=0.5,
 897        phase_closing_level=2,
 898        signal_closing_level=12,
 899        max_shift_correction=15,
 900    ):
 901        self._require_loaded_images()
 902        shifted_channel = self._normalize_channel_list(
 903            shifted_channel, argument_name="shifted_channel"
 904        )
 905
 906        if reset_channels:
 907            self.channel_images = {}
 908            self.channel_image_filenames = {}
 909            self.channel_images_by_field = {}
 910            for image in self.image_objects:
 911                image.channels = None
 912                for cache_name in ("bg_channels", "chann_interp2d"):
 913                    cache = getattr(image, cache_name, None)
 914                    if isinstance(cache, dict):
 915                        cache.clear()
 916
 917        channels_to_load = [
 918            channel for channel in shifted_channel
 919            if channel not in self.channel_images_by_field
 920        ]
 921        if channels_to_load:
 922            self.load_channel_images(channels_to_load)
 923        self.add_channels(self.image_objects, shifted_channel, load_data=False)
 924
 925        success_percentage_list = []
 926        bactoscoop_logger.info("Aligning contour, mesh and midline to channel image ...")
 927        for image_obj in tqdm_if_verbose(self.image_objects):
 928            if image_obj.channels is None or any(
 929                channel not in image_obj.channels for channel in shifted_channel
 930            ):
 931                # Missing channels were recorded by add_channels.
 932                continue
 933            try:
 934                success_percentage = image_obj.shift_correction(
 935                    shifted_channel,
 936                    log_sigma,
 937                    kernel_width,
 938                    min_overlap_ratio,
 939                    max_external_ratio,
 940                    phase_log_sigma,
 941                    phase_closing_level,
 942                    signal_closing_level,
 943                    max_shift_correction,
 944                )
 945            except Exception as e:
 946                bactoscoop_logger.debug(traceback.format_exc())
 947                self._record_processing_error(
 948                    "shift_correction", e, image_name=image_obj.image_name
 949                )
 950                continue
 951            success_percentage_list.append(success_percentage)
 952            cell_errors = getattr(image_obj, "shift_errors", [])
 953            for cell_error in cell_errors:
 954                self._record_cell_error(
 955                    "cell_shift_correction",
 956                    RuntimeError(cell_error["error"]),
 957                    image_name=image_obj.image_name,
 958                    channel=cell_error.get("channel"),
 959                    cell_id=cell_error.get("cell_id"),
 960                    error_type=cell_error.get("error_type"),
 961                )
 962            cell_count = len(getattr(image_obj, "cells", []))
 963            if cell_count and len(cell_errors) >= cell_count * len(shifted_channel):
 964                self._record_processing_error(
 965                    "shift_correction",
 966                    ValueError(
 967                        f"All cell shift-correction attempts failed for '{image_obj.image_name}'."
 968                    ),
 969                    image_name=image_obj.image_name,
 970                )
 971        if not success_percentage_list:
 972            bactoscoop_logger.warning("No shift-correction results were produced.")
 973            return 0.0
 974
 975        average_success_percentage = sum(success_percentage_list) / len(
 976            success_percentage_list
 977        )
 978
 979        bactoscoop_logger.info(f"Shift succes rate: {average_success_percentage}")
 980        return average_success_percentage
 981
 982    def batch_calculate_features(
 983        self,
 984        channel_method_pairs,
 985        all_data=True,
 986        reset=True,
 987        use_shifted_contours=False,
 988        shift_signal=False,
 989        max_mesh_size=600,
 990        retain_contour_on_object_mesh_failure_channels=None,
 991    ):
 992        """
 993        Calculate features of all image objects for specified channels and methods.
 994
 995        This method calculates features for all image objects based on the specified channel-method pairs. It takes a list of tuples, where each tuple contains channel names and the feature calculation method. The resulting feature dataframes are stored in a dictionary.
 996
 997        Parameters
 998        ----------
 999        channel_method_pairs : List[Tuple[List[str], str]]
1000            A list of tuples where each tuple contains the channel names and feature calculation method.
1001            Example of channel_method_pairs: [(['c1'], 'profiling'), (['c2', 'c3'], 'objects'), ([None], 'svm')].
1002            With the example pairs, this method will analyze c1 using the profiling method,
1003            then c2 and c3 using the objects method, and finally the phase contrast channel using the svm method.
1004
1005        add_profiling_data : bool, optional
1006            Whether to include profiling data in the feature calculation. (default is True)
1007
1008        reset : bool, optional
1009            If True, the dictionary storing the dataframes will be reset. If False, dataframes are added to the existing dictionary. (default is True)
1010        retain_contour_on_object_mesh_failure_channels : list[str] or None, optional
1011            Channels whose objects features retain contour-based measurements
1012            when any detected object mesh is invalid. Other channels omit that
1013            cell's objects feature row. None is equivalent to an empty list.
1014            This option does not affect other feature methods.
1015
1016        Returns
1017        -------
1018        Dict
1019            A dictionary containing dataframes with calculated features.
1020
1021        """
1022        self._require_loaded_images()
1023        if channel_method_pairs is None:
1024            raise ValueError("channel_method_pairs must be provided.")
1025        if retain_contour_on_object_mesh_failure_channels is None:
1026            retained_channels = frozenset()
1027        elif isinstance(retain_contour_on_object_mesh_failure_channels, str):
1028            raise TypeError(
1029                "retain_contour_on_object_mesh_failure_channels must be a list "
1030                "of channel names, not a string."
1031            )
1032        else:
1033            retained_channels = frozenset(
1034                retain_contour_on_object_mesh_failure_channels
1035            )
1036        if not all(isinstance(channel, str) for channel in retained_channels):
1037            raise TypeError(
1038                "retain_contour_on_object_mesh_failure_channels must contain "
1039                "only channel names."
1040            )
1041
1042        self.merged_features = None
1043        if reset:
1044            self.feature_dataframes = {}
1045
1046        for channels, method in channel_method_pairs:
1047            normalized_channels = (
1048                [None]
1049                if channels == [None]
1050                else self._normalize_channel_list(channels, argument_name="channels")
1051            )
1052            for channel in normalized_channels:
1053                key = f"{method}_{channel}_features"
1054                self.feature_dataframes.pop(key, None)
1055                bactoscoop_logger.info(
1056                    f"PROCESSING CHANNEL: {channel}, method: {method}"
1057                )
1058                feature_dfs = []
1059
1060                for img_obj in tqdm_if_verbose(self.image_objects):
1061                    if channel is not None and (
1062                        img_obj.channels is None or channel not in img_obj.channels
1063                    ):
1064                        self._record_processing_error(
1065                            "image_feature_calculation",
1066                            ValueError(
1067                                f"Channel {channel} is not loaded for image '{img_obj.image_name}'."
1068                            ),
1069                            image_name=img_obj.image_name,
1070                            channel=channel,
1071                        )
1072                        continue
1073                    try:
1074                        result_df = img_obj.calculate_features(
1075                            method,
1076                            channel,
1077                            all_data,
1078                            use_shifted_contours,
1079                            shift_signal,
1080                            max_mesh_size,
1081                            retained_channels,
1082                        )
1083                    except Exception as e:
1084                        bactoscoop_logger.debug(traceback.format_exc())
1085                        self._record_processing_error(
1086                            "image_feature_calculation",
1087                            e,
1088                            image_name=img_obj.image_name,
1089                            channel=channel,
1090                        )
1091                        continue
1092                    if result_df is not None:
1093                        feature_dfs.append(result_df)
1094                    for feature_error in getattr(img_obj, "feature_errors", []):
1095                        self._record_cell_error(
1096                            "cell_feature_calculation",
1097                            RuntimeError(feature_error["error"]),
1098                            image_name=img_obj.image_name,
1099                            channel=channel,
1100                            cell_id=feature_error.get("cell_id"),
1101                            error_type=feature_error.get("error_type"),
1102                        )
1103
1104                if feature_dfs:
1105                    concatenated_df = pd.concat(feature_dfs, axis=0)
1106                    concatenated_df.reset_index(drop=True, inplace=True)
1107                    self.feature_dataframes[key] = concatenated_df
1108                    has_rows = not concatenated_df.empty
1109                else:
1110                    has_rows = False
1111                if not has_rows:
1112                    self._record_processing_error(
1113                        "batch_calculate_features",
1114                        ValueError(
1115                            f"No feature rows were produced for channel {channel}, method {method}."
1116                        ),
1117                        channel=channel,
1118                    )
1119
1120        self._record_stage_success("batch_calculate_features")
1121        return self.feature_dataframes
1122
1123    def batch_calculate_signal_correlation_features(
1124        self, df, channels, feature_method_tuples
1125    ):
1126        from .signalcorrelation import SignalCorrelation
1127
1128        # Generate all unique pairs of channels
1129        channel_pairs = combinations(channels, 2)
1130
1131        for channel1, channel2 in channel_pairs:
1132            for features, method_names in feature_method_tuples:
1133                bactoscoop_logger.info(
1134                    f"Processing channel {channel1} vs {channel2} and features {features} from {self.image_folder_path}:"
1135                )
1136                for feature in tqdm_if_verbose(features):
1137                    prepared_feature = SignalCorrelation.prepare_feature_pair(
1138                        df, channel1, channel2, feature
1139                    )
1140
1141                    for method_name in method_names:
1142                        sc = SignalCorrelation(
1143                            df,
1144                            channel1,
1145                            channel2,
1146                            feature,
1147                            method_name,
1148                            prepared_feature=prepared_feature,
1149                        )
1150                        sc.calculate()
1151                        del sc
1152        return df
1153
1154    def _apply_curation_labels(self):
1155        """Apply curation labels by stable image identity, with frame fallback."""
1156        image_objects_by_name = {}
1157        image_objects_by_frame = {}
1158        for image in self.image_objects:
1159            image_objects_by_name.setdefault(image.image_name, []).append(image)
1160            image_objects_by_frame.setdefault(image.frame, []).append(image)
1161
1162        for _, row in self.curated_df.iterrows():
1163            image_name = row.get("image_name")
1164            cell_id = row["cell_id"]
1165            label = row["label"]
1166            image = None
1167
1168            if pd.notna(image_name):
1169                matches = image_objects_by_name.get(image_name, [])
1170                if len(matches) == 1:
1171                    image = matches[0]
1172                else:
1173                    reason = (
1174                        f"Image name '{image_name}' is not unique."
1175                        if len(matches) > 1
1176                        else f"No image object matches image name '{image_name}'."
1177                    )
1178                    self._record_processing_error(
1179                        "curation_mapping",
1180                        ValueError(reason),
1181                        image_name=image_name,
1182                        cell_id=cell_id,
1183                    )
1184                    continue
1185
1186            if image is None:
1187                frame = row.get("frame")
1188                matches = image_objects_by_frame.get(frame, [])
1189                if len(matches) == 1:
1190                    image = matches[0]
1191                else:
1192                    self._record_processing_error(
1193                        "curation_mapping",
1194                        ValueError(
1195                            f"Could not uniquely map curated cell {cell_id} to image "
1196                            f"(image_name={image_name!r}, frame={frame!r})."
1197                        ),
1198                        image_name=image_name if pd.notna(image_name) else None,
1199                        cell_id=cell_id,
1200                    )
1201                    continue
1202
1203            frame = row.get("frame")
1204            if pd.notna(frame) and image.frame != frame:
1205                self._record_processing_error(
1206                    "curation_mapping",
1207                    ValueError(
1208                        f"Curated cell {cell_id} names image '{image.image_name}' "
1209                        f"but has frame {frame}; expected {image.frame}."
1210                    ),
1211                    image_name=image.image_name,
1212                    cell_id=cell_id,
1213                )
1214                continue
1215
1216            cells = [cell for cell in image.cells if cell.cell_id == cell_id]
1217            if len(cells) != 1:
1218                reason = (
1219                    f"Cell ID {cell_id} was not found in image '{image.image_name}'."
1220                    if not cells
1221                    else f"Cell ID {cell_id} is not unique in image '{image.image_name}'."
1222                )
1223                self._record_processing_error(
1224                    "curation_mapping",
1225                    ValueError(reason),
1226                    image_name=image.image_name,
1227                    cell_id=cell_id,
1228                )
1229                continue
1230            if label == 0:
1231                image.cells.remove(cells[0])
1232
1233        self._record_stage_success("curate_dataset")
1234
1235    def curate_dataset(
1236        self, path_to_model, cols=4, control=False, save_curated_data=True
1237    ):
1238        """
1239        Curate the dataset based on a trained support vector machine model.
1240
1241        This method calculates SVM features and uses a trained SVM model to classify cells based on these features.
1242        It curates the dataset by discarding cells that are classified as label 0 (non-interesting).
1243        Optionally, it can include positive and negative control cells.
1244
1245        Parameters:
1246            path_to_model (str): Path to the trained SVM model.
1247
1248            cols (int): Retained for backward compatibility; currently unused.
1249
1250            control (bool): Whether to include positive and negative control cells.
1251
1252            save_curated_data (bool): Whether to write the curated mesh pickle.
1253                The in-memory mesh dataframe is updated either way.
1254
1255        Returns:
1256            None
1257        """
1258        self.batch_calculate_features(
1259            channel_method_pairs=[([None], "svm")], all_data=True, reset=True, max_mesh_size=800
1260        )
1261
1262        from .curation import Curation
1263
1264        cur = Curation(self.feature_dataframes["svm_None_features"])
1265
1266        self.curated_df = cur.compiled_curation(path_to_model, cols)
1267
1268        p1, p0 = cur.get_label_proportions()
1269        bactoscoop_logger.info(
1270            f"\nProportion of label 1: {p1}\nProportion of label 0: {p0}"
1271        )
1272
1273        if control:
1274            import bactoscoop.plot as plotting
1275
1276            self.pos, self.neg = cur.get_control(5, 5)
1277            if self.pos and self.neg is not None:
1278                bactoscoop_logger.info(
1279                    f"\nCell IDs and Frames for positive control are {self.pos}\nFrame and Cell IDs for negative control are {self.neg}"
1280                )
1281                plotting.plot_svm_controls(
1282                    self.pos, self.image_objects, message="Positive"
1283                )
1284                plotting.plot_svm_controls(
1285                    self.neg, self.image_objects, message="Negative"
1286                )
1287
1288        self._apply_curation_labels()
1289
1290
1291        # The in-memory mesh population follows the curated cells even when
1292        # the caller is only testing and does not want a file overwritten.
1293        self.add_meshdata_to_dataframe()
1294        if save_curated_data:
1295            self._to_pickle(
1296                "{}_curated_meshdata.pkl".format(self.name), self.mesh_df_collection
1297            )
1298
1299        del cur
1300
1301    def add_meshdata_to_dataframe(self):
1302        data = []
1303
1304        # Iterate over each image object
1305        for image in self.image_objects:
1306            # Iterate over each cell in the current image
1307            for cell in image.cells:
1308                # Extract the required attributes from the cell and image
1309                cell_id = cell.cell_id
1310                contour = cell.contour
1311                midline = cell.midline
1312                mesh = cell.mesh
1313                frame = image.frame
1314                image_name = image.image_name
1315
1316                # Append the data as a dictionary to the list
1317                data.append(
1318                    {
1319                        "image_name": image_name,
1320                        "frame": frame,
1321                        "cell_id": cell_id,
1322                        "contour": contour,
1323                        "midline": midline,
1324                        "mesh": mesh,
1325                    }
1326                )
1327
1328        # Create DataFrame from the collected data
1329        self.mesh_df_collection = pd.DataFrame(data)
1330
1331    def export_curated_masks(self, output_subfolder="curated_masks", overwrite=True):
1332        """Write one label mask per image from the SVM-curated cell contours.
1333
1334        This method is intended to be run after :meth:`curate_dataset`. It never
1335        changes the original Omnipose masks in ``masks``. Instead, it writes new
1336        masks to ``output_subfolder`` and records how every curated label overlaps
1337        the original labels, so the output can be used directly by BactoFeatures.
1338
1339        Parameters
1340        ----------
1341        output_subfolder : str, optional
1342            Folder below ``image_folder_path`` for the exported label TIFFs.
1343        overwrite : bool, optional
1344            If ``False``, fail before replacing an existing curated-mask TIFF.
1345
1346        Returns
1347        -------
1348        tuple[list[str], pandas.DataFrame]
1349            Paths to the exported masks and a provenance table with one row per
1350            curated cell.
1351        """
1352        self._require_image_folder_path()
1353        if not self.image_objects:
1354            raise ValueError(
1355                "No image objects are available. Load curated mesh data or run "
1356                "curate_dataset() before exporting curated masks."
1357            )
1358
1359        output_dir = os.path.join(self.image_folder_path, output_subfolder)
1360        os.makedirs(output_dir, exist_ok=True)
1361        mask_paths = []
1362        provenance_rows = []
1363
1364        for image in self.image_objects:
1365            if image.mask is None:
1366                raise ValueError(f"Original mask is not loaded for {image.image_name}.")
1367
1368            max_label = len(image.cells)
1369            output_dtype = np.uint16 if max_label <= np.iinfo(np.uint16).max else np.uint32
1370            curated_mask = np.zeros(image.mask.shape, dtype=output_dtype)
1371            stem = os.path.splitext(image.image_name)[0]
1372            output_path = os.path.join(output_dir, f"{stem}_curated_masks.tif")
1373            if os.path.exists(output_path) and not overwrite:
1374                raise FileExistsError(
1375                    f"Curated mask already exists: {output_path}. Set overwrite=True to replace it."
1376                )
1377
1378            for curated_label, cell in enumerate(image.cells, start=1):
1379                contour = np.asarray(cell.contour, dtype=float)
1380                if contour.ndim != 2 or contour.shape[1] != 2 or len(contour) < 3:
1381                    bactoscoop_logger.warning(
1382                        "Skipping curated cell %s in %s: invalid contour.",
1383                        cell.cell_id,
1384                        image.image_name,
1385                    )
1386                    continue
1387
1388                cell_mask = np.zeros(image.mask.shape, dtype=np.uint8)
1389                # Cell contours are (row, column); OpenCV expects (x, y).
1390                polygon = np.rint(contour[:, ::-1]).astype(np.int32).reshape(-1, 1, 2)
1391                cv.fillPoly(cell_mask, [polygon], color=1)
1392                cell_pixels = cell_mask.astype(bool)
1393                if not cell_pixels.any():
1394                    bactoscoop_logger.warning(
1395                        "Skipping curated cell %s in %s: contour has no in-image pixels.",
1396                        cell.cell_id,
1397                        image.image_name,
1398                    )
1399                    continue
1400
1401                curated_mask[cell_pixels] = curated_label
1402                source_labels = np.unique(image.mask[cell_pixels])
1403                source_labels = source_labels[source_labels != 0].astype(int).tolist()
1404                rows, columns = np.nonzero(cell_pixels)
1405                provenance_rows.append(
1406                    {
1407                        "image_name": image.image_name,
1408                        "frame": image.frame,
1409                        "curated_label": curated_label,
1410                        "bactoscoop_cell_id": cell.cell_id,
1411                        "centroid_x_px": float(columns.mean()),
1412                        "centroid_y_px": float(rows.mean()),
1413                        "area_px": int(cell_pixels.sum()),
1414                        "original_mask_path": os.path.join(
1415                            self.image_folder_path, "masks", f"{stem}_cp_masks.tif"
1416                        ),
1417                        "original_mask_labels": ";".join(map(str, source_labels)),
1418                        "curated_mask_path": output_path,
1419                    }
1420                )
1421
1422            tifffile.imwrite(output_path, curated_mask)
1423            mask_paths.append(output_path)
1424
1425        provenance = pd.DataFrame(provenance_rows)
1426        provenance_path = os.path.join(output_dir, "curated_mask_provenance.csv")
1427        provenance.to_csv(provenance_path, index=False)
1428        bactoscoop_logger.info(
1429            "Exported %s curated masks and %s curated cells to %s",
1430            len(mask_paths),
1431            len(provenance),
1432            output_dir,
1433        )
1434        return mask_paths, provenance
1435
1436    def merge_dataframes(self, include_metadata_tag=False, discard_morphological_nan=False):
1437        """
1438        Merge feature dataframes into a single dataframe.
1439    
1440        Merges multiple feature dataframes into a single dataframe, ensuring that columns are properly renamed
1441        to identify the channel and removes duplicates based on specific columns.
1442    
1443        Args:
1444            include_metadata_tag (bool): Whether to rename 'image_name', 'cell_id', and 'frame' to 'Metadata_*'.
1445            discard_nan (bool): Whether to discard rows with NaN in 'cell_area' and 'cell_length'.
1446    
1447        Returns:
1448            pd.DataFrame: The merged feature dataframe.
1449        """
1450        if not self.feature_dataframes:
1451            raise ValueError("No feature dataframes are available to merge.")
1452
1453        merged_dataframes = []
1454
1455        for key, df in self.feature_dataframes.items():
1456            # Keys are "method_channel_features"; channel names may contain underscores.
1457            if not key.endswith("_features"):
1458                raise ValueError(f"Invalid feature dataframe key: {key!r}")
1459            method_and_channel = key[: -len("_features")]
1460            method, separator, channel = method_and_channel.partition("_")
1461            if not separator or not method or not channel:
1462                raise ValueError(f"Invalid feature dataframe key: {key!r}")
1463
1464            # Set a prefix to identify the channel in column names if the channel is not None
1465            if channel != "None":
1466                df = df.rename(
1467                    columns={
1468                        col: f"{channel}_{col}"
1469                        for col in df.columns
1470                        if col not in ["image_name", "cell_id", "frame"]
1471                    }
1472                )
1473
1474            # Append the modified dataframe to the list
1475            merged_dataframes.append(df)
1476
1477        # Concatenate the dataframes vertically
1478        merged_data = pd.concat(merged_dataframes)
1479
1480        # Merge the dataframes based on 'image_name', 'cell_id', and 'frame'
1481        result = merged_data.pivot_table(
1482            index=["image_name", "cell_id", "frame"], aggfunc="first"
1483        ).reset_index()
1484
1485        # Discard rows with NaN in 'cell_area' and 'cell_length' if discard_nan is True
1486        if discard_morphological_nan:
1487            result = result.dropna(subset=["cell_area", "cell_length"])
1488
1489        if include_metadata_tag:
1490            # Rename 'image_name', 'cell_id', and 'frame' to 'Metadata_*'
1491            result = result.rename(
1492                columns={
1493                    "image_name": "Metadata_image_name",
1494                    "cell_id": "Metadata_cell_id",
1495                    "frame": "Metadata_frame"
1496                }
1497            )
1498
1499        # Reset the index to make it look like the final result
1500        self.merged_features = result.reset_index(drop=True)
1501
1502        return self.merged_features
1503
1504    def dataframe_to_pkl(self, pkl_name=None):
1505        """Save merged features in the image folder and return the pickle path.
1506
1507        A suffixless ``pkl_name`` is a legacy tag appended to the dataset name;
1508        a name ending in ``.pkl`` is used as the exact filename.
1509        """
1510        if self.merged_features is None:
1511            raise ValueError("Merge feature dataframes before saving them.")
1512        if pkl_name is None:
1513            filename = f"{self.name}_features.pkl"
1514        elif str(pkl_name).endswith(".pkl"):
1515            filename = str(pkl_name)
1516        else:
1517            filename = f"{self.name}_{pkl_name}.pkl"
1518        self._to_pickle(filename, self.merged_features)
1519        return os.path.join(self.image_folder_path, filename)
1520
1521    def meshdata_to_parquet(self, parquet_name=None):
1522        """Save the current mesh dataframe as reloadable Parquet in the image folder.
1523
1524        Geometry arrays become nested numeric lists in Parquet. Reload with
1525        ``batch_load_mesh("name.parquet", phase_channel=...)``.
1526        Requires the optional ``pyarrow`` package.
1527        """
1528        if self.mesh_df_collection is None or self.mesh_df_collection.empty:
1529            raise ValueError("No mesh data is available to save.")
1530        name = parquet_name or f"{self.name}_meshdata.parquet"
1531        self._to_parquet(name, self.mesh_df_collection, mesh_data=True)
1532
1533    def dataframe_to_parquet(self, parquet_name=None):
1534        """Save merged features as Parquet in the image folder (requires pyarrow)."""
1535        if self.merged_features is None:
1536            raise ValueError("Merge feature dataframes before saving them.")
1537        name = parquet_name or f"{self.name}_features.parquet"
1538        self._to_parquet(name, self.merged_features)
1539
1540    def _atomic_write(self, filename, writer):
1541        self._require_image_folder_path()
1542        if os.path.basename(filename) != filename or filename in {"", ".", ".."}:
1543            raise ValueError("Output filename must be a name within the image folder.")
1544        file_path = os.path.join(self.image_folder_path, filename)
1545        descriptor, temporary_path = tempfile.mkstemp(
1546            prefix=f".{filename}.", suffix=".tmp", dir=self.image_folder_path
1547        )
1548        os.close(descriptor)
1549        try:
1550            writer(temporary_path)
1551            with open(temporary_path, "rb+") as temporary_file:
1552                os.fsync(temporary_file.fileno())
1553            os.replace(temporary_path, file_path)
1554        finally:
1555            if os.path.exists(temporary_path):
1556                os.unlink(temporary_path)
1557
1558    def _to_parquet(self, filename, data, mesh_data=False):
1559        if not filename.lower().endswith(".parquet"):
1560            raise ValueError("Parquet filename must end in .parquet")
1561        output = data.copy()
1562        if mesh_data:
1563            for column in ("contour", "mesh", "midline"):
1564                if column not in output:
1565                    raise ValueError(f"Mesh dataframe is missing '{column}'.")
1566                output[column] = output[column].map(
1567                    lambda value: np.asarray(value).tolist()
1568                )
1569        self._atomic_write(
1570            filename,
1571            lambda path: output.to_parquet(
1572                path, engine="pyarrow", compression="zstd", index=False
1573            ),
1574        )
1575
1576    def _to_pickle(self, pkl_name, data):
1577        """
1578        Save data to a pkl file.
1579
1580        """
1581        self._atomic_write(
1582            pkl_name,
1583            lambda path: self._write_pickle_file(path, data),
1584        )
1585
1586    @staticmethod
1587    def _write_pickle_file(path, data):
1588        with open(path, "wb") as file:
1589            pickle.dump(data, file, protocol=pickle.HIGHEST_PROTOCOL)