import gc
import sys
import os
import shutil
import re
import traceback
import time
from copy import deepcopy
from datetime import datetime, timedelta
import inspect
import logging
import uuid
import json
from collections import defaultdict, Counter
import psutil
import zipfile
from functools import partial
from tqdm import tqdm
from natsort import natsorted
from typing import Literal, Iterable, Dict, Any, List, Union, Tuple, Set

import time
import cv2
import math
import numpy as np
import pandas as pd
import matplotlib
import scipy.optimize
import scipy.interpolate
import scipy.ndimage
import skimage
import skimage.io
import skimage.measure
import skimage.morphology
import skimage.draw
import skimage.exposure
import skimage.transform
import skimage.segmentation

from functools import wraps
from skimage.color import gray2rgb, gray2rgba, label2rgb

from qtpy.QtCore import (
    Qt, QPoint, QTextStream, QSize, QRect, QRectF,
    QEventLoop, QTimer, QEvent, QObject, Signal,
    QThread, QMutex, QWaitCondition, QSettings, PYQT6
)
from qtpy.QtGui import (
    QIcon, QKeySequence, QCursor, QGuiApplication, QPixmap, QColor,
    QFont, QKeyEvent, QMouseEvent
)
from qtpy.QtWidgets import (
    QAction, QLabel, QPushButton, QHBoxLayout, QSizePolicy,
    QMainWindow, QMenu, QToolBar, QGroupBox, QGridLayout,
    QScrollBar, QCheckBox, QToolButton, QSpinBox,
    QComboBox, QButtonGroup, QActionGroup, QFileDialog,
    QAbstractSlider, QMessageBox, QWidget, QGridLayout, QDockWidget,
    QGraphicsProxyWidget, QVBoxLayout, QRadioButton, 
    QSpacerItem, QScrollArea, QFormLayout, QGraphicsSceneMouseEvent 
)

import pyqtgraph as pg
pg.setConfigOption('imageAxisOrder', 'row-major')

from warnings import simplefilter
simplefilter(action="ignore", category=pd.errors.PerformanceWarning)

# NOTE: Enable icons
from . import qrc_resources

# Custom modules
from . import exception_handler
from . import base_cca_dict, lineage_tree_cols, lineage_tree_cols_std_val
from . import graphLayoutBkgrColor, darkBkgrColor
from . import cca_df_colnames
from . import load, prompts, apps, workers, html_utils
from . import core, myutils, dataPrep, widgets
from . import _warnings, issues_url
from . import measurements, printl
from . import colors, annotate
from . import user_manual_url
from . import recentPaths_path, settings_folderpath, settings_csv_path
from . import qutils, autopilot, QtScoped
from . import _palettes
from . import transformation
from . import measure
from . import cca_functions
from . import data_structure_docs_url
from . import exporters
from . import preprocess
from . import io
from . import whitelist
from .trackers.CellACDC import CellACDC_tracker
from .cca_functions import _calc_rot_vol
from .myutils import exec_time, setupLogger, ArgSpec
from .help import welcome, about
from .trackers.CellACDC_normal_division.CellACDC_normal_division_tracker import normal_division_lineage_tree, reorg_sister_cells_for_export
from .plot import imshow

np.seterr(invalid='ignore')

if os.name == 'nt':
    try:
        # Set taskbar icon in windows
        import ctypes
        myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
    except Exception as e:
        pass

GREEN_HEX = _palettes.green()

favourite_func_metrics_csv_path = os.path.join(
    settings_folderpath, 'favourite_func_metrics.csv'
)
custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json')
shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini')

_font = QFont()
_font.setPixelSize(11)

font_13px = QFont()
font_13px.setPixelSize(13)

SliderSingleStepAdd = QtScoped.SliderSingleStepAdd()
SliderSingleStepSub = QtScoped.SliderSingleStepSub()
SliderPageStepAdd = QtScoped.SliderPageStepAdd()
SliderPageStepSub = QtScoped.SliderPageStepSub()
SliderMove = QtScoped.SliderMove()

def qt_debug_trace():
    from qtpy.QtCore import pyqtRemoveInputHook
    pyqtRemoveInputHook()
    import pdb; pdb.set_trace()

def get_data_exception_handler(func):
    @wraps(func)
    def inner_function(self, *args, **kwargs):
        try:
            if func.__code__.co_argcount==1 and func.__defaults__ is None:
                result = func(self)
            elif func.__code__.co_argcount>1 and func.__defaults__ is None:
                result = func(self, *args)
            else:
                result = func(self, *args, **kwargs)
        except Exception as e:
            try:
                if self.progressWin is not None:
                    self.progressWin.workerFinished = True
                    self.progressWin.close()
                    self.progressWin = None
            except AttributeError:
                pass
            result = None
            posData = self.data[self.pos_i]
            acdc_df_filename = os.path.basename(posData.acdc_output_csv_path)
            segm_filename = os.path.basename(posData.segm_npz_path)
            traceback_str = traceback.format_exc()
            self.logger.exception(traceback_str)
            msg = widgets.myMessageBox(wrapText=False, showCentered=False)
            msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...')
            msg.setDetailedText(traceback_str)
            err_msg = html_utils.paragraph(f"""
                Error in function <code>{func.__name__}</code>.<br><br>
                One possbile explanation is that either the
                <code>{acdc_df_filename}</code> file<br>
                or the segmentation file <code>{segm_filename}</code><br>
                <b>are being synchronized by a cloud service (e.g., Google Drive 
                or OneDrive) or they are corrupted/damaged</b>.<br><br>
                <b>Try moving these files</b> (one by one) outside of the
                <code>{os.path.dirname(posData.relPath)}</code> folder
                <br>and reloading the data.<br><br>
                More details below or in the terminal/console.<br><br>
                Note that the <b>error details</b> from this session are
                also <b>saved in the following file</b>:<br><br>
                {self.log_path}<br><br>
                Please <b>send the log file</b> when reporting a bug, thanks!
            """)

            msg.critical(self, 'Critical error', err_msg)
            self.is_error_state = True
            raise e
        return result
    return inner_function

def resetViewRange(func):
    @wraps(func)
    def inner_function(self, *args, **kwargs):
        self.storeViewRange()
        if func.__code__.co_argcount==1 and func.__defaults__ is None:
            result = func(self)
        elif func.__code__.co_argcount>1 and func.__defaults__ is None:
            result = func(self, *args)
        else:
            result = func(self, *args, **kwargs)
        QTimer.singleShot(200, self.resetRange)
        return result
    return inner_function

def get_obj_by_label(rp, target_label):
    """
    Returns the object with the specified label from the given list of objects.

    Parameters
    ----------
    rp : list
        The list of objects to search through.
    target_label : str
        The label of the object to find.

    Returns
    -------
    object
        The object with the specified label, or None if not found.
    """
    for obj in rp:
        if obj.label == target_label:
            return obj
    return None

def find_distances_ID(rps, point=None, ID=None):
    """
    Calculate the distances between a given point and the centroids of a list of regionprops.

    Parameters
    ----------
    rps : list
        List of regionprops objects.
    point : tuple, optional
        The coordinates of the point. Defaults to None.
    ID : int, optional
        The label ID of the regionprops object. Defaults to None.

    Returns
    -------
    numpy.ndarray
        A matrix of distances between the point and the centroids.

    Raises
    ------
    ValueError
        If ID is not found in the list of regionprops (list of cells).
    ValueError
        If neither ID nor point is provided.
    ValueError
        If both ID and point are provided.
    """

    if ID is not None and point is None:
        try:
            point = [rp.centroid for rp in rps if rp.label == ID][0]
        except IndexError:
            raise(ValueError(f'ID {ID} not found in regionprops (list of cells).'))

    elif ID is None and point is None:
        raise(ValueError('Either ID or point must be provided.'))

    elif ID is not None and point is not None:
        raise(ValueError('Only one of ID or point must be provided.'))
    
    point = point[::-1] # rp are in (y, x) format (or (z, y, x) for 3D data) so I need to reverse order
    point=np.array([point])
    centroids = np.array([rp.centroid for rp in rps])
    diff = point[:, np.newaxis] - centroids
    dist_matrix = np.linalg.norm(diff, axis=2)
    return dist_matrix

def sort_IDs_dist(rps, point=None, ID=None):
    """Sorts the IDs of regionprops based on their distances to a given point.

    Parameters
    ----------
    rps : list
        A list of regionprops objects representing cells.
    point : tuple, optional
        The coordinates of the point to calculate distances from. 
        If not provided, it will be calculated based on the given ID.
    ID : int, optional
        The ID of the regionprops object to calculate distances from. 
        If this and point are both provided, or neither, an error will be 
        raised.

    Returns
    -------
    list
        A sorted list of IDs based on their distances to the given point.

    Raises
    ------
    ValueError
        If ID is not found in the list of regionprops objects.
    ValueError
        If neither ID nor point is provided.
    ValueError
        If both ID and point are provided.

    """
    if ID is not None and point is None:
        try:
            point = [rp.centroid for rp in rps if rp.label == ID][0]
        except IndexError:
            raise(ValueError(f'ID {ID} not found in regionprops (list of cells).'))

    elif ID is None and point is None:
        raise(ValueError('Either ID or point must be provided.'))

    elif ID is not None and point is not None:
        raise(ValueError('Only one of ID or point must be provided.'))
    

    IDs = [rp.label for rp in rps]
    if len(IDs) == 0:
        return []
    elif len(IDs) == 1:
        return IDs
    dist_matrix = find_distances_ID(rps, point=point)        
    dist_matrix = np.squeeze(dist_matrix)

    sorted_ids = sorted(zip(dist_matrix, IDs))
    sorted_ids = [ID for _, ID in sorted_ids]
    return sorted_ids

def disableWindow(func):
    @wraps(func)
    def inner_function(self, *args, **kwargs):
        self.setDisabled(True)
        try:
            result = func(self, *args, **kwargs)
            return result
        except Exception as err:
            raise err
        finally:
            self.setDisabled(False)
            self.activateWindow()
    return inner_function
      
class guiWin(QMainWindow):
    """Main Window."""

    sigClosed = Signal(object)
    sigExportFrame = Signal()

    def __init__(
            self, app, parent=None, buttonToRestore=None,
            mainWin=None, version=None, launcherSlot=None
        ):
        """Initializer."""

        super().__init__(parent)

        self._version = version

        from .trackers.YeaZ import tracking as tracking_yeaz
        self.tracking_yeaz = tracking_yeaz

        from .config import parser_args
        self.debug = parser_args['debug']

        self.buttonToRestore = buttonToRestore
        self.launcherSlot = launcherSlot
        self.mainWin = mainWin
        self.app = app
        self.closeGUI = False

        self.setAcceptDrops(True)
        self._appName = 'Cell-ACDC'

        self.lineage_tree = None
        self.already_synced_lin_tree = set()
        self.right_click_ID = None
        self.original_df_lin_tree = None
        self.original_df_lin_tree_i = None

    def setTooltips(self): #laoding tooltips for GUI from .\Cell_ACDC\docs\source\tooltips.rst
        tooltips = load.get_tooltips_from_docs()

        for key, tooltip in tooltips.items():
            setShortcut = getattr(self, key).shortcut().toString()
            if 'Shortcut: ' in tooltip:
                tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ')
            elif setShortcut != "":
                tooltip = re.sub(
                    r'Shortcut: \"(.*)\"', 
                    f"Shortcut: \"{setShortcut}\"", 
                    tooltip
                )
            else:
                tooltip = re.sub(
                    r'Shortcut: \"(.*)\"', 
                    f"Shortcut: \"No shortcut\"", 
                    tooltip
                )
            
            getattr(self, key).setToolTip(tooltip)

    def run(self, module='acdc_gui', logs_path=None):        
        self.setWindowIcon()
        self.setWindowTitle()
        
        self.is_win = sys.platform.startswith("win")
        if self.is_win:
            self.openFolderText = 'Show in Explorer...'
        else:
            self.openFolderText = 'Reveal in Finder...'

        self.is_error_state = False
        logger, logs_path, log_path, log_filename = setupLogger(
            module=module, logs_path=logs_path, caller=self._appName
        )
        if self._version is not None:
            logger.info(f'Initializing GUI v{self._version}')
        else:
            logger.info(f'Initializing GUI...')
        
        self.module = module
        self.logger = logger
        self.log_path = log_path
        self.log_filename = log_filename
        self.logs_path = logs_path

        self.initProfileModels()
        self.loadLastSessionSettings()

        self.newWindows = []
        self.progressWin = None
        self.slideshowWin = None
        self.ccaTableWin = None
        self.customAnnotButton = None
        self.ccaCheckerRunning = False
        self.isDataLoaded = False
        self.highlightedID = 0
        self.hoverLabelID = 0
        self.expandingID = -1
        self.count = 0
        self.isDilation = True
        self.flag = True
        self.currentPropsID = 0
        self.isSegm3D = False
        self.newSegmEndName = ''
        self.closeGUI = False
        self.warnKeyPressedMsg = None
        self.img1ChannelGradients = {}
        self.AutoPilotProfile = autopilot.AutoPilotProfile()
        self.storeStateWorker = None
        self.AutoPilot = None
        self.widgetsWithShortcut = {}
        self.invertBwAlreadyCalledOnce = False
        self.zoomOutKeyValue = Qt.Key_H
        self.preprocWorker = None
        self.combineWorker = None
        self.preprocessDialog = None
        self.combineDialog = None
        self.viewOriginalLabels = True
        self.keepDisabled = False
        self.whitelistAddNewIDsFrame = None
        self.whitelistOriginalIDs = None

        self.checkableButtons = []
        self.LeftClickButtons = []
        self.customAnnotDict = {}

        # Keep a list of functions that are not functional in 3D, yet
        self.functionsNotTested3D = []

        self.isSnapshot = False
        self.debugFlag = False
        self.pos_i = 0
        self.save_until_frame_i = 0
        self.countKeyPress = 0
        self.countRightClicks = 0
        self.xHoverImg, self.yHoverImg = None, None

        # Buttons added to QButtonGroup will be mutually exclusive
        self.checkableQButtonsGroup = QButtonGroup(self)
        self.checkableQButtonsGroup.setExclusive(False)

        self.lazyLoader = None

        self.gui_createCursors()
        self.gui_createActions()
        self.gui_createMenuBar()

        self.gui_createToolBars()
        self.gui_createControlsToolbar()
        self.gui_createShowPropsButton()
        self.gui_createRegionPropsDockWidget()
        self.gui_createQuickSettingsWidgets()
        self.setTooltips()
        self.gui_populateToolSettingsMenu()

        self.autoSaveGarbageWorkers = []
        self.autoSaveActiveWorkers = []

        self.gui_connectActions()
        self.gui_createStatusBar()
        # self.gui_createTerminalWidget()

        self.gui_createGraphicsPlots()
        self.gui_addGraphicsItems()

        self.gui_createImg1Widgets()
        self.gui_createLabWidgets()
        self.gui_createBottomWidgetsToBottomLayout()

        mainContainer = QWidget()
        self.setCentralWidget(mainContainer)

        mainLayout = self.gui_createMainLayout()
        self.mainLayout = mainLayout

        mainContainer.setLayout(mainLayout)

        self.isEditActionsConnected = False

        self.readRecentPaths()

        self.initShortcuts()
        self.show()
        QTimer.singleShot(100, self.resizeRangeWelcomeText)
        # self.installEventFilter(self)
        
        self.logger.info('GUI ready.')
    
    def setWindowIcon(self, icon=None):
        if icon is None:
            icon = QIcon(":icon.ico")
        super().setWindowIcon(icon)
    
    def setWindowTitle(self, title="Cell-ACDC - GUI"):
        super().setWindowTitle(title)
    
    def initProfileModels(self):
        self.logger.info('Initiliazing profilers...')
        
        from ._profile.spline_to_obj import model
        
        self.splineToObjModel = model.Model()

        self.splineToObjModel.fit()
    
    def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False):
        if force:
            if disabled:
                super().setDisabled(disabled)
                return
            else:
                self.keepDisabled = False
                super().setDisabled(disabled)
                return

        if keepDisabled is not None:
            self.keepDisabled = keepDisabled

        if self.keepDisabled:
            if disabled:
                super().setDisabled(disabled)
                return
            else:
                return
        else:
            super().setDisabled(disabled)
    
    def readRecentPaths(self, recent_paths_path=None):
        # Step 0. Remove the old options from the menu
        self.openRecentMenu.clear()

        # Step 1. Read recent Paths
        if recent_paths_path is None:
            recent_paths_path = recentPaths_path    
        
        if os.path.exists(recent_paths_path):
            df = pd.read_csv(recent_paths_path, index_col='index')
            df['path'] = df['path'].str.replace('\\', '/')
            df = df.drop_duplicates(subset=['path'])
            df.to_csv(recent_paths_path)
            if 'opened_last_on' in df.columns:
                df = df.sort_values('opened_last_on', ascending=False)
            recentPaths = df['path'].to_list()
        else:
            recentPaths = []
        
        # Step 2. Dynamically create the actions
        actions = []
        for path in recentPaths:
            if not os.path.exists(path):
                continue
            action = QAction(path, self)
            action.triggered.connect(partial(self.openRecentFile, path))
            actions.append(action)

        # Step 3. Add the actions to the menu
        self.openRecentMenu.addActions(actions)
    
    def addPathToOpenRecentMenu(self, path):
        for action in self.openRecentMenu.actions():
            if path == action.text():
                break
        else:
            action = QAction(path, self)
            action.triggered.connect(partial(self.openRecentFile, path))
        
        try:
            firstAction = self.openRecentMenu.actions()[0]
            self.openRecentMenu.insertAction(firstAction, action)
        except Exception as e:
            pass

    def loadLastSessionSettings(self):
        self.settings_csv_path = settings_csv_path
        if os.path.exists(settings_csv_path):
            self.df_settings = pd.read_csv(
                settings_csv_path, index_col='setting'
            )
            if 'is_bw_inverted' not in self.df_settings.index:
                self.df_settings.at['is_bw_inverted', 'value'] = 'No'
            else:
                self.df_settings.loc['is_bw_inverted'] = (
                    self.df_settings.loc['is_bw_inverted'].astype(str)
                )
            if 'fontSize' not in self.df_settings.index:
                self.df_settings.at['fontSize', 'value'] = 12
            if 'overlayColor' not in self.df_settings.index:
                self.df_settings.at['overlayColor', 'value'] = '255-255-0'
            if 'how_normIntensities' not in self.df_settings.index:
                raw = 'Do not normalize. Display raw image'
                self.df_settings.at['how_normIntensities', 'value'] = raw
        else:
            idx = ['is_bw_inverted', 'fontSize', 'overlayColor', 'how_normIntensities']
            values = ['No', 12, '255-255-0', 'raw']
            self.df_settings = pd.DataFrame({
                'setting': idx,'value': values}
            ).set_index('setting')
        
        if 'isLabelsVisible' not in self.df_settings.index:
            self.df_settings.at['isLabelsVisible', 'value'] = 'No'
        
        if 'isNextFrameVisible' not in self.df_settings.index:
            self.df_settings.at['isNextFrameVisible', 'value'] = 'No'
        
        if 'isRightImageVisible' not in self.df_settings.index:
            self.df_settings.at['isRightImageVisible', 'value'] = 'Yes'
        
        if 'manual_separate_draw_mode' not in self.df_settings.index:
            col = 'manual_separate_draw_mode'
            self.df_settings.at[col, 'value'] = 'threepoints_arc'
        
        if 'colorScheme' in self.df_settings.index:
            col = 'colorScheme'
            self._colorScheme = self.df_settings.at[col, 'value']
        else:
            self._colorScheme = 'light'

    def dragEnterEvent(self, event):
        file_path = event.mimeData().urls()[0].toLocalFile()
        if os.path.isdir(file_path):
            exp_path = file_path
            basename = os.path.basename(file_path)
            if basename.find('Position_')!=-1 or basename=='Images':
                event.acceptProposedAction()
            else:
                event.ignore()
        else:
            event.acceptProposedAction()

    def dropEvent(self, event):
        event.setDropAction(Qt.CopyAction)
        file_path = event.mimeData().urls()[0].toLocalFile()
        self.logger.info(f'Dragged and dropped path "{file_path}"')
        basename = os.path.basename(file_path)
        if os.path.isdir(file_path):
            exp_path = file_path
            self.openFolder(exp_path=exp_path)
        else:
            self.openFile(file_path=file_path)

    def changeEvent(self, event):
        try:
            self.delObjToolAction.setChecked(False)
        except Exception as err:
            return
    
    def leaveEvent(self, event):
        if self.slideshowWin is not None:
            posData = self.data[self.pos_i]
            mainWinGeometry = self.geometry()
            mainWinLeft = mainWinGeometry.left()
            mainWinTop = mainWinGeometry.top()
            mainWinWidth = mainWinGeometry.width()
            mainWinHeight = mainWinGeometry.height()
            mainWinRight = mainWinLeft+mainWinWidth
            mainWinBottom = mainWinTop+mainWinHeight

            slideshowWinGeometry = self.slideshowWin.geometry()
            slideshowWinLeft = slideshowWinGeometry.left()
            slideshowWinTop = slideshowWinGeometry.top()
            slideshowWinWidth = slideshowWinGeometry.width()
            slideshowWinHeight = slideshowWinGeometry.height()

            # Determine if overlap
            overlap = (
                (slideshowWinTop < mainWinBottom) and
                (slideshowWinLeft < mainWinRight)
            )

            autoActivate = (
                self.isDataLoaded and not
                overlap and not
                posData.disableAutoActivateViewerWindow
            )

            if autoActivate:
                self.slideshowWin.setFocus()
                self.slideshowWin.activateWindow()

    def enterEvent(self, event):
        event.accept()
        if self.slideshowWin is not None:
            posData = self.data[self.pos_i]
            mainWinGeometry = self.geometry()
            mainWinLeft = mainWinGeometry.left()
            mainWinTop = mainWinGeometry.top()
            mainWinWidth = mainWinGeometry.width()
            mainWinHeight = mainWinGeometry.height()
            mainWinRight = mainWinLeft+mainWinWidth
            mainWinBottom = mainWinTop+mainWinHeight

            slideshowWinGeometry = self.slideshowWin.geometry()
            slideshowWinLeft = slideshowWinGeometry.left()
            slideshowWinTop = slideshowWinGeometry.top()
            slideshowWinWidth = slideshowWinGeometry.width()
            slideshowWinHeight = slideshowWinGeometry.height()

            # Determine if overlap
            overlap = (
                (slideshowWinTop < mainWinBottom) and
                (slideshowWinLeft < mainWinRight)
            )

            autoActivate = (
                self.isDataLoaded and not
                overlap and not
                posData.disableAutoActivateViewerWindow
            )

            if autoActivate:
                # self.setFocus()
                self.activateWindow()

    def isPanImageClick(self, mouseEvent, modifiers):
        left_click = mouseEvent.button() == Qt.MouseButton.LeftButton
        return modifiers == Qt.AltModifier and left_click

    def isDefaultMiddleClick(self, mouseEvent, modifiers):
        if sys.platform == 'darwin':
            middle_click = (
                mouseEvent.button() == Qt.MouseButton.LeftButton
                and modifiers == Qt.ControlModifier
                and not self.brushButton.isChecked()
            )
        else:
            middle_click = mouseEvent.button() == Qt.MouseButton.MiddleButton
        return middle_click
        
    def isMiddleClick(self, mouseEvent, modifiers):
        if self.delObjAction is None:
            return self.isDefaultMiddleClick(mouseEvent, modifiers)
               
        delObjKeySequence, delObjQtButton = self.delObjAction
        if delObjKeySequence is None:
            # Setting only middle click on mac is allowed, however the 
            # delObjKeySequence is None and the tool button is never checked
            isDelObjectActive = True
        else:
            isDelObjectActive = self.delObjToolAction.isChecked()
            
        middle_click = (
            mouseEvent.button() == delObjQtButton and isDelObjectActive
        )
        
        return middle_click

    def gui_createCursors(self):
        pixmap = QPixmap(":wand_cursor.svg")
        self.wandCursor = QCursor(pixmap, 16, 16)

        pixmap = QPixmap(":curv_cursor.svg")
        self.curvCursor = QCursor(pixmap, 16, 16)

        pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg")
        self.polyLineRoiCursor = QCursor(pixmap, 16, 16)
        
        pixmap = QPixmap(":cross_cursor.svg")
        self.addPointsCursor = QCursor(pixmap, 16, 16)

    def gui_createMenuBar(self):
        menuBar = self.menuBar()
        menuBar.setNativeMenuBar(False)

        # File menu
        fileMenu = QMenu("&File", self)
        self.fileMenu = fileMenu
        menuBar.addMenu(fileMenu)
        if self.debug:
            fileMenu.addAction(self.createEmptyDataAction)
        fileMenu.addAction(self.newAction)
        fileMenu.addAction(self.newWindowAction)
        fileMenu.addSeparator()
        fileMenu.addAction(self.openFolderAction)
        fileMenu.addAction(self.openFileAction)
        # Open Recent submenu
        self.openRecentMenu = fileMenu.addMenu("Open Recent")
        fileMenu.addSeparator()
        fileMenu.addAction(self.manageVersionsAction)
        fileMenu.addAction(self.saveAction)
        fileMenu.addAction(self.saveAsAction)
        fileMenu.addAction(self.quickSaveAction)
        fileMenu.addSeparator()
        
        self.exportMenu = fileMenu.addMenu('Export')
        self.exportMenu.addAction(self.exportToVideoAction)
        self.exportMenu.addAction(self.exportToImageAction)
        fileMenu.addSeparator()
        fileMenu.addAction(self.loadFluoAction)
        fileMenu.addAction(self.loadPosAction)
        # Separator
        self.fileMenu.lastSeparator = fileMenu.addSeparator()
        fileMenu.addAction(self.exitAction)
        
        # Edit menu
        editMenu = menuBar.addMenu("&Edit")
        editMenu.addSeparator()

        editMenu.addAction(self.editShortcutsAction)
        editMenu.addAction(self.editTextIDsColorAction)
        editMenu.addAction(self.editOverlayColorAction)
        editMenu.addAction(self.manuallyEditCcaAction)
        editMenu.addAction(self.enableSmartTrackAction)
        editMenu.addAction(self.enableAutoZoomToCellsAction)

        # View menu
        self.viewMenu = menuBar.addMenu("&View")
        self.viewMenu.addSeparator()
        self.viewMenu.addAction(self.viewCcaTableAction)

        # Image menu
        ImageMenu = menuBar.addMenu("&Image")
        ImageMenu.addSeparator()
        ImageMenu.addAction(self.imgPropertiesAction)
        self.defaultRescaleIntensLutMenu = ImageMenu.addMenu(
            "Default method to rescale intensities (LUT)"
        )

        self.defaultRescaleIntensActionGroup = QActionGroup(
            self.defaultRescaleIntensLutMenu
        )
        howTexts = (
            'Rescale each 2D image', 
            'Rescale across z-stack',
            'Rescale across time frames',
            'Do no rescale, display raw image'
        )
        try:
            self.defaultRescaleIntensHow = (
                self.df_settings.at['default_rescale_intens_how', 'value']
            )
        except Exception as err:
            self.defaultRescaleIntensHow = howTexts[0]
            
        for howText in howTexts:
            action = QAction(howText, self.defaultRescaleIntensLutMenu)
            action.setCheckable(True)
            if howText == self.defaultRescaleIntensHow:
                action.setChecked(True)
                
            self.defaultRescaleIntensActionGroup.addAction(action)
            self.defaultRescaleIntensLutMenu.addAction(action)
        
        ImageMenu.addAction(self.addScaleBarAction)
        ImageMenu.addAction(self.addTimestampAction)
        
        self.rescaleIntensMenu = ImageMenu.addMenu('Rescale intensities (LUT)')
        
        ImageMenu.addAction(self.preprocessAction)
        ImageMenu.addAction(self.combineChannelsAction)
        ImageMenu.addAction(self.saveLabColormapAction)
        ImageMenu.addAction(self.shuffleCmapAction)
        ImageMenu.addAction(self.greedyShuffleCmapAction)
        ImageMenu.addAction(self.zoomToObjsAction)
        ImageMenu.addAction(self.zoomOutAction)

        # Segment menu
        SegmMenu = menuBar.addMenu("&Segment")
        self.segmentMenu = SegmMenu
        SegmMenu.addSeparator()
        self.segmSingleFrameMenu = SegmMenu.addMenu('Segment displayed frame')
        for action in self.segmActions:
            self.segmSingleFrameMenu.addAction(action)

        self.segmSingleFrameMenu.addSeparator()
        self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction)

        self.segmVideoMenu = SegmMenu.addMenu('Segment multiple frames')
        for action in self.segmActionsVideo:
            self.segmVideoMenu.addAction(action)

        self.segmVideoMenu.addSeparator()
        self.segmVideoMenu.addAction(self.addCustomModelVideoAction)
        
        self.segmWithPromptableModelMenu = SegmMenu.addMenu(
            'Segment with promptable model'
        )
        
        self.segmWithPromptableModelMenu.addAction(
            self.segmWithPromptableModelAction
        )
        
        self.segmWithPromptableModelMenu.addSeparator()
        self.segmWithPromptableModelMenu.addAction(
            self.addCustomPromptModelAction
        )

        SegmMenu.addAction(self.EditSegForLostIDsSetSettings)
        SegmMenu.addAction(self.postProcessSegmAction)
        SegmMenu.addAction(self.autoSegmAction)
        SegmMenu.addAction(self.relabelSequentialAction)
        SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened)

        # Tracking menu
        trackingMenu = menuBar.addMenu("&Tracking")
        self.trackingMenu = trackingMenu
        trackingMenu.addSeparator()
        selectTrackAlgoMenu = trackingMenu.addMenu(
            'Select real-time tracking algorithm'
        )
        for rtTrackerAction in self.trackingAlgosGroup.actions():
            selectTrackAlgoMenu.addAction(rtTrackerAction)

        trackingMenu.addAction(self.editRtTrackerParamsAction)
        trackingMenu.addAction(self.repeatTrackingVideoAction)

        trackingMenu.addAction(self.repeatTrackingMenuAction)
        trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened)
        
        if self.mainWin is not None:
            trackingMenu.addAction(
                self.mainWin.applyTrackingFromTableAction
            )
            trackingMenu.addAction(
                self.mainWin.applyTrackingFromTrackMateXMLAction
            )

        # Measurements menu
        measurementsMenu = menuBar.addMenu("&Measurements")
        self.measurementsMenu = measurementsMenu
        measurementsMenu.addSeparator()
        measurementsMenu.addAction(self.setMeasurementsAction)
        measurementsMenu.addAction(self.addCustomMetricAction)
        measurementsMenu.addAction(self.addCombineMetricAction)
        measurementsMenu.setDisabled(True)

        # Settings menu
        self.settingsMenu = QMenu("Settings", self)
        menuBar.addMenu(self.settingsMenu)
        self.settingsMenu.addAction(self.toggleColorSchemeAction)
        self.settingsMenu.addAction(self.pxModeAction)
        self.settingsMenu.addAction(self.highLowResAction)
        self.settingsMenu.addAction(self.editShortcutsAction)
        self.settingsMenu.addAction(self.showMirroredCursorAction)
        self.settingsMenu.addSeparator()

        # Mode menu (actions added when self.modeComboBox is created)
        self.modeMenu = menuBar.addMenu('Mode')
        self.modeMenu.menuAction().setVisible(False)

        # Help menu
        helpMenu = menuBar.addMenu("&Help")
        helpMenu.addAction(self.openLogFileAction)
        helpMenu.addAction(self.tipsAction)
        helpMenu.addAction(self.UserManualAction)
        helpMenu.addSeparator()
        helpMenu.addAction(self.aboutAction)
        self.helpMenu = helpMenu

    def gui_createToolBars(self):
        # File toolbar
        fileToolBar = self.addToolBar("File")
        # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize))
        fileToolBar.setMovable(False)

        self.segmNdimIndicatorAction = fileToolBar.addWidget(
            self.segmNdimIndicator
        )
        self.segmNdimIndicatorAction.setVisible(False)
        fileToolBar.addAction(self.newAction)
        fileToolBar.addAction(self.openFolderAction)
        fileToolBar.addAction(self.openFileAction)
        fileToolBar.addAction(self.manageVersionsAction)
        fileToolBar.addAction(self.saveAction)
        fileToolBar.addAction(self.showInExplorerAction)
        # fileToolBar.addAction(self.reloadAction)
        fileToolBar.addAction(self.undoAction)
        fileToolBar.addAction(self.redoAction)
        self.fileToolBar = fileToolBar
        self.setEnabledFileToolbar(False)

        self.undoAction.setEnabled(False)
        self.redoAction.setEnabled(False)

        # Navigation toolbar
        navigateToolBar = widgets.ToolBar("Navigation", self)
        navigateToolBar.setContextMenuPolicy(Qt.PreventContextMenu)
        # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize))
        self.addToolBar(navigateToolBar)
        navigateToolBar.addAction(self.findIdAction)

        self.slideshowButton = QToolButton(self)
        self.slideshowButton.setIcon(QIcon(":eye-plus.svg"))
        self.slideshowButton.setCheckable(True)
        self.slideshowButton.setShortcut('Ctrl+W')
        navigateToolBar.addWidget(self.slideshowButton)
        
        navigateToolBar.addAction(self.autoPilotButton)
        
        # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize))
        navigateToolBar.addAction(self.skipToNewIdAction)
        
        self.preprocessImageAction = QAction('Preprocess image', self)
        self.preprocessImageAction.setIcon(QIcon(":filter_image.svg"))
        navigateToolBar.addAction(self.preprocessImageAction)

        self.overlayButton = widgets.rightClickToolButton(parent=self)
        self.overlayButton.setIcon(QIcon(":overlay.svg"))
        self.overlayButton.setCheckable(True)

        self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton)
        # self.checkableButtons.append(self.overlayButton)
        # self.checkableQButtonsGroup.addButton(self.overlayButton)
        
        self.countObjsButton = QToolButton(self)
        self.countObjsButton.setIcon(QIcon(":count_objects.svg"))
        self.countObjsButton.setCheckable(True)
        self.countObjsButton.setShortcut('Ctrl+Shift+C')
        self.countObjsButtonAction = navigateToolBar.addWidget(
            self.countObjsButton
        )

        self.togglePointsLayerAction = QAction('Activate points layer', self)
        self.togglePointsLayerAction.setCheckable(True)
        self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg"))
        navigateToolBar.addAction(self.togglePointsLayerAction)

        self.overlayLabelsButton = widgets.rightClickToolButton(parent=self)
        self.overlayLabelsButton.setIcon(QIcon(":overlay_labels.svg"))
        self.overlayLabelsButton.setCheckable(True)
        # self.overlayLabelsButton.setVisible(False)
        self.overlayLabelsButtonAction = navigateToolBar.addWidget(
            self.overlayLabelsButton
        )
        self.overlayLabelsButtonAction.setVisible(False)

        self.rulerButton = QToolButton(self)
        self.rulerButton.setIcon(QIcon(":ruler.svg"))
        self.rulerButton.setCheckable(True)
        navigateToolBar.addWidget(self.rulerButton)
        self.checkableButtons.append(self.rulerButton)
        self.LeftClickButtons.append(self.rulerButton)

        # fluorescence image color widget
        colorsToolBar = widgets.ToolBar("Colors", self)

        self.overlayColorButton = pg.ColorButton(self, color=(230,230,230))
        self.overlayColorButton.setDisabled(True)
        colorsToolBar.addWidget(self.overlayColorButton)

        self.textIDsColorButton = pg.ColorButton(self)
        colorsToolBar.addWidget(self.textIDsColorButton)

        self.addToolBar(colorsToolBar)
        colorsToolBar.setVisible(False)

        self.navigateToolBar = navigateToolBar

        # cca toolbar
        ccaToolBar = widgets.ToolBar("Cell cycle annotations", self)
        self.addToolBar(ccaToolBar)

        # Assign mother to bud button
        self.assignBudMothButton = QToolButton(self)
        self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg"))
        self.assignBudMothButton.setCheckable(True)
        self.assignBudMothButton.setShortcut('A')
        self.assignBudMothButton.setVisible(False)
        self.assignBudMothButton.action = ccaToolBar.addWidget(self.assignBudMothButton)
        self.checkableButtons.append(self.assignBudMothButton)
        self.checkableQButtonsGroup.addButton(self.assignBudMothButton)
        self.functionsNotTested3D.append(self.assignBudMothButton)
        

        # Set is_history_known button
        self.setIsHistoryKnownButton = QToolButton(self)
        self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg"))
        self.setIsHistoryKnownButton.setCheckable(True)
        self.setIsHistoryKnownButton.setShortcut('U')
        self.setIsHistoryKnownButton.setVisible(False)
        self.setIsHistoryKnownButton.action = ccaToolBar.addWidget(self.setIsHistoryKnownButton)
        self.checkableButtons.append(self.setIsHistoryKnownButton)
        self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton)
        self.functionsNotTested3D.append(self.setIsHistoryKnownButton)
        
        ccaToolBar.addAction(self.assignBudMothAutoAction)
        ccaToolBar.addAction(self.editCcaToolAction)
        ccaToolBar.addAction(self.reInitCcaAction)
        ccaToolBar.setVisible(False)
        self.ccaToolBar = ccaToolBar
        self.functionsNotTested3D.append(self.assignBudMothAutoAction)
        self.functionsNotTested3D.append(self.reInitCcaAction)
        self.functionsNotTested3D.append(self.editCcaToolAction)

        # Edit toolbar
        editToolBar = widgets.ToolBar("Edit", self)
        editToolBar.setContextMenuPolicy(Qt.PreventContextMenu)
        
        self.addToolBar(editToolBar)

        self.brushButton = QToolButton(self)
        self.brushButton.setIcon(QIcon(":brush.svg"))
        self.brushButton.setCheckable(True)
        editToolBar.addWidget(self.brushButton)
        self.checkableButtons.append(self.brushButton)
        self.LeftClickButtons.append(self.brushButton)
        self.brushButton.keyPressShortcut = Qt.Key_B
        self.widgetsWithShortcut['Brush'] = self.brushButton

        self.eraserButton = QToolButton(self)
        self.eraserButton.setIcon(QIcon(":eraser.svg"))
        self.eraserButton.setCheckable(True)
        editToolBar.addWidget(self.eraserButton)
        self.eraserButton.keyPressShortcut = Qt.Key_X
        self.widgetsWithShortcut['Eraser'] = self.eraserButton
        self.checkableButtons.append(self.eraserButton)
        self.LeftClickButtons.append(self.eraserButton)

        self.curvToolButton = QToolButton(self)
        self.curvToolButton.setIcon(QIcon(":curvature-tool.svg"))
        self.curvToolButton.setCheckable(True)
        self.curvToolButton.setShortcut('C')
        self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton)
        self.LeftClickButtons.append(self.curvToolButton)
        # self.functionsNotTested3D.append(self.curvToolButton)
        self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton
        # self.checkableButtons.append(self.curvToolButton)

        self.wandToolButton = QToolButton(self)
        self.wandToolButton.setIcon(QIcon(":magic_wand.svg"))
        self.wandToolButton.setCheckable(True)
        self.wandToolButton.setShortcut('Ctrl+D')
        self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton)
        self.LeftClickButtons.append(self.wandToolButton)
        self.functionsNotTested3D.append(self.wandToolButton)
        self.widgetsWithShortcut['Magic wand'] = self.wandToolButton
        
        self.magicPromptsToolButton = QToolButton(self)
        self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg"))
        self.magicPromptsToolButton.setCheckable(True)
        self.magicPromptsToolButton.setShortcut('W')
        self.magicPromptsToolButton.action = editToolBar.addWidget(
            self.magicPromptsToolButton
        )
        self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton
        
        self.drawClearRegionButton = QToolButton(self)
        self.drawClearRegionButton.setCheckable(True)
        self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg"))
        self.widgetsWithShortcut['Clear freehand region'] = self.drawClearRegionButton
        
        self.checkableButtons.append(self.drawClearRegionButton)
        self.LeftClickButtons.append(self.drawClearRegionButton)
        
        self.drawClearRegionAction = editToolBar.addWidget(
            self.drawClearRegionButton
        )

        self.widgetsWithShortcut['Annotate mother/daughter pairing'] = (
            self.assignBudMothButton
        )
        self.widgetsWithShortcut['Annotate unknown history'] = (
            self.setIsHistoryKnownButton
        )
        
        self.copyLostObjButton = QToolButton(self)
        self.copyLostObjButton.setIcon(QIcon(":copyContour.svg"))
        self.copyLostObjButton.setCheckable(True)
        self.copyLostObjButton.setShortcut('V')
        self.copyLostObjButton.action = editToolBar.addWidget(
            self.copyLostObjButton
        )
        self.checkableButtons.append(self.copyLostObjButton)
        self.checkableQButtonsGroup.addButton(self.copyLostObjButton)
        self.widgetsWithShortcut['Copy lost object contour'] = (
            self.copyLostObjButton
        )
        self.functionsNotTested3D.append(self.copyLostObjButton)
        
        self.labelRoiButton = widgets.rightClickToolButton(parent=self)
        self.labelRoiButton.setIcon(QIcon(":label_roi.svg"))
        self.labelRoiButton.setCheckable(True)
        self.labelRoiButton.setShortcut('L')
        self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton)
        self.LeftClickButtons.append(self.labelRoiButton)
        self.checkableButtons.append(self.labelRoiButton)
        self.checkableQButtonsGroup.addButton(self.labelRoiButton)
        self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton
        # self.functionsNotTested3D.append(self.labelRoiButton)

        self.segmentToolAction = QAction('Segment with last used model', self)
        self.segmentToolAction.setIcon(QIcon(":segment.svg"))
        self.segmentToolAction.setShortcut('R')
        self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction
        editToolBar.addAction(self.segmentToolAction)

        self.SegForLostIDsButton = QToolButton(self)
        self.SegForLostIDsButton.setIcon(QIcon(":addDelPolyLineRoi_cursor.svg"))
        self.segForLostIDsAction = editToolBar.addWidget(
            self.SegForLostIDsButton
        )
        self.SegForLostIDsButton.clicked.connect(self.SegForLostIDsAction)

        # self.SegForLostIDsButton.setShortcut('U')
        # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton
        
        self.manualBackgroundButton = QToolButton(self)
        self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg"))
        self.manualBackgroundButton.setCheckable(True)
        self.manualBackgroundButton.setShortcut('G')
        self.LeftClickButtons.append(self.manualBackgroundButton)
        self.checkableButtons.append(self.manualBackgroundButton)
        self.checkableQButtonsGroup.addButton(self.manualBackgroundButton)
        self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton
        
        self.manualBackgroundAction = editToolBar.addWidget(
            self.manualBackgroundButton
        )
        
        self.delObjsOutSegmMaskAction = QAction(
            QIcon(":del_objs_out_segm.svg"), 
            'Select a segmentation file and delete all objects on the background', 
            self
        )
        self.delObjsOutSegmMaskAction.setShortcut('I')
        self.widgetsWithShortcut['Delete all objects outside segm'] = (
            self.delObjsOutSegmMaskAction
        )
        editToolBar.addAction(self.delObjsOutSegmMaskAction)

        self.hullContToolButton = QToolButton(self)
        self.hullContToolButton.setIcon(QIcon(":hull.svg"))
        self.hullContToolButton.setCheckable(True)
        self.hullContToolButton.setShortcut('O')
        self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton)
        self.checkableButtons.append(self.hullContToolButton)
        self.checkableQButtonsGroup.addButton(self.hullContToolButton)
        self.functionsNotTested3D.append(self.hullContToolButton)
        self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton

        self.fillHolesToolButton = QToolButton(self)
        self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg"))
        self.fillHolesToolButton.setCheckable(True)
        self.fillHolesToolButton.setShortcut('F')
        self.fillHolesToolButton.action = editToolBar.addWidget(
            self.fillHolesToolButton
        )
        self.checkableButtons.append(self.fillHolesToolButton)
        self.checkableQButtonsGroup.addButton(self.fillHolesToolButton)
        self.functionsNotTested3D.append(self.fillHolesToolButton)
        self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton

        self.moveLabelToolButton = QToolButton(self)
        self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg"))
        self.moveLabelToolButton.setCheckable(True)
        self.moveLabelToolButton.setShortcut('P')
        self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton)
        self.checkableButtons.append(self.moveLabelToolButton)
        self.checkableQButtonsGroup.addButton(self.moveLabelToolButton)
        self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton

        self.expandLabelToolButton = QToolButton(self)
        self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg"))
        self.expandLabelToolButton.setCheckable(True)
        self.expandLabelToolButton.setShortcut('E')
        self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton)
        self.expandLabelToolButton.hide()
        self.checkableButtons.append(self.expandLabelToolButton)
        self.LeftClickButtons.append(self.expandLabelToolButton)
        self.checkableQButtonsGroup.addButton(self.expandLabelToolButton)
        self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton

        self.editIDbutton = QToolButton(self)
        self.editIDbutton.setIcon(QIcon(":edit-id.svg"))
        self.editIDbutton.setCheckable(True)
        self.editIDbutton.setShortcut('N')
        editToolBar.addWidget(self.editIDbutton)
        self.checkableButtons.append(self.editIDbutton)
        self.checkableQButtonsGroup.addButton(self.editIDbutton)
        self.widgetsWithShortcut['Edit ID'] = self.editIDbutton

        self.separateBudButton = QToolButton(self)
        self.separateBudButton.setIcon(QIcon(":separate-bud.svg"))
        self.separateBudButton.setCheckable(True)
        self.separateBudButton.setShortcut('S')
        self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton)
        self.checkableButtons.append(self.separateBudButton)
        self.checkableQButtonsGroup.addButton(self.separateBudButton)
        # self.functionsNotTested3D.append(self.separateBudButton)
        self.widgetsWithShortcut['Separate objects'] = self.separateBudButton

        self.mergeIDsButton = QToolButton(self)
        self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg"))
        self.mergeIDsButton.setCheckable(True)
        self.mergeIDsButton.setShortcut('M')
        self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton)
        self.checkableButtons.append(self.mergeIDsButton)
        self.checkableQButtonsGroup.addButton(self.mergeIDsButton)
        # self.functionsNotTested3D.append(self.mergeIDsButton)
        self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton

        self.keepIDsButton = QToolButton(self)
        self.keepIDsButton.setIcon(QIcon(":keep_objects.svg"))
        self.keepIDsButton.setCheckable(True)
        self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton)
        self.keepIDsButton.setShortcut('K')
        self.checkableButtons.append(self.keepIDsButton)
        self.checkableQButtonsGroup.addButton(self.keepIDsButton)
        # self.functionsNotTested3D.append(self.keepIDsButton)
        self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton

        self.whitelistIDsButton = QToolButton(self)
        self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg"))
        self.whitelistIDsButton.setCheckable(True)
        self.whitelistIDsButton.action = editToolBar.addWidget(
            self.whitelistIDsButton
        )
        self.whitelistIDsButton.setShortcut('Ctrl+K')
        self.checkableButtons.append(self.whitelistIDsButton)
        self.checkableQButtonsGroup.addButton(self.whitelistIDsButton)
        self.LeftClickButtons.append(self.whitelistIDsButton)
        # self.functionsNotTested3D.append(self.whitelistIDsButton)
        self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = (
            self.whitelistIDsButton
        )

        self.binCellButton = QToolButton(self)
        self.binCellButton.setIcon(QIcon(":bin.svg"))
        self.binCellButton.setCheckable(True)
        # self.binCellButton.setShortcut('R')
        self.binCellButton.action = editToolBar.addWidget(self.binCellButton)
        self.checkableButtons.append(self.binCellButton)
        self.checkableQButtonsGroup.addButton(self.binCellButton)
        # self.functionsNotTested3D.append(self.binCellButton)

        self.manualTrackingButton = QToolButton(self)
        self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg"))
        self.manualTrackingButton.setCheckable(True)
        self.manualTrackingButton.setShortcut('T')
        self.checkableQButtonsGroup.addButton(self.manualTrackingButton)
        self.checkableButtons.append(self.manualTrackingButton)
        self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton

        self.ripCellButton = QToolButton(self)
        self.ripCellButton.setIcon(QIcon(":rip.svg"))
        self.ripCellButton.setCheckable(True)
        self.ripCellButton.setShortcut('D')
        self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton)
        self.checkableButtons.append(self.ripCellButton)
        self.checkableQButtonsGroup.addButton(self.ripCellButton)
        self.functionsNotTested3D.append(self.ripCellButton)
        self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton

        editToolBar.addAction(self.addDelRoiAction)
        # editToolBar.addAction(self.addDelPolyLineRoiAction)
        
        self.addDelPolyLineRoiAction = editToolBar.addWidget(
            self.addDelPolyLineRoiButton
        )
        self.addDelPolyLineRoiAction.roiType = 'polyline'
        
        editToolBar.addAction(self.delBorderObjAction)

        self.addDelRoiAction.toolbar = editToolBar
        self.functionsNotTested3D.append(self.addDelRoiAction)

        self.addDelPolyLineRoiAction.toolbar = editToolBar
        self.functionsNotTested3D.append(self.addDelPolyLineRoiAction)

        self.delBorderObjAction.toolbar = editToolBar
        self.functionsNotTested3D.append(self.delBorderObjAction)

        editToolBar.addAction(self.repeatTrackingAction)
        
        self.manualTrackingAction = editToolBar.addWidget(
            self.manualTrackingButton
        )

        self.functionsNotTested3D.append(self.repeatTrackingAction)
        self.functionsNotTested3D.append(self.manualTrackingAction)

        self.reinitLastSegmFrameAction = QAction(self)
        self.reinitLastSegmFrameAction.setIcon(QIcon(":reinitLastSegm.svg"))
        self.reinitLastSegmFrameAction.setVisible(False)
        editToolBar.addAction(self.reinitLastSegmFrameAction)
        editToolBar.setVisible(False)
        self.reinitLastSegmFrameAction.toolbar = editToolBar
        self.functionsNotTested3D.append(self.reinitLastSegmFrameAction)


        self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self)
        self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu)
        
        self.addToolBar(self.editLin_TreeBar)
        self.editLin_TreeGroup = QButtonGroup()
        self.editLin_TreeGroup.setExclusive(True)

        self.findNextMotherButton = QToolButton(self)
        self.findNextMotherButton.setIcon(QIcon(":magnGlass.svg"))
        self.findNextMotherButton.setCheckable(True)
        self.editLin_TreeBar.addWidget(self.findNextMotherButton)
        self.editLin_TreeGroup.addButton(self.findNextMotherButton)
        self.findNextMotherButton.setShortcut('F')
        self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton

        self.unknownLineageButton = QToolButton(self)
        self.unknownLineageButton.setIcon(QIcon(":history.svg"))
        self.unknownLineageButton.setCheckable(True)
        self.editLin_TreeBar.addWidget(self.unknownLineageButton)
        self.editLin_TreeGroup.addButton(self.unknownLineageButton)
        self.unknownLineageButton.setShortcut('U')
        self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton

        self.noToolLinTreeButton = QToolButton(self)
        self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg"))
        self.noToolLinTreeButton.setCheckable(True)
        self.editLin_TreeBar.addWidget(self.noToolLinTreeButton)
        self.editLin_TreeGroup.addButton(self.noToolLinTreeButton)
        self.noToolLinTreeButton.setShortcut('N')
        self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton

        self.propagateLinTreeButton = QToolButton(self)
        self.propagateLinTreeButton.setIcon(QIcon(":compute.svg"))
        self.editLin_TreeBar.addWidget(self.propagateLinTreeButton)
        self.propagateLinTreeButton.setShortcut('P')
        self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton
        self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction)

        self.viewLinTreeInfoButton = QToolButton(self)
        self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg"))
        self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton)
        self.viewLinTreeInfoButton.setShortcut('S')
        self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton
        self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction)
    

        modes_availible = [
            'Segmentation and Tracking',
            'Cell cycle analysis',
            'Viewer',
            'Custom annotations',
            'Normal division: Lineage tree'
        ]
        self.modeItems = modes_availible

        self.modeActionGroup = QActionGroup(self.modeMenu)
        for mode in self.modeItems:
            action = QAction(mode)
            action.setCheckable(True)
            self.modeActionGroup.addAction(action)
            self.modeMenu.addAction(action)
            if mode == 'Viewer':
                action.setChecked(True)

        self.editToolBar = editToolBar
        self.editToolBar.setVisible(False)
        self.navigateToolBar.setVisible(False)
        self.editLin_TreeBar.setVisible(False)

        self.gui_createAnnotateToolbar()

    @disableWindow
    def propagateLinTreeAction(self):
        """
        Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton.
        """
        posData = self.data[self.pos_i]
        self.lineage_tree.propagate(posData.frame_i)
        self.lin_tree_to_acdc_df(force_all=True)
        if posData.frame_i == self.original_df_lin_tree_i:
            self.original_df_lin_tree = self.lineage_tree.lineage_list[posData.frame_i]

        self.logger.info('Lineage tree propagated.')

    def gui_createAnnotateToolbar(self):
        # Edit toolbar
        self.annotateToolbar = widgets.ToolBar("Custom annotations", self)
        self.annotateToolbar.setContextMenuPolicy(Qt.PreventContextMenu)
        self.addToolBar(Qt.LeftToolBarArea, self.annotateToolbar)
        self.annotateToolbar.addAction(self.loadCustomAnnotationsAction)
        self.annotateToolbar.addAction(self.addCustomAnnotationAction)
        self.annotateToolbar.addAction(self.viewAllCustomAnnotAction)
        self.annotateToolbar.setVisible(False)

    def gui_createLazyLoader(self):
        if not self.lazyLoader is None:
            return

        self.lazyLoaderThread = QThread()
        self.lazyLoaderMutex = QMutex()
        self.lazyLoaderWaitCond = QWaitCondition()
        self.waitReadH5cond = QWaitCondition()
        self.readH5mutex = QMutex()
        self.lazyLoader = workers.LazyLoader(
            self.lazyLoaderMutex, self.lazyLoaderWaitCond, 
            self.waitReadH5cond, self.readH5mutex
        )
        self.lazyLoader.moveToThread(self.lazyLoaderThread)
        self.lazyLoader.wait = True

        self.lazyLoader.signals.finished.connect(self.lazyLoaderThread.quit)
        self.lazyLoader.signals.finished.connect(self.lazyLoader.deleteLater)
        self.lazyLoaderThread.finished.connect(self.lazyLoaderThread.deleteLater)

        self.lazyLoader.signals.progress.connect(self.workerProgress)
        self.lazyLoader.signals.sigLoadingNewChunk.connect(self.loadingNewChunk)
        self.lazyLoader.sigLoadingFinished.connect(self.lazyLoaderFinished)
        self.lazyLoader.signals.critical.connect(self.lazyLoaderCritical)
        self.lazyLoader.signals.finished.connect(self.lazyLoaderWorkerClosed)

        self.lazyLoaderThread.started.connect(self.lazyLoader.run)
        self.lazyLoaderThread.start()
    
    def gui_createStoreStateWorker(self):
        self.storeStateWorker = None
        return
        self.storeStateThread = QThread()
        self.autoSaveMutex = QMutex()
        self.autoSaveWaitCond = QWaitCondition()

        self.storeStateWorker = workers.StoreGuiStateWorker(
            self.autoSaveMutex, self.autoSaveWaitCond
        )

        self.storeStateWorker.moveToThread(self.storeStateThread)
        self.storeStateWorker.finished.connect(self.storeStateThread.quit)
        self.storeStateWorker.finished.connect(self.storeStateWorker.deleteLater)
        self.storeStateThread.finished.connect(self.storeStateThread.deleteLater)

        self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone)
        self.storeStateWorker.progress.connect(self.workerProgress)
        self.storeStateWorker.finished.connect(self.storeStateWorkerClosed)
        
        self.storeStateThread.started.connect(self.storeStateWorker.run)
        self.storeStateThread.start()

        self.logger.info('Store state worker started.')
    
    def storeStateWorkerDone(self):
        if self.storeStateWorker.callbackOnDone is not None:
            self.storeStateWorker.callbackOnDone()
        self.storeStateWorker.callbackOnDone = None

    def storeStateWorkerClosed(self):
        self.logger.info('Store state worker started.')
    
    def gui_createAutoSaveWorker(self):        
        if not hasattr(self, 'data'):
            return
        
        if not self.isDataLoaded:
            return 
        
        if self.autoSaveActiveWorkers:
            garbage = self.autoSaveActiveWorkers[-1]
            self.autoSaveGarbageWorkers.append(garbage)
            worker = garbage[0]
            worker._stop()

        posData = self.data[self.pos_i]
        autoSaveThread = QThread()
        self.autoSaveMutex = QMutex()
        self.autoSaveWaitCond = QWaitCondition()

        savedSegmData = posData.segm_data.copy()
        autoSaveWorker = workers.AutoSaveWorker(
            self.autoSaveMutex, self.autoSaveWaitCond, savedSegmData
        )
        autoSaveWorker.isAutoSaveON = self.autoSaveToggle.isChecked()

        autoSaveWorker.moveToThread(autoSaveThread)
        autoSaveWorker.finished.connect(autoSaveThread.quit)
        autoSaveWorker.finished.connect(autoSaveWorker.deleteLater)
        autoSaveThread.finished.connect(autoSaveThread.deleteLater)

        autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone)
        autoSaveWorker.progress.connect(self.workerProgress)
        autoSaveWorker.finished.connect(self.autoSaveWorkerClosed)
        autoSaveWorker.sigAutoSaveCannotProceed.connect(
            self.turnOffAutoSaveWorker
        )
        
        autoSaveThread.started.connect(autoSaveWorker.run)
        autoSaveThread.start()

        self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread))

        self.logger.info('Autosaving worker started.')
    
    def autoSaveWorkerStartTimer(self, worker, posData):
        self.autoSaveWorkerTimer = QTimer()
        self.autoSaveWorkerTimer.timeout.connect(
            partial(self.autoSaveWorkerTimerCallback, worker, posData)
        )
        self.autoSaveWorkerTimer.start(150)
    
    def autoSaveWorkerTimerCallback(self, worker, posData):
        if not self.isSaving:
            self.autoSaveWorkerTimer.stop()
            worker._enqueue(posData)
    
    def autoSaveWorkerDone(self):
        self.setStatusBarLabel(log=False)
    
    def ccaCheckerWorkerDone(self):
        self.setStatusBarLabel(log=False)
    
    def preprocWorkerIsQueueEmpty(self, isEmpty: bool):
        if isEmpty:
            self.preprocessDialog.appliedFinished()
        else:
            self.preprocessDialog.setDisabled(True)
            self.preprocessDialog.infoLabel.setText(
                'Computing preview...<br>'
                '<i>(Feel free to use Cell-ACDC while waiting)</i>'
            )
    
    def preprocWorkerPreviewDone(
            self, processed_data: np.ndarray, 
            key: Tuple[int, int, Union[int, str]]
        ):
        pos_i, frame_i, z_slice = key
        posData = self.data[pos_i]
        if not hasattr(posData, 'preproc_img_data'):
            posData.preproc_img_data = preprocess.PreprocessedData()
        
        posData.preproc_img_data[frame_i][z_slice] = processed_data
        self.img1.updateMinMaxValuesPreprocessedData(
            self.data, pos_i, frame_i, z_slice
        )
        
        self.setImageImg1()
    
    def preprocWorkerDone(
            self, 
            processed_data: np.ndarray, 
            how: str, 
        ):
        self.setStatusBarLabel(log=False)
        self.preprocessDialog.appliedFinished()
            
        posData = self.data[self.pos_i]
        if not hasattr(posData, 'preproc_img_data'):
            posData.preproc_img_data = preprocess.PreprocessedData()

        if how == 'current_image':
            if posData.SizeZ > 1:
                z_slice = self.z_slice_index()
                posData.preproc_img_data[posData.frame_i][z_slice] = (
                    processed_data
                )
            else:
                posData.preproc_img_data[posData.frame_i] = processed_data
                z_slice = 0
            self.img1.updateMinMaxValuesPreprocessedData(
                self.data, self.pos_i, posData.frame_i, z_slice
            )
        elif how == 'z_stack':
            for z_slice, processed_img in enumerate(processed_data):
                posData.preproc_img_data[posData.frame_i][z_slice] = (
                    processed_img
                )
                self.img1.updateMinMaxValuesPreprocessedData(
                    self.data, self.pos_i, posData.frame_i, z_slice
                )
        elif how == 'all_frames':
            for frame_i, processed_frame in enumerate(processed_data):
                if processed_frame.ndim == 2:
                    processed_frame = (processed_frame,)
                    
                for z_slice, processed_img in enumerate(processed_frame):
                    posData.preproc_img_data[frame_i][z_slice] = (
                        processed_img
                    )
                    self.img1.updateMinMaxValuesPreprocessedData(
                        self.data, self.pos_i, frame_i, z_slice
                    )
        elif how == 'all_pos':
            for pos_i, processed_pos_data in enumerate(processed_data):
                if processed_pos_data.ndim == 2:
                    processed_pos_data = (processed_pos_data,)

                posData = self.data[pos_i]
                if not hasattr(posData, 'preproc_img_data'):
                    posData.preproc_img_data = preprocess.PreprocessedData()
                for z_slice, processed_img in enumerate(processed_pos_data):
                    posData.preproc_img_data[0][z_slice] = (
                        processed_img
                    )
                    self.img1.updateMinMaxValuesPreprocessedData(
                        self.data, pos_i, 0, z_slice
                    )
            
        if not self.viewPreprocDataToggle.isChecked():
            self.viewPreprocDataToggle.setChecked(True)
        else:
            self.setImageImg1()

    def combineWorkerIsQueueEmpty(self, isEmpty: bool):
        if isEmpty:
            self.combineDialog.appliedFinished()
        else:
            self.combineDialog.setDisabled(True)
            self.combineDialog.infoLabel.setText(
                'Computing preview...<br>'
                '<i>(Feel free to use Cell-ACDC while waiting)</i>'
            )

    def combineWorkerPreviewDone(
            self, 
            processed_data: List[np.ndarray], 
            keys: List[Tuple[int, int, int]]
        ):
        unique_pos = {key[0] for key in keys}
        per_pos_data = {pos_i: [] for pos_i in unique_pos}

        for key, img in zip(keys, processed_data):
            pos_i, frame_i, z_slice = key
            per_pos_data[pos_i].append((key, img))

        for pos_i in unique_pos:    
            posData = self.data[pos_i]
            if not hasattr(posData, 'combine_img_data'):
                posData.combine_img_data = preprocess.PreprocessedData()

            n_dim_img = posData.img_data.ndim

            if n_dim_img == 4:
                for key, processed_data in per_pos_data[pos_i]:
                    pos_i, frame_i, z_slice = key
                    posData.combine_img_data[frame_i][z_slice] = processed_data
                    self.img1.updateMinMaxValuesCombinedData(
                            self.data, pos_i, frame_i, z_slice
                        )
            elif n_dim_img == 3:
                for key, processed_data in per_pos_data[pos_i]:
                    pos_i, frame_i, z_slice = key
                    posData.combine_img_data[frame_i] = processed_data
                    self.img1.updateMinMaxValuesCombinedData(
                        self.data, pos_i, frame_i, z_slice
                    )
            else:
                raise ValueError('Invalid number of dimensions in img_data.')
        
        posData = self.data[self.pos_i]
        curr_pos_i, curr_frame_i, curr_z_slice = self.pos_i,self.data[self.pos_i].frame_i, self.z_slice_index()
        current_combine_img = posData.combine_img_data[curr_frame_i]
        self.img1.updateMinMaxValuesCombinedData(
            self.data, curr_pos_i, curr_frame_i, curr_z_slice
        )
        
        self.setImageImg1()

    def combineWorkerAskLoadFluoChannels(self, requ_channels, pos_i):
        if pos_i is None:
            pos_i = list(range(len(self.data)))
        elif not isinstance(pos_i, list):
            pos_i = [pos_i]

        for i in pos_i:
            self.getChData(requ_ch=requ_channels, pos_i=i)
        self.combineWorker.wake_waitCondLoadFluoChannels()
    
    def combineWorkerDone(
            self, 
            processed_data: List[np.ndarray], 
            keys: List[Tuple[int, int, int]]
        ):
        self.setStatusBarLabel(log=False)
        self.combineDialog.appliedFinished()

        unique_pos = {key[0] for key in keys}
        per_pos_data = {pos_i: [] for pos_i in unique_pos}

        for key, img in zip(keys, processed_data):
            pos_i, frame_i, z_slice = key
            per_pos_data[pos_i].append((key, img))

        for pos_i in unique_pos:    
            posData = self.data[pos_i]
            if not hasattr(posData, 'combine_img_data'):
                posData.combine_img_data = preprocess.PreprocessedData()

            n_dim_img = posData.img_data.ndim


            if n_dim_img == 4:
                for key, processed_data in per_pos_data[pos_i]:
                    pos_i, frame_i, z_slice = key
                    posData.combine_img_data[frame_i][z_slice] = processed_data
                    self.img1.updateMinMaxValuesCombinedData(
                            self.data, pos_i, frame_i, z_slice
                        )
            else:
                for key, processed_data in per_pos_data[pos_i]:
                    pos_i, frame_i, z_slice = key
                    posData.combine_img_data[frame_i] = processed_data
                    self.img1.updateMinMaxValuesCombinedData(
                        self.data, pos_i, frame_i, z_slice
                    )
                
            if not self.viewCombineChannelDataToggle.isChecked():
                self.viewCombineChannelDataToggle.setChecked(True)
            else:
                self.setImageImg1()
       
    def goToFrameNumber(self, frame_n):
        posData = self.data[self.pos_i]
        posData.frame_i = frame_n - 1
        self.get_data()
        self.updateAllImages()
        self.updateScrollbars()
    
    def warnCcaIntegrity(self, txt, category):
        self.logger.warning(f'{html_utils.to_plain_text(txt)}')
        
        if 'disable_all' in self.disabled_cca_warnings:
            return
        
        if category in self.disabled_cca_warnings:
            return
        
        if txt in self.disabled_cca_warnings:
            return
        
        if self.isWarningCcaIntegrity:
            # Some other warning is still open --> avoid opening another one
            return
        
        self.isWarningCcaIntegrity = True
        disabled_warning = _warnings.warn_cca_integrity(
            txt, category, self, 
            go_to_frame_callback=self.goToFrameNumber
        )
        if disabled_warning:
            self.disabled_cca_warnings.add(disabled_warning)
        
        self.isWarningCcaIntegrity = False
    
    def fixWillDivide(self, warning_txt, IDs_will_divide_wrong):
        self.logger.info(warning_txt)
        self.logger.info('Fixing `will_divide` information...')
        
        global_cca_df = self.getConcatCcaDf()
        global_cca_df = (
            global_cca_df.reset_index()
            .set_index(['Cell_ID', 'generation_num'])
        )
        global_cca_df.loc[IDs_will_divide_wrong, 'will_divide'] = 0
        global_cca_df = (
            global_cca_df.reset_index()
            .set_index(['frame_i', 'Cell_ID'])
        )
        self.storeFromConcatCcaDf(global_cca_df)
        
    def autoSaveWorkerClosed(self, worker):
        if self.autoSaveActiveWorkers:
            self.logger.info('Autosaving worker closed.')
            try:
                self.autoSaveActiveWorkers.remove(worker)
            except Exception as e:
                pass

    def ccaCheckerWorkerClosed(self, worker):
        self.logger.info('Cell cycle annotations integrity checker stopped.') 
        self.ccaCheckerRunning = False           
    
    def preprocWorkerClosed(self, worker):
        self.logger.info('Pre-processing worker stopped.')

    def combineWorkerClosed(self, worker):
        self.logger.info('Combine worker stopped.')
    
    def gui_createMainLayout(self):
        mainLayout = QGridLayout()
        row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor
        mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1)

        row = 0
        col = 2
        mainLayout.addWidget(self.graphLayout, row, col, 1, 2)
        mainLayout.setRowStretch(row, 2)

        col = 4 # graphLayout spans two columns
        mainLayout.addWidget(self.labelsGrad, row, col)

        col = 5 
        mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1)

        col = 2
        row += 1
        self.resizeBottomLayoutLine = widgets.VerticalResizeHline()
        mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2)
        self.resizeBottomLayoutLine.dragged.connect(
            self.resizeBottomLayoutLineDragged
        )
        self.resizeBottomLayoutLine.clicked.connect(
            self.resizeBottomLayoutLineClicked
        )
        self.resizeBottomLayoutLine.released.connect(
            self.resizeBottomLayoutLineReleased
        )

        # row += 1
        # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2)

        # row, col = 1, 2
        # mainLayout.addLayout(
        #     self.bottomLayout, row, col, 1, 2, alignment=Qt.AlignLeft
        # )

        row += 1
        mainLayout.addWidget(self.bottomScrollArea, row, col, 1, 2)
        mainLayout.setRowStretch(row, 0)

        # row, col = 2, 1
        # mainLayout.addWidget(self.terminal, row, col, 1, 4)
        # self.terminal.hide()

        return mainLayout

    def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea):
        self.propsDockWidget = QDockWidget('Cell-ACDC objects', self)
        self.guiTabControl = widgets.guiTabControl(self.propsDockWidget)

        # self.guiTabControl.setFont(_font)

        self.propsDockWidget.setWidget(self.guiTabControl)
        self.propsDockWidget.setFeatures(
            QDockWidget.DockWidgetFeature.DockWidgetFloatable 
            | QDockWidget.DockWidgetFeature.DockWidgetMovable
        )
        self.propsDockWidget.setAllowedAreas(
            Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea
        )
        
        self.addDockWidget(side, self.propsDockWidget)
        self.propsDockWidget.hide()

    def gui_createControlsToolbar(self):
        self.controlToolBars = []
        self.addToolBarBreak()
        
        # Edit toolbar
        modeToolBar = widgets.ToolBar("Mode", self)
        self.addToolBar(modeToolBar)

        self.modeComboBox = widgets.ComboBox()
        self.modeComboBox.addItems(self.modeItems)
        self.modeComboBoxLabel = QLabel('    Mode: ')
        self.modeComboBoxLabel.setBuddy(self.modeComboBox)
        modeToolBar.addWidget(self.modeComboBoxLabel)
        modeToolBar.addWidget(self.modeComboBox)
        modeToolBar.setVisible(False)
        
        self.modeToolBar = modeToolBar
        
        self.autoPilotZoomToObjToolbar = widgets.ToolBar("Auto-zoom to objects", self)
        self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu)
        self.autoPilotZoomToObjToolbar.setMovable(False)
        self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar)
        # self.autoPilotZoomToObjToolbar.setIconSize(QSize(16, 16))
        self.autoPilotZoomToObjToolbar.setVisible(False)
        self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True
        self.controlToolBars.append(self.autoPilotZoomToObjToolbar)
        
        # Widgets toolbar
        brushEraserToolBar = widgets.ToolBar("Widgets", self)
        self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar)
        self.controlToolBars.append(brushEraserToolBar)

        self.editIDspinbox = widgets.SpinBox()
        # self.editIDspinbox.setMaximum(2**32-1)
        editIDLabel = QLabel('   ID: ')
        self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel)
        self.editIDspinboxAction = brushEraserToolBar.addWidget(self.editIDspinbox)
        self.editIDLabelAction.setVisible(False)
        self.editIDspinboxAction.setVisible(False)
        self.editIDspinboxAction.setDisabled(True)
        self.editIDLabelAction.setDisabled(True)

        brushEraserToolBar.addWidget(QLabel(' '))
        self.autoIDcheckbox = QCheckBox('Auto-ID')
        self.autoIDcheckbox.setChecked(True)
        self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox)
        self.autoIDcheckboxAction.setVisible(False)

        self.brushSizeSpinbox = widgets.SpinBox(disableKeyPress=True)
        self.brushSizeSpinbox.setValue(4)
        brushSizeLabel = QLabel('   Size: ')
        brushSizeLabel.setBuddy(self.brushSizeSpinbox)
        self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel)
        self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox)
        self.brushSizeLabelAction.setVisible(False)
        self.brushSizeAction.setVisible(False)
        
        brushEraserToolBar.addWidget(QLabel('  '))
        self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes')
        self.brushAutoFillAction = brushEraserToolBar.addWidget(
            self.brushAutoFillCheckbox
        )
        self.brushAutoFillAction.setVisible(False)
        if 'brushAutoFill' in self.df_settings.index:
            checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes'
            self.brushAutoFillCheckbox.setChecked(checked)
        
        brushEraserToolBar.addWidget(QLabel('  '))
        self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering')
        self.brushAutoHideAction = brushEraserToolBar.addWidget(
            self.brushAutoHideCheckbox
        )
        self.brushAutoHideCheckbox.setChecked(True)
        self.brushAutoHideAction.setVisible(False)
        if 'brushAutoHide' in self.df_settings.index:
            checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes'
            self.brushAutoHideCheckbox.setChecked(checked)
        
        brushEraserToolBar.setVisible(False)
        self.brushEraserToolBar = brushEraserToolBar

        self.wandControlsToolbar = widgets.ToolBar("Magic wand controls", self)
        self.wandToleranceSlider = widgets.sliderWithSpinBox(
            title='Tolerance', title_loc='in_line'
        )
        self.wandToleranceSlider.setValue(5)

        self.wandAutoFillCheckbox = QCheckBox('Auto-fill holes')

        col = 3
        self.wandToleranceSlider._layout.addWidget(
            self.wandAutoFillCheckbox, 0, col
        )

        col += 1
        self.wandToleranceSlider._layout.setColumnStretch(col, 21)

        self.wandControlsToolbar.addWidget(self.wandToleranceSlider)

        self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar)
        self.wandControlsToolbar.setVisible(False)
        self.controlToolBars.append(self.wandControlsToolbar)

        separatorW = 5
        self.labelRoiToolbar = widgets.ToolBar("Magic labeller controls", self)
        self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: '))
        self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True)
        self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox)

        self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW))
        self.labelRoiToolbar.addWidget(widgets.QVLine())
        self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW))

        self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox(
            'Remove objs. touched by new ones'
        )
        self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox)
        self.labelRoiAutoClearBorderCheckbox = QCheckBox(
            'Clear ROI borders before adding new objs.'
        )
        self.labelRoiAutoClearBorderCheckbox.setChecked(True)
        self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox)
        
        self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW))
        self.labelRoiToolbar.addWidget(widgets.QVLine())
        self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW))

        group = QButtonGroup()
        group.setExclusive(True)
        self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI')
        self.labelRoiIsRectRadioButton.setChecked(True)
        self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI')
        self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI')
        group.addButton(self.labelRoiIsRectRadioButton)
        group.addButton(self.labelRoiIsFreeHandRadioButton)
        group.addButton(self.labelRoiIsCircularRadioButton)
        self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton)
        self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton)
        self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton)
        self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): '))
        self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True)
        self.labelRoiCircularRadiusSpinbox.setMinimum(1)
        self.labelRoiCircularRadiusSpinbox.setValue(11)
        self.labelRoiCircularRadiusSpinbox.setDisabled(True)
        self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox)
        
        self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW))
        self.labelRoiToolbar.addWidget(widgets.QVLine())
        self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW))

        startFrameLabel = QLabel('Start frame n. ')
        startFrameLabel.setDisabled(True)
        self.labelRoiToolbar.addWidget(startFrameLabel)
        self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True)
        self.labelRoiStartFrameNoSpinbox.label = startFrameLabel
        self.labelRoiStartFrameNoSpinbox.setValue(1)
        self.labelRoiStartFrameNoSpinbox.setMinimum(1)
        self.labelRoiToolbar.addWidget(self.labelRoiStartFrameNoSpinbox)
        self.labelRoiStartFrameNoSpinbox.setDisabled(True)

        self.labelRoiFromCurrentFrameAction = QAction(self)
        self.labelRoiFromCurrentFrameAction.setText('Segment from current frame')
        self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg"))
        self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction)
        self.labelRoiFromCurrentFrameAction.setDisabled(True)

        self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3))
        stopFrameLabel = QLabel(' Stop frame n. ')
        stopFrameLabel.setDisabled(True)
        self.labelRoiToolbar.addWidget(stopFrameLabel)
        self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True)
        self.labelRoiStopFrameNoSpinbox.label = stopFrameLabel
        self.labelRoiStopFrameNoSpinbox.setValue(1)
        self.labelRoiStopFrameNoSpinbox.setMinimum(1)
        self.labelRoiToolbar.addWidget(self.labelRoiStopFrameNoSpinbox)
        self.labelRoiStopFrameNoSpinbox.setDisabled(True)

        self.labelRoiToEndFramesAction = QAction(self)
        self.labelRoiToEndFramesAction.setText('Segment all remaining frames')
        self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg"))
        self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction)
        self.labelRoiToEndFramesAction.setDisabled(True)

        self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames')
        self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox)

        self.labelRoiViewCurrentModelAction = QAction(self)
        self.labelRoiViewCurrentModelAction.setText(
            'View current model\'s parameters'
        )
        self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg"))
        self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction)
        self.labelRoiViewCurrentModelAction.setDisabled(True)

        self.addToolBar(Qt.TopToolBarArea, self.labelRoiToolbar)
        self.controlToolBars.append(self.labelRoiToolbar)
        self.labelRoiToolbar.setVisible(False)
        self.labelRoiTypesGroup = group

        self.loadLabelRoiLastParams()

        self.labelRoiTrangeCheckbox.toggled.connect(
            self.labelRoiTrangeCheckboxToggled
        )
        self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect(
            self.storeLabelRoiParams
        )
        self.labelRoiIsCircularRadioButton.toggled.connect(
            self.labelRoiIsCircularRadioButtonToggled
        )
        self.labelRoiCircularRadiusSpinbox.valueChanged.connect(
            self.updateLabelRoiCircularSize
        )
        self.labelRoiCircularRadiusSpinbox.valueChanged.connect(
            self.storeLabelRoiParams
        )
        self.labelRoiZdepthSpinbox.valueChanged.connect(
            self.storeLabelRoiParams
        )
        self.labelRoiAutoClearBorderCheckbox.toggled.connect(
            self.storeLabelRoiParams
        )
        group.buttonToggled.connect(self.storeLabelRoiParams)

        self.labelRoiToEndFramesAction.triggered.connect(
            self.labelRoiToEndFramesTriggered
        )
        self.labelRoiFromCurrentFrameAction.triggered.connect(
            self.labelRoiFromCurrentFrameTriggered
        )
        self.labelRoiViewCurrentModelAction.triggered.connect(
            self.labelRoiViewCurrentModel
        )

        self.keepIDsToolbar = widgets.ToolBar("Keep IDs controls", self)
        self.keepIDsConfirmAction = QAction()
        self.keepIDsConfirmAction.setIcon(QIcon(":greenTick.svg"))
        self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection')
        self.keepIDsConfirmAction.setDisabled(True)
        self.keepIDsToolbar.addAction(self.keepIDsConfirmAction)
        self.keepIDsToolbar.addWidget(QLabel('  IDs to keep: '))
        instructionsText = (
            ' (Separate IDs by comma. Use a dash to denote a range of IDs)'
        )
        instructionsLabel = QLabel(instructionsText)
        self.keptIDsLineEdit = widgets.KeepIDsLineEdit(
            instructionsLabel, parent=self
        )
        self.keepIDsToolbar.addWidget(self.keptIDsLineEdit)
        self.keepIDsToolbar.addWidget(instructionsLabel)
        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
        self.keepIDsToolbar.addWidget(spacer)
        self.addToolBar(Qt.TopToolBarArea, self.keepIDsToolbar)
        self.keepIDsToolbar.setVisible(False)
        self.controlToolBars.append(self.keepIDsToolbar)

        self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects)
        self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs)
        self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects)
        
        # closeToolbarAction = QAction(
        #     QIcon(":cancelButton.svg"), "Close toolbar...", self
        # )
        # closeToolbarAction.triggered.connect(self.closeToolbars)
        # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction)
        
        self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine())
        self.autoPilotZoomToObjToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW))
        
        spinBox = widgets.SpinBox()
        spinBox.setMinimum(1)
        spinBox.label = QLabel('  Zoom to ID: ')
        spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label)
        spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox)
        spinBox.editingFinished.connect(self.zoomToObj)
        spinBox.sigUpClicked.connect(self.autoZoomNextObj)
        spinBox.sigDownClicked.connect(self.autoZoomPrevObj)
        self.autoPilotZoomToObjSpinBox = spinBox
        toggle = widgets.Toggle()
        self.autoPilotZoomToObjToggle = toggle
        toggle.toggled.connect(self.autoPilotZoomToObjToggled)
        toggle.label = QLabel('  Auto-pilot: ')
        tooltip = (
            'When auto-pilot is active, you can use Up/Down arrows to '
            'automatically zoom to the next/previous object.\n\n'
            'Alternatively, you can type the ID of the object you want to '
            'zoom to.'
        )
        toggle.label.setToolTip(tooltip)
        toggle.setToolTip(tooltip)
        self.autoPilotZoomToObjToolbar.addWidget(toggle.label)
        self.autoPilotZoomToObjToolbar.addWidget(toggle)
        
        self.pointsLayersToolbars = []
        
        self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self)
        self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu)
        
        self.pointsLayersToolbar.sigAddPointsLayer.connect(
            self.addPointsLayerTriggered
        )
        
        self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar)
        
        self.pointsLayersToolbar.setVisible(False)
        self.pointsLayersToolbar.keepVisibleWhenActive = True
        self.controlToolBars.append(self.pointsLayersToolbar)
        
        self.pointsLayersToolbars.append(
            self.pointsLayersToolbar
        )

        self.manualTrackingToolbar = widgets.ManualTrackingToolBar(
            "Manual tracking controls", self
        )
        self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject)
        self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost)
        self.manualTrackingToolbar.sigClearGhostContour.connect(
            self.clearGhostContour
        )
        self.manualTrackingToolbar.sigClearGhostMask.connect(
            self.clearGhostMask
        )
        self.manualTrackingToolbar.sigGhostOpacityChanged.connect(
            self.updateGhostMaskOpacity
        )

        self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar)
        self.manualTrackingToolbar.setVisible(False)
        self.controlToolBars.append(self.manualTrackingToolbar)
        
        self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar(
            "Manual background controls", self
        )
        self.manualBackgroundToolbar.sigIDchanged.connect(
            self.initManualBackgroundObject
        )
        self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar)
        self.manualBackgroundToolbar.setVisible(False)
        self.controlToolBars.append(self.manualBackgroundToolbar)
        
        # Copy lost object contour toolbar
        self.copyLostObjToolbar = widgets.CopyLostObjectToolbar(
            "Copy lost object controls", self
        )
        for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items():
            self.widgetsWithShortcut[name] = action

        self.copyLostObjToolbar.sigCopyAllObjects.connect(
            self.copyAllLostObjects
        )
        
        self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar)
        self.copyLostObjToolbar.setVisible(False)
        # self.controlToolBars.append(self.copyLostObjToolbar)
        
        # Copy lost object contour toolbar
        self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar(
            "Draw freehand region and clear objects controls", self
        )
        
        self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar)
        self.drawClearRegionToolbar.setVisible(False)
        self.controlToolBars.append(self.drawClearRegionToolbar)

        try:
            addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes'
        except KeyError:
            addNewIDToggleState = True
        
        self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar(
            addNewIDToggleState, self
        )
        for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items():
            self.widgetsWithShortcut[name] = action
        
        self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar)
        self.whitelistIDsToolbar.setVisible(False)
        self.controlToolBars.append(self.whitelistIDsToolbar)
        
        self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self)
        for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items():
            self.widgetsWithShortcut[name] = action
        
        self.magicPromptsToolbar.sigComputeOnZoom.connect(
            self.magicPromptsComputeOnZoomTriggered
        )
        self.magicPromptsToolbar.sigComputeOnImage.connect(
            self.magicPromptsComputeOnImageTriggered
        )
        self.magicPromptsToolbar.sigInitSelectedModel.connect(
            self.magicPromptsInitModel
        )
        self.magicPromptsToolbar.sigViewModelParams.connect(
            self.viewSetMagicPromptModelParams
        )
        self.magicPromptsToolbar.sigClearPoints.connect(
            self.magicPromptsClearPoints
        )
        
        self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar)
        self.magicPromptsToolbar.setVisible(False)
        self.magicPromptsToolbar.keepVisibleWhenActive = True
        self.controlToolBars.append(self.magicPromptsToolbar)
        
        self.promptSegmentPointsLayerToolbar = (
            widgets.PromptableModelPointsLayerToolbar(parent=self)
        )
        self.promptSegmentPointsLayerToolbar.setContextMenuPolicy(
            Qt.PreventContextMenu
        )
        
        self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar)
        self.promptSegmentPointsLayerToolbar.setVisible(False)
        
        self.pointsLayersToolbars.append(
            self.promptSegmentPointsLayerToolbar
        )
        
        # Second level toolbar
        secondLevelToolbar = widgets.ToolBar("Second level toolbar", self)
        self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar)
        self.delObjToolAction = QAction(self)
        self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg"))
        self.delObjToolAction.setCheckable(True)
        self.delObjToolAction.setToolTip(
            'Customisable delete object action\n\n'
            'Go to the `Settings --> Customise keyboard shortcuts...` menu '
            'on the top menubar\n'
            'to customise the action required to delete '
            'an object with a click.'
        )
        secondLevelToolbar.addAction(self.delObjToolAction)
        secondLevelToolbar.setMovable(False)
        self.secondLevelToolbar = secondLevelToolbar
        self.secondLevelToolbar.setVisible(False)
        
    def gui_populateToolSettingsMenu(self):
        brushHoverModeActionGroup = QActionGroup(self)
        brushHoverModeActionGroup.setExclusive(True)
        self.brushHoverCenterModeAction = QAction()
        self.brushHoverCenterModeAction.setCheckable(True)
        self.brushHoverCenterModeAction.setText(
            'Use center of the brush/eraser cursor to determine hover ID'
        )
        self.brushHoverCircleModeAction = QAction()
        self.brushHoverCircleModeAction.setCheckable(True)
        self.brushHoverCircleModeAction.setText(
            'Use the entire circle of the brush/eraser cursor to determine hover ID'
        )
        brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction)
        brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction)
        brushHoverModeMenu = self.settingsMenu.addMenu(
            'Brush/eraser cursor hovering mode'
        )
        brushHoverModeMenu.addAction(self.brushHoverCenterModeAction)
        brushHoverModeMenu.addAction(self.brushHoverCircleModeAction)

        if 'useCenterBrushCursorHoverID' not in self.df_settings.index:
            self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes'

        useCenterBrushCursorHoverID = self.df_settings.at[
            'useCenterBrushCursorHoverID', 'value'
        ] == 'Yes'
        self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID)
        self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID)

        self.brushHoverCenterModeAction.toggled.connect(
            self.useCenterBrushCursorHoverIDtoggled
        )

        self.settingsMenu.addSeparator()

        keepToolActiveNames = {
            'Segment range of frames': self.labelRoiTrangeCheckbox
        }
        for button in self.checkableQButtonsGroup.buttons():
            if button.toolTip() == "":
                toolName = "MISSING"
                continue
            else:
                toolName = re.findall(r'Name: (.*)', button.toolTip())[0]
            keepToolActiveNames[toolName] = button
        
        keepToolActiveNames = dict(natsorted(keepToolActiveNames.items()))
        self.keepToolActiveActions = dict()
        all_checked = True
        for toolName, button in keepToolActiveNames.items():
            menu = self.settingsMenu.addMenu(f'{toolName} tool')
            action = QAction(button)
            action.setText('Keep tool active after using it')
            action.setCheckable(True)
            if toolName in self.df_settings.index:
                action.setChecked(True)
            else:
                all_checked = False
            action.toggled.connect(self.keepToolActiveActionToggled)
            menu.addAction(action)
            self.keepToolActiveActions[toolName] = action
        
        self.settingsMenu.addSeparator()

        self.keepAllToolsActiveToggle = QAction()
        self.keepAllToolsActiveToggle.setText(
            'Keep all tools active after using them'
        )
        self.keepAllToolsActiveToggle.setCheckable(True)
        self.keepAllToolsActiveToggle.setChecked(all_checked)
        self.keepAllToolsActiveToggle.toggled.connect(
            self.keepAllToolsActiveActionToggled
        )
        self.settingsMenu.addAction(self.keepAllToolsActiveToggle)
        self.settingsMenu.addSeparator()
        
        askHowFutureFramesMenu = self.settingsMenu.addMenu(
            'Ask how to propagate changes to future frames'
        )
        self.askHowFutureFramesActions = {}
        askHowFutureFramesActionsKeys = (
            'Delete ID', 
            'Exclude cell from analysis', 
            'Annotate cell as dead', 
            'Edit ID',
            'Keep ID'
        )
        for key in askHowFutureFramesActionsKeys:
            askHowFutureFramesAction = QAction()
            askHowFutureFramesAction.setText(f'Ask for "{key}" action')
            askHowFutureFramesAction.setCheckable(True)
            askHowFutureFramesAction.setChecked(True)
            askHowFutureFramesAction.setDisabled(True)
            askHowFutureFramesMenu.addAction(askHowFutureFramesAction)
            self.askHowFutureFramesActions[key] = askHowFutureFramesAction
        
        warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups')
        self.warnLostCellsAction = QAction()
        self.warnLostCellsAction.setText('Show pop-up warning for lost cells')
        self.warnLostCellsAction.setCheckable(True)
        self.warnLostCellsAction.setChecked(True)
        warningsMenu.addAction(self.warnLostCellsAction)

        warnEditingWithAnnotTexts = {
            'Delete ID': 'Show warning when deleting ID that has annotations',
            'Separate IDs': 'Show warning when separating IDs that have annotations',
            'Edit ID': 'Show warning when editing ID that has annotations',
            'Annotate ID as dead':
                'Show warning when annotating dead ID that has annotations',
            'Delete ID with eraser':
                'Show warning when erasing ID that has annotations',
            'Add new ID with brush tool':
                'Show warning when adding new ID (brush) that has annotations',
            'Merge IDs':
                'Show warning when merging IDs that have annotations',
            'Add new ID with curvature tool':
                'Show warning when adding new ID (curv. tool) that has annotations',
            'Add new ID with magic-wand':
                'Show warning when adding new ID (magic-wand) that has annotations',
            'Delete IDs using ROI':
                'Show warning when using ROIs to delete IDs that have annotations',
        }
        self.warnEditingWithAnnotActions = {}
        for key, desc in warnEditingWithAnnotTexts.items():
            action = QAction()
            action.setText(desc)
            action.setCheckable(True)
            action.setChecked(True)
            action.removeAnnot = False
            self.warnEditingWithAnnotActions[key] = action
            warningsMenu.addAction(action)


    def gui_createStatusBar(self):
        self.statusbar = self.statusBar()
        # Permanent widget
        self.wcLabel = QLabel('')
        self.statusbar.addPermanentWidget(self.wcLabel)

        # self.toggleTerminalButton = widgets.ToggleTerminalButton()
        # self.statusbar.addWidget(self.toggleTerminalButton)
        # self.toggleTerminalButton.sigClicked.connect(
        #     self.gui_terminalButtonClicked
        # )

        self.statusBarLabel = QLabel('')
        self.statusbar.addWidget(self.statusBarLabel)
    
    def gui_createTerminalWidget(self):
        self.terminal = widgets.QLog(logger=self.logger)
        self.terminal.connect()
        self.terminalDock = QDockWidget('Log', self)

        self.terminalDock.setWidget(self.terminal)
        self.terminalDock.setFeatures(
            QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable
        )
        self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea)
        self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock)
        # self.terminalDock.widget().layout().setContentsMargins(10,0,10,0)
        self.terminalDock.setVisible(False)
    
    @resetViewRange
    def gui_terminalButtonClicked(self, terminalVisible):
        self.terminalDock.setVisible(terminalVisible)

    def gui_createActions(self):
        # File actions
        self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='')
        self.segmNdimIndicator.setCheckable(True)
        self.segmNdimIndicator.setChecked(True)
        # self.segmNdimIndicator.setDisabled(True)        
        
        if self.debug:
            self.createEmptyDataAction = QAction(self)
            self.createEmptyDataAction.setText("DEBUG: Create empty data")
            
        self.newWindowAction = QAction("New Window", self)
        
        self.newAction = QAction(self)
        self.newAction.setText("&New Segmentation File...")
        self.newAction.setIcon(QIcon(":file-new.svg"))
        self.openFolderAction = QAction(
            QIcon(":folder-open.svg"), "&Load Folder...", self
        )
        self.openFileAction = QAction(
            QIcon(":image.svg"),"&Open Image/Video File...", self
        )
        self.manageVersionsAction = QAction(
            QIcon(":manage_versions.svg"), "Load Older Versions...", self
        )
        self.manageVersionsAction.setDisabled(True)
        self.saveAction = QAction(QIcon(":file-save.svg"), "Save", self)
        self.saveAsAction = QAction("Save as...", self)
        self.exportToVideoAction = QAction("&Video...", self)
        self.exportToImageAction = QAction("&Image...", self)
        self.quickSaveAction = QAction("Save Only Segmentation Masks", self)
        self.loadFluoAction = QAction("Load Fluorescence Images...", self)
        self.loadPosAction = QAction("Load Different Position...", self)
        # self.reloadAction = QAction(
        #     QIcon(":reload.svg"), "Reload segmentation file", self
        # )
        self.nextAction = QAction('Next', self)
        self.prevAction = QAction('Previous', self)
        self.showInExplorerAction = QAction(
            QIcon(":drawer.svg"), f"&{self.openFolderText}", self
        )
        self.exitAction = QAction("&Exit", self)
        self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self)
        self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self)
        # String-based key sequences
        self.newWindowAction.setShortcut('Ctrl+Shift+N')
        self.newAction.setShortcut('Ctrl+N')
        self.openFolderAction.setShortcut('Ctrl+O')
        self.loadPosAction.setShortcut('Shift+P')
        self.saveAsAction.setShortcut('Ctrl+Shift+S')
        self.exportToVideoAction.setShortcut('Ctrl+Shift+V')
        self.exportToImageAction.setShortcut('Ctrl+Shift+I')
        self.saveAction.setShortcut('Ctrl+Alt+S')
        self.quickSaveAction.setShortcut('Ctrl+S')
        self.undoAction.setShortcut('Ctrl+Z')
        self.redoAction.setShortcut('Ctrl+Y')
        self.nextAction.setShortcut(Qt.Key_Right)
        self.prevAction.setShortcut(Qt.Key_Left)
        self.addAction(self.nextAction)
        self.addAction(self.prevAction)
        # Help tips
        newTip = "Create a new segmentation file"
        self.newAction.setStatusTip(newTip)
        self.newAction.setWhatsThis("Create a new empty segmentation file")

        self.autoPilotButton = QAction(self)
        self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg"))
        self.autoPilotButton.setCheckable(True)
        self.autoPilotButton.setShortcut('Ctrl+Shift+A')
        
        self.findIdAction = QAction(self)
        self.findIdAction.setIcon(QIcon(":find.svg"))
        self.findIdAction.setShortcut('Ctrl+F')
        
        self.skipToNewIdAction = QAction(self)
        self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg"))
        self.skipToNewIdAction.setShortcut(
            widgets.KeySequenceFromText(Qt.Key_PageUp)
        )

        self.skipToNewIdAction.setDisabled(True)

        # Edit actions
        models = myutils.get_list_of_models()
        models = [*models, 'local_seg'] # Add local_seg for SegForLostIDsAction
        self.segmActions = []
        self.modelNames = []
        self.acdcSegment_li = []
        self.models = []
        for model_name in models:
            action = QAction(f"{model_name}...")
            self.segmActions.append(action)
            self.modelNames.append(model_name)
            self.models.append(None)
            self.acdcSegment_li.append(None)
            action.setDisabled(True)

        self.addCustomModelFrameAction = QAction('Add custom model...', self)
        self.addCustomModelVideoAction = QAction('Add custom model...', self)
        
        self.segmWithPromptableModelAction = QAction(
            'Select promptable model...', self
        )
        self.addCustomPromptModelAction = QAction(
            'Add custom promptable model...', self
        )

        self.segmActionsVideo = []
        for model_name in models:
            action = QAction(f"{model_name}...")
            self.segmActionsVideo.append(action)
            action.setDisabled(True)

        self.postProcessSegmAction = QAction(
            "Segmentation post-processing...", self
        )
        self.postProcessSegmAction.setDisabled(True)
        self.postProcessSegmAction.setCheckable(True)

        self.EditSegForLostIDsSetSettings = QAction(
            "Edit settings for Segmenting lost IDs...", self
        )
        self.EditSegForLostIDsSetSettings.triggered.connect(
            self.SegForLostIDsSetSettings
        )

        self.repeatTrackingAction = QAction(
            QIcon(":repeat-tracking.svg"), "Repeat tracking", self
        )
        self.repeatTrackingAction.setShortcut('Shift+T')
        self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction
        

        self.editRtTrackerParamsAction = QAction(
            'Edit real-time tracker parameters...', self
        )
        
        self.repeatTrackingMenuAction = QAction(
            'Track current frame with real-time tracker...', self
        )
        self.repeatTrackingMenuAction.setDisabled(True)
        self.repeatTrackingMenuAction.setShortcut('Shift+T')

        self.repeatTrackingVideoAction = QAction(
            'Select a tracker and track multiple frames...', self
        )
        self.repeatTrackingVideoAction.setDisabled(True)
        self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T')

        self.trackingAlgosGroup = QActionGroup(self)
        self.trackWithAcdcAction = QAction('Cell-ACDC', self)
        self.trackWithAcdcAction.setCheckable(True)
        self.trackingAlgosGroup.addAction(self.trackWithAcdcAction)

        self.trackWithYeazAction = QAction('YeaZ', self)
        self.trackWithYeazAction.setCheckable(True)
        self.trackingAlgosGroup.addAction(self.trackWithYeazAction)

        rt_trackers = myutils.get_list_of_real_time_trackers()
        for rt_tracker in rt_trackers:
            rtTrackerAction = QAction(rt_tracker, self)
            rtTrackerAction.setCheckable(True)
            self.trackingAlgosGroup.addAction(rtTrackerAction)

        self.trackWithAcdcAction.setChecked(True)
        aliases = myutils.aliases_real_time_trackers()

        if 'tracking_algorithm' in self.df_settings.index:
            trackingAlgo = self.df_settings.at['tracking_algorithm', 'value']
            if trackingAlgo in aliases:
                trackingAlgo = aliases[trackingAlgo]
            if trackingAlgo == 'Cell-ACDC':
                self.trackWithAcdcAction.setChecked(True)
            elif trackingAlgo == 'YeaZ':
                self.trackWithYeazAction.setChecked(True)
            else:
                for rtTrackerAction in self.trackingAlgosGroup.actions():
                    if rtTrackerAction.text() == trackingAlgo:
                        rtTrackerAction.setChecked(True)
                        break

        self.setMeasurementsAction = QAction('Set measurements...')
        self.addCustomMetricAction = QAction('Add custom measurement...')
        self.addCombineMetricAction = QAction('Add combined measurement...')

        # Standard key sequence
        # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy)
        # self.pasteAction.setShortcut(QKeySequence.StandardKey.Paste)
        # self.cutAction.setShortcut(QKeySequence.StandardKey.Cut)
        # Help actions
        self.tipsAction = QAction("Tips and tricks...", self)
        self.UserManualAction = QAction("User Documentation...", self)
        self.openLogFileAction = QAction("Open log file...", self)
        self.aboutAction = QAction("About Cell-ACDC", self)
        # self.aboutAction = QAction("&About...", self)

        # Assign mother to bud button
        self.assignBudMothAutoAction = QAction(self)
        self.assignBudMothAutoAction.setIcon(QIcon(":autoAssign.svg"))
        self.assignBudMothAutoAction.setVisible(False)

        self.editCcaToolAction = QAction(self)
        self.editCcaToolAction.setIcon(QIcon(":edit_cca.svg"))
        # self.editCcaToolAction.setDisabled(True)
        self.editCcaToolAction.setVisible(False)

        self.reInitCcaAction = QAction(self)
        self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg"))
        self.reInitCcaAction.setVisible(False)

        self.toggleColorSchemeAction = QAction(
            'Switch to light theme'
        )
        self.gui_updateSwitchColorSchemeActionText()
        
        self.pxModeAction = widgets.CheckableAction(
            'Fixed size text annotations'
        )
        self.pxModeAction.setChecked(True)
        pxModeTooltip = (
            'When the text annotations are with fixed size they scale relative '
            'to the object when zooming in/out (fixed size in pixels).\n'
            'This is typically faster to render, but it makes annotations '
            'smaller/larger when zooming in/out, respectively.\n\n'
            'Try activating it to speed up the annotation of many objects '
            'in high resolution mode.\n\n'
            'After activating it, you might need to increase the font size '
            'from the menu on the top menubar `Edit --> Font size`.'
        )
        self.pxModeAction.setToolTip(pxModeTooltip)
        
        self.highLowResAction = widgets.CheckableAction(
            'High resolution text annotations'
        )
        self.widgetsWithShortcut['High resolution'] = self.highLowResAction
        self.highLowResAction.setShortcut('Y')
        highLowResTooltip = (
            'Resolution of the text annotations. High resolution results '
            'in slower update of the annotations.\n'
            'Not recommended with a number of segmented objects > 500.\n\n'
            'Shortcut: "Y" key'
        )
        self.highLowResAction.setToolTip(highLowResTooltip)
        
        self.editShortcutsAction = QAction(
            'Customize keyboard shortcuts...', self
        )
        self.editShortcutsAction.setShortcut('Ctrl+K')
        
        self.showMirroredCursorAction = QAction(
            'Show mirrored cursor on images', self
        )
        self.showMirroredCursorAction.setCheckable(True)
        if 'showMirroredCursor' in self.df_settings.index:
            checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes'
            self.showMirroredCursorAction.setChecked(checked)
        else:
            self.showMirroredCursorAction.setChecked(True)
        self.showMirroredCursorAction.setShortcut('Ctrl+M')

        self.editTextIDsColorAction = QAction('Text annotation color...', self)
        self.editTextIDsColorAction.setDisabled(True)

        self.editOverlayColorAction = QAction('Overlay color...', self)
        self.editOverlayColorAction.setDisabled(True)

        self.manuallyEditCcaAction = QAction(
            'Edit cell cycle annotations...', self
        )
        self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P')
        self.manuallyEditCcaAction.setDisabled(True)

        self.viewCcaTableAction = QAction(
            'View cell cycle annotations...', self
        )
        self.viewCcaTableAction.setDisabled(True)
        self.viewCcaTableAction.setShortcut('Ctrl+P')
        
        
        self.addScaleBarAction = QAction('Add scale bar', self)
        self.addScaleBarAction.setCheckable(True)
        
        self.addTimestampAction = QAction('Add timestamp', self)
        self.addTimestampAction.setCheckable(True)

        self.invertBwAction = QAction('Invert black/white', self)
        self.invertBwAction.setCheckable(True)
        checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes'
        self.invertBwAction.setChecked(checked)

        self.shuffleCmapAction =  QAction('Randomly shuffle colormap', self)
        self.shuffleCmapAction.setShortcut('Shift+S')

        self.greedyShuffleCmapAction =  QAction(
            'Optimise colormap', self
        )
        self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S')

        self.saveLabColormapAction = QAction(
            'Save labels colormap...', self
        )

        self.normalizeRawAction = QAction(
            'Do not normalize. Display raw image', self)
        self.normalizeToFloatAction = QAction(
            'Convert to floating point format with values [0, 1]', self)
        # self.normalizeToUbyteAction = QAction(
        #     'Rescale to 8-bit unsigned integer format with values [0, 255]', self)
        self.normalizeRescale0to1Action = QAction(
            'Rescale to [0, 1]', self)
        self.normalizeByMaxAction = QAction(
            'Normalize by max value', self)
        self.normalizeRawAction.setCheckable(True)
        self.normalizeToFloatAction.setCheckable(True)
        # self.normalizeToUbyteAction.setCheckable(True)
        self.normalizeRescale0to1Action.setCheckable(True)
        self.normalizeByMaxAction.setCheckable(True)
        self.normalizeQActionGroup = QActionGroup(self)
        self.normalizeQActionGroup.addAction(self.normalizeRawAction)
        self.normalizeQActionGroup.addAction(self.normalizeToFloatAction)
        # self.normalizeQActionGroup.addAction(self.normalizeToUbyteAction)
        self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action)
        self.normalizeQActionGroup.addAction(self.normalizeByMaxAction)

        self.preprocessAction = QAction(
            'Pre-processing...', self
        )
        self.preprocessAction.setShortcut('Alt+Shift+P')

        self.combineChannelsAction = QAction(
            'Combine channels...', self
        )
        self.combineChannelsAction.setShortcut('Alt+Shift+C')
        
        self.zoomToObjsAction = QAction(
            'Zoom to objects  (Shortcut: H key)', self
        )
        self.zoomOutAction = QAction(
            'Zoom out  (Shortcut: double press H key)', self
        )

        self.relabelSequentialAction = QAction(
            'Relabel IDs sequentially...', self
        )
        self.relabelSequentialAction.setShortcut('Ctrl+L')
        self.relabelSequentialAction.setDisabled(True)

        self.setLastUserNormAction()

        self.autoSegmAction = QAction(
            'Enable automatic segmentation', self)
        self.autoSegmAction.setCheckable(True)
        self.autoSegmAction.setDisabled(True)

        self.enableSmartTrackAction = QAction(
            'Smart handling of enabling/disabling tracking', self)
        self.enableSmartTrackAction.setCheckable(True)
        self.enableSmartTrackAction.setChecked(True)

        self.enableAutoZoomToCellsAction = QAction(
            'Automatic zoom to all cells when pressing "Next/Previous"', self)
        self.enableAutoZoomToCellsAction.setCheckable(True)

        self.imgPropertiesAction = QAction('Properties...', self)
        self.imgPropertiesAction.setDisabled(True)

        self.addDelRoiAction = QAction(self)
        self.addDelRoiAction.roiType = 'rect'
        self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg"))
        
        self.addDelPolyLineRoiButton = QToolButton(self)
        self.addDelPolyLineRoiButton.setCheckable(True)
        self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg"))
        
        self.checkableButtons.append(self.addDelPolyLineRoiButton)
        self.LeftClickButtons.append(self.addDelPolyLineRoiButton)
       
        self.delBorderObjAction = QAction(self)
        self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg"))

        self.loadCustomAnnotationsAction = QAction(self)
        self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg"))
        self.loadCustomAnnotationsAction.setToolTip(
            'Load previously used custom annotations'
        )
    
        self.addCustomAnnotationAction = QAction(self)
        self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg"))
        self.addCustomAnnotationAction.setToolTip('Add custom annotation')
        # self.functionsNotTested3D.append(self.addCustomAnnotationAction)

        self.viewAllCustomAnnotAction = QAction(self)
        self.viewAllCustomAnnotAction.setCheckable(True)
        self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg"))
        self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations')
        # self.functionsNotTested3D.append(self.viewAllCustomAnnotAction)

        # self.imgGradLabelsAlphaUpAction = QAction(self)
        # self.imgGradLabelsAlphaUpAction.setVisible(False)
        # self.imgGradLabelsAlphaUpAction.setShortcut('Ctrl+Up')
    
    def gui_updateSwitchColorSchemeActionText(self):
        if self._colorScheme == 'dark':
            txt = 'Switch to light theme'
        else:
            txt = 'Switch to dark theme'
        self.toggleColorSchemeAction.setText(txt)

    def gui_connectActions(self):
        # Connect File actions
        if self.debug:
            self.createEmptyDataAction.triggered.connect(self._createEmptyData)
        self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked)
        self.newWindowAction.triggered.connect(self.openNewWindow)
        self.newAction.triggered.connect(self.newFile)
        self.openFolderAction.triggered.connect(self.openFolder)
        self.openFileAction.triggered.connect(self.openFile)
        self.manageVersionsAction.triggered.connect(self.manageVersions)
        self.saveAction.triggered.connect(self.saveData)
        self.saveAsAction.triggered.connect(self.saveAsData)
        self.exportToVideoAction.triggered.connect(self.exportToVideoTriggered)
        self.exportToImageAction.triggered.connect(self.exportToImageTriggered)
        self.quickSaveAction.triggered.connect(self.quickSave)
        self.viewPreprocDataToggle.toggled.connect(
            self.viewPreprocDataToggled
        )
        self.viewCombineChannelDataToggle.toggled.connect(
            self.viewCombineChannelDataToggled
        )
        self.autoSaveToggle.toggled.connect(self.autoSaveToggled)
        self.ccaIntegrCheckerToggle.toggled.connect(
            self.ccaIntegrCheckerToggled
        )
        self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled)
        self.highLowResAction.clicked.connect(self.highLowResToggled)
        self.showInExplorerAction.triggered.connect(self.showInExplorer_cb)
        self.exitAction.triggered.connect(self.close)
        self.undoAction.triggered.connect(self.undo)
        self.redoAction.triggered.connect(self.redo)
        self.nextAction.triggered.connect(self.nextActionTriggered)
        self.prevAction.triggered.connect(self.prevActionTriggered)

        self.toggleColorSchemeAction.triggered.connect(self.onToggleColorScheme)
        self.pxModeAction.clicked.connect(self.pxModeActionToggled)
        self.editShortcutsAction.triggered.connect(self.editShortcuts_cb)
        self.showMirroredCursorAction.toggled.connect(
            self.showMirroredCursorToggled
        )

        # Connect Help actions
        self.tipsAction.triggered.connect(self.showTipsAndTricks)
        self.UserManualAction.triggered.connect(myutils.browse_docs)
        self.openLogFileAction.triggered.connect(self.openLogFile)
        self.aboutAction.triggered.connect(self.showAbout)
        # Connect Open Recent to dynamically populate it
        # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent)
        self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton)

        self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget)

        self.loadCustomAnnotationsAction.triggered.connect(
            self.loadCustomAnnotations
        )
        self.addCustomAnnotationAction.triggered.connect(
            self.addCustomAnnotation
        )
        self.viewAllCustomAnnotAction.toggled.connect(
            self.viewAllCustomAnnot
        )
        self.addCustomModelVideoAction.triggered.connect(
            self.showInstructionsCustomModel
        )
        self.addCustomModelFrameAction.triggered.connect(
            self.showInstructionsCustomModel
        )
        self.addCustomModelFrameAction.callback = self.segmFrameCallback
        self.addCustomModelVideoAction.callback = self.segmVideoCallback
        
        self.addCustomPromptModelAction.triggered.connect(
            self.showInstructionsCustomPromptModel
        )
        self.segmWithPromptableModelAction.triggered.connect(
            self.segmWithPromptableModelActionTriggered
        )
    
    def zProjLockViewToggled(self, checked):
        self.updateZproj(self.zProjComboBox.currentText())
    
    def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True):
        if channel == self.user_ch_name:
            lutItem = self.imgGrad
        else:
            _, lutItem, _ = self.overlayLayersItems[channel]
            
        for action in lutItem.rescaleActionGroup.actions():
            if action.text() == how:
                action.trigger()
                # self.rescaleIntensitiesLut(setImage=setImage)
                break
    
    def customLevelsLutChanged(self, levels, imageItem=None):
        imageItem.setLevels(levels)
    
    def rescaleIntensitiesLut(
            self, 
            action: QAction=None, 
            setImage: bool=True,
            imageItem=None
        ):
        if not self.isDataLoaded:
            self.logger.info(
                'WARNING: Data is not loaded. '
                'Intensities will be rescaled later.'
            )
            return 

        if imageItem is None:
            imageItem = self.img1
            channel = self.user_ch_name
        else:
            channel = imageItem.channelName
        
        triggeredByUser = True
        if action is None:
            triggeredByUser = False
            action = imageItem.lutItem.rescaleActionGroup.checkedAction()
        
        posData = self.data[self.pos_i]
        how = action.text()
        
        if how == 'Rescale each 2D image':
            if how == self.rescaleIntensChannelHowMapper[channel]:
                # No need to update since we have autoscale
                return       
            
            imageItem.setEnableAutoLevels(True)
            if setImage:
                imageItem.setImage(imageItem.image)
            return
        
        lutLevelsCh = posData.lutLevels[channel]
        
        if how == 'Rescale across z-stack':            
            imageItem.setEnableAutoLevels(False)
            levels_key = (how, posData.frame_i)
            levels = lutLevelsCh.get(levels_key)
            if levels is None:
                image_data = posData.img_data[posData.frame_i]
                levels = (image_data.min(), image_data.max())
            lutLevelsCh[levels_key] = levels
            imageItem.setLevels(levels)
        elif how == 'Rescale across time frames':            
            imageItem.setEnableAutoLevels(False)
            
            levels_key = (how, None)
            levels = lutLevelsCh.get(levels_key)
            if levels is None:
                image_data = posData.img_data
                levels = (image_data.min(), image_data.max())
                
            lutLevelsCh[levels_key] = levels
            imageItem.setLevels(levels)
        elif how == 'Choose custom levels...':
            if triggeredByUser:
                image_data = posData.img_data
                current_min, current_max = imageItem.getLevels() 
                dtype_max = np.iinfo(image_data.dtype).max
                win = apps.SetCustomLevelsLut(
                    init_min_value=current_min,
                    init_max_value=current_max,
                    maximum_max_value=dtype_max,
                    parent=self
                )
                win.sigLevelsChanged.connect(
                    partial(self.customLevelsLutChanged, imageItem=imageItem)
                )
                win.exec_()
                if win.cancel:
                    self.logger.info('Custom LUT levels setting cancelled.')
                    self.updateAllImages()
                    return
                selectedLevels = win.selectedLevels
            else:
                selectedLevels = imageItem.getLevels()
            imageItem.setEnableAutoLevels(False)
            imageItem.setLevels(selectedLevels)
        elif how == 'Do no rescale, display raw image':            
            imageItem.setEnableAutoLevels(False)
            levels_key = (how, None)
            levels = lutLevelsCh.get(levels_key)
            if levels is None:
                image_data = posData.img_data
                dtype_max = np.iinfo(image_data.dtype).max
                levels = (0, dtype_max)
            lutLevelsCh[levels_key] = levels
            imageItem.setLevels(levels)
        
        self.rescaleIntensChannelHowMapper[channel] = how
        
        if setImage:
            imageItem.setImage(imageItem.image)
    
    def onToggleColorScheme(self):
        if self.toggleColorSchemeAction.text().find('light') != -1:
            self._colorScheme = 'light'
            setDarkModeToggleChecked = False
        else:
            self._colorScheme = 'dark'
            setDarkModeToggleChecked = True
        self.gui_updateSwitchColorSchemeActionText()
        _warnings.warnRestartCellACDCcolorModeToggled(
            self._colorScheme, app_name=self._appName, parent=self
        )
        load.rename_qrc_resources_file(self._colorScheme)
        self.statusBarLabel.setText(html_utils.paragraph(
            f'<i>Restart {self._appName} for the change to take effect</i>', 
            font_color='red'
        ))
        self.df_settings.at['colorScheme', 'value'] = self._colorScheme
        self.df_settings.to_csv(settings_csv_path)
    
    def showMirroredCursorToggled(self, checked):
        value = 'Yes' if checked else 'No'
        self.df_settings.at['showMirroredCursor', 'value'] = value
        self.df_settings.to_csv(settings_csv_path)
        
        if not checked:
            self.clearCursors()
    
    def clearCursors(self):
        self.ax1_cursor.setData([], [])
        self.ax2_cursor.setData([], [])              
        self.setHoverToolSymbolData(
            [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle),
        )  
        eraserCursors = (
            self.ax1_EraserCircle, self.ax2_EraserCircle,
            self.ax1_EraserX, self.ax2_EraserX
        )
        self.setHoverToolSymbolData([], [], eraserCursors)
    
    def activeEraserCircleCursors(self, isHoverImg1):
        if self.showMirroredCursorAction.isChecked():
            return self.ax1_EraserCircle, self.ax2_EraserCircle
        
        if isHoverImg1:
            return self.ax1_EraserCircle,
        else:
            return self.ax2_EraserCircle,
    
    def activeEraserXCursors(self, isHoverImg1):
        if self.showMirroredCursorAction.isChecked():
            return self.ax1_EraserX, self.ax2_EraserX
        
        if isHoverImg1:
            return self.ax1_EraserX,
        else:
            return self.ax2_EraserX,
    
    def activeBrushCircleCursors(self, isHoverImg1):
        if self.showMirroredCursorAction.isChecked():
            return self.ax1_BrushCircle, self.ax2_BrushCircle
        
        if isHoverImg1:
            return self.ax1_BrushCircle,
        else:
            return self.ax2_BrushCircle,
    
    def gui_connectEditActions(self):
        self.showInExplorerAction.setEnabled(True)
        self.setEnabledFileToolbar(True)
        self.loadFluoAction.setEnabled(True)
        self.isEditActionsConnected = True

        self.preprocessImageAction.triggered.connect(
            self.preprocessAction.trigger
        )
        self.combineChannelsAction.triggered.connect(
            self.combineChannelsActionTriggered
        )

        self.overlayButton.toggled.connect(self.overlay_cb)
        self.countObjsButton.toggled.connect(self.countObjectsCb)
        self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled)
        self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb)
        self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu)
        self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu)
        self.overlayLabelsButton.sigRightClick.connect(
            self.showOverlayLabelsContextMenu
        )
        self.rulerButton.toggled.connect(self.ruler_cb)
        self.loadFluoAction.triggered.connect(self.loadFluo_cb)
        self.loadPosAction.triggered.connect(self.loadPosTriggered)
        # self.reloadAction.triggered.connect(self.reload_cb)
        self.findIdAction.triggered.connect(self.findID)
        self.autoPilotButton.toggled.connect(self.autoPilotToggled)
        self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID)        
        self.slideshowButton.toggled.connect(self.launchSlideshow)
        
        self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb)

        self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback)
        self.segmVideoMenu.triggered.connect(self.segmVideoCallback)
        
        self.postProcessSegmAction.toggled.connect(self.postProcessSegm)
        self.autoSegmAction.toggled.connect(self.autoSegm_cb)
        self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked)
        self.repeatTrackingAction.triggered.connect(self.repeatTracking)
        self.manualTrackingButton.toggled.connect(self.manualTracking_cb)
        self.manualBackgroundButton.toggled.connect(self.manualBackground_cb)
        self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking)
        self.repeatTrackingVideoAction.triggered.connect(
            self.repeatTrackingVideo
        )
        for rtTrackerAction in self.trackingAlgosGroup.actions():
            rtTrackerAction.toggled.connect(self.rtTrackerActionToggled)
        self.editRtTrackerParamsAction.triggered.connect(
            self.initRealTimeTracker
        )
        self.delObjsOutSegmMaskAction.triggered.connect(
            self.delObjsOutSegmMaskActionTriggered
        )
        self.mergeIDsButton.toggled.connect(self.mergeObjs_cb)
        self.brushButton.toggled.connect(self.Brush_cb)
        self.eraserButton.toggled.connect(self.Eraser_cb)
        self.curvToolButton.toggled.connect(self.curvTool_cb)
        self.wandToolButton.toggled.connect(self.wand_cb)
        self.labelRoiButton.toggled.connect(self.labelRoi_cb)
        self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb)
        self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb)
        self.reInitCcaAction.triggered.connect(self.reInitCca)
        self.moveLabelToolButton.toggled.connect(self.moveLabelButtonToggled)
        self.editCcaToolAction.triggered.connect(
            self.manualEditCcaToolbarActionTriggered
        )
        self.assignBudMothAutoAction.triggered.connect(
            self.autoAssignBud_YeastMate
        )
        self.keepIDsButton.toggled.connect(self.keepIDs_cb)

        self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb)

        self.whitelistIDsToolbar.sigWhitelistChanged.connect(
            self.whitelistIDsChanged
        )

        self.whitelistIDsToolbar.sigWhitelistAccepted.connect(
            self.whitelistIDsAccepted
        )

        self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs)

        self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled)

        self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb)

        self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect(
            self.whitelistTrackOGagainstPreviousFrame_cb
        )

        self.expandLabelToolButton.toggled.connect(self.expandLabelCallback)

        self.reinitLastSegmFrameAction.triggered.connect(
            self.reInitLastSegmFrame
        )

        
        self.defaultRescaleIntensActionGroup.triggered.connect(
            self.defaultRescaleIntensLutActionToggled
        )
        
        # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca)
        self.manuallyEditCcaAction.triggered.connect(self.manualEditCca)
        self.addScaleBarAction.toggled.connect(self.addScaleBar)
        self.addTimestampAction.toggled.connect(self.addTimestamp)
        self.invertBwAction.toggled.connect(self.invertBw)
        self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap)

        self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack)
        # Brush/Eraser size action
        self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb)
        self.autoIDcheckbox.toggled.connect(self.autoIDtoggled)
        # Mode
        self.modeActionGroup.triggered.connect(self.changeModeFromMenu)
        self.modeComboBox.sigTextChanged.connect(self.changeMode)
        self.modeComboBox.activated.connect(self.clearComboBoxFocus)
        self.equalizeHistPushButton.toggled.connect(self.equalizeHist)
        
        self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton)
        self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton)
        self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor)
        self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor)
        self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor)
        self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors)

        self.setMeasurementsAction.triggered.connect(self.showSetMeasurements)
        self.addCustomMetricAction.triggered.connect(self.addCustomMetric)
        self.addCombineMetricAction.triggered.connect(self.addCombineMetric)

        self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor)
        self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor)
        self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap)
        self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved)
        self.labelsGrad.textColorButton.sigColorChanging.connect(
            self.updateTextLabelsColor
        )
        self.labelsGrad.textColorButton.sigColorChanged.connect(
            self.saveTextLabelsColor
        )
        # self.addFontSizeActions(
        #     self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked
        # )

        self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap)
        self.labelsGrad.greedyShuffleCmapAction.triggered.connect(
            self.greedyShuffleCmap
        )
        self.shuffleCmapAction.triggered.connect(self.shuffle_cmap)
        self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap)
        self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW)
        self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem)
        self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem)
        self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem)
        
        self.labelsGrad.defaultSettingsAction.triggered.connect(
            self.restoreDefaultSettings
        )

        # self.addFontSizeActions(
        #     self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked
        # )
        self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW)
        self.imgGrad.textColorButton.disconnect()
        self.imgGrad.textColorButton.clicked.connect(
            self.editTextIDsColorAction.trigger
        )
        self.imgGrad.labelsAlphaSlider.valueChanged.connect(
            self.updateLabelsAlpha
        )
        self.imgGrad.defaultSettingsAction.triggered.connect(
            self.restoreDefaultSettings
        )

        # Drawing mode
        self.drawIDsContComboBox.currentIndexChanged.connect(
            self.drawIDsContComboBox_cb
        )
        self.drawIDsContComboBox.activated.connect(self.clearComboBoxFocus)

        self.annotateRightHowCombobox.currentIndexChanged.connect(
            self.annotateRightHowCombobox_cb
        )
        self.annotateRightHowCombobox.activated.connect(self.clearComboBoxFocus)

        self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode)

        # Left
        self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked)
        self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked)
        self.annotContourCheckbox.clicked.connect(self.annotOptionClicked)
        self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked)
        self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked)
        self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked)
        self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked)

        # Right 
        self.annotIDsCheckboxRight.clicked.connect(
            self.annotOptionClickedRight)
        self.annotCcaInfoCheckboxRight.clicked.connect(
            self.annotOptionClickedRight)
        self.annotContourCheckboxRight.clicked.connect(
            self.annotOptionClickedRight)
        self.annotSegmMasksCheckboxRight.clicked.connect(
            self.annotOptionClickedRight)
        self.drawMothBudLinesCheckboxRight.clicked.connect(
            self.annotOptionClickedRight)
        self.drawNothingCheckboxRight.clicked.connect(
            self.annotOptionClickedRight)
        self.annotNumZslicesCheckboxRight.clicked.connect(
            self.annotOptionClickedRight
        )
        
        self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered)

        self.addDelRoiAction.triggered.connect(self.addDelROI)
        self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb)
        self.delBorderObjAction.triggered.connect(self.delBorderObj)
        
        self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled)
        self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled)

        self.imgGrad.sigLookupTableChanged.connect(self.imgGradLUT_cb)
        self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked)
        self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked)
        self.imgGrad.gradient.sigGradientChangeFinished.connect(
            self.imgGradLUTfinished_cb
        )

        # self.normalizeQActionGroup.triggered.connect(
        #     self.normaliseIntensitiesActionTriggered
        # )
        self.imgPropertiesAction.triggered.connect(self.editImgProperties)

        self.relabelSequentialAction.triggered.connect(
            self.relabelSequentialCallback
        )

        self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback)
        self.zoomOutAction.triggered.connect(self.zoomOut)
        self.preprocessAction.triggered.connect(self.preprocessActionTriggered)
        self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered)

        self.viewCcaTableAction.triggered.connect(self.viewCcaTable)

        self.guiTabControl.propsQGBox.idSB.valueChanged.connect(
            self.propsWidgetIDvalueChanged
        )
        self.guiTabControl.highlightCheckbox.toggled.connect(
            self.highlightIDonHoverCheckBoxToggled
        )
        self.guiTabControl.highlightSearchedCheckbox.toggled.connect(
            self.highlightSearchedIDcheckBoxToggled
        )
        intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox
        intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect(
            self.updatePropsWidget
        )
        intensMeasurQGBox.channelCombobox.currentTextChanged.connect(
            self.updatePropsWidget
        )
        
        propsQGBox = self.guiTabControl.propsQGBox
        propsQGBox.additionalPropsCombobox.currentTextChanged.connect(
            self.updatePropsWidget
        )

    def gui_createShowPropsButton(self, side='left'):
        self.leftSideDocksLayout = QVBoxLayout()            
        self.leftSideDocksLayout.setSpacing(0)
        self.leftSideDocksLayout.setContentsMargins(0,0,0,0)
        self.rightSideDocksLayout = QVBoxLayout()            
        self.rightSideDocksLayout.setSpacing(0)
        self.rightSideDocksLayout.setContentsMargins(0,0,0,0)
        self.showPropsDockButton = widgets.expandCollapseButton()
        self.showPropsDockButton.setDisabled(True)
        self.showPropsDockButton.setFocusPolicy(Qt.NoFocus)
        self.showPropsDockButton.setToolTip('Show object properties')
        if side == 'left':
            self.leftSideDocksLayout.addWidget(self.showPropsDockButton)
        else:
            self.rightSideDocksLayout.addWidget(self.showPropsDockButton)
            
    def gui_createQuickSettingsWidgets(self):
        self.quickSettingsLayout = QVBoxLayout()
        self.quickSettingsGroupbox = widgets.GroupBox()
        self.quickSettingsGroupbox.setTitle('Quick settings')

        layout = QFormLayout()
        layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
        layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter)
        
        self.viewPreprocDataToggle = widgets.Toggle()
        viewPreprocDataToggleTooltip = (
            'View pre-processed data. See menu `Image --> Pre-processing...`\n'
            'on the top menubar.'
        )
        self.viewPreprocDataToggle.setChecked(False)
        self.viewPreprocDataToggle.setToolTip(viewPreprocDataToggleTooltip)
        viewPreprocDataToggleLabel = QLabel('View pre-processed image')
        viewPreprocDataToggleLabel.setToolTip(viewPreprocDataToggleTooltip)
        layout.addRow(viewPreprocDataToggleLabel, self.viewPreprocDataToggle)

        self.viewCombineChannelDataToggle = widgets.Toggle()
        viewCombineChannelDataToggleTooltip = (
            'View combined channel. See menu `Image --> combing channels...`\n'
            'on the top menubar.'
        )
        self.viewCombineChannelDataToggle.setChecked(False)
        self.viewCombineChannelDataToggle.setToolTip(viewCombineChannelDataToggleTooltip)
        viewCombineChannelDataToggleLabel = QLabel('View combined channels')
        viewCombineChannelDataToggleLabel.setToolTip(viewCombineChannelDataToggleTooltip)
        layout.addRow(viewCombineChannelDataToggleLabel, self.viewCombineChannelDataToggle)

        self.autoSaveToggle = widgets.Toggle()
        autoSaveTooltip = (
            'Automatically store a copy of the segmentation data and of '
            'the annotations in the `.recovery` folder after every edit.'
        )
        self.autoSaveToggle.setChecked(True)
        self.autoSaveToggle.setToolTip(autoSaveTooltip)
        autoSaveLabel = QLabel('Autosave segm.')
        autoSaveLabel.setToolTip(autoSaveTooltip)
        layout.addRow(autoSaveLabel, self.autoSaveToggle)
        
        self.ccaIntegrCheckerToggle = widgets.Toggle()
        ccaIntegrCheckerToggleTooltip = (
            'Toggle background cell cycle annotations integrity checker ON/OFF'
        )
        self.ccaIntegrCheckerToggle.setChecked(False)
        self.ccaIntegrCheckerToggle.setToolTip(ccaIntegrCheckerToggleTooltip)
        label = QLabel('Cc annot. checker')
        label.setToolTip(ccaIntegrCheckerToggleTooltip)
        layout.addRow(label, self.ccaIntegrCheckerToggle)
        if 'is_cca_integrity_checker_activated' in self.df_settings.index:
            idx = 'is_cca_integrity_checker_activated'
            val = int(self.df_settings.at[idx, 'value'])
            self.ccaIntegrCheckerToggle.setChecked(not val)
        
        self.annotLostObjsToggle = widgets.Toggle()
        annotLostObjsToggleTooltip = (
            'Toggle annotation of lost objects mode ON/OFF'
        )
        self.annotLostObjsToggle.setChecked(True)
        self.annotLostObjsToggle.setToolTip(annotLostObjsToggleTooltip)
        label = QLabel('Annot. lost objects')
        label.setToolTip(annotLostObjsToggleTooltip)
        layout.addRow(label, self.annotLostObjsToggle)

        self.realTimeTrackingToggle = widgets.Toggle()
        self.realTimeTrackingToggle.setChecked(True)
        self.realTimeTrackingToggle.setDisabled(True)
        label = QLabel('Real-time tracking')
        label.setDisabled(True)
        self.realTimeTrackingToggle.label = label
        layout.addRow(label, self.realTimeTrackingToggle)

        # Font size
        self.fontSizeSpinBox = widgets.SpinBox()
        self.fontSizeSpinBox.setMinimum(1)
        self.fontSizeSpinBox.setMaximum(99)
        layout.addRow('Font size', self.fontSizeSpinBox) 
        savedFontSize = str(self.df_settings.at['fontSize', 'value'])
        if savedFontSize.find('pt') != -1:
            savedFontSize = savedFontSize[:-2]
        self.fontSize = int(savedFontSize)
        if 'pxMode' not in self.df_settings.index:
            # Users before introduction of pxMode had pxMode=False, but now 
            # the new default is True. This requires larger font size.
            self.fontSize = 2*self.fontSize
            self.df_settings.at['pxMode', 'value'] = 1
            self.df_settings.to_csv(settings_csv_path)
        self.fontSizeSpinBox.setValue(self.fontSize)
        self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) 
        self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize)
        self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize)

        self.quickSettingsGroupbox.setLayout(layout)
        self.quickSettingsLayout.addWidget(self.quickSettingsGroupbox)
        self.quickSettingsLayout.addStretch(1)

    def gui_createImg1Widgets(self):
        # Toggle contours/ID combobox
        self.drawIDsContComboBoxSegmItems = [
            'Draw IDs and contours',
            'Draw IDs and overlay segm. masks',
            'Draw only cell cycle info',
            'Draw cell cycle info and contours',
            'Draw cell cycle info and overlay segm. masks',
            'Draw only mother-bud lines',
            'Draw only IDs',
            'Draw only contours',
            'Draw only overlay segm. masks',
            'Draw nothing'
        ]
        self.drawIDsContComboBox = QComboBox()
        self.drawIDsContComboBox.setFont(_font)
        self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems)
        self.drawIDsContComboBox.setVisible(False)

        self.annotIDsCheckbox = widgets.CheckBox(
            'IDs', keyPressCallback=self.resetFocus)
        self.annotCcaInfoCheckbox = widgets.CheckBox(
            'Cell cycle info', keyPressCallback=self.resetFocus)
        self.annotNumZslicesCheckbox = widgets.CheckBox(
            'No. z-slices/object', keyPressCallback=self.resetFocus)

        self.annotContourCheckbox = widgets.CheckBox(
            'Contours', keyPressCallback=self.resetFocus)
        self.annotSegmMasksCheckbox = widgets.CheckBox(
            'Segm. masks', keyPressCallback=self.resetFocus)

        self.drawMothBudLinesCheckbox = widgets.CheckBox(
            'Only mother-daughter line', keyPressCallback=self.resetFocus
        )

        self.drawNothingCheckbox = widgets.CheckBox(
            'Do not annotate', keyPressCallback=self.resetFocus
        )

        self.annotOptionsWidget = QWidget()
        annotOptionsLayout = QHBoxLayout()

        # Show tree info checkbox
        self.showTreeInfoCheckbox = widgets.CheckBox(
            'Show tree info', keyPressCallback=self.resetFocus
        )
        self.showTreeInfoCheckbox.setFont(_font)
        sp = self.showTreeInfoCheckbox.sizePolicy()
        sp.setRetainSizeWhenHidden(True)
        self.showTreeInfoCheckbox.setSizePolicy(sp)
        self.showTreeInfoCheckbox.hide()

        annotOptionsLayout.addWidget(self.showTreeInfoCheckbox)
        annotOptionsLayout.addWidget(QLabel(' | '))
        annotOptionsLayout.addWidget(self.annotIDsCheckbox)
        annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox)
        annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox)
        annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox)
        annotOptionsLayout.addWidget(QLabel(' | '))
        annotOptionsLayout.addWidget(self.annotContourCheckbox)
        annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox)
        annotOptionsLayout.addWidget(QLabel(' | '))
        annotOptionsLayout.addWidget(self.drawNothingCheckbox)
        annotOptionsLayout.addWidget(self.drawIDsContComboBox)
        self.annotOptionsLayout = annotOptionsLayout

        # Toggle highlight z+-1 objects combobox
        self.highlightZneighObjCheckbox = widgets.CheckBox(
            'Highlight objects in neighbouring z-slices', 
            keyPressCallback=self.resetFocus
        )
        self.highlightZneighObjCheckbox.setFont(_font)
        self.highlightZneighObjCheckbox.hide()

        annotOptionsLayout.addWidget(self.highlightZneighObjCheckbox)
        self.annotOptionsWidget.setLayout(annotOptionsLayout)

        # Annotations options right image
        self.annotIDsCheckboxRight = widgets.CheckBox(
            'IDs', keyPressCallback=self.resetFocus)
        self.annotCcaInfoCheckboxRight = widgets.CheckBox(
            'Cell cycle info', keyPressCallback=self.resetFocus)
        self.annotNumZslicesCheckboxRight = widgets.CheckBox(
            'No. z-slices/object', keyPressCallback=self.resetFocus
        )

        self.annotContourCheckboxRight = widgets.CheckBox(
            'Contours', keyPressCallback=self.resetFocus)
        self.annotSegmMasksCheckboxRight = widgets.CheckBox(
            'Segm. masks', keyPressCallback=self.resetFocus)

        self.drawMothBudLinesCheckboxRight = widgets.CheckBox(
            'Only mother-daughter line', keyPressCallback=self.resetFocus
        )

        self.drawNothingCheckboxRight = widgets.CheckBox(
            'Do not annotate', keyPressCallback=self.resetFocus)

        self.annotOptionsWidgetRight = QWidget()
        annotOptionsLayoutRight = QHBoxLayout()

        annotOptionsLayoutRight.addWidget(QLabel('       '))
        annotOptionsLayoutRight.addWidget(QLabel(' | '))
        annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight)
        annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight)
        annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight)
        annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight)
        annotOptionsLayoutRight.addWidget(QLabel(' | '))
        annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight)
        annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight)
        annotOptionsLayoutRight.addWidget(QLabel(' | '))
        annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight)
        self.annotOptionsLayoutRight = annotOptionsLayoutRight
        
        self.annotOptionsWidgetRight.setLayout(annotOptionsLayoutRight)

        # Frames scrollbar
        self.navigateScrollBar = widgets.navigateQScrollBar(Qt.Horizontal)
        self.navigateScrollBar.setDisabled(True)
        self.navigateScrollBar.setMinimum(1)
        self.navigateScrollBar.setMaximum(1)
        self.navigateScrollBar.setToolTip(
            'NOTE: The maximum frame number that can be visualized with this '
            'scrollbar\n'
            'is the last visited frame with the selected mode\n'
            '(see "Mode" selector on the top-right).\n\n'
            'If the scrollbar does not move it means that you never visited\n'
            'any frame with current mode.\n\n'
            'Note that the "Viewer" mode allows you to scroll ALL frames.'
        )
        t_label = QLabel('frame n.  ')
        t_label.setFont(_font)
        self.t_label = t_label

        # z-slice scrollbars
        self.zSliceScrollBar = widgets.linkedQScrollbar(Qt.Horizontal)

        self.zProjComboBox = QComboBox()
        self.zProjComboBox.setFont(_font)
        self.zProjComboBox.addItems([
            'single z-slice',
            'max z-projection',
            'mean z-projection',
            'median z-proj.'
        ])
        self.zProjLockViewButton = widgets.LockPushButton()
        self.zProjLockViewButton.setCheckable(True)
        self.zProjLockViewButton.setToolTip(
            'If active, the selected z-slice view is applied to all frames'
        )
        self.zProjLockViewButton.hide()
        
        self.switchPlaneCombobox = widgets.SwitchPlaneCombobox()
        self.switchPlaneCombobox.setToolTip(
            'Switch viewed plane'
        )

        self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal)
        _z_label = QLabel('Overlay z-slice  ')
        _z_label.setFont(_font)
        _z_label.setDisabled(True)
        self.overlay_z_label = _z_label

        self.zProjOverlay_CB = QComboBox()
        self.zProjOverlay_CB.setFont(_font)
        self.zProjOverlay_CB.addItems([
            'single z-slice', 'max z-projection', 'mean z-projection',
            'median z-proj.', 'same as above'
        ])
        self.zSliceOverlay_SB.setDisabled(True)

        self.img1BottomGroupbox = self.gui_getImg1BottomWidgets()
    
    def gui_getImg1BottomWidgets(self):
        bottomLeftLayout = QGridLayout()
        self.bottomLeftLayout = bottomLeftLayout
        container = QGroupBox('Navigate and annotate left image')

        row = 0
        bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4)
        # bottomLeftLayout.addWidget(
        #     self.drawIDsContComboBox, row, 1, 1, 2,
        #     alignment=Qt.AlignCenter
        # )

        # bottomLeftLayout.addWidget(
        #     self.showTreeInfoCheckbox, row, 0, 1, 1,
        #     alignment=Qt.AlignCenter
        # )

        row += 1
        navWidgetsLayout = QHBoxLayout()
        self.navSpinBox = widgets.SpinBox(disableKeyPress=True)
        self.navSpinBox.setMinimum(1)
        self.navSpinBox.setMaximum(100)
        self.navSizeLabel = QLabel('/ND')
        navWidgetsLayout.addWidget(self.t_label)
        navWidgetsLayout.addWidget(self.navSpinBox)
        navWidgetsLayout.addWidget(self.navSizeLabel)
        bottomLeftLayout.addLayout(
            navWidgetsLayout, row, 0, alignment=Qt.AlignRight
        )
        bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) 
        sp = self.navigateScrollBar.sizePolicy()
        sp.setRetainSizeWhenHidden(True)
        self.navigateScrollBar.setSizePolicy(sp)
        self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged)
        self.navSpinBox.editingFinished.connect(
            self.navigateSpinboxEditingFinished
        )

        self.lastTrackedFrameLabel = QLabel()
        self.lastTrackedFrameLabel.setFont(_font)
        bottomLeftLayout.addWidget(self.lastTrackedFrameLabel, row, 3)
        
        row += 1
        zSliceCheckboxLayout = QHBoxLayout()
        self.zSliceCheckbox = QCheckBox('z-slice')
        self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True)
        self.zSliceSpinbox.setMinimum(1)
        self.SizeZlabel = QLabel('/ND')
        self.zSliceCheckbox.setToolTip(
            'Activate/deactivate control of the z-slices with keyboard arrows.\n\n'
            'SHORTCUT to toggle ON/OFF: "Z" key'
        )
        zSliceCheckboxLayout.addWidget(self.zSliceCheckbox)
        zSliceCheckboxLayout.addWidget(self.zSliceSpinbox)
        zSliceCheckboxLayout.addWidget(self.SizeZlabel)
        bottomLeftLayout.addLayout(
            zSliceCheckboxLayout, row, 0, alignment=Qt.AlignRight
        )
        bottomLeftLayout.addWidget(self.zSliceScrollBar, row, 1, 1, 2)
        bottomLeftLayout.addWidget(self.zProjComboBox, row, 3)
        bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 4)
        bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 5)
        self.zSliceSpinbox.connectValueChanged(self.onZsliceSpinboxValueChange)
        self.zSliceSpinbox.editingFinished.connect(self.zSliceScrollBarReleased)

        row += 1
        bottomLeftLayout.addWidget(
            self.overlay_z_label, row, 0, alignment=Qt.AlignRight
        )
        bottomLeftLayout.addWidget(self.zSliceOverlay_SB, row, 1, 1, 2)

        bottomLeftLayout.addWidget(self.zProjOverlay_CB, row, 3)

        row += 1
        self.alphaScrollbarRow = row

        bottomLeftLayout.setColumnStretch(0,0)
        bottomLeftLayout.setColumnStretch(1,3)
        bottomLeftLayout.setColumnStretch(2,0)

        container.setLayout(bottomLeftLayout)
        return container

    def gui_createLabWidgets(self):
        bottomRightLayout = QVBoxLayout()
        self.rightBottomGroupbox = widgets.GroupBox(
            'Annotate right image independent of left image', 
            keyPressCallback=self.resetFocus
        )
        self.rightBottomGroupbox.setCheckable(True)
        self.rightBottomGroupbox.setChecked(False)
        self.rightBottomGroupbox.hide()

        self.annotateRightHowCombobox = QComboBox()
        self.annotateRightHowCombobox.setFont(_font)
        self.annotateRightHowCombobox.addItems(self.drawIDsContComboBoxSegmItems)
        self.annotateRightHowCombobox.setCurrentIndex(
            self.drawIDsContComboBox.currentIndex()
        )
        self.annotateRightHowCombobox.setVisible(False)

        self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox)

        self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl(
            labelText='Frame n. '
        )
        self.rightImageFramesScrollbar.setVisible(False)
        
        bottomRightLayout.addWidget(self.annotOptionsWidgetRight)
        bottomRightLayout.addWidget(self.rightImageFramesScrollbar)
        bottomRightLayout.addStretch(1)

        self.rightBottomGroupbox.setLayout(bottomRightLayout)

        self.rightBottomGroupbox.toggled.connect(self.rightImageControlsToggled)

    def rightImageControlsToggled(self, checked):
        if self.isDataLoading:
            return
        if checked:
            self.annotateRightHowCombobox.setCurrentText(
                self.drawIDsContComboBox.currentText()
            )
        self.updateAllImages()
    
    def setFocusGraphics(self):
        self.graphLayout.setFocus()
    
    def setFocusMain(self):
        # on macOS with Qt6 setFocus causes crashes. Disabled for now.
        return 
    
    def resetFocus(self):
        self.setFocusGraphics()
        self.setFocusMain()

    def gui_createBottomWidgetsToBottomLayout(self):
        # self.bottomDockWidget = QDockWidget(self)
        bottomScrollArea = widgets.ScrollArea(resizeVerticalOnShow=True)
        bottomScrollArea.sigLeaveEvent.connect(self.setFocusMain)
        bottomWidget = QWidget()
        bottomScrollAreaLayout = QVBoxLayout()
        self.bottomLayout = QHBoxLayout()
        self.bottomLayout.addLayout(self.quickSettingsLayout)
        self.bottomLayout.addStretch(1)
        self.bottomLayout.addWidget(self.img1BottomGroupbox)
        self.bottomLayout.addStretch(1)
        self.bottomLayout.addWidget(self.rightBottomGroupbox)
        self.bottomLayout.addStretch(1)   

        bottomScrollAreaLayout.addLayout(self.bottomLayout)
        bottomScrollAreaLayout.addStretch(1)

        bottomWidget.setLayout(bottomScrollAreaLayout)
        bottomScrollArea.setWidgetResizable(True)
        bottomScrollArea.setWidget(bottomWidget)
        self.bottomScrollArea = bottomScrollArea
        
        if 'bottom_sliders_zoom_perc' in self.df_settings.index:
            val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value'])
            zoom_perc = val
        else:
            zoom_perc = 100
        self.bottomLayoutContextMenu = QMenu('Bottom layout', self)
        zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom')
        actions = []
        self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu)
        for perc in np.arange(50, 151, 10):
            action = QAction(f'{perc}%', zoomMenu)
            action.setCheckable(True)
            if perc == zoom_perc:
                action.setChecked(True)
            action.toggled.connect(self.zoomBottomLayoutActionTriggered)
            actions.append(action)
            self.bottomLayoutContextMenu.zoomActionGroup.addAction(action)
        zoomMenu.addActions(actions)
        resetAction = self.bottomLayoutContextMenu.addAction(
            'Reset default height'
        )
        resetAction.triggered.connect(self.resizeGui)
        retainSpaceAction = self.bottomLayoutContextMenu.addAction(
            'Retain space of hidden sliders'
        )
        retainSpaceAction.setCheckable(True)
        if 'retain_space_hidden_sliders' in self.df_settings.index:
            retainSpaceChecked = (
                self.df_settings.at['retain_space_hidden_sliders', 'value']
                == 'Yes'
            )
        else:
            retainSpaceChecked = True
        retainSpaceAction.setChecked(retainSpaceChecked)
        retainSpaceAction.toggled.connect(self.retainSpaceSlidersToggled)
        self.retainSpaceSlidersAction = retainSpaceAction
        self.setBottomLayoutStretch()
    
    def gui_resetBottomLayoutHeight(self):
        self.h = self.defaultWidgetHeightBottomLayout
        self.checkBoxesHeight = 14
        self.fontPixelSize = 11
        self.resizeSlidersArea()

    def gui_createGraphicsPlots(self):
        self.graphLayout = pg.GraphicsLayoutWidget()
        if self.invertBwAction.isChecked():
            self.graphLayout.setBackground(graphLayoutBkgrColor)
            self.titleColor = 'black'
        else:
            self.graphLayout.setBackground(darkBkgrColor)
            self.titleColor = 'white'

        self.lutItemsLayout = self.graphLayout.addLayout(row=1, col=0)
        # self.lutItemsLayout.setBorder('w')

        # Left plot
        self.ax1 = widgets.MainPlotItem(showWelcomeText=True)
        self.ax1.invertY(True)
        self.ax1.setAspectLocked(True)
        self.ax1.hideAxis('bottom')
        self.ax1.hideAxis('left')
        self.plotsCol = 1
        self.graphLayout.addItem(self.ax1, row=1, col=1)

        # Right plot
        self.ax2 = widgets.MainPlotItem()
        self.ax2.setAspectLocked(True)
        self.ax2.invertY(True)
        self.ax2.hideAxis('bottom')
        self.ax2.hideAxis('left')
        # self.currentFrameLabelItem = pg.LabelItem(
        #     color=self.titleColor, size='13px'
        # )
        self.graphLayout.addItem(self.ax2, row=1, col=2)

    def gui_addGraphicsItems(self):
        # Auto image adjustment button
        proxy = QGraphicsProxyWidget()
        equalizeHistPushButton = QPushButton("Enhance contrast")
        widthHint = equalizeHistPushButton.sizeHint().width()
        equalizeHistPushButton.setMaximumWidth(widthHint)
        equalizeHistPushButton.setCheckable(True)
        if not self.invertBwAction.isChecked():
            equalizeHistPushButton.setStyleSheet(
                'QPushButton {background-color: #282828; color: #F0F0F0;}'
            )
        self.equalizeHistPushButton = equalizeHistPushButton
        proxy.setWidget(equalizeHistPushButton)
        self.graphLayout.addItem(proxy, row=0, col=0)
        self.equalizeHistPushButton = equalizeHistPushButton

        # Left image histogram
        self.imgGrad = widgets.myHistogramLUTitem(parent=self, name='image')
        self.imgGrad.restoreState(self.df_settings)
        self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0)
        for action in self.imgGrad.rescaleActionGroup.actions():
            if action.text() == self.defaultRescaleIntensHow:
                action.setChecked(True)
            self.rescaleIntensMenu.addAction(action)
        
        # Colormap gradient widget
        self.labelsGrad = widgets.labelsGradientWidget(parent=self)
        try:
            stateFound = self.labelsGrad.restoreState(self.df_settings)
        except Exception as e:
            self.logger.exception(traceback.format_exc())
            print('======================================')
            self.logger.info(
                'Failed to restore previously used colormap. '
                'Using default colormap "viridis"'
            )
            self.labelsGrad.item.loadPreset('viridis')
        
        # Add actions to imgGrad gradient item
        self.imgGrad.gradient.menu.addAction(
            self.labelsGrad.showLabelsImgAction
        )
        self.imgGrad.gradient.menu.addAction(
            self.labelsGrad.showRightImgAction
        )
        self.imgGrad.gradient.menu.addAction(
            self.labelsGrad.showNextFrameAction
        )
        
        self.imgGrad.gradient.menu.addSeparator()
        
        self.imgGrad.gradient.menu.addMenu(self.exportMenu)            
        
        # Add actions to view menu
        self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction)
        self.viewMenu.addAction(self.labelsGrad.showRightImgAction)
        
        # Right image histogram
        self.imgGradRight = widgets.baseHistogramLUTitem(
            name='image', parent=self, gradientPosition='left'
        )
        self.imgGradRight.gradient.menu.addAction(
            self.labelsGrad.showLabelsImgAction
        )
        self.imgGradRight.gradient.menu.addAction(
            self.labelsGrad.showRightImgAction
        )
        self.imgGradRight.gradient.menu.addAction(
            self.labelsGrad.showNextFrameAction
        )
        
        self.imgGrad.setChildLutItem(self.imgGradRight)

        # Title
        self.titleLabel = pg.LabelItem(
            justify='center', color=self.titleColor, size='14pt'
        )
        self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2)        

    def gui_createTextAnnotColors(self, r, g, b, custom=False):
        if custom:
            self.objLabelAnnotRgb = (int(r), int(g), int(b))
            self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9))
            self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220)
        else:
            self.objLabelAnnotRgb = (255, 255, 255) # white
            self.SphaseAnnotRgb = (229, 229, 229)
            self.G1phaseAnnotRgba = (204, 204, 204, 220)
        self.dividedAnnotRgb = (245, 188, 1) # orange

        self.emptyBrush = pg.mkBrush((0,0,0,0))
        self.emptyPen = pg.mkPen((0,0,0,0))
    
    def gui_setTextAnnotColors(self):
        self.textAnnot[0].setColors(
            self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb,
            self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb
        )

        self.textAnnot[1].setColors(
            self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb,
            self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb
        )


    def gui_createPlotItems(self):
        if 'textIDsColor' in self.df_settings.index:
            rgbString = self.df_settings.at['textIDsColor', 'value']
            r, g, b = colors.rgb_str_to_values(rgbString)
            self.gui_createTextAnnotColors(r, g, b, custom=True)
            self.textIDsColorButton.setColor((r, g, b))
        else:
            self.gui_createTextAnnotColors(0,0,0, custom=False)

        if 'labels_text_color' in self.df_settings.index:
            rgbString = self.df_settings.at['labels_text_color', 'value']
            r, g, b = colors.rgb_str_to_values(rgbString)
            self.ax2_textColor = (r, g, b)
        else:
            self.ax2_textColor = (255, 0, 0)
        
        self.emptyLab = np.zeros((2,2), dtype=np.uint8)

        # Right image item linked to left
        self.rightImageItem = widgets.ChildImageItem(
            linkedScrollbar=self.rightImageFramesScrollbar
        )
        self.imgGradRight.setImageItem(self.rightImageItem)   
        self.ax2.addItem(self.rightImageItem)
        
        # Left image
        self.img1 = widgets.ParentImageItem(
            linkedImageItem=self.rightImageItem,
            activatingActions=(
                self.labelsGrad.showRightImgAction,
                self.labelsGrad.showNextFrameAction
            )
        )
        self.imgGrad.setImageItem(self.img1)
        self.img1.lutItem = self.imgGrad
        self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut)
        self.ax1.addItem(self.img1)

        # Right image
        self.img2 = widgets.labImageItem()
        self.ax2.addItem(self.img2)

        self.topLayerItems = []
        self.topLayerItemsRight = []

        self.gui_createContourPens()
        self.gui_createMothBudLinePens()

        self.eraserCirclePen = pg.mkPen(width=1.5, color='r')
        
        # Temporary line item connecting bud to new mother
        self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen)
        self.topLayerItems.append(self.BudMothTempLine)
        
        # Temporary line item connecting objects to merge
        self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen)
        self.topLayerItems.append(self.mergeObjsTempLine)

        # Overlay segm. masks item
        self.labelsLayerImg1 = widgets.BaseLabelsImageItem()
        self.ax1.addItem(self.labelsLayerImg1)

        self.labelsLayerRightImg = widgets.BaseLabelsImageItem()
        self.ax2.addItem(self.labelsLayerRightImg)

        # Red/green border rect item
        self.GreenLinePen = pg.mkPen(color='g', width=2)
        self.RedLinePen = pg.mkPen(color='r', width=2)
        self.ax1BorderLine = pg.PlotDataItem()
        self.topLayerItems.append(self.ax1BorderLine)
        self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2))
        self.topLayerItems.append(self.ax2BorderLine)

        # Brush/Eraser/Wand.. layer item
        self.tempLayerRightImage = pg.ImageItem()
        # self.tempLayerImg1 = pg.ImageItem()
        self.tempLayerImg1 = widgets.ParentImageItem(
            linkedImageItem=self.tempLayerRightImage,
            activatingAction=(self.labelsGrad.showRightImgAction, )
        )
        self.topLayerItems.append(self.tempLayerImg1)
        self.topLayerItemsRight.append(self.tempLayerRightImage)

        # Highlighted ID layer items
        self.highLightIDLayerImg1 = pg.ImageItem()
        self.topLayerItems.append(self.highLightIDLayerImg1)  

        # Highlighted ID layer items
        self.highLightIDLayerRightImage = pg.ImageItem()
        self.topLayerItemsRight.append(self.highLightIDLayerRightImage)

        # Keep IDs temp layers
        self.keepIDsTempLayerRight = pg.ImageItem()
        self.keepIDsTempLayerLeft = widgets.ParentImageItem(
            linkedImageItem=self.keepIDsTempLayerRight,
            activatingAction=self.labelsGrad.showRightImgAction
        )
        self.topLayerItems.append(self.keepIDsTempLayerLeft)
        self.topLayerItemsRight.append(self.keepIDsTempLayerRight)

        # Searched ID contour
        self.searchedIDitemRight = pg.ScatterPlotItem()
        self.searchedIDitemRight.setData(
            [], [], symbol='s', pxMode=False, size=1,
            brush=pg.mkBrush(color=(255,0,0,150)),
            pen=pg.mkPen(width=2, color='r'), tip=None
        )
        self.searchedIDitemLeft = pg.ScatterPlotItem()
        self.searchedIDitemLeft.setData(
            [], [], symbol='s', pxMode=False, size=1,
            brush=pg.mkBrush(color=(255,0,0,150)),
            pen=pg.mkPen(width=2, color='r'), tip=None
        )
        self.topLayerItems.append(self.searchedIDitemLeft)
        self.topLayerItemsRight.append(self.searchedIDitemRight)

        
        # Brush circle img1
        self.ax1_BrushCircle = pg.ScatterPlotItem()
        self.ax1_BrushCircle.setData(
            [], [], symbol='o', pxMode=False,
            brush=pg.mkBrush((255,255,255,50)),
            pen=pg.mkPen(width=2), tip=None
        )
        self.topLayerItems.append(self.ax1_BrushCircle)

        # Eraser circle img1
        self.ax1_EraserCircle = pg.ScatterPlotItem()
        self.ax1_EraserCircle.setData(
            [], [], symbol='o', pxMode=False,
            brush=None, pen=self.eraserCirclePen, tip=None
        )
        self.topLayerItems.append(self.ax1_EraserCircle)

        self.ax1_EraserX = pg.ScatterPlotItem()
        self.ax1_EraserX.setData(
            [], [], symbol='x', pxMode=False, size=3,
            brush=pg.mkBrush(color=(255,0,0,50)),
            pen=pg.mkPen(width=1, color='r'), tip=None
        )
        self.topLayerItems.append(self.ax1_EraserX)

        # Brush circle img1
        self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem()
        self.labelRoiCircItemLeft.cleared = False
        self.labelRoiCircItemLeft.setData(
            [], [], symbol='o', pxMode=False,
            brush=pg.mkBrush(color=(255,0,0,0)),
            pen=pg.mkPen(color='r', width=2), tip=None
        )
        self.labelRoiCircItemRight = widgets.LabelRoiCircularItem()
        self.labelRoiCircItemRight.cleared = False
        self.labelRoiCircItemRight.setData(
            [], [], symbol='o', pxMode=False,
            brush=pg.mkBrush(color=(255,0,0,0)),
            pen=pg.mkPen(color='r', width=2), tip=None
        )
        self.topLayerItems.append(self.labelRoiCircItemLeft)
        self.topLayerItemsRight.append(self.labelRoiCircItemRight)
        
        self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem()
        self.ax1_binnedIDs_ScatterPlot.setData(
            [], [], symbol='t', pxMode=False,
            brush=pg.mkBrush((255,0,0,50)), size=15,
            pen=pg.mkPen(width=3, color='r'), tip=None
        )
        self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot)
        
        self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem()
        self.ax1_ripIDs_ScatterPlot.setData(
            [], [], symbol='x', pxMode=False,
            brush=pg.mkBrush((255,0,0,50)), size=15,
            pen=pg.mkPen(width=2, color='r'), tip=None
        )
        self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot)

        # Ruler plotItem and scatterItem
        rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2)
        self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen)
        self.ax1_rulerAnchorsItem = pg.ScatterPlotItem(
            symbol='o', size=9,
            brush=pg.mkBrush((255,0,0,50)),
            pen=pg.mkPen((255,0,0), width=2), tip=None
        )
        self.topLayerItems.append(self.ax1_rulerPlotItem)
        self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem)
        self.topLayerItems.append(self.ax1_rulerAnchorsItem)

        # Start point of polyline roi
        self.ax1_point_ScatterPlot = pg.ScatterPlotItem()
        self.ax1_point_ScatterPlot.setData(
            [], [], symbol='o', pxMode=False, size=3,
            pen=pg.mkPen(width=2, color='r'),
            brush=pg.mkBrush((255,0,0,50)), tip=None
        )
        self.topLayerItems.append(self.ax1_point_ScatterPlot)

        # Experimental: scatter plot to add a point marker
        self.startPointPolyLineItem = pg.ScatterPlotItem()
        self.startPointPolyLineItem.setData(
            [], [], symbol='o', size=9,
            pen=pg.mkPen(width=2, color='r'),
            brush=pg.mkBrush((255,0,0,50)),
            hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None
        )
        self.topLayerItems.append(self.startPointPolyLineItem)

        # Eraser circle img2
        self.ax2_EraserCircle = pg.ScatterPlotItem()
        self.ax2_EraserCircle.setData(
            [], [], symbol='o', pxMode=False, brush=None,
            pen=self.eraserCirclePen, tip=None
        )
        self.ax2.addItem(self.ax2_EraserCircle)
        self.ax2_EraserX = pg.ScatterPlotItem()
        self.ax2_EraserX.setData(
            [], [], symbol='x', pxMode=False, size=3,
            brush=pg.mkBrush(color=(255,0,0,50)),
            pen=pg.mkPen(width=1.5, color='r')
        )
        self.ax2.addItem(self.ax2_EraserX)

        # Brush circle img2
        self.ax2_BrushCirclePen = pg.mkPen(width=2)
        self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50))
        self.ax2_BrushCircle = pg.ScatterPlotItem()
        self.ax2_BrushCircle.setData(
            [], [], symbol='o', pxMode=False,
            brush=self.ax2_BrushCircleBrush,
            pen=self.ax2_BrushCirclePen, tip=None
        )
        self.ax2.addItem(self.ax2_BrushCircle)

        # Annotated metadata markers (ScatterPlotItem)
        self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem()
        self.ax2_binnedIDs_ScatterPlot.setData(
            [], [], symbol='t', pxMode=False,
            brush=pg.mkBrush((255,0,0,50)), size=15,
            pen=pg.mkPen(width=3, color='r'), tip=None
        )
        self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot)
        
        self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem()
        self.ax2_ripIDs_ScatterPlot.setData(
            [], [], symbol='x', pxMode=False,
            brush=pg.mkBrush((255,0,0,50)), size=15,
            pen=pg.mkPen(width=2, color='r'), tip=None
        )
        self.ax2.addItem(self.ax2_ripIDs_ScatterPlot)

        self.freeRoiItem = widgets.PlotCurveItem(
            pen=pg.mkPen(color='r', width=2)
        )
        self.topLayerItems.append(self.freeRoiItem)
        
        self.warnPairingItem = widgets.PlotCurveItem(
            pen=pg.mkPen(color='r', width=5, style=Qt.DashLine),
            pxMode=False
        )
        self.topLayerItems.append(self.warnPairingItem)

        self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1)
        self.ghostContourItemRight = widgets.GhostContourItem(self.ax2)

        self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1)
        self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2)
        
        self.manualBackgroundObjItem = widgets.GhostContourItem(
            self.ax1, penColor='r', textColor='r'
        )
        self.manualBackgroundImageItem = pg.ImageItem()
    
    def gui_createLabelRoiItem(self):
        Y, X = self.currentLab2D.shape
        # Label ROI rectangle
        pen = pg.mkPen('r', width=3)
        self.labelRoiItem = widgets.ROI(
            (0,0), (0,0),
            maxBounds=QRectF(QRect(0,0,X,Y)),
            scaleSnap=True,
            translateSnap=True,
            pen=pen, hoverPen=pen
        )

        posData = self.data[self.pos_i]
        if self.labelRoiZdepthSpinbox.value() == 0:
            self.labelRoiZdepthSpinbox.setValue(posData.SizeZ)
        self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1)
    
    def gui_createOverlayColors(self):
        fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name]
        self.logger.info(
            f'Number of TIFF files detected: {len(fluoChannels)}'
        )
        self.overlayColors = {}
        for c, ch in enumerate(fluoChannels):
            if f'{ch}_rgb' in self.df_settings.index:
                rgb_text = self.df_settings.at[f'{ch}_rgb', 'value']
                rgb = tuple([int(val) for val in rgb_text.split('_')])
                self.overlayColors[ch] = rgb
            else:
                if c >= len(self.overlayRGBs) -1:
                    i = c/len(fluoChannels)
                    additional_color_num = c - len(self.overlayRGBs) + 1
                    rgbs = [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) for _ in range(additional_color_num)]
                    self.overlayRGBs.extend(rgbs)
                self.overlayColors[ch] = self.overlayRGBs[c]

    def gui_createOverlayItems(self):
        self.imgGrad.setAxisLabel(self.user_ch_name)
        self.overlayLayersItems = {}
        fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name]
        for c, ch in enumerate(fluoChannels):
            overlayItems = self.getOverlayItems(ch)                
            self.overlayLayersItems[ch] = overlayItems
            imageItem, lutItem, alphaScrollBar = overlayItems
            self.ax1.addItem(imageItem)
            self.lutItemsLayout.addItem(lutItem, row=0, col=c+1)
        self.plotsCol = len(self.ch_names)

    def gui_getLostObjScatterItem(self):
        self.objLostAnnotRgb = (245, 184, 0)
        brush = pg.mkBrush((*self.objLostAnnotRgb, 150))
        pen = pg.mkPen(self.objLostAnnotRgb, width=1)
        lostObjScatterItem = pg.ScatterPlotItem(
            size=self.contLineWeight+1, pen=pen, 
            brush=brush, pxMode=False, symbol='s'
        )
        return lostObjScatterItem

    def gui_getTrackedLostObjScatterItem(self):
        self.objLostTrackedAnnotRgb = (0, 255, 0)
        brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150))
        pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1)
        lostObjScatterItem = pg.ScatterPlotItem(
            size=self.contLineWeight+1, pen=pen, 
            brush=brush, pxMode=False, symbol='s'
        )
        return lostObjScatterItem

    def _gui_createGraphicsItems(self):
        for _posData in self.data:
            _posData.allData_li = [None]*_posData.SizeT
            
        posData = self.data[self.pos_i]

        allIDs, posData = core.count_objects(posData, self.logger.info)
        
        self.highLowResAction.setChecked(True)
        numItems = len(allIDs)
        if numItems > 1500:
            cancel, switchToLowRes = _warnings.warnTooManyItems(
                self, numItems, self.progressWin
            )
            if cancel:
                self.progressWin.workerFinished = True
                self.progressWin.close()
                self.progressWin = None
                self.loadingDataAborted()
                return
            if switchToLowRes:
                self.highLowResAction.setChecked(False)
            else:
                # Many items requires pxMode active to be fast enough
                self.pxModeAction.setChecked(True)

        self.logger.info(f'Creating graphical items...')

        self.ax1_contoursImageItem = pg.ImageItem()
        
        self.ax1_lostObjImageItem = pg.ImageItem()
        self.ax2_lostObjImageItem = pg.ImageItem()
        
        self.ax1_lostTrackedObjImageItem = pg.ImageItem()
        self.ax2_lostTrackedObjImageItem = pg.ImageItem()
        
        self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem(
            symbol='s', pxMode=False, brush=self.oldMothBudLineBrush,
            size=self.mothBudLineWeight, pen=None
        )
        self.ax1_newMothBudLinesItem = pg.ScatterPlotItem(
            symbol='s', pxMode=False, brush=self.newMothBudLineBrush,
            size=self.mothBudLineWeight, pen=None
        )
        self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem()
        self.yellowContourScatterItem = self.gui_getLostObjScatterItem()

        self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem()
        self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem()

        brush = pg.mkBrush((0,255,0,200))
        pen = pg.mkPen('g', width=1)
        self.ccaFailedScatterItem = pg.ScatterPlotItem(
            size=self.contLineWeight+1, pen=pen, 
            brush=brush, pxMode=False, symbol='s'
        )

        self.ax2_contoursImageItem = pg.ImageItem()
        self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem(
            symbol='s', pxMode=False, brush=self.oldMothBudLineBrush,
            size=self.mothBudLineWeight, pen=None
        )
        self.ax2_newMothBudLinesItem = pg.ScatterPlotItem(
            symbol='s', pxMode=False, brush=self.newMothBudLineBrush,
            size=self.mothBudLineWeight, pen=None
        )
        self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem()
        self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem()
        
        self.gui_createTextAnnotItems(allIDs) # here
        self.gui_setTextAnnotColors()# here

        self.setDisabledAnnotOptions(False)

        self.progressWin.mainPbar.setMaximum(0)
        self.gui_addOverlayLayerItems()
        self.gui_addTopLayerItems()

        self.gui_addCreatedAxesItems()
        self.gui_add_ax_cursors()
        self.progressWin.workerFinished = True
        self.progressWin.close()
        self.progressWin = None

        self.loadingDataCompleted()
    
    def gui_createTextAnnotItems(self, allIDs):
        self.textAnnot = {}
        isHighResolution = self.highLowResAction.isChecked()
        pxMode = self.pxModeAction.isChecked()
        for ax in range(2):
            ax_textAnnot = annotate.TextAnnotations()
            ax_textAnnot.initFonts(self.fontSize)
            ax_textAnnot.createItems(
                isHighResolution, allIDs, pxMode=pxMode
            )
            self.textAnnot[ax] = ax_textAnnot
    
    def gui_addOverlayLayerItems(self):
        for items in self.overlayLabelsItems.values():
            imageItem, contoursItem, gradItem = items
            self.ax1.addItem(imageItem)
            self.ax1.addItem(contoursItem)
    
    def gui_addTopLayerItems(self):
        for item in self.topLayerItems:
            self.ax1.addItem(item)
        
        for item in self.topLayerItemsRight:
            self.ax2.addItem(item)

        # self.ax2.addItem(self.currentFrameLabelItem)
        
    def gui_createMothBudLinePens(self):
        if 'mothBudLineSize' in self.df_settings.index:
            val = self.df_settings.at['mothBudLineSize', 'value']
            self.mothBudLineWeight = int(val)
        else:
            self.mothBudLineWeight = 2

        self.newMothBudlineColor = (255, 0, 0)            
        if 'mothBudLineColor' in self.df_settings.index:
            val = self.df_settings.at['mothBudLineColor', 'value']
            rgba = colors.rgba_str_to_values(val)
            self.mothBudLineColor = rgba[0:3]
        else:
            self.mothBudLineColor = (255,165,0)

        try:
            self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect()
            self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect()
        except Exception as e:
            pass
        try:
            for act in self.imgGrad.mothBudLineWightActionGroup.actions():
                act.toggled.disconnect()
        except Exception as e:
            pass
        for act in self.imgGrad.mothBudLineWightActionGroup.actions():
            if act.lineWeight == self.mothBudLineWeight:
                act.setChecked(True)
            else:
                act.setChecked(False)
        self.imgGrad.mothBudLineColorButton.setColor(self.mothBudLineColor[:3])

        self.imgGrad.mothBudLineColorButton.sigColorChanging.connect(
            self.updateMothBudLineColour
        )
        self.imgGrad.mothBudLineColorButton.sigColorChanged.connect(
            self.saveMothBudLineColour
        )
        for act in self.imgGrad.mothBudLineWightActionGroup.actions():
            act.toggled.connect(self.mothBudLineWeightToggled)

        # MOther-bud lines brushes
        self.NewBudMoth_Pen = pg.mkPen(
            color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, 
            style=Qt.DashLine
        )
        self.OldBudMoth_Pen = pg.mkPen(
            color=self.mothBudLineColor, width=self.mothBudLineWeight, 
            style=Qt.DashLine
        )
        
        self.redDashLinePen = pg.mkPen(
            color='r', width=2, style=Qt.DashLine
        )

        self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor)
        self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor)

    def gui_createContourPens(self):
        if 'contLineWeight' in self.df_settings.index:
            val = self.df_settings.at['contLineWeight', 'value']
            self.contLineWeight = int(val)
        else:
            self.contLineWeight = 1
        if 'contLineColor' in self.df_settings.index:
            val = self.df_settings.at['contLineColor', 'value']
            rgba = colors.rgba_str_to_values(val)
            self.contLineColor = rgba
            self.newIDlineColor = [min(255, v+50) for v in self.contLineColor]
        else:
            self.contLineColor = (255, 0, 0, 200)
            self.newIDlineColor = (255, 0, 0, 255)

        try:
            self.imgGrad.contoursColorButton.sigColorChanging.disconnect()
            self.imgGrad.contoursColorButton.sigColorChanged.disconnect()
        except Exception as e:
            pass
        try:
            for act in self.imgGrad.contLineWightActionGroup.actions():
                act.toggled.disconnect()
        except Exception as e:
            pass
        for act in self.imgGrad.contLineWightActionGroup.actions():
            if act.lineWeight == self.contLineWeight:
                act.setChecked(True)
        self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3])

        self.imgGrad.contoursColorButton.sigColorChanging.connect(
            self.updateContColour
        )
        self.imgGrad.contoursColorButton.sigColorChanged.connect(
            self.saveContColour
        )
        for act in self.imgGrad.contLineWightActionGroup.actions():
            act.toggled.connect(self.contLineWeightToggled)

        # Contours pens
        self.oldIDs_cpen = pg.mkPen(
            color=self.contLineColor, width=self.contLineWeight
        )
        self.newIDs_cpen = pg.mkPen(
            color=self.newIDlineColor, width=self.contLineWeight+1
        )
        self.tempNewIDs_cpen = pg.mkPen(
            color='g', width=self.contLineWeight+1
        )

    def gui_createGraphicsItems(self):
        # Create enough PlotDataItems and LabelItems to draw contours and IDs.
        self.progressWin = apps.QDialogWorkerProgress(
            title='Creating axes items', parent=self,
            pbarDesc='Creating axes items (see progress in the terminal)...'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)

        QTimer.singleShot(50, self._gui_createGraphicsItems)

    def gui_connectGraphicsEvents(self):
        self.img1.hoverEvent = self.gui_hoverEventImg1
        self.img2.hoverEvent = self.gui_hoverEventImg2
        self.img1.mousePressEvent = self.gui_mousePressEventImg1
        self.img1.mouseMoveEvent = self.gui_mouseDragEventImg1
        self.img1.mouseReleaseEvent = self.gui_mouseReleaseEventImg1
        self.img2.mousePressEvent = self.gui_mousePressEventImg2
        self.img2.mouseMoveEvent = self.gui_mouseDragEventImg2
        self.img2.mouseReleaseEvent = self.gui_mouseReleaseEventImg2
        self.rightImageItem.mousePressEvent = self.gui_mousePressRightImage
        self.rightImageItem.mouseMoveEvent = self.gui_mouseDragRightImage
        self.rightImageItem.mouseReleaseEvent = self.gui_mouseReleaseRightImage
        self.rightImageItem.hoverEvent = self.gui_hoverEventRightImage
        # self.imgGrad.gradient.showMenu = self.gui_gradientContextMenuEvent
        self.imgGradRight.gradient.showMenu = self.gui_rightImageShowContextMenu
        # self.imgGrad.vb.contextMenuEvent = self.gui_gradientContextMenuEvent
        self.ax1.sigRangeChanged.connect(self.viewRangeChanged)

    def gui_initImg1BottomWidgets(self):
        self.zSliceScrollBar.hide()
        self.zProjComboBox.hide()
        self.zProjLockViewButton.hide()
        self.zSliceOverlay_SB.hide()
        self.zProjOverlay_CB.hide()
        self.overlay_z_label.hide()
        self.zSliceCheckbox.hide()
        self.zSliceSpinbox.hide()
        self.SizeZlabel.hide()

    @exception_handler
    def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent):
        modifiers = QGuiApplication.keyboardModifiers()
        alt = modifiers == Qt.AltModifier
        shift = modifiers == Qt.ShiftModifier
        isMod = alt
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        left_click = event.button() == Qt.MouseButton.LeftButton and not alt
        middle_click = self.isMiddleClick(event, modifiers)
        right_click = event.button() == Qt.MouseButton.RightButton and not alt
        isPanImageClick = self.isPanImageClick(event, modifiers)
        eraserON = self.eraserButton.isChecked()
        brushON = self.brushButton.isChecked()
        separateON = self.separateBudButton.isChecked()
        self.typingEditID = False

        # Drag image if neither brush or eraser are On pressed
        dragImg = (
            left_click and not eraserON and not
            brushON and not middle_click
        )
        if isPanImageClick:
            dragImg = True

        # Enable dragging of the image window like pyqtgraph original code
        if dragImg:
            pg.ImageItem.mousePressEvent(self.img2, event)
            event.ignore()
            return

        if mode == 'Viewer' and middle_click:
            self.startBlinkingModeCB()
            event.ignore()
            return

        x, y = event.pos().x(), event.pos().y()
        xdata, ydata = int(x), int(y)
        Y, X = self.get_2Dlab(posData.lab).shape
        if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y:
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
        else:
            return

        # Check if right click on ROI
        isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click)
        if isClickOnDelRoi:
            return

        # show gradient widget menu if none of the right-click actions are ON
        # and event is not coming from image 1
        is_right_click_action_ON = any([
            b.isChecked() for b in self.checkableQButtonsGroup.buttons()
        ])
        is_right_click_custom_ON = any([
            b.isChecked() for b in self.customAnnotDict.keys()
        ])
        is_event_from_img1 = False
        if hasattr(event, 'isImg1Sender'):
            is_event_from_img1 = event.isImg1Sender
        
        is_only_right_click = (
            right_click and not is_right_click_action_ON and not middle_click
        )
        
        showLabelsGradMenu = (
            is_only_right_click and not is_event_from_img1
        )
        
        if showLabelsGradMenu:
            self.labelsGrad.showMenu(event)
            event.ignore()
            return

        editInViewerMode = (
            (is_right_click_action_ON or is_right_click_custom_ON)
            and (right_click or middle_click) and mode=='Viewer'
        )

        if editInViewerMode:
            self.startBlinkingModeCB()
            event.ignore()
            return
        
        # Left-click is used for brush, eraser, separate bud, curvature tool
        # and magic labeller
        # Brush and eraser are mutually exclusive but we want to keep the eraser
        # or brush ON and disable them temporarily to allow left-click with
        # separate ON
        canErase = eraserON and not separateON and not dragImg
        canBrush = brushON and not separateON and not dragImg
        canDelete = mode == 'Segmentation and Tracking' or self.isSnapshot

        # Erase with brush and left click on the right image
        # NOTE: contours, IDs and rp will be updated
        # on gui_mouseReleaseEventImg2
        if left_click and canErase:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            Y, X = self.get_2Dlab(posData.lab).shape
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False, storeOnlyZoom=True)
            self.yPressAx2, self.xPressAx2 = y, x
            # Keep a global mask to compute which IDs got erased
            self.erasedIDs = set()
            lab_2D = self.get_2Dlab(posData.lab)
            self.erasedID = self.getHoverID(xdata, ydata)

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)

            # Build eraser mask
            mask = np.zeros(lab_2D.shape, bool)
            mask[ymin:ymax, xmin:xmax][diskMask] = True

            # If user double-pressed 'b' then erase over ALL labels
            color = self.eraserButton.palette().button().color().name()
            eraseOnlyOneID = (
                color != self.doublePressKeyButtonColor
                and self.erasedID != 0
            )
            if eraseOnlyOneID:
                mask[lab_2D!=self.erasedID] = False

            self.eraseOnlyOneID = eraseOnlyOneID

            self.erasedIDs.update(lab_2D[mask])
            self.setTempImg1Eraser(mask, init=True)
            self.applyEraserMask(mask)
            self.setImageImg2()

            self.isMouseDragImg2 = True

        # Paint with brush and left click on the right image
        # NOTE: contours, IDs and rp will be updated
        # on gui_mouseReleaseEventImg2
        elif left_click and canBrush:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            lab_2D = self.get_2Dlab(posData.lab)
            Y, X = lab_2D.shape
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False, storeOnlyZoom=True)
            self.yPressAx2, self.xPressAx2 = y, x

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)
            diskSlice = (slice(ymin, ymax), slice(xmin, xmax))

            ID = self.getHoverID(xdata, ydata)

            if ID > 0:
                self.ax2BrushID = ID
                self.isNewID = False
            else:
                self.setBrushID()
                self.ax2BrushID = posData.brushID
                self.isNewID = True

            self.updateLookuptable(lenNewLut=self.ax2BrushID+1)
            self.isMouseDragImg2 = True

            # Draw new objects
            localLab = lab_2D[diskSlice]
            mask = diskMask.copy()
            if not self.isPowerBrush():
                mask[localLab!=0] = False

            self.applyBrushMask(mask, self.ax2BrushID, toLocalSlice=diskSlice)

            self.setImageImg2(updateLookuptable=False)
            self.lastHoverID = -1

        # Delete ID (set to 0)
        elif middle_click and canDelete:
            t0 = time.perf_counter()
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            delID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if delID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                delID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.<br>'
                        'Enter here ID(s) that you want to delete<br><br>'
                        'You can enter multiple IDs separated by comma',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID),
                    allowList=True
                )
                delID_prompt.exec_()
                if delID_prompt.cancel:
                    return
                delIDs = delID_prompt.EntryID
            else:
                delIDs = [delID]

            # Ask to propagate change to all future visited frames
            key = 'Delete ID'
            askAction = self.askHowFutureFramesActions[key]
            doNotShow = not askAction.isChecked()
            (UndoFutFrames, applyFutFrames, endFrame_i,
            doNotShowAgain) = self.propagateChange(
                delIDs, key, doNotShow,
                posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID
            )
            
            if UndoFutFrames is None:
                return

            # Store undo state before modifying stuff
            self.storeUndoRedoStates(UndoFutFrames)  
            posData.doNotShowAgain_DelID = doNotShowAgain
            posData.UndoFutFrames_DelID = UndoFutFrames
            posData.applyFutFrames_DelID = applyFutFrames
            includeUnvisited = posData.includeUnvisitedInfo['Delete ID']

            delID_mask = self.deleteIDmiddleClick(
                delIDs, applyFutFrames, includeUnvisited
            )
            if delID_mask.ndim == 3:
                delID_mask = delID_mask[self.z_lab()]

            if self.isSnapshot:
                self.fixCcaDfAfterEdit('Delete ID')
            else:
                self.warnEditingWithCca_df('Delete ID', update_images=False)
            
            self.setImageImg2()
            delROIsIDs = self.setAllTextAnnotations()
            self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False)

            how = self.drawIDsContComboBox.currentText()
            if how.find('overlay segm. masks') != -1:
                self.labelsLayerImg1.image[delID_mask] = 0
                self.labelsLayerImg1.setImage(self.labelsLayerImg1.image)
            
            how_ax2 = self.getAnnotateHowRightImage()
            if how_ax2.find('overlay segm. masks') != -1:
                self.labelsLayerRightImg.image[delID_mask] = 0
                self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image)
            
            self.highlightLostNew()
        # Separate bud or objects with same ID
        elif right_click and separateON:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(self.get_2Dlab(posData.lab), y, x)
                sepID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter here ID that you want to split',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                sepID_prompt.exec_()
                if sepID_prompt.cancel:
                    return
                else:
                    ID = sepID_prompt.EntryID
                y, x = posData.rp[posData.IDs_idxs[ID]].centroid[-2:]
                xdata, ydata = int(x), int(y)

            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)
            max_ID = max(posData.IDs, default=1)

            if self.isSegm3D and not shift:
                z = self.zSliceScrollBar.sliderPosition()
                posData.lab, splittedIDs = measure.separate_with_label(
                    posData.lab, posData.rp, [ID], max_ID, 
                    click_coords_list=[(z, ydata, xdata)]
                )
                success = True
                # self.set_2Dlab(lab2D)
            elif not shift:
                result = core.split_along_convexity_defects(
                    ID, self.get_2Dlab(posData.lab), max_ID
                )
                lab2D, success, splittedIDs = result
                self.set_2Dlab(lab2D)
            else:
                success = False
            
            # If automatic bud separation was not successfull call manual one
            if not success:
                posData.disableAutoActivateViewerWindow = True
                img = self.getDisplayedImg1()
                col = 'manual_separate_draw_mode'
                drawMode = self.df_settings.at[col, 'value']
                manualSep = apps.manualSeparateGui(
                    self.get_2Dlab(posData.lab), ID, img,
                    fontSize=self.fontSize,
                    IDcolor=self.lut[ID],
                    parent=self,
                    drawMode=drawMode
                )
                manualSep.setState(self.lastManualSeparateState)
                manualSep.show()
                manualSep.centerWindow()
                manualSep.show(block=True)
                if manualSep.cancel:
                    posData.disableAutoActivateViewerWindow = False
                    if not self.separateBudButton.findChild(QAction).isChecked():
                        self.separateBudButton.setChecked(False)
                    return
                self.lastManualSeparateState = manualSep.state()
                lab2D = self.get_2Dlab(posData.lab)
                lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0]
                self.set_2Dlab(lab2D)
                splittedIDs = [obj.label for obj in manualSep.rp]
                posData.disableAutoActivateViewerWindow = False
                self.storeManualSeparateDrawMode(manualSep.drawMode)

            # Update data (rp, etc)
            self.update_rp()

            # Repeat tracking
            self.trackSubsetIDs(splittedIDs)

            if self.isSnapshot:
                self.fixCcaDfAfterEdit('Separate IDs')
                self.updateAllImages()
            else:
                self.warnEditingWithCca_df('Separate IDs')

            self.store_data()

            if not self.separateBudButton.findChild(QAction).isChecked():
                self.separateBudButton.setChecked(False)

        # Fill holes
        elif right_click and self.fillHolesToolButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                clickedBkgrID = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter here the ID that you want to '
                         'fill the holes of',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                clickedBkgrID.exec_()
                if clickedBkgrID.cancel:
                    return
                else:
                    ID = clickedBkgrID.EntryID

            if ID in posData.lab:
                # Store undo state before modifying stuff
                self.storeUndoRedoStates(False)
                obj_idx = posData.IDs.index(ID)
                obj = posData.rp[obj_idx]
                objMask = self.getObjImage(obj.image, obj.bbox)
                localFill = scipy.ndimage.binary_fill_holes(objMask)
                posData.lab[self.getObjSlice(obj.slice)][localFill] = ID

                self.update_rp()
                self.updateAllImages()

                if not self.fillHolesToolButton.findChild(QAction).isChecked():
                    self.fillHolesToolButton.setChecked(False)
        
        # Hull contour
        elif right_click and self.hullContToolButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                mergeID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter here the ID that you want to '
                         'replace with Hull contour',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                mergeID_prompt.exec_()
                if mergeID_prompt.cancel:
                    return
                else:
                    ID = mergeID_prompt.EntryID

            if ID in posData.lab:
                # Store undo state before modifying stuff
                self.storeUndoRedoStates(False)
                obj_idx = posData.IDs.index(ID)
                obj = posData.rp[obj_idx]
                objMask = self.getObjImage(obj.image, obj.bbox)
                localHull = skimage.morphology.convex_hull_image(objMask)
                posData.lab[self.getObjSlice(obj.slice)][localHull] = ID

                self.update_rp()
                self.updateAllImages()

                if not self.hullContToolButton.findChild(QAction).isChecked():
                    self.hullContToolButton.setChecked(False)

        # Move label
        elif right_click and self.moveLabelToolButton.isChecked():
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)

            x, y = event.pos().x(), event.pos().y()
            self.startMovingLabel(x, y)

        # Fill holes
        elif right_click and self.fillHolesToolButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                clickedBkgrID = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter here the ID that you want to '
                         'fill the holes of',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                clickedBkgrID.exec_()
                if clickedBkgrID.cancel:
                    return
                else:
                    ID = clickedBkgrID.EntryID

        # Merge IDs
        elif right_click and self.mergeIDsButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                mergeID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter here first ID that you want to merge',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                mergeID_prompt.exec_()
                if mergeID_prompt.cancel:
                    self.mergeObjsTempLine.setData([], [])
                    return
                else:
                    ID = mergeID_prompt.EntryID

            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)
            self.firstID = ID
            
            obj_idx = posData.IDs_idxs[ID]
            obj = posData.rp[obj_idx]
            yc, xc = self.getObjCentroid(obj.centroid)
            self.clickObjYc, self.clickObjXc = int(yc), int(xc)

        # Edit ID
        elif right_click and self.editIDbutton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                editID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                        'Enter here ID that you want to replace with a new one',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                editID_prompt.show(block=True)

                if editID_prompt.cancel:
                    return
                else:
                    ID = editID_prompt.EntryID
            
            obj_idx = posData.IDs_idxs[ID]
            y, x = posData.rp[obj_idx].centroid[-2:]
            xdata, ydata = int(x), int(y)

            posData.disableAutoActivateViewerWindow = True
            currentIDs = posData.IDs.copy()
            self.setAllIDs(onlyVisited=True)
            editID = apps.editID_QWidget(
                ID, posData.IDs, doNotShowAgain=self.doNotAskAgainExistingID,
                parent=self, entryID=self.getNearestLostObjID(y, x), 
                nextUniqueID=self.setBrushID(return_val=True), 
                allIDs=posData.allIDs
            )
            editID.show(block=True)
            if editID.cancel:
                posData.disableAutoActivateViewerWindow = False
                if not self.editIDbutton.findChild(QAction).isChecked():
                    self.editIDbutton.setChecked(False)
                return

            if editID.assignNewID:
                self.assignNewIDfromClickedID(ID, event)
                return
            
            if not self.doNotAskAgainExistingID:    
                self.editIDmergeIDs = editID.mergeWithExistingID
            self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID
            
            self.applyEditID(ID, currentIDs, editID.how, x, y)
        
        elif (right_click or left_click) and self.keepIDsButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                keepID_win = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                        'Enter ID that you want to keep',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                keepID_win.exec_()
                if keepID_win.cancel:
                    return
                else:
                    ID = keepID_win.EntryID
            
            if ID in self.keptObjectsIDs:
                self.keptObjectsIDs.remove(ID)
                self.clearHighlightedText()
            else:
                self.keptObjectsIDs.append(ID)
                self.highlightLabelID(ID)
            
            self.updateTempLayerKeepIDs()

        # Annotate cell as removed from the analysis
        elif right_click and self.binCellButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                binID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID that you want to remove from the analysis',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                binID_prompt.exec_()
                if binID_prompt.cancel:
                    return
                else:
                    ID = binID_prompt.EntryID

            # Ask to propagate change to all future visited frames
            key = 'Exclude cell from analysis'
            askAction = self.askHowFutureFramesActions[key]
            doNotShow = not askAction.isChecked()
            (UndoFutFrames, applyFutFrames, endFrame_i,
            doNotShowAgain) = self.propagateChange(
                ID, key, doNotShow,
                posData.UndoFutFrames_BinID,
                posData.applyFutFrames_BinID
            )

            if UndoFutFrames is None:
                # User cancelled the process
                return

            posData.doNotShowAgain_BinID = doNotShowAgain
            posData.UndoFutFrames_BinID = UndoFutFrames
            posData.applyFutFrames_BinID = applyFutFrames

            self.current_frame_i = posData.frame_i

            # Apply Exclude cell from analysis to future frames if requested
            if applyFutFrames:
                # Store current data before going to future frames
                self.store_data()
                for i in range(posData.frame_i+1, endFrame_i+1):
                    posData.frame_i = i
                    self.get_data()
                    if ID in posData.binnedIDs:
                        posData.binnedIDs.remove(ID)
                    else:
                        posData.binnedIDs.add(ID)
                    self.update_rp_metadata(draw=False)
                    self.store_data(autosave=i==endFrame_i)

                self.app.restoreOverrideCursor()

            # Back to current frame
            if applyFutFrames:
                posData.frame_i = self.current_frame_i
                self.get_data()

            # Store undo state before modifying stuff
            self.storeUndoRedoStates(UndoFutFrames)

            if ID in posData.binnedIDs:
                posData.binnedIDs.remove(ID)
            else:
                posData.binnedIDs.add(ID)

            self.annotate_rip_and_bin_IDs(updateLabel=True)

            # Gray out ore restore binned ID
            self.updateLookuptable()

            if not self.binCellButton.findChild(QAction).isChecked():
                self.binCellButton.setChecked(False)

        # Annotate cell as dead
        elif right_click and self.ripCellButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                ripID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID that you want to annotate as dead',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                ripID_prompt.exec_()
                if ripID_prompt.cancel:
                    return
                else:
                    ID = ripID_prompt.EntryID

            # Ask to propagate change to all future visited frames
            key = 'Annotate cell as dead'
            askAction = self.askHowFutureFramesActions[key]
            doNotShow = not askAction.isChecked()
            (UndoFutFrames, applyFutFrames, endFrame_i,
            doNotShowAgain) = self.propagateChange(
                ID, key, doNotShow,
                posData.UndoFutFrames_RipID,
                posData.applyFutFrames_RipID
            )

            if UndoFutFrames is None:
                return

            posData.doNotShowAgain_RipID = doNotShowAgain
            posData.UndoFutFrames_RipID = UndoFutFrames
            posData.applyFutFrames_RipID = applyFutFrames

            self.current_frame_i = posData.frame_i

            # Apply Edit ID to future frames if requested
            if applyFutFrames:
                # Store current data before going to future frames
                self.store_data()
                for i in range(posData.frame_i+1, endFrame_i+1):
                    posData.frame_i = i
                    self.get_data()
                    if ID in posData.ripIDs:
                        posData.ripIDs.remove(ID)
                    else:
                        posData.ripIDs.add(ID)
                    self.update_rp_metadata(draw=False)
                    self.store_data(autosave=i==endFrame_i)
                self.app.restoreOverrideCursor()

            # Back to current frame
            if applyFutFrames:
                posData.frame_i = self.current_frame_i
                self.get_data()

            # Store undo state before modifying stuff
            self.storeUndoRedoStates(UndoFutFrames)

            if ID in posData.ripIDs:
                posData.ripIDs.remove(ID)
            else:
                posData.ripIDs.add(ID)

            self.annotate_rip_and_bin_IDs(updateLabel=True)

            # Gray out dead ID
            self.updateLookuptable()
            self.store_data()

            if self.isSnapshot:
                self.fixCcaDfAfterEdit('Annotate ID as dead')
                self.updateAllImages()
            else:
                self.warnEditingWithCca_df('Annotate ID as dead')

            if not self.ripCellButton.findChild(QAction).isChecked():
                self.ripCellButton.setChecked(False)

    def resetExpandLabel(self):
        self.expandingID = -1
    
    def expandLabelCallback(self, checked):
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.sender())
            self.connectLeftClickButtons()
            self.expandFootprintSize = 1
        else:
            self.hoverLabelID = 0
            self.expandingID = 0
            self.updateAllImages()
    
    def expandLabel(self, dilation=True):
        posData = self.data[self.pos_i]
        if self.hoverLabelID == 0:
            self.isExpandingLabel = False
            return

        # Re-initialize label to expand when we hover on a different ID
        # or we change direction
        reinitExpandingLab = (
            self.expandingID != self.hoverLabelID
            or dilation != self.isDilation
        )

        ID = self.hoverLabelID

        obj = posData.rp[posData.IDs.index(ID)]

        if reinitExpandingLab:
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)
            # hoverLabelID different from previously expanded ID --> reinit
            self.isExpandingLabel = True
            self.expandingID = ID
            self.expandingLab = np.zeros_like(self.currentLab2D)
            self.expandingLab[obj.coords[:,-2], obj.coords[:,-1]] = ID
            self.expandFootprintSize = 1

        prevCoords = (obj.coords[:,-2], obj.coords[:,-1])
        self.currentLab2D[obj.coords[:,-2], obj.coords[:,-1]] = 0
        lab_2D = self.get_2Dlab(posData.lab)
        lab_2D[obj.coords[:,-2], obj.coords[:,-1]] = 0

        footprint = skimage.morphology.disk(self.expandFootprintSize)
        if dilation:
            expandedLab = skimage.morphology.dilation(
                self.expandingLab, footprint
            )
            self.isDilation = True
        else:
            expandedLab = skimage.morphology.erosion(
                self.expandingLab, footprint
            )
            self.isDilation = False

        # Prevent expanding into neighbouring labels
        expandedLab[self.currentLab2D>0] = 0

        # Get coords of the dilated/eroded object
        expandedObj = skimage.measure.regionprops(expandedLab)[0]
        expandedObjCoords = (expandedObj.coords[:,-2], expandedObj.coords[:,-1])

        # Add the dilated/erored object
        self.currentLab2D[expandedObjCoords] = self.expandingID
        lab_2D[expandedObjCoords] = self.expandingID

        self.set_2Dlab(lab_2D)
        self.currentLab2D = lab_2D
        
        self.update_rp()
        
        if self.labelsGrad.showLabelsImgAction.isChecked():
            self.img2.setImage(img=self.currentLab2D, autoLevels=False)

        self.setTempImg1ExpandLabel(prevCoords, expandedObjCoords)

    def startMovingLabel(self, xPos, yPos):
        posData = self.data[self.pos_i]
        xdata, ydata = int(xPos), int(yPos)
        lab_2D = self.get_2Dlab(posData.lab)
        ID = lab_2D[ydata, xdata]
        if ID == 0:
            self.isMovingLabel = False
            return

        posData = self.data[self.pos_i]
        self.isMovingLabel = True

        self.searchedIDitemRight.setData([], [])
        self.searchedIDitemLeft.setData([], [])
        self.movingID = ID
        self.prevMovePos = (xdata, ydata)
        movingObj = posData.rp[posData.IDs.index(ID)]
        self.movingObjCoords = movingObj.coords.copy()
        yy, xx = movingObj.coords[:,-2], movingObj.coords[:,-1]
        self.currentLab2D[yy, xx] = 0

    def moveLabel(self, xPos, yPos):
        posData = self.data[self.pos_i]
        lab_2D = self.get_2Dlab(posData.lab)
        Y, X = lab_2D.shape
        xdata, ydata = int(xPos), int(yPos)
        if xdata<0 or ydata<0 or xdata>=X or ydata>=Y:
            return

        self.clearObjContour(ID=self.movingID, ax=0)

        xStart, yStart = self.prevMovePos
        deltaX = xdata-xStart
        deltaY = ydata-yStart

        yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1]

        if self.isSegm3D:
            zz = self.movingObjCoords[:,0]
            posData.lab[zz, yy, xx] = 0
        else:
            posData.lab[yy, xx] = 0

        self.movingObjCoords[:,-2] = self.movingObjCoords[:,-2]+deltaY
        self.movingObjCoords[:,-1] = self.movingObjCoords[:,-1]+deltaX

        yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1]

        yy[yy<0] = 0
        xx[xx<0] = 0
        yy[yy>=Y] = Y-1
        xx[xx>=X] = X-1

        if self.isSegm3D:
            zz = self.movingObjCoords[:,0]
            posData.lab[zz, yy, xx] = self.movingID
        else:
            posData.lab[yy, xx] = self.movingID
        
        self.currentLab2D = self.get_2Dlab(posData.lab)
        if self.labelsGrad.showLabelsImgAction.isChecked():
            self.img2.setImage(self.currentLab2D, autoLevels=False)
        
        self.setTempImg1MoveLabel()

        self.prevMovePos = (xdata, ydata)

    @exception_handler
    def gui_mouseDragEventImg1(self, event):
        x, y = event.pos().x(), event.pos().y()
        
        if hasattr(self, 'scaleBar'):
            if self.scaleBarDialog is not None:
                self.scaleBarDialog.locCombobox.setCurrentText('Custom')
            if self.scaleBar.isHighlighted() and self.scaleBar.clicked:
                self.scaleBar.move(x, y)
                return
        
        if hasattr(self, 'timestamp'):
            if self.timestampDialog is not None:
                self.timestampDialog.locCombobox.setCurrentText('Custom')
            if self.timestamp.isHighlighted() and self.timestamp.clicked:
                self.timestamp.move(x, y)
                return
        
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer':
            return
        
        posData = self.data[self.pos_i]
        Y, X = self.get_2Dlab(posData.lab).shape
        xdata, ydata = int(x), int(y)
        if not myutils.is_in_bounds(xdata, ydata, X, Y):
            return
        
        if self.isRightClickDragImg1 and self.curvToolButton.isChecked():
            self.drawAutoContour(y, x)

        # Brush dragging mouse --> keep painting
        elif self.isMouseDragImg1 and self.brushButton.isChecked():
            lab_2D = self.get_2Dlab(posData.lab)

            # t1 = time.perf_counter()

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)
            rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X)

            # t2 = time.perf_counter()

            diskSlice = (slice(ymin, ymax), slice(xmin, xmax))

            # Build brush mask
            mask = np.zeros(lab_2D.shape, bool)
            mask[diskSlice][diskMask] = True
            mask[rrPoly, ccPoly] = True

            # If user double-pressed 'b' then draw over the labels
            color = self.brushButton.palette().button().color().name()
            drawUnder = color != self.doublePressKeyButtonColor

            # t3 = time.perf_counter()
            if drawUnder:
                mask[lab_2D!=0] = False
                self.setHoverToolSymbolColor(
                    xdata, ydata, self.ax2_BrushCirclePen,
                    (self.ax2_BrushCircle, self.ax1_BrushCircle),
                    self.brushButton, brush=self.ax2_BrushCircleBrush
                )

            # t4 = time.perf_counter()

            # Apply brush mask
            self.applyBrushMask(mask, posData.brushID)

            self.setImageImg2(updateLookuptable=False)

            # t5 = time.perf_counter()

            lab2D = self.get_2Dlab(posData.lab)
            brushMask = np.logical_and(
                lab2D[diskSlice] == posData.brushID, diskMask
            )
            self.setTempImg1Brush(
                False, brushMask, posData.brushID, 
                toLocalSlice=diskSlice
            )

            # t6 = time.perf_counter()

            # printl(
            #     'Brush exec times =\n'
            #     f'  * {(t1-t0)*1000 = :.4f} ms\n'
            #     f'  * {(t2-t1)*1000 = :.4f} ms\n'
            #     f'  * {(t3-t2)*1000 = :.4f} ms\n'
            #     f'  * {(t4-t3)*1000 = :.4f} ms\n'
            #     f'  * {(t5-t4)*1000 = :.4f} ms\n'
            #     f'  * {(t6-t5)*1000 = :.4f} ms\n'
            #     f'  * {(t6-t0)*1000 = :.4f} ms'
            # )

        # Eraser dragging mouse --> keep erasing
        elif self.isMouseDragImg1 and self.eraserButton.isChecked():
            posData = self.data[self.pos_i]
            lab_2D = self.get_2Dlab(posData.lab)
            rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X)

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)

            diskSlice = (slice(ymin, ymax), slice(xmin, xmax))

            # Build eraser mask
            mask = np.zeros(lab_2D.shape, bool)
            mask[ymin:ymax, xmin:xmax][diskMask] = True
            mask[rrPoly, ccPoly] = True

            if self.eraseOnlyOneID:
                mask[lab_2D!=self.erasedID] = False
                self.setHoverToolSymbolColor(
                    xdata, ydata, self.eraserCirclePen,
                    (self.ax2_EraserCircle, self.ax1_EraserCircle),
                    self.eraserButton, hoverRGB=self.img2.lut[self.erasedID],
                    ID=self.erasedID
                )

            self.erasedIDs.update(lab_2D[mask])
            self.applyEraserMask(mask)

            self.setImageImg2()
            
            for erasedID in self.erasedIDs:
                if erasedID == 0:
                    continue
                self.erasedLab[lab_2D==erasedID] = erasedID
                self.erasedLab[mask] = 0

            eraserMask = mask[diskSlice]
            self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice)
            self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice, ax=1)

        # Move label dragging mouse --> keep moving
        elif self.isMovingLabel and self.moveLabelToolButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            self.moveLabel(x, y)

        # Wand dragging mouse --> keep doing the magic
        elif self.isMouseDragImg1 and self.wandToolButton.isChecked():
            tol = self.wandToleranceSlider.value()
            flood_mask = skimage.segmentation.flood(
                self.flood_img, (ydata, xdata), tolerance=tol
            )
            drawUnderMask = np.logical_or(
                posData.lab==0, posData.lab==posData.brushID
            )
            flood_mask = np.logical_and(flood_mask, drawUnderMask)

            self.flood_mask[flood_mask] = True

            if self.wandAutoFillCheckbox.isChecked():
                self.flood_mask = scipy.ndimage.binary_fill_holes(
                    self.flood_mask
                )

            if np.any(self.flood_mask):
                mask = np.logical_or(
                    self.flood_mask,
                    posData.lab==posData.brushID
                )
                self.setTempImg1Brush(False, mask, posData.brushID)
        
        # Label ROI dragging mouse --> draw ROI
        elif self.isMouseDragImg1 and self.labelRoiButton.isChecked():
            if self.labelRoiIsRectRadioButton.isChecked():
                x0, y0 = self.labelRoiItem.pos()
                w, h = (xdata-x0), (ydata-y0)
                self.labelRoiItem.setSize((w, h))
            elif self.labelRoiIsFreeHandRadioButton.isChecked():
                self.freeRoiItem.addPoint(xdata, ydata)
        
        # Draw freehand clear region --> draw region
        elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked():
            self.freeRoiItem.addPoint(xdata, ydata)
    
    # @exec_time
    def fillHolesID(self, ID, sender='brush'):
        posData = self.data[self.pos_i]
        if sender == 'brush':
            if not self.brushAutoFillCheckbox.isChecked():
                return False
            
            lab2D = self.get_2Dlab(posData.lab)
            mask = lab2D == ID
            filledMask = scipy.ndimage.binary_fill_holes(mask)
            lab2D[filledMask] = ID

            self.set_2Dlab(lab2D)
            return True
        return False

    def highlightIDonHoverCheckBoxToggled(self, checked):
        doHighlight = (
            self.guiTabControl.highlightCheckbox.isChecked()
            or self.guiTabControl.highlightSearchedCheckbox.isChecked()
        )
        if not doHighlight:
            self.highlightedID = 0
            self.initLookupTableLab()
        else:
            self.highlightedID = self.guiTabControl.propsQGBox.idSB.value()
            self.highlightSearchedID(self.highlightedID, force=True)
            self.updatePropsWidget(self.highlightedID)
        self.updateAllImages()
    
    def highlightSearchedIDcheckBoxToggled(self, checked):
        self.highlightIDonHoverCheckBoxToggled(checked)
        if checked:
            posData = self.data[self.pos_i]
            self.highlightedID = self.getHighlightedID()
            if self.highlightedID == 0:
                return
            objIdx = posData.IDs_idxs[self.highlightedID]
            obj_idx = posData.IDs_idxs.get(self.highlightedID)
            if obj_idx is None:
                return
            obj = posData.rp[objIdx]
            self.goToZsliceSearchedID(obj) 
    
    def setHighlightID(self, doHighlight):
        if not doHighlight:
            self.highlightedID = 0
            self.initLookupTableLab()
        else:
            self.highlightedID = self.guiTabControl.propsQGBox.idSB.value()
            self.highlightSearchedID(self.highlightedID, force=True)
            self.updatePropsWidget(self.highlightedID)
        self.updateAllImages()
    
    def propsWidgetIDvalueChanged(self, ID):
        posData = self.data[self.pos_i]
        if ID == 0:
            self.updatePropsWidget(int(ID))
            return
        
        propsQGBox = self.guiTabControl.propsQGBox
        obj_idx = posData.IDs_idxs.get(ID)
        if obj_idx is None:
            s = f'Object ID {int(ID):d} does not exist'
            propsQGBox.notExistingIDLabel.setText(s)
            return
        
        obj = posData.rp[obj_idx]
        self.goToZsliceSearchedID(obj)
        self.updatePropsWidget(int(ID))

    def updatePropsWidget(self, ID, fromHover=False):
        if isinstance(ID, str):
            # Function called by currentTextChanged of channelCombobox or
            # additionalMeasCombobox. We set self.currentPropsID = 0 to force update
            ID = self.guiTabControl.propsQGBox.idSB.value()
            self.currentPropsID = -1

        ID = int(ID)
        
        update = (
            self.propsDockWidget.isVisible()
            and ID != 0 and ID!=self.currentPropsID
        )
        if not update:
            return

        posData = self.data[self.pos_i]
        if not hasattr(posData, 'rp'):
            return 
        
        if posData.rp is None:
            self.update_rp()

        if not posData.IDs:
            # empty segmentation mask
            return

        if fromHover and not self.guiTabControl.highlightCheckbox.isChecked():
            # Do not highlight on hover
            return

        propsQGBox = self.guiTabControl.propsQGBox

        obj_idx = posData.IDs_idxs.get(ID)
        if obj_idx is None:
            s = f'Object ID {int(ID):d} does not exist'
            propsQGBox.notExistingIDLabel.setText(s)
            return

        propsQGBox.notExistingIDLabel.setText('')
        self.currentPropsID = ID
        propsQGBox.idSB.setValue(ID)
        
        doHighlight = (
            self.guiTabControl.highlightCheckbox.isChecked()
            or self.guiTabControl.highlightSearchedCheckbox.isChecked()
        )
        if doHighlight:
            self.highlightSearchedID(ID)
        
        obj = posData.rp[obj_idx]

        if self.isSegm3D:
            if self.zProjComboBox.currentText() == 'single z-slice':
                local_z = self.z_lab() - obj.bbox[0]
                area_pxl = np.count_nonzero(obj.image[local_z])
            else:
                area_pxl = np.count_nonzero(obj.image.max(axis=0))
        else:
            area_pxl = obj.area

        propsQGBox.cellAreaPxlSB.setValue(area_pxl)

        pixelSizeQGBox = self.guiTabControl.pixelSizeQGBox
        PhysicalSizeX = pixelSizeQGBox.pixelWidthWidget.value()
        PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value()
        PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value()
        
        yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX

        area_um2 = area_pxl*yx_pxl_to_um2

        propsQGBox.cellAreaUm2DSB.setValue(area_um2)

        if self.isSegm3D:
            PhysicalSizeZ = posData.PhysicalSizeZ
            vol_vox_3D = obj.area
            vol_fl_3D = vol_vox_3D*PhysicalSizeZ*PhysicalSizeY*PhysicalSizeX
            propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D)
            propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D)

        vol_vox, vol_fl = _calc_rot_vol(
            obj, PhysicalSizeY, PhysicalSizeX
        )
        propsQGBox.cellVolVoxSB.setValue(int(vol_vox))
        propsQGBox.cellVolFlDSB.setValue(vol_fl)


        minor_axis_length = max(1, obj.minor_axis_length)
        elongation = obj.major_axis_length/minor_axis_length
        propsQGBox.elongationDSB.setValue(elongation)

        solidity = obj.solidity
        propsQGBox.solidityDSB.setValue(solidity)

        additionalPropName = propsQGBox.additionalPropsCombobox.currentText()
        additionalPropValue = getattr(obj, additionalPropName)
        propsQGBox.additionalPropsCombobox.indicator.setValue(additionalPropValue)

        intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox
        selectedChannel = intensMeasurQGBox.channelCombobox.currentText()
        
        try:
            _, filename = self.getPathFromChName(selectedChannel, posData)
            image = posData.ol_data_dict[filename][posData.frame_i]
        except Exception as e:
            image = posData.img_data[posData.frame_i]

        if posData.SizeZ > 1 and not self.isSegm3D:
            z = self.zSliceScrollBar.sliderPosition()
            objData = image[z][obj.slice][obj.image]
            img = self.img1.image
        else:
            objData = image[obj.slice][obj.image]
            img = image

        intensMeasurQGBox.minimumDSB.setValue(np.min(objData))
        intensMeasurQGBox.maximumDSB.setValue(np.max(objData))
        intensMeasurQGBox.meanDSB.setValue(np.mean(objData))
        intensMeasurQGBox.medianDSB.setValue(np.median(objData))

        funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText()
        func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc]
        if funcDesc == 'Concentration':
            bkgrVal = np.median(img[posData.lab == 0])
            amount = func(objData, bkgrVal, obj.area)
            value = amount/vol_vox
        elif funcDesc == 'Amount':
            bkgrVal = np.median(img[posData.lab == 0])
            amount = func(objData, bkgrVal, obj.area)
            value = amount
        else:
            value = func(objData)

        intensMeasurQGBox.additionalMeasCombobox.indicator.setValue(value)
    
    def gui_hoverEventRightImage(self, event):
        try:
            posData = self.data[self.pos_i]
        except AttributeError:
            return

        if event.isExit():
            self.resetCursors()

        self.gui_hoverEventImg1(event, isHoverImg1=False)
        setMirroredCursor = (
            self.app.overrideCursor() is None and not event.isExit()
            and self.showMirroredCursorAction.isChecked()
        )
        if setMirroredCursor:
            x, y = event.pos()
            self.ax1_cursor.setData([x], [y])
    
    def onCtrlPressedFirstTime(self):
        x, y = self.xHoverImg, self.yHoverImg
        if x is None:
            self.xyOnCtrlPressedFirstTime = None
            return

        xdata, ydata = int(x), int(y)
        Y, X = self.currentLab2D.shape

        if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y):
            self.xyOnCtrlPressedFirstTime = None
            return
        
        ID = self.currentLab2D[ydata, xdata]
        if ID == 0:
            self.xyOnCtrlPressedFirstTime = None
            return 
        
        self.xyOnCtrlPressedFirstTime = (xdata, ydata)
    
    def onCtrlReleased(self):
        self.xyOnCtrlPressedFirstTime = None
        self.isCtrlDown = False
    
    def gui_hoverEventImg1(self, event, isHoverImg1=True):
        try:
            posData = self.data[self.pos_i]
        except AttributeError:
            return
        
        # Update x, y, value label bottom right
        if not event.isExit():
            self.xHoverImg, self.yHoverImg = event.pos()
        else:
            self.xHoverImg, self.yHoverImg = None, None

        if event.isExit():
            self.resetCursor()
            
        if not event.isExit() and self.slideshowWin is not None:
            self.slideshowWin.setMirroredCursorPos(*event.pos())
        
        # Alt key was released --> restore cursor
        modifiers = QGuiApplication.keyboardModifiers()
        cursorsInfo = self.gui_setCursor(modifiers, event)
        self.highlightHoverLostObj(modifiers, event)
        
        drawRulerLine = (
            (self.rulerButton.isChecked() 
            or self.addDelPolyLineRoiButton.isChecked())
            and self.tempSegmentON and not event.isExit()
        )
        if drawRulerLine:
            self.drawTempRulerLine(event)

        if not event.isExit():
            x, y = event.pos()
            xdata, ydata = int(x), int(y)
            _img = self.img1.image
            Y, X = _img.shape[:2]
            if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y:
                ID = self.currentLab2D[ydata, xdata]
                self.updatePropsWidget(ID, fromHover=True)
                activeToolButton = self.getActiveToolButton()
                hoverText = self.hoverValuesFormatted(
                    xdata, ydata, activeToolButton, isHoverImg1
                )
                self.checkHighlightScaleBar(x, y, activeToolButton)
                self.checkHighlightTimestamp(x, y, activeToolButton)
                self.wcLabel.setText(hoverText)
        else:
            self.clickedOnBud = False
            self.BudMothTempLine.setData([], [])
            self.wcLabel.setText('')
        
        if cursorsInfo['setKeepObjCursor']:
            x, y = event.pos()
            self.highlightHoverIDsKeptObj(x, y)
        
        if cursorsInfo['setManualTrackingCursor']:
            x, y = event.pos()
            # self.highlightHoverID(x, y)
            self.drawManualTrackingGhost(x, y)
        
        if cursorsInfo['setManualBackgroundCursor']:
            x, y = event.pos()
            # self.highlightHoverID(x, y)
            self.drawManualBackgroundObj(x, y)
        
        if (
                not cursorsInfo['setManualTrackingCursor'] 
                and not cursorsInfo['setManualBackgroundCursor']
            ):
            self.clearGhost()

        setMoveLabelCursor = cursorsInfo['setMoveLabelCursor']
        setExpandLabelCursor = cursorsInfo['setExpandLabelCursor']
        if setMoveLabelCursor or setExpandLabelCursor:
            x, y = event.pos()
            self.updateHoverLabelCursor(x, y)

        # Draw eraser circle
        if cursorsInfo['setEraserCursor']:
            x, y = event.pos()
            self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1)
            self.hideItemsHoverBrush(xy=(x, y))
        elif self.eraserButton.isChecked() and not event.isExit():
            if self.xyOnCtrlPressedFirstTime is not None:
                self.updateEraserCursor(
                    x, y, xyLocked=self.xyOnCtrlPressedFirstTime, 
                    isHoverImg1=isHoverImg1
                )
                self.hideItemsHoverBrush(xy=(x, y))
        else:
            eraserCursors = (
                self.ax1_EraserCircle, self.ax2_EraserCircle,
                self.ax1_EraserX, self.ax2_EraserX
            )
            self.setHoverToolSymbolData([], [], eraserCursors)

        # Draw Brush circle
        if cursorsInfo['setBrushCursor']:
            x, y = event.pos()
            self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1)
            self.hideItemsHoverBrush(xy=(x, y))
        elif cursorsInfo['setAddPointCursor']:
            x, y = event.pos()
            self.setHoverCircleAddPoint(x, y)
        else:
            self.setHoverToolSymbolData(
                [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle),
            )
        
        # Draw label ROi circular cursor
        setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor']
        if setLabelRoiCircCursor:
            x, y = event.pos()
        else:
            x, y = None, None
        self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor)

        drawMothBudLine = (
            self.assignBudMothButton.isChecked() and self.clickedOnBud
            and not event.isExit()
        )
        if drawMothBudLine:
            self.drawTempMothBudLine(event, posData)

        drawMergeObjsLine = (
            self.mergeIDsButton.isChecked() and not event.isExit()
        )
        if drawMergeObjsLine:
            self.drawTempMergeObjsLine(event, posData, modifiers)

        # Temporarily draw spline curve
        # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy
        drawSpline = (
            self.curvToolButton.isChecked() and self.splineHoverON
            and not event.isExit()
        )
        if drawSpline:
            self.hoverEventDrawSpline(event)
        
        setMirroredCursor = (
            self.app.overrideCursor() is None and not event.isExit()
            and isHoverImg1 and self.showMirroredCursorAction.isChecked()
        )
        if setMirroredCursor:
            x, y = event.pos()
            self.ax2_cursor.setData([x], [y])
        else:
            self.ax2_cursor.setData([], [])
            
        return cursorsInfo
    
    def drawTempMothBudLine(self, event, posData):
        x, y = event.pos()
        y2, x2 = y, x
        xdata, ydata = int(x), int(y)
        y1, x1 = self.yClickBud, self.xClickBud
        ID = self.get_2Dlab(posData.lab)[ydata, xdata]
        if ID == 0:
            self.BudMothTempLine.setData([x1, x2], [y1, y2])
        else:
            obj_idx = posData.IDs_idxs[ID]
            obj = posData.rp[obj_idx]
            y2, x2 = self.getObjCentroid(obj.centroid)
            self.BudMothTempLine.setData([x1, x2], [y1, y2])
    
    def drawTempMergeObjsLine(self, event, posData, modifiers):
        if self.clickObjYc is None:
            return
        modifier = modifiers == Qt.ShiftModifier
        x, y = event.pos()
        y2, x2 = y, x
        xdata, ydata = int(x), int(y)
        y1, x1 = self.clickObjYc, self.clickObjXc
        ID = self.get_2Dlab(posData.lab)[ydata, xdata]
        if ID != 0:
            obj_idx = posData.IDs_idxs[ID]
            obj = posData.rp[obj_idx]
            y2, x2 = self.getObjCentroid(obj.centroid)
        
        if modifier and ID > 0:
            self.mergeObjsTempLine.addPoint(x2, y2)
        elif not modifier:
            self.mergeObjsTempLine.setData([x1, x2], [y1, y2])
        
    def gui_add_ax_cursors(self):
        try:
            self.ax1.removeItem(self.ax1_cursor)
            self.ax2.removeItem(self.ax2_cursor)
        except Exception as e:
            pass

        self.ax2_cursor = pg.ScatterPlotItem(
            symbol='+', pxMode=True, pen=pg.mkPen('k', width=1),
            brush=pg.mkBrush('w'), size=16, tip=None
        )
        self.ax2.addItem(self.ax2_cursor)

        self.ax1_cursor = pg.ScatterPlotItem(
            symbol='+', pxMode=True, pen=pg.mkPen('k', width=1),
            brush=pg.mkBrush('w'), size=16, tip=None
        )
        self.ax1.addItem(self.ax1_cursor)

    def gui_setCursor(self, modifiers, event):
        noModifier = modifiers == Qt.NoModifier
        shift = modifiers == Qt.ShiftModifier
        ctrl = modifiers == Qt.ControlModifier
        alt = modifiers == Qt.AltModifier
        
        # Alt key was released --> restore cursor
        if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier:
            self.app.restoreOverrideCursor()

        setBrushCursor = (
            self.brushButton.isChecked() and not event.isExit()
            and (noModifier or shift or ctrl)
        )
        setEraserCursor = (
            self.eraserButton.isChecked() and not event.isExit()
            and noModifier
        )
        setAddDelPolyLineCursor = (
            self.addDelPolyLineRoiButton.isChecked() and not event.isExit()
            and noModifier
        )
        setLabelRoiCircCursor = (
            self.labelRoiButton.isChecked() and not event.isExit()
            and (noModifier or shift or ctrl)
            and self.labelRoiIsCircularRadioButton.isChecked()
        )
        setWandCursor = (
            self.wandToolButton.isChecked() and not event.isExit()
            and noModifier
        )
        setLabelRoiCursor = (
            self.labelRoiButton.isChecked() and not event.isExit()
            and noModifier
        )
        setMoveLabelCursor = (
            self.moveLabelToolButton.isChecked() and not event.isExit()
            and noModifier
        )
        setExpandLabelCursor = (
            self.expandLabelToolButton.isChecked() and not event.isExit()
            and noModifier
        )
        setCurvCursor = (
            self.curvToolButton.isChecked() and not event.isExit()
            and noModifier
        )
        setKeepObjCursor = (
            self.keepIDsButton.isChecked() and not event.isExit()
            and noModifier
        )
        setCustomAnnotCursor = (
            self.customAnnotButton is not None and not event.isExit()
            and noModifier
        )
        setManualTrackingCursor = (
            self.manualTrackingButton.isChecked() 
            and not event.isExit()
            and noModifier
        )
        setManualBackgroundCursor = (
            self.manualBackgroundButton.isChecked()
            and not event.isExit()
            and noModifier
        )
        magicPromptsON = self.magicPromptsToolButton.isChecked()
        pointsLayerON = self.togglePointsLayerAction.isChecked()
        addPointsByClickingButton = self.buttonAddPointsByClickingActive()
        setAddPointCursor = (
            (pointsLayerON or magicPromptsON)
            and addPointsByClickingButton is not None
            and not event.isExit()
            and noModifier
        )
        overrideCursor = self.app.overrideCursor()
        setPanImageCursor = alt and not event.isExit()
        if setPanImageCursor and overrideCursor is None:
            self.app.setOverrideCursor(Qt.SizeAllCursor)
        elif setBrushCursor or setEraserCursor or setLabelRoiCircCursor:
            self.app.setOverrideCursor(Qt.CrossCursor)
        elif setWandCursor and overrideCursor is None:
            self.app.setOverrideCursor(self.wandCursor)
        elif setLabelRoiCursor and overrideCursor is None:
            self.app.setOverrideCursor(Qt.CrossCursor)
        elif setCurvCursor and overrideCursor is None:
            self.app.setOverrideCursor(self.curvCursor)
        elif setCustomAnnotCursor and overrideCursor is None:
            self.app.setOverrideCursor(Qt.PointingHandCursor)
        elif setAddDelPolyLineCursor:
            self.app.setOverrideCursor(self.polyLineRoiCursor)
        elif setCustomAnnotCursor:
            x, y = event.pos()
            self.highlightHoverID(x, y)        
        elif setKeepObjCursor and overrideCursor is None:
            self.app.setOverrideCursor(Qt.PointingHandCursor)        
        elif setManualTrackingCursor and overrideCursor is None:
            self.app.setOverrideCursor(Qt.PointingHandCursor)
        elif setManualBackgroundCursor and overrideCursor is None:
            self.app.setOverrideCursor(Qt.PointingHandCursor)
        elif setAddPointCursor:
            self.app.setOverrideCursor(self.addPointsCursor)
        
        return {
            'setBrushCursor': setBrushCursor,
            'setEraserCursor': setEraserCursor,
            'setAddDelPolyLineCursor': setAddDelPolyLineCursor,
            'setLabelRoiCircCursor': setLabelRoiCircCursor,
            'setWandCursor': setWandCursor,
            'setLabelRoiCursor': setLabelRoiCursor,
            'setMoveLabelCursor': setMoveLabelCursor,
            'setExpandLabelCursor': setExpandLabelCursor,
            'setCurvCursor': setCurvCursor,
            'setKeepObjCursor': setKeepObjCursor,
            'setCustomAnnotCursor': setCustomAnnotCursor,
            'setManualTrackingCursor': setManualTrackingCursor,
            'setManualBackgroundCursor': setManualBackgroundCursor,
            'setAddPointCursor': setAddPointCursor,
        }
    
    def warnAddingPointWithExistingId(self, point_id, table_endname=''):
        posData = self.data[self.pos_i]
        if not point_id in posData.IDs_idxs:
            return True
        
        msg = widgets.myMessageBox(wrapText=False)
        txt = (f"""
            Cell ID {point_id} <b>already exists</b>!<br><br>
            Are you sure you want to add this point?
        """)
        if table_endname:
            txt = (f"""
                The loaded table <code>{table_endname}</code> has point id 
                {point_id}.
                <br><br>However, {txt}
            """)
        txt = html_utils.paragraph(txt)
        _, _, yesButton = msg.warning(
            self, f'Cell ID {point_id} already exist', txt,
            buttonsTexts=(
                'Cancel', 'No, do not add', f'Yes, add point id {point_id}'
            )
        )
        return msg.clickedButton == yesButton
    
    def gui_hoverEventImg2(self, event):
        try:
            posData = self.data[self.pos_i]
        except AttributeError:
            return
            
        if not event.isExit():
            self.xHoverImg, self.yHoverImg = event.pos()
        else:
            self.xHoverImg, self.yHoverImg = None, None

        # Cursor left image --> restore cursor
        if event.isExit() and self.app.overrideCursor() is not None:
            while self.app.overrideCursor() is not None:
                self.app.restoreOverrideCursor()

        # Alt key was released --> restore cursor
        modifiers = QGuiApplication.keyboardModifiers()
        noModifier = modifiers == Qt.NoModifier
        shift = modifiers == Qt.ShiftModifier
        ctrl = modifiers == Qt.ControlModifier
        if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier:
            self.app.restoreOverrideCursor()

        setBrushCursor = (
            self.brushButton.isChecked() and not event.isExit()
            and (noModifier or shift or ctrl)
        )
        setEraserCursor = (
            self.eraserButton.isChecked() and not event.isExit()
            and noModifier
        )
        setLabelRoiCircCursor = (
            self.labelRoiButton.isChecked() and not event.isExit()
            and (noModifier or shift or ctrl)
            and self.labelRoiIsCircularRadioButton.isChecked()
        )
        if setBrushCursor or setEraserCursor or setLabelRoiCircCursor:
            self.app.setOverrideCursor(Qt.CrossCursor)

        setMoveLabelCursor = (
            self.moveLabelToolButton.isChecked() and not event.isExit()
            and noModifier
        )

        setExpandLabelCursor = (
            self.expandLabelToolButton.isChecked() and not event.isExit()
            and noModifier
        )

        # Cursor is moving on image while Alt key is pressed --> pan cursor
        alt = QGuiApplication.keyboardModifiers() == Qt.AltModifier
        setPanImageCursor = alt and not event.isExit()
        if setPanImageCursor and self.app.overrideCursor() is None:
            self.app.setOverrideCursor(Qt.SizeAllCursor)
        
        setKeepObjCursor = (
            self.keepIDsButton.isChecked() and not event.isExit()
            and noModifier
        )
        if setKeepObjCursor and self.app.overrideCursor() is None:
            self.app.setOverrideCursor(Qt.PointingHandCursor)

        # Update x, y, value label bottom right
        if not event.isExit():
            x, y = event.pos()
            xdata, ydata = int(x), int(y)
            _img = self.currentLab2D
            Y, X = _img.shape                
            # hoverText = self.hoverValuesFormatted(xdata, ydata)
            # self.wcLabel.setText(hoverText)
        else:
            if self.eraserButton.isChecked() or self.brushButton.isChecked():
                self.gui_mouseReleaseEventImg2(event)
            self.wcLabel.setText(f'')

        if setMoveLabelCursor or setExpandLabelCursor:
            x, y = event.pos()
            self.updateHoverLabelCursor(x, y)
        
        if setKeepObjCursor:
            x, y = event.pos()
            self.highlightHoverIDsKeptObj(x, y)

        # Draw eraser circle
        if setEraserCursor:
            x, y = event.pos()
            self.updateEraserCursor(x, y, isHoverImg1=False)
        else:
            self.setHoverToolSymbolData(
                [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle,
                         self.ax1_EraserX, self.ax2_EraserX)
            )

        # Draw Brush circle
        if setBrushCursor:
            x, y = event.pos()
            self.updateBrushCursor(x, y, isHoverImg1=False)
        else:
            self.setHoverToolSymbolData(
                [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle),
            )
        
        # Draw label ROi circular cursor
        if setLabelRoiCircCursor:
            x, y = event.pos()
        else:
            x, y = None, None
        self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor)
    
    def gui_imgGradShowContextMenu(self, x, y):
        if hasattr(self, 'scaleBar'):
            if self.scaleBar.isHighlighted():
                self.scaleBar.showContextMenu(x, y)
                return
        
        if hasattr(self, 'timestamp'):
            if self.timestamp.isHighlighted():
                self.timestamp.showContextMenu(x, y)
                return
            
        self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y)))
    
    def gui_rightImageShowContextMenu(self, event):
        try:
            # Convert QPointF to QPoint
            self.imgGradRight.gradient.menu.popup(event.screenPos().toPoint())
        except AttributeError:
            self.imgGradRight.gradient.menu.popup(event.screenPos())

    @exception_handler
    def gui_mouseDragEventImg2(self, event):
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer':
            return

        Y, X = self.get_2Dlab(posData.lab).shape
        x, y = event.pos().x(), event.pos().y()
        xdata, ydata = int(x), int(y)
        if not myutils.is_in_bounds(xdata, ydata, X, Y):
            return

        # Eraser dragging mouse --> keep erasing
        if self.isMouseDragImg2 and self.eraserButton.isChecked():
            posData = self.data[self.pos_i]
            lab_2D = self.get_2Dlab(posData.lab)
            Y, X = lab_2D.shape
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            brushSize = self.brushSizeSpinbox.value()
            rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X)

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)

            # Build eraser mask
            mask = np.zeros(lab_2D.shape, bool)
            mask[ymin:ymax, xmin:xmax][diskMask] = True
            mask[rrPoly, ccPoly] = True

            if self.eraseOnlyOneID:
                mask[lab_2D!=self.erasedID] = False
                self.setHoverToolSymbolColor(
                    xdata, ydata, self.eraserCirclePen,
                    (self.ax2_EraserCircle, self.ax1_EraserCircle),
                    self.eraserButton, hoverRGB=self.img2.lut[self.erasedID],
                    ID=self.erasedID
                )

            self.erasedIDs.update(lab_2D[mask])

            self.applyEraserMask(mask)
            self.setImageImg2(updateLookuptable=False)

        # Brush paint dragging mouse --> keep painting
        if self.isMouseDragImg2 and self.brushButton.isChecked():
            posData = self.data[self.pos_i]
            lab_2D = self.get_2Dlab(posData.lab)
            Y, X = lab_2D.shape
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)
            rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X)

            # Build brush mask
            mask = np.zeros(lab_2D.shape, bool)
            mask[ymin:ymax, xmin:xmax][diskMask] = True
            mask[rrPoly, ccPoly] = True

            # If user double-pressed 'b' then draw over the labels
            color = self.brushButton.palette().button().color().name()
            if color != self.doublePressKeyButtonColor:
                mask[lab_2D!=0] = False
                self.setHoverToolSymbolColor(
                    xdata, ydata, self.ax2_BrushCirclePen,
                    (self.ax2_BrushCircle, self.ax1_BrushCircle),
                    self.eraserButton, brush=self.ax2_BrushCircleBrush
                )

            # Apply brush mask
            self.applyBrushMask(mask, self.ax2BrushID)

            self.setImageImg2()

        # Move label dragging mouse --> keep moving
        elif self.isMovingLabel and self.moveLabelToolButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            self.moveLabel(x, y)

    @exception_handler
    def gui_mouseReleaseEventImg2(self, event):
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer':
            return

        Y, X = self.get_2Dlab(posData.lab).shape
        try:
            x, y = event.pos().x(), event.pos().y()
        except Exception as e:
            return
            
        xdata, ydata = int(x), int(y)
        if not myutils.is_in_bounds(xdata, ydata, X, Y):
            self.isMouseDragImg2 = False
            self.updateAllImages()
            return

        # Eraser mouse release --> update IDs and contours
        if self.isMouseDragImg2 and self.eraserButton.isChecked():
            self.isMouseDragImg2 = False
            
            # Update data (rp, etc)
            self.update_rp(update_IDs=len(self.erasedIDs) > 0)

            for ID in self.erasedIDs:
                if ID not in posData.lab:
                    if self.isSnapshot:
                        self.fixCcaDfAfterEdit('Delete ID with eraser')
                        self.updateAllImages()
                    else:
                        self.warnEditingWithCca_df('Delete ID with eraser')
                    break

        # Brush mouse release --> update IDs and contours
        elif self.isMouseDragImg2 and self.brushButton.isChecked():
            # t0 = time.perf_counter()
            
            self.isMouseDragImg2 = False
            
            self.fillHolesID(self.ax2BrushID, sender='brush')
            
            self.update_rp(update_IDs=self.isNewID, )

            # t1 = time.perf_counter()
            self.trackManuallyAddedObject(posData.brushID, self.isNewID)

            # t2 = time.perf_counter()
            # Update images
            if self.isNewID:
                editTxt = 'Add new ID with brush tool'
                if self.isSnapshot:
                    self.fixCcaDfAfterEdit(editTxt)
                    self.updateAllImages()
                else:
                    self.warnEditingWithCca_df(editTxt)
            else:
                self.updateAllImages()

        # Move label mouse released, update move
        elif self.isMovingLabel and self.moveLabelToolButton.isChecked():
            self.isMovingLabel = False

            # Update data (rp, etc)
            self.update_rp()

            # Repeat tracking
            self.tracking(enforce=True, assign_unique_new_IDs=False)

            self.updateAllImages()

            if not self.moveLabelToolButton.findChild(QAction).isChecked():
                self.moveLabelToolButton.setChecked(False)

        # Merge IDs
        elif self.mergeIDsButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            lab2D = self.get_2Dlab(posData.lab)
            ID = lab2D[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    lab2D, y, x
                )
                mergeID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID that you want to merge with ID '
                         f'{self.firstID}',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                mergeID_prompt.exec_()
                if mergeID_prompt.cancel:
                    return
                else:
                    ID = mergeID_prompt.EntryID
                    obj_idx = posData.IDs_idxs[ID]
                    obj = posData.rp[obj_idx]
                    y2, x2 = self.getObjCentroid(obj.centroid)
                    self.mergeObjsTempLine.addPoint(x2, y2)
            
            xx, yy = self.mergeObjsTempLine.getData()
            IDs_to_merge = lab2D[yy.astype(int), xx.astype(int)]
            for ID in IDs_to_merge:
                if ID == 0:
                    continue
                posData.lab[posData.lab==ID] = self.firstID
            
            self.mergeObjsTempLine.setData([], [])
            self.clickObjYc, self.clickObjXc = None, None

            # Update data (rp, etc)
            self.update_rp()

            ask_back_prop = True

            if posData.frame_i == 0:
                ask_back_prop = False
                prev_IDs = []
            else:
                prev_IDs = posData.allData_li[posData.frame_i-1]['IDs']

            if  all(ID not in prev_IDs for ID in IDs_to_merge):
                ask_back_prop = False
            
            if not self.isFrameCcaAnnotated() and ask_back_prop:
                proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}')
                if proceed:
                    self.propagateMergeObjsPast(IDs_to_merge)
                    self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done

            # Repeat tracking
            self.tracking(
                enforce=True, assign_unique_new_IDs=False,
                separateByLabel=False
            )

            if self.isSnapshot:
                self.fixCcaDfAfterEdit('Merge IDs')
                self.updateAllImages()
            else:
                self.warnEditingWithCca_df('Merge IDs')
            
            if not self.mergeIDsButton.findChild(QAction).isChecked():
                self.mergeIDsButton.setChecked(False)
            self.store_data()

    @exception_handler
    def gui_mouseReleaseEventImg1(self, event):
        modifiers = QGuiApplication.keyboardModifiers()
        ctrl = modifiers == Qt.ControlModifier
        alt = modifiers == Qt.AltModifier
        right_click = event.button() == Qt.MouseButton.RightButton and not alt
        
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer':
            return

        Y, X = self.get_2Dlab(posData.lab).shape
        x, y = event.pos().x(), event.pos().y()
        xdata, ydata = int(x), int(y)
        if not myutils.is_in_bounds(xdata, ydata, X, Y):
            self.isMouseDragImg2 = False
            self.updateAllImages()
            return

        if hasattr(self, 'scaleBar'):
            if self.scaleBar.isHighlighted() and self.scaleBar.clicked:
                self.scaleBar.clicked = False
                return
        
        if hasattr(self, 'timestamp'):
            if self.timestamp.isHighlighted() and self.timestamp.clicked:
                self.timestamp.clicked = False
                return
        
        sendRightClickImg2 = (
            (mode=='Segmentation and Tracking' or self.isSnapshot)
            and right_click
        )
        if sendRightClickImg2:
            # Allow right-click actions on both images
            self.gui_mouseReleaseEventImg2(event)

        # Right-click curvature tool mouse release
        if self.isRightClickDragImg1 and self.curvToolButton.isChecked():
            self.isRightClickDragImg1 = False
            try:
                self.splineToObj(isRightClick=True)
                self.update_rp()
                self.trackManuallyAddedObject(posData.brushID, True)
                if self.isSnapshot:
                    self.fixCcaDfAfterEdit('Add new ID with curvature tool')
                    self.updateAllImages()
                else:
                    self.warnEditingWithCca_df('Add new ID with curvature tool')
                self.clearCurvItems()
                self.curvTool_cb(True)
            except ValueError:
                self.clearCurvItems()
                self.curvTool_cb(True)
                pass

        # Eraser mouse release --> update IDs and contours
        elif self.isMouseDragImg1 and self.eraserButton.isChecked():
            self.isMouseDragImg1 = False

            self.tempLayerImg1.setImage(self.emptyLab)

            # Update data (rp, etc)
            self.update_rp()

            for ID in self.erasedIDs:
                if ID == 0:
                    continue
                if ID not in posData.IDs:
                    if self.isSnapshot:
                        self.fixCcaDfAfterEdit('Delete ID with eraser')
                        self.updateAllImages()
                    else:
                        self.warnEditingWithCca_df('Delete ID with eraser')
                    break
            else:
                self.updateAllImages()

        # Brush button mouse release
        elif self.isMouseDragImg1 and self.brushButton.isChecked():
            self.isMouseDragImg1 = False

            self.tempLayerImg1.setImage(self.emptyLab)
            
            posData = self.data[self.pos_i]
            self.fillHolesID(posData.brushID, sender='brush')
            
            # Update data (rp, etc)
            self.update_rp(update_IDs=self.isNewID,
                           )
            
            # Repeat tracking
            if self.autoIDcheckbox.isChecked():
                self.trackManuallyAddedObject(posData.brushID, self.isNewID)

            # Update images
            if self.isNewID:
                editTxt = 'Add new ID with brush tool'
                if self.isSnapshot:
                    self.fixCcaDfAfterEdit(editTxt)
                    self.updateAllImages()
                else:
                    self.warnEditingWithCca_df(editTxt)
            else:
                self.updateAllImages()
            
            self.isNewID = False

        # Wand tool release, add new object
        elif self.isMouseDragImg1 and self.wandToolButton.isChecked():
            self.isMouseDragImg1 = False

            self.tempLayerImg1.setImage(self.emptyLab)

            posData = self.data[self.pos_i]
            posData.lab[self.flood_mask] = posData.brushID

            # Update data (rp, etc)
            self.update_rp()

            # Repeat tracking
            self.trackManuallyAddedObject(posData.brushID, self.isNewID)

            if self.isSnapshot:
                self.fixCcaDfAfterEdit('Add new ID with magic-wand')
                self.updateAllImages()
            else:
                self.warnEditingWithCca_df('Add new ID with magic-wand')
        
        # Label ROI mouse release --> label the ROI with labelRoiWorker
        elif self.isMouseDragImg1 and self.labelRoiButton.isChecked():
            self.labelRoiRunning = True
            self.app.setOverrideCursor(Qt.WaitCursor)
            self.isMouseDragImg1 = False

            if self.labelRoiIsFreeHandRadioButton.isChecked():
                self.freeRoiItem.closeCurve()
            
            proceed = self.labelRoiCheckStartStopFrame()
            if not proceed:
                self.labelRoiCancelled()
                return

            roiImg, self.labelRoiSlice = self.getLabelRoiImage()

            if roiImg.size == 0:
                self.labelRoiCancelled()
                return

            if self.labelRoiModel is None:
                cancel = self.initLabelRoiModel()
                if cancel:
                    self.labelRoiCancelled()
                    return
            
            roiSecondChannel = None
            if self.secondChannelName is not None:
                secondChannelData = self.getSecondChannelData()
                roiSecondChannel = secondChannelData[self.labelRoiSlice]
            
            isTimelapse = self.labelRoiTrangeCheckbox.isChecked()
            if isTimelapse:
                start_n = self.labelRoiStartFrameNoSpinbox.value()
                stop_n = self.labelRoiStopFrameNoSpinbox.value()
                self.progressWin = apps.QDialogWorkerProgress(
                    title='ROI segmentation', parent=self,
                    pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...'
                )
                self.progressWin.show(self.app)
                self.progressWin.mainPbar.setMaximum(stop_n-start_n)                

            self.app.restoreOverrideCursor() 
            labelRoiWorker = self.labelRoiActiveWorkers[-1]
            labelRoiWorker.start(
                roiImg, posData, roiSecondChannel=roiSecondChannel, 
                isTimelapse=isTimelapse
            )            
            self.app.setOverrideCursor(Qt.WaitCursor)
            self.logger.info(
                f'Magic labeller started on image ROI = {self.labelRoiSlice}...'
            )

        # Move label mouse released, update move
        elif self.isMovingLabel and self.moveLabelToolButton.isChecked():
            self.isMovingLabel = False

            # Update data (rp, etc)
            self.update_rp()

            # Repeat tracking
            self.tracking(enforce=True, assign_unique_new_IDs=False)

            if not self.moveLabelToolButton.findChild(QAction).isChecked():
                self.moveLabelToolButton.setChecked(False)
            else:
                self.updateAllImages()

        # Assign mother to bud
        elif self.assignBudMothButton.isChecked() and self.clickedOnBud:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud]:
                return

            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                mothID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID that you want to annotate as mother cell',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                mothID_prompt.exec_()
                if mothID_prompt.cancel:
                    return
                else:
                    ID = mothID_prompt.EntryID
                    obj_idx = posData.IDs.index(ID)
                    y, x = posData.rp[obj_idx].centroid
                    xdata, ydata = int(x), int(y)

            if self.isSnapshot:
                # Store undo state before modifying stuff
                self.storeUndoRedoStates(False)

            relationship = posData.cca_df.at[ID, 'relationship']
            ccs = posData.cca_df.at[ID, 'cell_cycle_stage']
            is_history_known = posData.cca_df.at[ID, 'is_history_known']
            # We allow assiging a cell in G1 as mother only on first frame
            # OR if the history is unknown
            if relationship == 'bud' and posData.frame_i > 0 and is_history_known:
                self.assignBudMothButton.setChecked(False)
                txt = html_utils.paragraph(
                    f'You clicked on <b>ID {ID}</b> which is a <b>BUD</b>.<br><br>'
                    'To assign a bud <b>start by clicking on the bud</b> '
                    'and release on a cell in G1'
                )
                msg = widgets.myMessageBox()
                msg.critical(
                    self, 'Released on a bud', txt
                )
                self.assignBudMothButton.setChecked(True)
                return

            elif posData.frame_i == 0:
                # Check that clicked bud actually is smaller that mother
                # otherwise warn the user that he might have clicked first
                # on a mother
                budID = self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud]
                new_mothID = self.get_2Dlab(posData.lab)[ydata, xdata]
                bud_obj_idx = posData.IDs.index(budID)
                new_moth_obj_idx = posData.IDs.index(new_mothID)
                rp_budID = posData.rp[bud_obj_idx]
                rp_new_mothID = posData.rp[new_moth_obj_idx]
                if rp_budID.area >= rp_new_mothID.area:
                    self.assignBudMothButton.setChecked(False)
                    msg = widgets.myMessageBox()
                    txt = (
                        f'You clicked FIRST on ID {budID} and then on {new_mothID}.\n'
                        f'For me this means that you want ID {budID} to be the '
                        f'BUD of ID {new_mothID}.\n'
                        f'However <b>ID {budID} is bigger than {new_mothID}</b> '
                        f'so maybe you shoul have clicked FIRST on {new_mothID}?\n\n'
                        'What do you want me to do?'
                    )
                    txt = html_utils.paragraph(txt)
                    swapButton, keepButton = msg.warning(
                        self, 'Which one is bud?', txt,
                        buttonsTexts=(
                            f'Assign ID {new_mothID} as the bud of ID {budID}',
                            f'Keep ID {budID} as the bud of  ID {new_mothID}'
                        )
                    )
                    if msg.clickedButton == swapButton:
                        (xdata, ydata,
                        self.xClickBud, self.yClickBud) = (
                            self.xClickBud, self.yClickBud,
                            xdata, ydata
                        )
                    self.assignBudMothButton.setChecked(True)

            elif is_history_known and not self.clickedOnHistoryKnown:
                self.assignBudMothButton.setChecked(False)
                budID = self.get_2Dlab(posData.lab)[ydata, xdata]
                # Allow assigning an unknown cell ONLY to another unknown cell
                txt = (
                    f'You started by clicking on ID {budID} which has '
                    'UNKNOWN history, but you then clicked/released on '
                    f'ID {ID} which has KNOWN history.\n\n'
                    'Only two cells with UNKNOWN history can be assigned as '
                    'relative of each other.'
                )
                msg = QMessageBox()
                msg.critical(
                    self, 'Released on a cell with KNOWN history', txt, msg.Ok
                )
                self.assignBudMothButton.setChecked(True)
                return

            self.clickedOnHistoryKnown = is_history_known
            self.xClickMoth, self.yClickMoth = xdata, ydata
            
            if ccs != 'G1' and posData.frame_i > 0:
                self.assignBudMothButton.setChecked(False)
                self.onMotherNotInG1(ID)
                self.assignBudMothButton.setChecked(True)
            else:
                self.annotateBudToDifferentMother()

            if not self.assignBudMothButton.findChild(QAction).isChecked():
                self.assignBudMothButton.setChecked(False)

            self.clickedOnBud = False
            self.BudMothTempLine.setData([], [])
        
        elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked():
            self.freeRoiItem.closeCurve()
            self.clearObjsFreehandRegion()

    def gui_clickedDelRoi(self, event, left_click, right_click):
        posData = self.data[self.pos_i]
        x, y = event.pos().x(), event.pos().y()

        # Check if right click on ROI
        delROIs = (
            posData.allData_li[posData.frame_i]['delROIs_info']['rois'].copy()
        )
        for r, roi in enumerate(delROIs):
            ROImask = self.getDelRoiMask(roi)
            if self.isSegm3D:
                clickedOnROI = ROImask[self.z_lab(), int(y), int(x)]
            else:
                clickedOnROI = ROImask[int(y), int(x)]
            raiseContextMenuRoi = right_click and clickedOnROI
            dragRoi = left_click and clickedOnROI
            if raiseContextMenuRoi:
                self.roi_to_del = roi
                self.roiContextMenu = QMenu(self)
                separator = QAction(self)
                separator.setSeparator(True)
                self.roiContextMenu.addAction(separator)
                action = QAction('Remove ROI')
                action.triggered.connect(self.removeDelROI)
                self.roiContextMenu.addAction(action)
                try:
                    # Convert QPointF to QPoint
                    self.roiContextMenu.exec_(event.screenPos().toPoint())
                except AttributeError:
                    self.roiContextMenu.exec_(event.screenPos())
                return True
            elif dragRoi:
                event.ignore()
                return True
        return False

    def gui_getHoveredSegmentsPolyLineRoi(self):
        posData = self.data[self.pos_i]
        delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
        segments = []
        for roi in delROIs_info['rois']:
            if not isinstance(roi, pg.PolyLineROI):
                continue       
            for seg in roi.segments:       
                if seg.currentPen == seg.hoverPen:
                    seg.roi = roi
                    segments.append(seg)
        return segments
    
    def gui_getHoveredHandlesPolyLineRoi(self):
        posData = self.data[self.pos_i]
        delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
        handles = []
        for roi in delROIs_info['rois']:
            if not isinstance(roi, pg.PolyLineROI):
                continue           
            for handle in roi.getHandles():       
                if handle.currentPen == handle.hoverPen:
                    handle.roi = roi
                    handles.append(handle)
        return handles
    
    @exception_handler
    def gui_mousePressRightImage(self, event):
        modifiers = QGuiApplication.keyboardModifiers()
        ctrl = modifiers == Qt.ControlModifier
        alt = modifiers == Qt.AltModifier
        isMod = alt
        right_click = event.button() == Qt.MouseButton.RightButton and not isMod
        is_right_click_action_ON = any([
            b.isChecked() for b in self.checkableQButtonsGroup.buttons()
        ])
        self.typingEditID = False
        showLabelsGradMenu = right_click and not is_right_click_action_ON
        if showLabelsGradMenu:
            self.gui_rightImageShowContextMenu(event)
            event.ignore()
        else: 
            self.gui_mousePressEventImg1(event)

    @exception_handler
    def gui_mouseDragRightImage(self, event):
        self.gui_mouseDragEventImg1(event)

    @exception_handler
    def gui_mouseReleaseRightImage(self, event):
        self.gui_mouseReleaseEventImg1(event)
    
    def drawTempRulerLine(self, event):
        x, y = event.pos()
        x1, y1 = int(x), int(y)
        xxRA, yyRA = self.ax1_rulerAnchorsItem.getData()
        x0, y0 = xxRA[0], yyRA[0]
        if self.isCtrlDown:
            x1, y1 = transformation.snap_xy_to_closest_angle(
                x0, y0, x1, y1
            )
        self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1])
    
    @exception_handler
    def gui_mousePressEventImg1(self, event: QMouseEvent):
        self.typingEditID = False
        modifiers = QGuiApplication.keyboardModifiers()
        ctrl = modifiers == Qt.ControlModifier
        alt = modifiers == Qt.AltModifier
        isMod = alt
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        isCcaMode = mode == 'Cell cycle analysis'
        isCustomAnnotMode = mode == 'Custom annotations'
        left_click = event.button() == Qt.MouseButton.LeftButton and not isMod
        middle_click = self.isMiddleClick(event, modifiers)
        right_click = event.button() == Qt.MouseButton.RightButton
        isPanImageClick = self.isPanImageClick(event, modifiers)
        brushON = self.brushButton.isChecked()
        curvToolON = self.curvToolButton.isChecked()
        histON = self.setIsHistoryKnownButton.isChecked()
        eraserON = self.eraserButton.isChecked()
        rulerON = self.rulerButton.isChecked()
        wandON = self.wandToolButton.isChecked() and not isPanImageClick
        polyLineRoiON = self.addDelPolyLineRoiButton.isChecked()
        labelRoiON = self.labelRoiButton.isChecked()
        keepObjON = self.keepIDsButton.isChecked()
        whitelistIDsON = self.whitelistIDsButton.isChecked()
        separateON = self.separateBudButton.isChecked()
        addPointsByClickingButton = self.buttonAddPointsByClickingActive()
        manualBackgroundON = self.manualBackgroundButton.isChecked()
        magicPromptsON = self.magicPromptsToolButton.isChecked()
        pointsLayerON = self.togglePointsLayerAction.isChecked()
        copyContourON = (
            self.copyLostObjButton.isChecked()
            and self.ax1_lostObjScatterItem.hoverLostID>0
        )
        findNextMotherButtonON = self.findNextMotherButton.isChecked()
        unknownLineageButtonON = self.unknownLineageButton.isChecked()
        drawClearRegionON = self.drawClearRegionButton.isChecked()

        # Check if right-click on segment of polyline roi to add segment
        segments = self.gui_getHoveredSegmentsPolyLineRoi()
        if len(segments) == 1 and right_click:
            seg = segments[0]
            seg.roi.segmentClicked(seg, event)
            return
        
        # Check if right-click on handle of polyline roi to remove it
        handles = self.gui_getHoveredHandlesPolyLineRoi()
        if len(handles) == 1 and right_click:
            handle = handles[0]
            handle.roi.removeHandle(handle)
            return

        # Check if click on ROI
        isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click)
        if isClickOnDelRoi:
            return
        
        dragImgLeft = (
            left_click and not brushON and not histON
            and not curvToolON and not eraserON and not rulerON
            and not wandON and not polyLineRoiON and not labelRoiON
            and not middle_click and not keepObjON and not separateON
            and not manualBackgroundON and not drawClearRegionON
            and addPointsByClickingButton is None and not whitelistIDsON
        )
        if isPanImageClick:
            dragImgLeft = True

        is_right_click_custom_ON = any([
            b.isChecked() for b in self.customAnnotDict.keys()
        ])

        canAnnotateDivision = (
             not self.assignBudMothButton.isChecked()
             and not self.setIsHistoryKnownButton.isChecked()
             and not self.curvToolButton.isChecked()
             and not is_right_click_custom_ON
             and not labelRoiON
             and not separateON
        )

        # In timelapse mode division can be annotated if isCcaMode and right-click
        # while in snapshot mode with Ctrl+right-click
        isAnnotateDivision = (
            (right_click and isCcaMode and canAnnotateDivision)
            or (right_click and ctrl and self.isSnapshot)
        )

        isCustomAnnot = (
            (right_click or dragImgLeft)
            and (isCustomAnnotMode or self.isSnapshot)
            and self.customAnnotButton is not None
        )

        is_right_click_action_ON = any([
            b.isChecked() for b in self.checkableQButtonsGroup.buttons()
        ])

        isOnlyRightClick = (
            right_click and canAnnotateDivision and not isAnnotateDivision
            and not isMod and not is_right_click_action_ON
            and not is_right_click_custom_ON and not copyContourON 
            and not findNextMotherButtonON and not unknownLineageButtonON
        )
        
        if isOnlyRightClick:
            # Start timer or check if it is a double-right-click
            if self.countRightClicks == 0:
                self.isDoubleRightClick = False
                self.countRightClicks = 1
                self.doubleRightClickTimeElapsed = False
                screenPos = event.screenPos()
                self._img1_click_xy = (screenPos.x(), screenPos.y())
                QTimer.singleShot(400, self.doubleRightClickTimerCallBack)
                return
            elif (
                self.countRightClicks == 1 
                and not self.doubleRightClickTimeElapsed
            ):            
                self.isDoubleRightClick = True
                self.countRightClicks = 0
                self.editIDbutton.setChecked(True)

        # Left click actions
        canCurv = (
            curvToolON and not self.assignBudMothButton.isChecked()
            and not brushON and not dragImgLeft and not eraserON
            and not polyLineRoiON and not labelRoiON
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not magicPromptsON
        )
        canBrush = (
            brushON and not curvToolON and not rulerON
            and not dragImgLeft and not eraserON and not wandON
            and not labelRoiON and not manualBackgroundON
            and addPointsByClickingButton is None and not drawClearRegionON
            and not magicPromptsON
        )
        canErase = (
            eraserON and not curvToolON and not rulerON
            and not dragImgLeft and not brushON and not wandON
            and not polyLineRoiON and not labelRoiON
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not magicPromptsON
        )
        canRuler = (
            rulerON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not wandON
            and not polyLineRoiON and not labelRoiON
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not magicPromptsON
        )
        canWand = (
            wandON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not polyLineRoiON and not labelRoiON
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not magicPromptsON
        )
        canPolyLine = (
            polyLineRoiON and not wandON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not labelRoiON and not manualBackgroundON
            and addPointsByClickingButton is None
            and not drawClearRegionON and not magicPromptsON
        )
        canLabelRoi = (
            labelRoiON and not wandON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not polyLineRoiON and not keepObjON
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not whitelistIDsON and not magicPromptsON
        )
        canKeep = (
            keepObjON and not wandON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not polyLineRoiON and not labelRoiON 
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not whitelistIDsON and not magicPromptsON
        )
        canWhitelistIDs = (
            whitelistIDsON and not wandON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not polyLineRoiON and not labelRoiON 
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not keepObjON and not magicPromptsON
        )
        canAddPoint = (
            (pointsLayerON or magicPromptsON)
            and addPointsByClickingButton is not None and not wandON 
            and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not polyLineRoiON and not labelRoiON  and not keepObjON
            and not manualBackgroundON and not drawClearRegionON
        )
        canAddManualBackgroundObj = (
            manualBackgroundON and not wandON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not polyLineRoiON and not labelRoiON 
            and addPointsByClickingButton is None
            and not keepObjON and not drawClearRegionON
            and not magicPromptsON and not whitelistIDsON
        )
        canDrawClearRegion = (
            drawClearRegionON and not wandON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not labelRoiON and not manualBackgroundON
            and addPointsByClickingButton is None
            and not polyLineRoiON and not magicPromptsON
            and not whitelistIDsON
        )
        canWand = (
            magicPromptsON and not curvToolON and not brushON
            and not dragImgLeft and not brushON and not rulerON
            and not polyLineRoiON and not labelRoiON
            and addPointsByClickingButton is None
            and not manualBackgroundON and not drawClearRegionON
            and not wandON and not whitelistIDsON
        )
        
        # Enable dragging of the image window or the scalebar
        if dragImgLeft and not isCustomAnnot:
            x, y = event.pos().x(), event.pos().y()
            if hasattr(self, 'scaleBar'):
                if self.scaleBar.isHighlighted():
                    self.scaleBar.mousePressed(x, y)
                    return
            if hasattr(self, 'timestamp'):
                if self.timestamp.isHighlighted():
                    self.timestamp.mousePressed(x, y)
                    return
            pg.ImageItem.mousePressEvent(self.img1, event)
            event.ignore()
            return

        isAllowedActionViewer = (canAddPoint or canRuler)
        
        if mode == 'Viewer' and not isAllowedActionViewer:
            self.startBlinkingModeCB()
            event.ignore()
            return
        
        # Allow right-click or middle-click actions on both images
        eventOnImg2 = (
            (
                right_click or (middle_click and not canAddPoint) 
                # or (left_click and separateON)
            )
            and (mode=='Segmentation and Tracking' or self.isSnapshot)
            and not isAnnotateDivision and not manualBackgroundON
        )
        if eventOnImg2:
            event.isImg1Sender = True
            self.gui_mousePressEventImg2(event)

        x, y = event.pos().x(), event.pos().y()
        xdata, ydata = int(x), int(y)
        Y, X = self.get_2Dlab(posData.lab).shape
        if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y:
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
        else:
            return

        # Paint new IDs with brush and left click on the left image
        if left_click and canBrush:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            lab_2D = self.get_2Dlab(posData.lab)
            Y, X = lab_2D.shape
            
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False, storeOnlyZoom=True)

            ID = self.getHoverID(xdata, ydata)

            if ID > 0:
                posData.brushID = ID
                self.isNewID = False
            else:
                # Update brush ID. Take care of disappearing cells to remember
                # to not use their IDs anymore in the future
                self.isNewID = True
                self.setBrushID()
                self.updateLookuptable(lenNewLut=posData.brushID+1)

            self.brushColor = self.lut[posData.brushID]/255

            self.yPressAx2, self.xPressAx2 = y, x

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)
            diskSlice = (slice(ymin, ymax), slice(xmin, xmax))

            self.isMouseDragImg1 = True

            # Draw new objects
            localLab = lab_2D[diskSlice]
            mask = diskMask.copy()
            if not self.isPowerBrush() and not ctrl:
                mask[localLab!=0] = False

            self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice)

            self.setImageImg2(updateLookuptable=False)

            img = self.img1.image.copy()
            how = self.drawIDsContComboBox.currentText()
            lab2D = self.get_2Dlab(posData.lab)
            self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool)
            brushMask = localLab == posData.brushID
            brushMask = np.logical_and(brushMask, diskMask)
            self.setTempImg1Brush(
                True, brushMask, posData.brushID, toLocalSlice=diskSlice
            )

            self.lastHoverID = -1

        elif left_click and canErase:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            lab_2D = self.get_2Dlab(posData.lab)
            Y, X = lab_2D.shape

            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False, storeOnlyZoom=True)

            self.yPressAx2, self.xPressAx2 = y, x
            # Keep a list of erased IDs got erased
            self.erasedIDs = set()
            
            if self.xyOnCtrlPressedFirstTime is not None:
                self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime)
            else: 
                self.erasedID = self.getHoverID(xdata, ydata)

            ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)

            # Build eraser mask
            mask = np.zeros(lab_2D.shape, bool)
            mask[ymin:ymax, xmin:xmax][diskMask] = True


            # If user double-pressed 'b' then erase over ALL labels
            color = self.eraserButton.palette().button().color().name()
            eraseOnlyOneID = (
                color != self.doublePressKeyButtonColor
                and self.erasedID != 0
            )

            self.eraseOnlyOneID = eraseOnlyOneID

            if eraseOnlyOneID:
                mask[lab_2D!=self.erasedID] = False

            self.getDisplayedImg1()
            self.setTempImg1Eraser(mask, init=True)
            self.applyEraserMask(mask)

            self.erasedIDs.update(lab_2D[mask])  

            for erasedID in self.erasedIDs:
                if erasedID == 0:
                    continue
                self.erasedLab[lab_2D==erasedID] = erasedID
            
            self.isMouseDragImg1 = True

        elif canAddPoint:
            action = addPointsByClickingButton.action
            self.storeUndoAddPoint(action)
            x, y = event.pos().x(), event.pos().y()
            hoveredPoints = action.scatterItem.pointsAt(event.pos())
            if hoveredPoints:
                removed_id = self.removeClickedPoints(action, hoveredPoints)
                if not magicPromptsON:
                    addPointsByClickingButton.pointIdSpinbox.setValue(removed_id)
                    addPointsByClickingButton.pointIdSpinbox.removedId = (
                        removed_id
                    )
                else:
                    self.restorePrevPointIdRightClick(addPointsByClickingButton)
            else:
                point_id = self.getAddedPointId(
                    magicPromptsON, addPointsByClickingButton, 
                    right_click, left_click, middle_click
                )
                if point_id is None:
                    return
                
                self.addClickedPoint(action, x, y, id)
                id = self.getClickedPointNewId(
                    action, id, addPointsByClickingButton.pointIdSpinbox,
                    isMagicPrompts=magicPromptsON
                )
                addPointsByClickingButton.pointIdSpinbox.setValue(
                    id, setLinkedWidget=False
                )
                    
            self.drawPointsLayers(computePointsLayers=False)
        
        elif left_click and canDrawClearRegion:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            self.freeRoiItem.addPoint(xdata, ydata)
            
            self.isMouseDragImg1 = True
        
        elif left_click and canRuler or canPolyLine:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            closePolyLine = (
                len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0
            )
            if not self.tempSegmentON or canPolyLine:
                # Keep adding anchor points for polyline
                self.ax1_rulerAnchorsItem.setData([xdata], [ydata])
                self.tempSegmentON = True
            else:
                self.tempSegmentON = False
                xxRA, yyRA = self.ax1_rulerAnchorsItem.getData()
                x0, y0 = xxRA[0], yyRA[0]
                if self.isCtrlDown:
                    x1, y1 = transformation.snap_xy_to_closest_angle(
                        x0, y0, xdata, ydata
                    )
                else:
                    x1, y1 = xdata, ydata
                lengthText = self.getRulerLengthText()
                self.ax1_rulerPlotItem.setData(
                    [x0, x1], [y0, y1], lengthText=lengthText
                )
                self.ax1_rulerAnchorsItem.setData([x0, x1], [y0, y1])
            
            xxPolyLine = self.startPointPolyLineItem.getData()[0]
            if canPolyLine and len(xxPolyLine) == 0:
                # Create and add roi item
                self.createDelPolyLineRoi()
                # Add start point of polyline roi
                self.startPointPolyLineItem.setData([xdata], [ydata])
                self.polyLineRoi.points.append((xdata, ydata))
            elif canPolyLine:
                # Add points to polyline roi and eventually close it
                if not closePolyLine:
                    self.polyLineRoi.points.append((xdata, ydata))
                self.addPointsPolyLineRoi(closed=closePolyLine)
                if closePolyLine:
                    # Close polyline ROI
                    if len(self.polyLineRoi.getLocalHandlePositions()) == 2:
                        self.polyLineRoi = self.replacePolyLineRoiWithLineRoi(
                            self.polyLineRoi
                        )
                    self.tempSegmentON = False
                    self.ax1_rulerAnchorsItem.setData([], [])
                    self.ax1_rulerPlotItem.setData([], [])
                    self.startPointPolyLineItem.setData([], [])
                    self.addRoiToDelRoiInfo(self.polyLineRoi)
                    # Call roi moving on closing ROI
                    self.delROImoving(self.polyLineRoi)
                    self.delROImovingFinished(self.polyLineRoi)
        
        elif left_click and canKeep:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                keepID_win = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                        'Enter ID that you want to keep',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                keepID_win.exec_()
                if keepID_win.cancel:
                    return
                else:
                    ID = keepID_win.EntryID
            
            if ID in self.keptObjectsIDs:
                self.keptObjectsIDs.remove(ID)
                self.clearHighlightedText()
            else:
                self.keptObjectsIDs.append(ID)
                self.highlightLabelID(ID)
            
            self.updateTempLayerKeepIDs()
        
        elif left_click and canWhitelistIDs:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]

            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                keepID_win = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                        'Enter ID that you want to select',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                keepID_win.exec_()
                if keepID_win.cancel:
                    return
                else:
                    ID = keepID_win.EntryID
            
            posData = self.data[self.pos_i]

            if not posData.whitelist:
                wl_init = False
                if not hasattr(self, 'tempWhitelistIDs'):
                    self.tempWhitelistIDs = set() # not updated, only use in this context
                    current_whitelist = self.tempWhitelistIDs
                else:
                    current_whitelist = self.tempWhitelistIDs
            else:
                wl_init = True
                current_whitelist = posData.whitelist.get(posData.frame_i)

            if ID in current_whitelist:
                current_whitelist.remove(ID)
                self.removeHighlightLabelID(IDs=[ID])
            else:
                current_whitelist.add(ID)
                self.highlightLabelID(ID)
            
            self.whitelistIDsToolbar.whitelistLineEdit.setText(
                current_whitelist
            )
            
            if wl_init:
                posData.whitelist[posData.frame_i] = current_whitelist
            else:
                self.tempWhitelistIDs = current_whitelist

            self.whitelistUpdateTempLayer()

        elif right_click and copyContourON:
            hoverLostID = self.ax1_lostObjScatterItem.hoverLostID
            self.copyLostObjectContour(hoverLostID)
            self.update_rp()
            self.updateAllImages()
            self.store_data()

        elif right_click and canCurv:
            # Draw manually assisted auto contour
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            Y, X = self.get_2Dlab(posData.lab).shape

            self.autoCont_x0 = xdata
            self.autoCont_y0 = ydata
            self.xxA_autoCont, self.yyA_autoCont = [], []
            self.curvAnchors.addPoints([x], [y])
            img = self.getDisplayedImg1()
            self.autoContObjMask = np.zeros(img.shape, np.uint8)
            self.isRightClickDragImg1 = True

        elif left_click and canCurv:
            # Draw manual spline
            x, y = event.pos().x(), event.pos().y()
            Y, X = self.get_2Dlab(posData.lab).shape

            # Check if user clicked on starting anchor again --> close spline
            closeSpline = False
            clickedAnchors = self.curvAnchors.pointsAt(event.pos())
            xxA, yyA = self.curvAnchors.getData()
            if len(xxA)>0:
                if len(xxA) == 1:
                    self.splineHoverON = True
                x0, y0 = xxA[0], yyA[0]
                if len(clickedAnchors)>0:
                    xA_clicked, yA_clicked = clickedAnchors[0].pos()
                    if x0==xA_clicked and y0==yA_clicked:
                        x = x0
                        y = y0
                        closeSpline = True

            # Add anchors
            self.curvAnchors.addPoints([x], [y])
            try:
                xx, yy = self.curvHoverPlotItem.getData()
                self.curvPlotItem.setData(xx, yy)
            except Exception as e:
                # traceback.print_exc()
                pass
            
            if closeSpline:
                self.splineHoverON = False
                self.splineToObj()
                self.update_rp()
                self.trackManuallyAddedObject(posData.brushID, True)
                if self.isSnapshot:
                    self.fixCcaDfAfterEdit('Add new ID with curvature tool')
                    self.updateAllImages()
                else:
                    self.warnEditingWithCca_df('Add new ID with curvature tool')
                self.clearCurvItems()
                self.curvTool_cb(True)

        elif left_click and canWand:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            Y, X = self.get_2Dlab(posData.lab).shape
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)

            self.isNewID = False
            posData.brushID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if posData.brushID == 0:
                self.setBrushID()
                self.updateLookuptable(
                    lenNewLut=posData.brushID+1
                )
                self.isNewID = True
            self.brushColor = self.img2.lut[posData.brushID]/255

            # NOTE: flood is on mousedrag or release
            tol = self.wandToleranceSlider.value()
            self.flood_img = myutils.to_uint8(self.getDisplayedImg1())
            flood_mask = skimage.segmentation.flood(
                self.flood_img, (ydata, xdata), tolerance=tol
            )
            bkgrLabMask = self.get_2Dlab(posData.lab)==0

            drawUnderMask = np.logical_or(
                posData.lab==0, posData.lab==posData.brushID
            )
            self.flood_mask = np.logical_and(flood_mask, drawUnderMask)

            if self.wandAutoFillCheckbox.isChecked():
                self.flood_mask = scipy.ndimage.binary_fill_holes(
                    self.flood_mask
                )

            if np.any(self.flood_mask):
                mask = np.logical_or(
                    self.flood_mask,
                    posData.lab==posData.brushID
                )
                self.setTempImg1Brush(True, mask, posData.brushID)
            self.isMouseDragImg1 = True
        
        elif right_click and self.manualTrackingButton.isChecked():
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            manualTrackID = self.manualTrackingToolbar.spinboxID.value()
            clickedID = self.getClickedID(
                xdata, ydata, text=f'that you want to assign to {manualTrackID}'
            )
            if clickedID is None:
                return

            if clickedID == manualTrackID:
                self.manualTrackingToolbar.showWarning(
                    f'The clicked object already has ID = {manualTrackID}'
                )
                return

            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)

            posData = self.data[self.pos_i]
            currentIDs = posData.IDs.copy()
            if manualTrackID in currentIDs:
                tempID = max(currentIDs) + 1
                posData.lab[posData.lab == clickedID] = tempID
                posData.lab[posData.lab == manualTrackID] = clickedID
                posData.lab[posData.lab == tempID] = manualTrackID
                self.manualTrackingToolbar.showWarning(
                    f'The ID {manualTrackID} already exists --> '
                    f'ID {manualTrackID} has been swapped with {clickedID}'
                )
            else:
                posData.lab[posData.lab == clickedID] = manualTrackID
                self.manualTrackingToolbar.showInfo(
                    f'ID {clickedID} changed to {manualTrackID}.'
                )
            
            self.update_rp()
            self.updateAllImages()
        
        elif right_click and manualBackgroundON:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            
            delID = posData.manualBackgroundLab[ydata, xdata]
            if delID == 0:
                return
            
            self.clearManualBackgroundObject(delID)
            textItem = self.manualBackgroundTextItems.pop(delID)
            self.ax1.removeItem(textItem)
            self.setManualBackgroundImage()
        
        elif left_click and canAddManualBackgroundObj:
            x, y = event.pos().x(), event.pos().y()
            
            self.addManualBackgroundObject(x, y)            
            self.setManualBackgroundImage()
            self.setManualBackgrounNextID()

        # Label ROI mouse press
        elif (left_click or right_click) and canLabelRoi:
            if right_click:
                # Force model initialization on mouse release
                self.labelRoiModel = None
            
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)

            if self.labelRoiIsRectRadioButton.isChecked():
                self.labelRoiItem.setPos((xdata, ydata))
            elif self.labelRoiIsFreeHandRadioButton.isChecked():
                self.freeRoiItem.addPoint(xdata, ydata)
            
            self.isMouseDragImg1 = True

        # Annotate cell cycle division
        elif isAnnotateDivision:
            if posData.cca_df is None:
                return

            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                divID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID that you want to annotate as divided',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                divID_prompt.exec_()
                if divID_prompt.cancel:
                    return
                else:
                    ID = divID_prompt.EntryID
                    obj_idx = posData.IDs.index(ID)
                    y, x = posData.rp[obj_idx].centroid
                    xdata, ydata = int(x), int(y)

            if not self.isSnapshot:
                # Store undo state before modifying stuff
                self.storeUndoRedoStates(False)
                # Annotate or undo division
                self.manualCellCycleAnnotation(ID)
            else:
                self.undoBudMothAssignment(ID)

        # Assign bud to mother (mouse down on bud)
        elif right_click and self.assignBudMothButton.isChecked():
            if self.clickedOnBud:
                # NOTE: self.clickedOnBud is set to False when assigning a mother
                # is successfull in mouse release event
                # We still have to click on a mother
                return

            if posData.cca_df is None:
                return

            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                budID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID of a bud you want to correct mother assignment',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                budID_prompt.exec_()
                if budID_prompt.cancel:
                    return
                else:
                    ID = budID_prompt.EntryID

            obj_idx = posData.IDs.index(ID)
            y, x = posData.rp[obj_idx].centroid
            xdata, ydata = int(x), int(y)

            relationship = posData.cca_df.at[ID, 'relationship']
            is_history_known = posData.cca_df.at[ID, 'is_history_known']
            self.clickedOnHistoryKnown = is_history_known
            # We allow assiging a cell in G1 as bud only on first frame
            # OR if the history is unknown
            if relationship != 'bud' and posData.frame_i > 0 and is_history_known:
                txt = (f'You clicked on ID {ID} which is NOT a bud.\n'
                       'To assign a bud to a cell start by clicking on a bud '
                       'and release on a cell in G1')
                msg = QMessageBox()
                msg.critical(
                    self, 'Not a bud', txt, msg.Ok
                )
                return

            self.clickedOnBud = True
            self.xClickBud, self.yClickBud = xdata, ydata

        # Annotate (or undo) that cell has unknown history
        elif right_click and self.setIsHistoryKnownButton.isChecked():
            if posData.cca_df is None:
                return

            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                unknownID_prompt = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID that you want to annotate as '
                         '"history UNKNOWN/KNOWN"',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                unknownID_prompt.exec_()
                if unknownID_prompt.cancel:
                    return
                else:
                    ID = unknownID_prompt.EntryID
                    obj_idx = posData.IDs.index(ID)
                    y, x = posData.rp[obj_idx].centroid
                    xdata, ydata = int(x), int(y)

            self.annotateIsHistoryKnown(ID)
            if not self.setIsHistoryKnownButton.findChild(QAction).isChecked():
                self.setIsHistoryKnownButton.setChecked(False)

        elif isCustomAnnot:
            x, y = event.pos().x(), event.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            if ID == 0:
                nearest_ID = self.nearest_nonzero(
                    self.get_2Dlab(posData.lab), y, x
                )
                clickedBkgrDialog = apps.QLineEditDialog(
                    title='Clicked on background',
                    msg='You clicked on the background.\n'
                         'Enter ID that you want to annotate as divided',
                    parent=self, allowedValues=posData.IDs,
                    defaultTxt=str(nearest_ID)
                )
                clickedBkgrDialog.exec_()
                if clickedBkgrDialog.cancel:
                    return
                else:
                    ID = clickedBkgrDialog.EntryID
                    obj_idx = posData.IDs.index(ID)
                    y, x = posData.rp[obj_idx].centroid
                    xdata, ydata = int(x), int(y)

            button = self.doCustomAnnotation(ID)
            if button is None:
                return
            
            keepActive = self.customAnnotDict[button]['state']['keepActive']
            if not keepActive:
                button.setChecked(False)

        elif right_click and findNextMotherButtonON:
            if posData.frame_i == 0:
                return
            
            self.find_mother_action(posData, event, ydata, xdata)

        elif right_click and unknownLineageButtonON:
            if posData.frame_i == 0:
                return
            
            self.annotate_unknown_lineage_action(posData, event, ydata, xdata)

    def repeat_click_and_backup(self, posData, event, ydata, xdata):
        """
        This function is part of the lin_tree edit functionality. 
        It handles the back up of the original self.lineage_tree.lineage_list 
        df and the repeated clicking on the same ID to cycle through pssible mothers.

        Parameters
        ----------
        posData : ???
            The position data.
        event : ???
            The event object.
        ydata : int
            The y-coordinate data.
        xdata : int
            The x-coordinate data.

        Returns
        -------
        tuple
            A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell.
        """
        if self.original_df_lin_tree is None:
            self.original_df_lin_tree = self.lineage_tree.lineage_list[posData.frame_i].copy()
            self.original_df_lin_tree_i = posData.frame_i
        elif self.original_df_lin_tree_i != posData.frame_i:
            printl('[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!')
            self.original_df_lin_tree = self.lineage_tree.lineage_list[posData.frame_i].copy()
            self.original_df_lin_tree_i = posData.frame_i

        if not self.right_click_ID:
            self.right_click_i = 0
            self.right_click_ID = 0

        x, y = event.pos().x(), event.pos().y()
        point = int(x), int(y)
        ID = self.get_2Dlab(posData.lab)[ydata, xdata]

        if ID == 0:
            return None, None

        if self.right_click_ID != ID:
            self.right_click_i = 0
            self.right_click_ID = ID
            self.original_mother_skipped = False
        elif event.modifiers() & Qt.ShiftModifier:
            self.right_click_i -= 1
        else:
            self.right_click_i += 1

        return point, ID

    def getDistanceListMissingIDs(self, point, ID):
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i
        if self.getDistanceListMissingIDsCachedFrame != frame_i:
            self.distanceListMissingIDs = dict()
            self.getDistanceListMissingIDsCachedFrame = frame_i
            self.store_data(autosave=False)
            self.get_data()

        if ID not in self.distanceListMissingIDs.keys():
            prev_rp = posData.allData_li[frame_i-1]['regionprops']
            relevant_rp = [
                obj for obj in prev_rp if obj.label not in posData.IDs
            ]
            len_relevant_rp = len(relevant_rp)
            if len_relevant_rp == 0:
                self.logger.info('No missing IDs found in previous frame.')
                return []
            elif len_relevant_rp == 1:
                self.distanceListMissingIDs[ID] = [relevant_rp[0].label]
                return [relevant_rp[0].label]
            else:
                sorted_missing_IDs = sort_IDs_dist(relevant_rp, point=point)
                self.distanceListMissingIDs[ID] = sorted_missing_IDs
                return sorted_missing_IDs
        else:
            return self.distanceListMissingIDs[ID]
    
    def find_mother_action(self, posData, event, ydata, xdata):
        """
        This function is part of the lin_tree edit functionality.
        Associated with the right-click action of the 'findNextMotherButton' button.
        Handles the right click action, which cycles through possible mothers of the clicked cell.
        Changes the parent ID of the clicked cell to the next possible mother in self.lineage_tree.lineage_list.

        Parameters
        ----------
        posData : ???
            The position data object.
        event : ???
            The event object.
        ydata : int
            The y-coordinate data.
        xdata : int
            The x-coordinate data.
        """
        point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata)

        if point is None:
            return
        
        lin_tree_df = self.lineage_tree.lineage_list[posData.frame_i]
        filtered_IDs = self.getDistanceListMissingIDs(point, ID)
        if len(filtered_IDs) == 0:
            self.logger.info('No mother candidates found.')
            return

        i = self.right_click_i % len(filtered_IDs)
        i = abs(i)  # Ensure i is non-negative
        new_mother = filtered_IDs[i]

        if lin_tree_df.loc[ID]['parent_ID_tree'] == new_mother and self.original_mother_skipped == False: # if a mother is already present, skip it 
            self.right_click_i += 1
            self.original_mother_skipped = True

            i = self.right_click_i % len(filtered_IDs)
            i = abs(i)  # Ensure i is non-negative
            new_mother = filtered_IDs[i]

        lin_tree_df.at[ID, 'parent_ID_tree'] = new_mother
        self.lineage_tree.insert_lineage_df(lin_tree_df, posData.frame_i, consider_children=False, update_fams=False, raw_input=True, propagate=False)
        self.drawAllLineageTreeLines()

    def annotate_unknown_lineage_action(self, posData, event, ydata, xdata):
        """
        This function is part of the lin_tree edit functionality.
        Associated with the right-click action of the 'unknownLineageButton' button.
        Annotates an unknown lineage by setting its parent ID to -1 in the lineage tree (self.lineage_tree.lineage_list)

        Parameters
        ----------
        posData : ???
            The position data.
        event : ???
            The event that triggered the annotation.
        ydata : int
            The y-coordinate data.
        xdata : int
            The x-coordinate data.
        """
        point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata)

        if point is None:
            return

        lin_tree_df = self.lineage_tree.lineage_list[posData.frame_i]
        lin_tree_df.at[ID, 'parent_ID_tree'] = -1
        self.lineage_tree.insert_lineage_df(lin_tree_df, posData.frame_i, consider_children=False, update_fams=False, raw_input=True, propagate=False)
        self.drawAllLineageTreeLines()

    def gui_addCreatedAxesItems(self):
        self.ax1.addItem(self.ax1_contoursImageItem)
        self.ax1.addItem(self.ax1_lostObjImageItem)
        self.ax1.addItem(self.ax1_lostTrackedObjImageItem)
        self.ax1.addItem(self.ax1_oldMothBudLinesItem)
        self.ax1.addItem(self.ax1_newMothBudLinesItem)
        self.ax1.addItem(self.ax1_lostObjScatterItem)
        self.ax1.addItem(self.ax1_lostTrackedScatterItem)
        self.ax1.addItem(self.ccaFailedScatterItem)
        self.ax1.addItem(self.yellowContourScatterItem)

        self.ax2.addItem(self.ax2_contoursImageItem)
        self.ax2.addItem(self.ax2_lostObjImageItem)
        self.ax2.addItem(self.ax2_lostTrackedObjImageItem)
        self.ax2.addItem(self.ax2_oldMothBudLinesItem)
        self.ax2.addItem(self.ax2_newMothBudLinesItem)
        self.ax2.addItem(self.ax2_lostObjScatterItem)

        self.textAnnot[0].addToPlotItem(self.ax1)
        self.textAnnot[1].addToPlotItem(self.ax2)

    def SegForLostIDsSetSettings(self):

        try:
            prev_model = str(self.df_settings.at['SegForLostIDsModel', 'value'])
        except KeyError:
            prev_model = None
        win = apps.QDialogSelectModel(parent=self, customFirst=prev_model)
        win.exec_()
        if win.cancel:
            self.logger.info('Seg for lost IDs cancelled.')
            return
        base_model_name = win.selectedModel

        if base_model_name:
            self.df_settings.at['SegForLostIDsModel', 'value'] = base_model_name
            self.df_settings.to_csv(self.settings_csv_path)

        model_name = 'local_seg'

        idx = self.modelNames.index(model_name)
        acdcSegment = self.acdcSegment_li[idx]

        if acdcSegment is None or base_model_name != self.local_seg_base_model_name:
            self.logger.info(f'Importing {base_model_name}...')
            acdcSegment = myutils.import_segment_module(base_model_name)
            self.acdcSegment_li[idx] = acdcSegment
            self.local_seg_base_model_name = base_model_name  
        
        extra_params = ['overlap_threshold',
                        'padding',
                        'size_perc_threshold',
                        'distance_filler_growth',
                        'max_interations',
                        'allow_only_tracked_cells']

        extra_types = [float, float, float, float, int, bool]

        extra_defaults = [0.5, 0.8, 0.5, 1., 2, False]

        extra_desc = ['Overlap threshold with other already segemented cells over which newly segmented cells are discarded', 
                    'Padding of the box used for new segmentation around the segmentation from the previous frame', 
                    'Relative size threshold of the new segmentation compared to the segmentation from the previous frame',
                    """Cells which are already segmented are filled with random noise sampled from background 
                    to ensure that they don't get segmented again. 
                    This parameter controls the additional padding around the already segmented cells.""",
                    """The algorithm will try and segment the maximum amount 
                    of cells in the image by running the model several 
                    times and filling new found cells with background noise. 
                    How many of these iterations should be run?""",
                    "If no new cell IDs should be permitted (based on real time tracking)"]

        extra_ArgSpec = []
        for i, param in enumerate(extra_params):
            param = ArgSpec(name=param, 
                            default=extra_defaults[i],
                            type=extra_types[i],
                            desc=extra_desc[i],
                            docstring='')

            extra_ArgSpec.append(param)

        init_params, segment_params = myutils.getModelArgSpec(acdcSegment)
        segment_params = [arg for arg in segment_params if arg[0] != 'diameter']
                
        extraParamsTitle = 'Settings for local segmentation'
        win = self.initSegmModelParams(
            base_model_name, acdcSegment, init_params, segment_params,
            extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle,
            initLastParams=True, ini_filename='segmentation_for_lostIDs.ini',
        )

        if win is None:
            self.logger.info('Segmentation for lost IDs cancelled.')
            return

        init_kwargs_new = {}
        args_new = {}
        for key, val in win.init_kwargs.items():
            if key in extra_params:
                args_new[key] = val
            else:
                init_kwargs_new[key] = val

        for key, val in win.extra_kwargs.items():
            if key in extra_params:
                args_new[key] = val

        self.SegForLostIDsSettings = {
            'win': win,
            'init_kwargs_new': init_kwargs_new,
            'args_new': args_new,
            'base_model_name': base_model_name,
        }

    def SegForLostIDsAction(self):

        self.setFrameNavigationDisabled(disable=True, why='Segmentation for lost IDs')
        posData = self.data[self.pos_i]
        if posData.frame_i == 0:
            self.logger.info(
                'Segmentation for lost IDs not available on first frame.'
            )
            _warnings.warn_segment_for_lost_IDs_first_frame(qparent=self)
            return
        self.storeUndoRedoStates(False)
        self.progressWin = apps.QDialogWorkerProgress(
            title='Segmenting for lost IDs', parent=self,
            pbarDesc=f'Segmenting for lost IDs...'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)
        
        self.startSegForLostIDsWorker()

    def onSegForLostInit(self):
        self.logger.info('Settings for segmentation for lost IDs not set.')
        self.SegForLostIDsSetSettings()
        self.SegForLostIDsWaitCond.wakeAll()
    
    def SegForLostIDsWorkerAskInstallModel(self, model_name):
        if model_name == 'cellpose_custom':
            myutils.check_install_cellpose(version="any")
        else:
            myutils.check_install_package(model_name)
        
        self.SegForLostIDsWaitCond.wakeAll()

    def startSegForLostIDsWorker(self):
        self.SegForLostIDsMutex = QMutex()
        self.SegForLostIDsWaitCond = QWaitCondition()
        self._thread = QThread()

        # Initialize the worker with mutex and wait condition
        self.SegForLostIDsWorker = workers.SegForLostIDsWorker(
            self, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond
        )

        # Connect the worker's signal to the main thread's slot
        self.SegForLostIDsWorker.sigAskInit.connect(self.onSegForLostInit)
        self.SegForLostIDsWorker.sigAskInstallModel.connect(
            self.SegForLostIDsWorkerAskInstallModel
        )
        self.SegForLostIDsWorker.sigshowImageDebug.connect(
            self.showImageDebug
        )

        self.SegForLostIDsWorker.sigStoreData.connect(self.onSigStoreDataSegForLostIDsWorker)
        self.SegForLostIDsWorker.sigUpdateRP.connect(self.onSigUpdateRPSegForLostIDsWorker)
        self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker)
        # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker)
        # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker)
        # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker)
        self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect(self.onSigTrackManuallyAddedObjectSegForLostIDsWorker)

        # Move the worker to the thread
        self.SegForLostIDsWorker.moveToThread(self._thread)

        # Manage thread lifecycle
        self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit)
        self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorker.deleteLater)
        self._thread.finished.connect(self._thread.deleteLater)

        # Connect other worker signals to the appropriate slots
        self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorkerFinished)
        self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress)
        self.SegForLostIDsWorker.signals.initProgressBar.connect(self.workerInitProgressbar)
        self.SegForLostIDsWorker.signals.progressBar.connect(self.workerUpdateProgressbar)
        self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical)

        # Start the thread and worker
        self._thread.started.connect(self.SegForLostIDsWorker.run)
        self._thread.start()
    
    def onSigStoreDataSegForLostIDsWorker(self, autosave):
        self.onSigStoreData(
            self.SegForLostIDsWaitCond, autosave=autosave)
        
    def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr):
        self.onSigUpdateRP(self.SegForLostIDsWaitCond,
                           wl_update=wl_update, 
                           wl_track_og_curr=wl_track_og_curr)
        
    def onSigGetDataSegForLostIDsWorker(self):
        self.onSigGetData(
            self.SegForLostIDsWaitCond)

    # def onSigGet2DlabSegForLostIDsWorker(self):
    #     posData = self.data[self.pos_i]
    #     lab = self.get_2Dlab(posData.lab)
    #     self.SegForLostIDsWorker.lab = lab
    #     self.SegForLostIDsWaitCond.wakeAll()
    
    # def onSigGetTrackedSegForLostIDsWorker(self):
    #     self.SegForLostIDsWorker.trackedLostIDs = self.getTrackedLostIDs()
    #     self.SegForLostIDsWaitCond.wakeAll()
    
    # def onSigGetBrushIDSegForLostIDsWorker(self):
    #     self.SegForLostIDsWorker.brushID = self.setBrushID(useCurrentLab=True, return_val=True)
    #     self.SegForLostIDsWaitCond.wakeAll()

    def onSigTrackManuallyAddedObjectSegForLostIDsWorker(self, added_IDs, isNewID, wl_update, wl_track_og_curr):
        self.trackManuallyAddedObject(added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr)
        self.SegForLostIDsWaitCond.wakeAll()

        
    def onSigStoreData(self, waitcond, pos_i=None, enforce=True, debug=False, mainThread=True,
            autosave=True, store_cca_df_copy=False):
        self.store_data(pos_i=pos_i, enforce=enforce, debug=debug, mainThread=mainThread,
            autosave=autosave, store_cca_df_copy=store_cca_df_copy)
        waitcond.wakeAll()

    def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, 
                  wl_update=True, wl_track_og_curr=False):
        self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs,
                        wl_update=wl_update, wl_track_og_curr=wl_track_og_curr)
        waitcond.wakeAll()

    def onSigGetData(self, waitcond, debug=False):
        self.get_data(debug=debug)
        waitcond.wakeAll()

    def SegForLostIDsWorkerFinished(self):

        self.updateAllImages()
        self.update_rp()
        self.store_data(autosave=True)
        self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs')

        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        
    def showImageDebug(self, img):
        imshow(img)
    
    def gui_raiseBottomLayoutContextMenu(self, event):
        try:
            # Convert QPointF to QPoint
            self.bottomLayoutContextMenu.popup(event.globalPos().toPoint())
        except AttributeError:
            self.bottomLayoutContextMenu.popup(event.globalPos())
    
    def areContoursRequested(self, ax):
        if ax == 0 and self.annotContourCheckbox.isChecked():
            return True

        if ax == 1:
            if not self.labelsGrad.showRightImgAction.isChecked():
                return False

            isRightDifferentAnnot = self.rightBottomGroupbox.isChecked()
            areContRequestedRight = self.annotContourCheckboxRight.isChecked()
           
            if isRightDifferentAnnot and areContRequestedRight:
                return True
            
            areContRequestedLeft = self.annotContourCheckbox.isChecked()
            if not isRightDifferentAnnot and areContRequestedLeft:
                return True
        return False
    
    def areMothBudLinesRequested(self, ax):
        if ax == 0:
            if self.annotCcaInfoCheckbox.isChecked():
                return True
            if self.drawMothBudLinesCheckbox.isChecked():
                return True
        else:
            if not self.labelsGrad.showRightImgAction.isChecked():
                return False
            
            isRightDifferentAnnot = self.rightBottomGroupbox.isChecked()
            areLinesRequestedRight = self.annotCcaInfoCheckboxRight.isChecked()
            if isRightDifferentAnnot and areLinesRequestedRight:
                return True
        
            areLinesRequestedLeft = self.drawMothBudLinesCheckboxRight.isChecked()
            if not isRightDifferentAnnot and areLinesRequestedLeft:
                return True
        return False
    
    def getMothBudLineScatterItem(self, ax, new):
        if ax == 0:
            if new:
                return self.ax1_newMothBudLinesItem
            else:
                return self.ax1_oldMothBudLinesItem
        else:
            if new:
                return self.ax2_newMothBudLinesItem
            else:
                return self.ax2_oldMothBudLinesItem
    
    def labelRoiIsCircularRadioButtonToggled(self, checked):
        if checked:
            self.labelRoiCircularRadiusSpinbox.setDisabled(False)
        else:
            self.labelRoiCircularRadiusSpinbox.setDisabled(True)
    
    def pxModeActionToggled(self, checked):
        self.df_settings.at['pxMode', 'value'] = int(checked)
        self.df_settings.to_csv(self.settings_csv_path)
        
        if not self.isDataLoaded:
            return
        
        if self.highLowResAction.isChecked():
            for ax in range(2):
                self.textAnnot[ax].setPxMode(checked)
        
        self.updateAllImages()
    
    def relabelSequentialCallback(self): 
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer' or mode == 'Cell cycle analysis':
            self.startBlinkingModeCB()
            return
        
        posData = self.data[self.pos_i]
        selectedPos = (posData.pos_foldername, )
        if len(self.data) > 1:
            selectedPos = self.askSelectPos(action='to process')
            if selectedPos is None:
                self.logger.info('Re-labelling process stopped.')
                return
        
        self.store_data()
        # acdc_df_concat = self.getConcatAcdcDf()
        # load.store_unsaved_acdc_df(
        #     posData, acdc_df_concat, 
        #     log_func=self.logger.info
        # )
        # if posData.SizeT > 1:
        self.progressWin = apps.QDialogWorkerProgress(
            title='Re-labelling sequential', parent=self,
            pbarDesc='Relabelling sequential...'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)
        self.startRelabellingWorker(selectedPos)
        
        # elif posData:
        #     self.storeUndoRedoStates(False)
        #     posData.lab, oldIDs, newIDs = core.relabel_sequential(posData.lab)
        #     # Update annotations based on relabelling
        #     self.update_cca_df_relabelling(posData, oldIDs, newIDs)
        #     self.updateAnnotatedIDs(oldIDs, newIDs, logger=self.logger.info)
        #     self.store_data()
        #     self.update_rp()
        #     li = list(zip(oldIDs, newIDs))
        #     s = '\n'.join([str(pair).replace(',', ' -->') for pair in li])
        #     s = f'IDs relabelled as follows:\n{s}'
        #     self.logger.info(s)
        #     self.updateAllImages()
    
    def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print):
        logger('Updating annotated IDs...')
        posData = self.data[self.pos_i]

        mapper = dict(zip(oldIDs, newIDs))
        posData.ripIDs = set([mapper[ripID] for ripID in posData.ripIDs])
        posData.binnedIDs = set([mapper[binID] for binID in posData.binnedIDs])
        self.keptObjectsIDs = widgets.KeptObjectIDsList(
            self.keptIDsLineEdit, self.keepIDsConfirmAction
        )

        customAnnotButtons = list(self.customAnnotDict.keys())
        for button in customAnnotButtons:
            customAnnotValues = self.customAnnotDict[button]
            annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i]
            mappedAnnotIDs = {}
            for frame_i, annotIDs_i in annotatedIDs.items():
                mappedIDs = [mapper[ID] for ID in annotIDs_i]
                mappedAnnotIDs[frame_i] = mappedIDs
            customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs

    def rtTrackerActionToggled(self, checked):
        if not checked:
            return

        aliases = myutils.aliases_real_time_trackers(reverse=True)
        if self.sender().text() in aliases:
            trackingAlgo = aliases[self.sender().text()]
        else:
            trackingAlgo = self.sender().text()
        self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo
        self.df_settings.to_csv(self.settings_csv_path)

        if self.sender().text() == 'YeaZ':
            msg = widgets.myMessageBox(wrapText=False)
            info_txt = html_utils.paragraph(f"""
                Note that YeaZ tracking algorithm tends to be sliglhtly more accurate
                overall, but it is <b>less capable of detecting segmentation
                errors.</b><br><br>
                If you need to correct as many segmentation errors as possible
                we recommend using Cell-ACDC tracking algorithm.
            """)
            msg.information(self, 'Info about YeaZ', info_txt)
        
        self.isRealTimeTrackerInitialized = False
        self.initRealTimeTracker()

    def autoPilotToggled(self, checked):
        self.autoPilotZoomToObjToolbar.setVisible(checked)
        if checked:
            self.autoPilotZoomToObjToggle.setChecked(False)
            self.autoPilotZoomToObjToggle.toggle()
    
    def findID(self):
        posData = self.data[self.pos_i]
        searchIDdialog = apps.FindIDDialog(
            title='Search object by ID',
            msg='Enter object ID to find and highlight',
            parent=self,
        )
        searchIDdialog.exec_()
        if searchIDdialog.cancel:
            return

        searchedID = searchIDdialog.EntryID
        if searchedID in posData.IDs:
            self.goToObjectID(searchedID)
            return

        if posData.SizeT == 1:
            self.warnIDnotFound(searchedID)
            return
        
        if searchedID in posData.lost_IDs:
            self.goToLostObjectID(searchedID)
            return
        
        tracked_lost_IDs = self.getTrackedLostIDs()
        if searchedID in tracked_lost_IDs:
            self.goToAcceptedLostObjectID(searchedID)
            return
        
        self.logger.info(f'Searching ID {searchedID} in other frames...')
        
        frame_i_found = self.startSearchIDworker(searchedID)
        if frame_i_found is None:
            self.warnIDnotFound(searchedID)
            return
        
        self.logger.info(
            f'Object ID {searchedID} found at frame n. {frame_i_found+1}.'
        )
        proceed = self.askGoToFrameFoundID(searchedID, frame_i_found)
        if not proceed:
            return
        
        posData.frame_i = frame_i_found
        self.get_data()
        self.updateAllImages()
        self.updateScrollbars()
        
        self.goToObjectID(searchedID)
    
    @disableWindow
    def startSearchIDworker(self, searchedID):
        posData = self.data[self.pos_i]
        
        desc = 'Searching ID in all frames...'
        
        self.progressWin = apps.QDialogWorkerProgress(
            title=desc, parent=self.mainWin, pbarDesc=desc
        )
        self.progressWin.mainPbar.setMaximum(posData.SizeT)
        self.progressWin.show(self.app)
        
        self.searchIDthread = QThread()
        self.searchIDworker = workers.SimpleWorker(
            posData, self.searchIDworkerCallback, 
            func_args=(searchedID, )
        )
        self.searchIDworker.frame_i_found = None
        self.searchIDworker.moveToThread(self.searchIDthread)
        
        self.searchIDworker.signals.finished.connect(
            self.searchIDthread.quit
        )
        self.searchIDworker.signals.finished.connect(
            self.searchIDworker.deleteLater
        )
        self.searchIDthread.finished.connect(self.searchIDthread.deleteLater)
        
        self.searchIDworker.signals.critical.connect(
            self.searchIDworkerCritical
        )
        self.searchIDworker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.searchIDworker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.searchIDworker.signals.progress.connect(
            self.workerProgress
        )
        self.searchIDworker.signals.finished.connect(
            self.searchIDworkerFinished
        )
        
        self.searchIDthread.started.connect(self.searchIDworker.run)
        self.searchIDthread.start()
        
        self.searchIDworkerLoop = QEventLoop()
        self.searchIDworkerLoop.exec_()
        
        return self.searchIDworker.frame_i_found
    
    def searchIDworkerCritical(self, error):
        self.searchIDworkerLoop.exit()
        self.workerCritical(error)
    
    def searchIDworkerFinished(self):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        
        self.searchIDworkerLoop.exit()
    
    def searchIDworkerCallback(self, posData, searchedID):
        self.searchIDworker.signals.initProgressBar.emit(0)
        self.setAllIDs()
        self.searchIDworker.signals.initProgressBar.emit(posData.SizeT)
        frame_i_found = None
        for frame_i in range(len(posData.segm_data)):
            if frame_i >= len(posData.allData_li):
                break
            lab = posData.allData_li[frame_i]['labels']
            if lab is None:
                rp = skimage.measure.regionprops(posData.segm_data[frame_i])
                IDs = set([obj.label for obj in rp])
            else:
                IDs = posData.allData_li[frame_i]['IDs']
            
            if searchedID in IDs:
                frame_i_found = frame_i
                break
            
            self.searchIDworker.signals.progressBar.emit(1)
            
        self.searchIDworker.frame_i_found = frame_i_found
    
    def warnIDnotFound(self, searchedID):
        msg = widgets.myMessageBox(wrapText=False)
        txt = html_utils.paragraph(f"""
            Object ID {searchedID} was not found.<br><br>
        """)
        msg.warning(self, f'ID {searchedID} not found', txt)
    
    def goToObjectID(self, ID):
        posData = self.data[self.pos_i]
        objIdx = posData.IDs_idxs[ID]
        obj = posData.rp[objIdx]
        self.goToZsliceSearchedID(obj)
        
        self.highlightSearchedID(ID)
        propsQGBox = self.guiTabControl.propsQGBox
        propsQGBox.idSB.setValue(ID)
    
    def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)):
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i
        prev_rp = posData.allData_li[frame_i-1]['regionprops']
        prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs']
        obj = prev_rp[prev_IDs_idxs[lostID]]
        self.goToZsliceSearchedID(obj)
        
        imageItem = self.getLostObjImageItem(0)
        thickness = 1
        if not hasattr(self, 'lostObjContoursImage'):
            self.initLostObjContoursImage()
        else:
            self.lostObjContoursImage[:] = 0  

        contours = []
        obj_contours = self.getObjContours(obj, all_external=True)
        contours.extend(obj_contours)
        
        self.addLostObjsToImage(obj, lostID)
        self.drawLostObjContoursImage(
            imageItem, contours, thickness=2, color=color
        )
        
    def goToAcceptedLostObjectID(self, acceptedLostID):
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i
        prev_rp = posData.allData_li[frame_i-1]['regionprops']
        prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs']
        obj = prev_rp[prev_IDs_idxs[acceptedLostID]]
        self.goToZsliceSearchedID(obj)
        
        self.updateLostTrackedContoursImage(tracked_lost_IDs=[acceptedLostID])
    
    def askGoToFrameFoundID(self, searchedID, frame_i_found):
        msg = widgets.myMessageBox(wrapText=False)
        txt = html_utils.paragraph(f"""
            Object ID {searchedID} was found at frame n. {frame_i_found+1}.<br><br>
            Do you want to go to frame n. {frame_i_found+1}.
        """)
        noButton, yesButton = msg.information(
            self, f'ID {searchedID} found at frame n. {frame_i_found+1}', txt,
            buttonsTexts=(
                'No, stay on current frame', 
                f'Yes, go to frame n. {frame_i_found+1}'
            )
        )
        return msg.clickedButton == yesButton
    
    def skipForwardToNewID(self):
        self.progressWin = apps.QDialogWorkerProgress(
            title='Searching the next frame with a new object', parent=self,
            pbarDesc=f'Searching the next frame with a new object...'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)
        
        self.startFindNextNewIdWorker()
    
    def startFindNextNewIdWorker(self):
        posData = self.data[self.pos_i]
        self._thread = QThread()
        self.findNextNewIdWorker = workers.FindNextNewIdWorker(posData, self)
        self.findNextNewIdWorker.moveToThread(self._thread)
        
        self.findNextNewIdWorker.signals.finished.connect(self._thread.quit)
        self.findNextNewIdWorker.signals.finished.connect(
            self.findNextNewIdWorker.deleteLater
        )
        self._thread.finished.connect(self._thread.deleteLater)

        self.findNextNewIdWorker.signals.finished.connect(
            self.findNextNewIdWorkerFinished
        )
        self.findNextNewIdWorker.signals.progress.connect(self.workerProgress)
        self.findNextNewIdWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.findNextNewIdWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.findNextNewIdWorker.signals.critical.connect(
            self.workerCritical
        )

        self._thread.started.connect(self.findNextNewIdWorker.run)
        self._thread.start()
    
    def findNextNewIdWorkerFinished(self, next_frame_i):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
                    
        self.navSpinBox.setValue(next_frame_i+1)
        self.framesScrollBarReleased()

    def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree
        if self.progressWin is not None:
            self.progressWin.logConsole.append(text)
        self.logger.log(getattr(logging, loggerLevel), text)

    def workerFinished(self):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        self.logger.info('Worker process ended.')
        self.updateAllImages()
        self.titleLabel.setText('Done', color='w')
    
    def savePreprocWorkerFinished(self):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        
        self.setStatusBarLabel()
        self.logger.info('Pre-processed data saved!')
        self.titleLabel.setText('Pre-processed data saved!', color='w')

    def saveCombinedChannelsWorkerFinished(self):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        
        self.setStatusBarLabel()
        self.logger.info('Combined channels data saved!')
        self.titleLabel.setText('Combined channels data saved!', color='w')

    def saveCombineWorkerFinished(self):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        
        self.setStatusBarLabel()
        self.logger.info('Combined channels saved!')
        self.titleLabel.setText('Combined channels saved!', color='w')
    
    def delObjsOutSegmMaskWorkerFinished(self, result):
        posData = self.data[self.pos_i]
        worker, cleared_segm_data, delIDs = result
        if posData.SizeT == 1:
            cleared_segm_data = cleared_segm_data[np.newaxis]
        
        self.update_cca_df_deletedIDs(posData, delIDs)
        
        current_frame_i = posData.frame_i
        for frame_i, cleared_lab in enumerate(cleared_segm_data):
            # Store change
            posData.allData_li[frame_i]['labels'] = cleared_lab
            # Get the rest of the stored metadata based on the new lab
            posData.frame_i = frame_i
            self.get_data()
            self.store_data(autosave=False)
        
        # Back to current frame
        posData.frame_i = current_frame_i
        self.get_data()
        
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        self.logger.info('Deleting objects outside of ROIs finished.')
        self.titleLabel.setText(
            'Deleting objects outside of ROIs finished.', color='w'
        )
        self.updateAllImages()
    
    def loadingNewChunk(self, chunk_range):
        coord0_chunk, coord1_chunk = chunk_range
        desc = (
            f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...'
        )
        self.progressWin = apps.QDialogWorkerProgress(
            title='Loading data...', parent=self, pbarDesc=desc
        )
        self.progressWin.mainPbar.setMaximum(0)
        self.progressWin.show(self.app)
    
    def lazyLoaderFinished(self):
        self.logger.info('Load chunk data worker done.')
        if self.lazyLoader.updateImgOnFinished:
            self.updateAllImages()

        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None

    @exception_handler
    def trackingWorkerFinished(self):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        self.logger.info('Worker process ended.')
        askDisableRealTimeTracking = (
            self.trackingWorker.trackingOnNeverVisitedFrames
            and self.realTimeTrackingToggle.isChecked()
        )
        if askDisableRealTimeTracking:
            msg = widgets.myMessageBox()
            title = 'Disable real-time tracking?'
            txt = (
                'You perfomed tracking on frames that you have '
                '<b>never visited.</b><br><br>'
                'Cell-ACDC default behaviour is to <b>track them again</b> when you '
                'will visit them.<br><br>'
                'However, you can <b>overwrite this behaviour</b> and explicitly '
                'disable tracking for all of the frames you already tracked.<br><br>'
                'NOTE: you can reactivate real-time tracking by clicking on the '
                '"Reset last segmented frame" button on the top toolbar.<br><br>'
                'What do you want me to do?'
            )
            _, disableTrackingButton = msg.information(
                self, title, html_utils.paragraph(txt),
                buttonsTexts=(
                    'Keep real-time tracking active (recommended)',
                    'Disable real-time tracking'
                )
            )
            if msg.clickedButton == disableTrackingButton:
                self.logger.info('Disabling real time tracking...')
                self.realTimeTrackingToggle.setChecked(False)
                # posData = self.data[self.pos_i]
                # current_frame_i = posData.frame_i
                # for frame_i in range(self.start_n-1, self.stop_n):
                #     posData.frame_i = frame_i
                #     self.get_data()
                #     self.store_data(autosave=frame_i==self.stop_n-1)
                # posData.last_tracked_i = frame_i
                # self.setNavigateScrollBarMaximum()

                # # Back to current frame
                # posData.frame_i = current_frame_i
                # self.get_data()
        posData = self.data[self.pos_i]
        self.updateAllImages()
        self.titleLabel.setText('Done', color='w')

    def workerInitProgressbar(self, totalIter):
        self.progressWin.mainPbar.setValue(0)
        if totalIter == 1:
            totalIter = 0
        self.progressWin.mainPbar.setMaximum(totalIter)

    def workerUpdateProgressbar(self, step):
        self.progressWin.mainPbar.update(step)
    
    def workerInitInnerPbar(self, totalIter):
        self.progressWin.innerPbar.setValue(0)
        if totalIter == 1:
            totalIter = 0
        self.progressWin.innerPbar.setMaximum(totalIter)
    
    def workerUpdateInnerPbar(self, step):
        self.progressWin.innerPbar.update(step)

    def startTrackingWorker(self, posData, video_to_track):
        self.thread = QThread()
        self.trackingWorker = workers.trackingWorker(
            posData, self, video_to_track
        )
        self.trackingWorker.moveToThread(self.thread)
        self.trackingWorker.finished.connect(self.thread.quit)
        self.trackingWorker.finished.connect(self.trackingWorker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)

        # Custom signals
        self.trackingWorker.signals.progress = self.trackingWorker.progress
        self.trackingWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.trackingWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.trackingWorker.signals.sigInitInnerPbar.connect(
            self.workerInitInnerPbar
        )
        self.trackingWorker.progress.connect(self.workerProgress)
        self.trackingWorker.critical.connect(self.workerCritical)
        self.trackingWorker.finished.connect(self.trackingWorkerFinished)

        self.trackingWorker.debug.connect(self.workerDebug)

        self.thread.started.connect(self.trackingWorker.run)
        self.thread.start()

    def startRelabellingWorker(self, posFoldernames):
        self.thread = QThread()
        self.worker = workers.relabelSequentialWorker(self, posFoldernames)
        self.worker.moveToThread(self.thread)
        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)

        self.worker.progress.connect(self.workerProgress)
        self.worker.critical.connect(self.workerCritical)
        self.worker.finished.connect(self.workerFinished)
        self.worker.finished.connect(self.relabelWorkerFinished)

        self.worker.debug.connect(self.workerDebug)

        self.thread.started.connect(self.worker.run)
        self.thread.start()
    
    def startPostProcessSegmWorker(
            self, postProcessKwargs, customPostProcessGroupedFeatures, 
            customPostProcessFeatures
        ):
        self.thread = QThread()
        self.postProcessWorker = workers.PostProcessSegmWorker(
            postProcessKwargs, customPostProcessGroupedFeatures, 
            customPostProcessFeatures, self
        )
        
        self.postProcessWorker.moveToThread(self.thread)
        self.postProcessWorker.signals.finished.connect(self.thread.quit)
        self.postProcessWorker.signals.finished.connect(
            self.postProcessWorker.deleteLater
        )
        self.thread.finished.connect(self.thread.deleteLater)

        self.postProcessWorker.signals.finished.connect(
            self.postProcessSegmWorkerFinished
        )
        self.postProcessWorker.signals.progress.connect(self.workerProgress)
        self.postProcessWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.postProcessWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.postProcessWorker.signals.critical.connect(
            self.workerCritical
        )

        self.thread.started.connect(self.postProcessWorker.run)
        self.thread.start()
    
    def relabelWorkerFinished(self):
        self.updateAllImages()

    def workerDebug(self, item):
        tracked_video, worker = item
        from cellacdc.plot import imshow
        imshow(tracked_video)
        worker.waitCond.wakeAll()

    def keepToolActiveActionToggled(self, checked, toolName=None):
        if toolName is None:
            parentToolButton = self.sender().parent()
            toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0]

        if checked:
            self.df_settings.at[toolName, 'value'] = 'keepActive'
        else:
            self.df_settings = self.df_settings.drop(
                index=toolName, errors='ignore'
            )
        self.df_settings.to_csv(self.settings_csv_path)

    def keepAllToolsActiveActionToggled(self, checked):
        for action in self.keepToolActiveActions.values():
            action.setChecked(checked)

        data_loaded = True
        if not hasattr(self, 'data'):
            data_loaded = False
            try:
                self.labelRoiTrangeCheckbox.disconnect()
            except TypeError:
                pass
        self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction?

        if data_loaded:
            self.labelRoiTrangeCheckbox.toggled.connect(
                self.labelRoiTrangeCheckboxToggled
            )

    def determineSlideshowWinPos(self):
        screens = self.app.screens()
        self.numScreens = len(screens)
        winScreen = self.screen()

        # Center main window and determine location of slideshow window
        # depending on number of screens available
        if self.numScreens > 1:
            for screen in screens:
                if screen != winScreen:
                    winScreen = screen
                    break

        winScreenGeom = winScreen.geometry()
        winScreenCenter = winScreenGeom.center()
        winScreenCenterX = winScreenCenter.x()
        winScreenCenterY = winScreenCenter.y()
        winScreenLeft = winScreenGeom.left()
        winScreenTop = winScreenGeom.top()
        self.slideshowWinLeft = winScreenCenterX - int(850/2)
        self.slideshowWinTop = winScreenCenterY - int(800/2)

    def nonViewerEditMenuOpened(self):
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer':
            self.startBlinkingModeCB()

    def getDistantGray(self, desiredGray, bkgrGray):
        isDesiredSimilarToBkgr = (
            abs(desiredGray-bkgrGray) < 0.3
        )
        if isDesiredSimilarToBkgr:
            return 1-desiredGray
        else:
            return desiredGray

    def RGBtoGray(self, R, G, B):
        # see https://stackoverflow.com/questions/17615963/standard-rgb-to-grayscale-conversion
        C_linear = (0.2126*R + 0.7152*G + 0.0722*B)/255
        if C_linear <= 0.0031309:
            gray = 12.92*C_linear
        else:
            gray = 1.055*(C_linear)**(1/2.4) - 0.055
        return gray

    def ruler_cb(self, checked):
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.sender())
            self.connectLeftClickButtons()
        else:
            self.tempSegmentON = False
            self.ax1_rulerPlotItem.setData([], [])
            self.ax1_rulerAnchorsItem.setData([], [])
    
    def editImgProperties(self, checked=True):
        posData = self.data[self.pos_i]
        posData.askInputMetadata(
            len(self.data),
            ask_SizeT=True,
            ask_TimeIncrement=True,
            ask_PhysicalSizes=True,
            save=True, singlePos=True,
            askSegm3D=False
        )
        if hasattr(self, 'timestamp'):
            self.timestamp.setSecondsPerFrame(posData.TimeIncrement)
            self.updateTimestampFrame()
        
        if hasattr(self, 'scaleBar'):
            self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX)

    def setHoverToolSymbolData(self, xx, yy, ScatterItems, size=None):
        if not xx:
            self.ax1_lostObjScatterItem.setVisible(True)
            self.ax2_lostObjScatterItem.setVisible(True)

            self.ax1_lostTrackedScatterItem.setVisible(True)
            self.ax2_lostTrackedScatterItem.setVisible(True)

        for item in ScatterItems:
            if size is None:
                item.setData(xx, yy)
            else:
                item.setData(xx, yy, size=size)
    
    def updateLabelRoiCircularSize(self, value):
        self.labelRoiCircItemLeft.setSize(value)
        self.labelRoiCircItemRight.setSize(value)
    
    def updateLabelRoiCircularCursor(self, x, y, checked):
        if not self.labelRoiButton.isChecked():
            return
        if not self.labelRoiIsCircularRadioButton.isChecked():
            return
        if self.labelRoiRunning:
            return

        size = self.labelRoiCircularRadiusSpinbox.value()
        if not checked:
            xx, yy = [], []
        else:
            xx, yy = [x], [y]
        
        if not xx and len(self.labelRoiCircItemLeft.getData()[0]) == 0:
            return

        self.labelRoiCircItemLeft.setData(xx, yy, size=size)
        self.labelRoiCircItemRight.setData(xx, yy, size=size)
    
    def getLabelRoiImage(self):
        posData = self.data[self.pos_i]

        if self.labelRoiTrangeCheckbox.isChecked():
            start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1
            stop_frame_n = self.labelRoiStopFrameNoSpinbox.value()
            tRangeLen = stop_frame_n-start_frame_i
        else:
            tRangeLen = 1
        
        if tRangeLen > 1:
            tRange = (start_frame_i, stop_frame_n)
        else:
            tRange = None
        
        if self.isSegm3D:
            if tRangeLen > 1:
                imgData = posData.img_data
            else:
                # Filtered data not existing
                imgData = posData.img_data[posData.frame_i]

            roi_zdepth = self.labelRoiZdepthSpinbox.value()
            if roi_zdepth == posData.SizeZ:
                z0 = 0
                z1 = posData.SizeZ
            elif roi_zdepth == 1:
                z0 = self.zSliceScrollBar.sliderPosition()
                z1 = z0 + 1
            else:
                if roi_zdepth%2 != 0:
                    roi_zdepth +=1
                half_zdepth = int(roi_zdepth/2)
                zc = self.zSliceScrollBar.sliderPosition() + 1
                z0 = zc-half_zdepth
                z0 = z0 if z0>=0 else 0
                z1 = zc+half_zdepth
                z1 = z1 if z1<posData.SizeZ else posData.SizeZ

            if self.labelRoiIsRectRadioButton.isChecked():
                labelRoiSlice = self.labelRoiItem.slice(
                    zRange=(z0,z1), tRange=tRange
                )
            elif self.labelRoiIsFreeHandRadioButton.isChecked():
                labelRoiSlice = self.freeRoiItem.slice(
                    zRange=(z0,z1), tRange=tRange
                )
            elif self.labelRoiIsCircularRadioButton.isChecked():
                labelRoiSlice = self.labelRoiCircItemLeft.slice(
                    zRange=(z0,z1), tRange=tRange
                )
        else:
            if self.labelRoiIsRectRadioButton.isChecked():
                labelRoiSlice = self.labelRoiItem.slice(tRange=tRange)
            elif self.labelRoiIsFreeHandRadioButton.isChecked():
                labelRoiSlice = self.freeRoiItem.slice(tRange=tRange)
            elif self.labelRoiIsCircularRadioButton.isChecked():
                labelRoiSlice = self.labelRoiCircItemLeft.slice(tRange=tRange)
            if tRangeLen > 1:
                imgData = posData.img_data
            else:
                imgData = self.img1.image

        roiImg = imgData[labelRoiSlice]
        if self.labelRoiIsFreeHandRadioButton.isChecked():
            mask = self.freeRoiItem.mask()
        elif self.labelRoiIsCircularRadioButton.isChecked():
            mask = self.labelRoiCircItemLeft.mask()
        else:
            mask = None
        
        if mask is not None:
            # Copy roiImg otherwise we are replacing minimum inside original image
            roiImg = roiImg.copy()
            # Fill outside of freehand roi with minimum of the ROI image
            if tRangeLen > 1:
                for i in range(tRangeLen):
                    ith_roiImg = roiImg[i]
                    if self.isSegm3D:
                        roiImg[i, :, ~mask] = ith_roiImg.min()
                    else:
                        roiImg[i, ~mask] = ith_roiImg.min()
            else:
                if self.isSegm3D:
                    roiImg[:, ~mask] = roiImg.min()
                else:
                    roiImg[~mask] = roiImg.min()

        return roiImg, labelRoiSlice
    
    def getClickedID(self, xdata, ydata, text=''):
        posData = self.data[self.pos_i]
        ID = self.get_2Dlab(posData.lab)[ydata, xdata]
        if ID == 0:
            msg = (
                'You clicked on the background.\n'
                f'Enter here the ID {text}'
            )
            nearest_ID = self.nearest_nonzero(
                self.get_2Dlab(posData.lab), xdata, ydata
            )
            clickedBkgrID = apps.QLineEditDialog(
                title='Clicked on background',
                msg=msg, parent=self, allowedValues=posData.IDs,
                defaultTxt=str(nearest_ID)
            )
            clickedBkgrID.exec_()
            if clickedBkgrID.cancel:
                return
            else:
                ID = clickedBkgrID.EntryID
        return ID
    
    # @exec_time
    def applyEditID(
            self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y
        ):  
        posData = self.data[self.pos_i]
        
        # Ask to propagate change to all future visited frames
        key = 'Edit ID'
        askAction = self.askHowFutureFramesActions[key]
        doNotShow = not askAction.isChecked()
        (UndoFutFrames, applyFutFrames, endFrame_i,
        doNotShowAgain) = self.propagateChange(
            clickedID, key, doNotShow,
            posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID,
            applyTrackingB=True
        )

        if UndoFutFrames is None:
            return

        # Store undo state before modifying stuff
        self.storeUndoRedoStates(UndoFutFrames)
        maxID = max(posData.IDs, default=0)
        for old_ID, new_ID in oldIDnewIDMapper:
            if new_ID in currentIDs and not self.editIDmergeIDs:
                tempID = maxID + 1
                posData.lab[posData.lab == old_ID] = maxID + 1
                posData.lab[posData.lab == new_ID] = old_ID
                posData.lab[posData.lab == tempID] = new_ID
                maxID += 1

                old_ID_idx = currentIDs.index(old_ID)
                new_ID_idx = currentIDs.index(new_ID)

                # Append information for replicating the edit in tracking
                # List of tuples (y, x, replacing ID)
                objo = posData.rp[old_ID_idx]
                yo, xo = self.getObjCentroid(objo.centroid)
                objn = posData.rp[new_ID_idx]
                yn, xn = self.getObjCentroid(objn.centroid)
                if not math.isnan(yo) and not math.isnan(yn):
                    yn, xn = int(yn), int(xn)
                    posData.editID_info.append((yn, xn, new_ID))
                    yo, xo = int(clicked_y), int(clicked_x)
                    posData.editID_info.append((yo, xo, old_ID))
            else:
                posData.lab[posData.lab == old_ID] = new_ID
                if new_ID > maxID:
                    maxID = new_ID
                old_ID_idx = posData.IDs.index(old_ID)

                # Append information for replicating the edit in tracking
                # List of tuples (y, x, replacing ID)
                obj = posData.rp[old_ID_idx]
                y, x = self.getObjCentroid(obj.centroid)
                if not math.isnan(y) and not math.isnan(y):
                    y, x = int(y), int(x)
                    posData.editID_info.append((y, x, new_ID))
            
            self.updateAssignedObjsAcdcTrackerSecondStep(new_ID)
        
        # Update rps
        self.update_rp()

        # Since we manually changed an ID we don't want to repeat tracking
        self.setAllTextAnnotations()        
        self.highlightLostNew()
        # self.checkIDsMultiContour()

        # Update colors for the edited IDs
        self.updateLookuptable()

        if self.isSnapshot:
            self.fixCcaDfAfterEdit('Edit ID')
            self.updateAllImages()
        else:
            self.warnEditingWithCca_df('Edit ID', update_images=False)
        
        if not self.editIDbutton.findChild(QAction).isChecked():
            self.editIDbutton.setChecked(False)

        posData.disableAutoActivateViewerWindow = True

        # Perform desired action on future frames
        posData.doNotShowAgain_EditID = doNotShowAgain
        posData.UndoFutFrames_EditID = UndoFutFrames
        posData.applyFutFrames_EditID = applyFutFrames
        includeUnvisited = posData.includeUnvisitedInfo['Edit ID']

        if applyFutFrames:
            self.changeIDfutureFrames(
                endFrame_i, oldIDnewIDMapper, includeUnvisited
            )
    
    def getHoverID(self, xdata, ydata):
        if not hasattr(self, 'diskMask'):
            return 0
        
        modifiers = QGuiApplication.keyboardModifiers()
        ctrl = modifiers == Qt.ControlModifier

        if self.isPowerBrush() and not ctrl:
            return 0        

        if not self.autoIDcheckbox.isChecked():
            return self.editIDspinbox.value()

        ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata)
        posData = self.data[self.pos_i]
        lab_2D = self.get_2Dlab(posData.lab)
        ID = lab_2D[ydata, xdata]
        self.isHoverZneighID = False
        if self.isSegm3D:
            z = self.z_lab()
            SizeZ = posData.lab.shape[0]
            doNotLinkThroughZ = (
                self.brushButton.isChecked() and self.isShiftDown
            )
            if doNotLinkThroughZ:
                if self.brushHoverCenterModeAction.isChecked() or ID>0:
                    hoverID = ID
                else:
                    masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask]
                    hoverID = np.bincount(masked_lab).argmax()
            else:
                if z > 0:
                    ID_z_under = posData.lab[z-1, ydata, xdata]
                    if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0:
                        hoverIDa = ID_z_under
                    else:
                        lab = posData.lab
                        masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask]
                        hoverIDa = np.bincount(masked_lab_a).argmax()
                else:
                    hoverIDa = 0

                if self.brushHoverCenterModeAction.isChecked() or ID>0:
                    hoverIDb = lab_2D[ydata, xdata]
                else:
                    masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask]
                    hoverIDb = np.bincount(masked_lab_b).argmax()

                if z < SizeZ-1:
                    ID_z_above = posData.lab[z+1, ydata, xdata]
                    if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0:
                        hoverIDc = ID_z_above
                    else:
                        lab = posData.lab
                        masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask]
                        hoverIDc = np.bincount(masked_lab_c).argmax()
                else:
                    hoverIDc = 0

                if hoverIDa > 0:
                    hoverID = hoverIDa
                    self.isHoverZneighID = True
                elif hoverIDb > 0:
                    hoverID = hoverIDb
                elif hoverIDc > 0:
                    hoverID = hoverIDc
                    self.isHoverZneighID = True
                else:
                    hoverID = 0
        else:
            if self.brushButton.isChecked() and self.isShiftDown:
                # Force new ID with brush and Shift
                hoverID = 0
            elif self.brushHoverCenterModeAction.isChecked() or ID>0:
                hoverID = ID
            else:
                masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask]
                hoverID = np.bincount(masked_lab).argmax()
            
        self.editIDspinbox.setValue(hoverID)

        return hoverID

    def setHoverToolSymbolColor(
            self, xdata, ydata, pen, ScatterItems, button,
            brush=None, hoverRGB=None, ID=None
        ):

        posData = self.data[self.pos_i]
        Y, X = self.get_2Dlab(posData.lab).shape
        if not myutils.is_in_bounds(xdata, ydata, X, Y):
            return

        self.isHoverZneighID = False
        if ID is None:
            hoverID = self.getHoverID(xdata, ydata)
        else:
            hoverID = ID

        if hoverID == 0:
            for item in ScatterItems:
                item.setPen(pen)
                item.setBrush(brush)
        else:
            try:
                rgb = self.lut[hoverID]
                rgb = rgb if hoverRGB is None else hoverRGB
                rgbPen = np.clip(rgb*1.1, 0, 255)
                for item in ScatterItems:
                    item.setPen(*rgbPen, width=2)
                    item.setBrush(*rgb, 100)
            except IndexError:
                pass
        
        checkChangeID = (
            self.isHoverZneighID and not self.isShiftDown
            and self.lastHoverID != hoverID
        )
        if checkChangeID:
            # We are hovering an ID in z+1 or z-1
            self.restoreBrushID = hoverID
            # self.changeBrushID()
        
        self.lastHoverID = hoverID
    
    def isPowerBrush(self):
        color = self.brushButton.palette().button().color().name()
        return color == self.doublePressKeyButtonColor
    
    def isPowerEraser(self):
        color = self.eraserButton.palette().button().color().name()
        return color == self.doublePressKeyButtonColor
    
    def isPowerButton(self, button):
        color = button.palette().button().color().name()
        return color == self.doublePressKeyButtonColor

    def getCheckNormAction(self):
        normalize = False
        how = ''
        for action in self.normalizeQActionGroup.actions():
            if action.isChecked():
                how = action.text()
                normalize = True
                break
        return action, normalize, how

    def normalizeIntensities(self, img):
        action, normalize, how = self.getCheckNormAction()
        if not normalize:
            return img
        
        if how == 'Do not normalize. Display raw image':
            img = img 
        elif how == 'Convert to floating point format with values [0, 1]':
            img = myutils.img_to_float(img)
        # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]':
        #     img = skimage.img_as_float(img)
        #     img = (img*255).astype(np.uint8)
        #     return img
        elif how == 'Rescale to [0, 1]':
            img = skimage.img_as_float(img)
            img = skimage.exposure.rescale_intensity(img)
        elif how == 'Normalize by max value':
            img = img/np.max(img)
        return img

    def removeAlldelROIsCurrentFrame(self):
        posData = self.data[self.pos_i]
        delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
        rois = delROIs_info['rois'].copy()
        for roi in rois:
            self.ax2.removeDelRoiItem(roi)

        for item in self.ax2.items:
            if isinstance(item, pg.ROI):
                self.ax2.removeDelRoiItem(item)
        
        for item in self.ax1.items:
            if isinstance(item, pg.ROI) and item != self.labelRoiItem:
                self.ax1.removeDelRoiItem(item)

    def removeDelROI(self, event):
        posData = self.data[self.pos_i]
        
        delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
        idx = delROIs_info['rois'].index(self.roi_to_del)
        delROIs_info['rois'].pop(idx)
        delROIs_info['delMasks'].pop(idx)
        delROIs_info['delIDsROI'].pop(idx)
        delROIs_info['state'].pop(idx)
        
        self.removeDelROIFromFutureFrames(self.roi_to_del)
        self.updateAllImages()
    
    def removeDelROIFromFutureFrames(self, roi_to_del):
        posData = self.data[self.pos_i]
        
        # Restore deleted IDs from already visited future frames
        current_frame_i = posData.frame_i    
        for i in range(posData.frame_i+1, posData.SizeT):
            if posData.allData_li[i]['labels'] is None:
                break
            
            delROIs_info = posData.allData_li[i]['delROIs_info']
            try:
                idx = delROIs_info['rois'].index(roi_to_del) 
            except IndexError:
                continue
            
            posData.frame_i = i
            idx = delROIs_info['rois'].index(roi_to_del)         
            if delROIs_info['delIDsROI'][idx]:
                posData.lab = posData.allData_li[i]['labels']
                self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False)
                posData.allData_li[i]['labels'] = posData.lab
                self.get_data()
                self.store_data(autosave=False)
            delROIs_info['rois'].pop(idx)
            delROIs_info['delMasks'].pop(idx)
            delROIs_info['delIDsROI'].pop(idx)
            delROIs_info['state'].pop(idx)
        
        if isinstance(self.roi_to_del, pg.PolyLineROI):
            # PolyLine ROIs are only on ax1
            self.ax1.removeItem(self.roi_to_del)
        elif not self.labelsGrad.showLabelsImgAction.isChecked():
            # Rect ROI is on ax1 because ax2 is hidden
            self.ax1.removeItem(self.roi_to_del)
        else:
            # Rect ROI is on ax2 because ax2 is visible
            self.ax2.removeItem(self.roi_to_del)

        # Back to current frame
        posData.frame_i = current_frame_i
        posData.lab = posData.allData_li[posData.frame_i]['labels']                   
        self.get_data()
        self.store_data()

    def updateDelROIinFutureFrames(self, roi: pg.ROI):
        posData = self.data[self.pos_i]
        update_images = False
        
        roiState = roi.getState()
        # Restore deleted IDs from already visited future frames
        current_frame_i = posData.frame_i    
        delROIs_info = posData.allData_li[current_frame_i]['delROIs_info']
        idx = delROIs_info['rois'].index(roi)       
        delROIs_info['state'][idx] = roiState
        
        for i in range(posData.frame_i+1, posData.SizeT):
            delROIs_info = posData.allData_li[i]['delROIs_info']
            idx = delROIs_info['rois'].index(roi)       
            delROIs_info['state'][idx] = roiState
            if posData.allData_li[i]['labels'] is None:
                continue
            
            posData.frame_i = i
            posData.lab = posData.allData_li[i]['labels']
            self.restoreAnnotDelROI(roi, enforce=False, draw=False)
            posData.allData_li[i]['labels'] = posData.lab
            self.get_data()
            self.store_data(autosave=False)
            update_images = True
        
        # Back to current frame
        posData.frame_i = current_frame_i
        posData.lab = posData.allData_li[posData.frame_i]['labels']                   
        self.get_data()
        self.store_data()
        
        if not update_images:
            return
        
        # self.updateAllImages()
    
    # @exec_time
    def getPolygonBrush(self, yxc2, Y, X):
        # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles
        y1, x1 = self.yPressAx2, self.xPressAx2
        y2, x2 = yxc2
        R = self.brushSizeSpinbox.value()
        r = R

        arcsin_den = np.sqrt((x2-x1)**2+(y2-y1)**2)
        arctan_den = (x2-x1)
        if arcsin_den!=0 and arctan_den!=0:
            beta = np.arcsin((R-r)/arcsin_den)
            gamma = -np.arctan((y2-y1)/arctan_den)
            alpha = gamma-beta
            x3 = x1 + r*np.sin(alpha)
            y3 = y1 + r*np.cos(alpha)
            x4 = x2 + R*np.sin(alpha)
            y4 = y2 + R*np.cos(alpha)

            alpha = gamma+beta
            x5 = x1 - r*np.sin(alpha)
            y5 = y1 - r*np.cos(alpha)
            x6 = x2 - R*np.sin(alpha)
            y6 = y2 - R*np.cos(alpha)

            rr_poly, cc_poly = skimage.draw.polygon([y3, y4, y6, y5],
                                                    [x3, x4, x6, x5],
                                                    shape=(Y, X))
        else:
            rr_poly, cc_poly = [], []

        self.yPressAx2, self.xPressAx2 = y2, x2
        return rr_poly, cc_poly

    def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1):
        h, w = shape
        y_above = yd+1 if yd+1 < h else yd
        y_below = yd-1 if yd > 0 else yd
        x_right = xd+1 if xd+1 < w else xd
        x_left = xd-1 if xd > 0 else xd
        if alfa_dir == 0:
            yy = [y_below, y_below, yd, y_above, y_above]
            xx = [xd, x_right, x_right, x_right, xd]
        elif alfa_dir == 45:
            yy = [y_below, y_below, y_below, yd, y_above]
            xx = [x_left, xd, x_right, x_right, x_right]
        elif alfa_dir == 90:
            yy = [yd, y_below, y_below, y_below, yd]
            xx = [x_left, x_left, xd, x_right, x_right]
        elif alfa_dir == 135:
            yy = [y_above, yd, y_below, y_below, y_below]
            xx = [x_left, x_left, x_left, xd, x_right]
        elif alfa_dir == -180 or alfa_dir == 180:
            yy = [y_above, y_above, yd, y_below, y_below]
            xx = [xd, x_left, x_left, x_left, xd]
        elif alfa_dir == -135:
            yy = [y_below, yd, y_above, y_above, y_above]
            xx = [x_left, x_left, x_left, xd, x_right]
        elif alfa_dir == -90:
            yy = [yd, y_above, y_above, y_above, yd]
            xx = [x_left, x_left, xd, x_right, x_right]
        else:
            yy = [y_above, y_above, y_above, yd, y_below]
            xx = [x_left, xd, x_right, x_right, x_right]
        if connectivity == 1:
            return yy[1:4], xx[1:4]
        else:
            return yy, xx

    def drawAutoContour(self, y2, x2):
        y1, x1 = self.autoCont_y0, self.autoCont_x0
        Dy = abs(y2-y1)
        Dx = abs(x2-x1)
        edge = self.getDisplayedImg1()
        if Dy != 0 or Dx != 0:
            # NOTE: numIter takes care of any lag in mouseMoveEvent
            numIter = int(round(max((Dy, Dx))))
            alfa = np.arctan2(y1-y2, x2-x1)
            base = np.pi/4
            alfa_dir = round((base * round(alfa/base))*180/np.pi)
            for _ in range(numIter):
                y1, x1 = self.autoCont_y0, self.autoCont_x0
                yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape)
                a_dir = edge[yy, xx]
                min_int = np.max(a_dir)
                min_i = list(a_dir).index(min_int)
                y, x = yy[min_i], xx[min_i]
                try:
                    xx, yy = self.curvHoverPlotItem.getData()
                except TypeError:
                    xx, yy = [], []
                if x == xx[-1] and yy == yy[-1]:
                    # Do not append point equal to last point
                    return
                xx = np.r_[xx, x]
                yy = np.r_[yy, y]
                try:
                    self.curvHoverPlotItem.setData(xx, yy)
                    self.curvPlotItem.setData(xx, yy)
                except TypeError:
                    pass
                self.autoCont_y0, self.autoCont_x0 = y, x
                # self.smoothAutoContWithSpline()

    def smoothAutoContWithSpline(self, n=3):
        try:
            xx, yy = self.curvHoverPlotItem.getData()
            # Downsample by taking every nth coord
            xxA, yyA = xx[::n], yy[::n]
            rr, cc = skimage.draw.polygon(yyA, xxA)
            self.autoContObjMask[rr, cc] = 1
            rp = skimage.measure.regionprops(self.autoContObjMask)
            if not rp:
                return
            obj = rp[0]
            cont = self.getObjContours(obj)
            xxC, yyC = cont[:,0], cont[:,1]
            xxA, yyA = xxC[::n], yyC[::n]
            self.xxA_autoCont, self.yyA_autoCont = xxA, yyA
            xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True)
            if len(xxS)>0:
                self.curvPlotItem.setData(xxS, yyS)
        except TypeError:
            pass

    def updateIsHistoryKnown():
        """
        This function is called every time the user saves and it is used
        for updating the status of cells where we don't know the history

        There are three possibilities:

        1. The cell with unknown history is a BUD
           --> we don't know when that  bud emerged --> 'emerg_frame_i' = -1
        2. The cell with unknown history is a MOTHER cell
           --> we don't know emerging frame --> 'emerg_frame_i' = -1
               AND generation number --> we start from 'generation_num' = 2
        3. The cell with unknown history is a CELL in G1
           --> we don't know emerging frame -->  'emerg_frame_i' = -1
               AND generation number --> we start from 'generation_num' = 2
               AND relative's ID in the previous cell cycle --> 'relative_ID' = -1
        """
        pass

    def getStatusKnownHistoryBud(self, ID):
        posData = self.data[self.pos_i]
        cca_df_ID = None
        for i in range(posData.frame_i-1, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=i, return_df=True)
            is_cell_existing = is_bud_existing = ID in cca_df_i.index
            if not is_cell_existing:
                bud_cca_dict = base_cca_dict.copy()
                bud_cca_dict['cell_cycle_stage'] = 'S'
                bud_cca_dict['generation_num'] = 0
                bud_cca_dict['relationship'] = 'bud'
                bud_cca_dict['emerg_frame_i'] = i+1
                bud_cca_dict['is_history_known'] = True
                cca_df_ID = pd.Series(bud_cca_dict)
                return cca_df_ID
    
    def setHistoryKnowledge(self, ID, cca_df):
        posData = self.data[self.pos_i]
        is_history_known = cca_df.at[ID, 'is_history_known']
        if is_history_known:
            cca_df.at[ID, 'is_history_known'] = False
            cca_df.at[ID, 'cell_cycle_stage'] = 'G1'
            cca_df.at[ID, 'generation_num'] += 2
            cca_df.at[ID, 'emerg_frame_i'] = -1
            cca_df.at[ID, 'relative_ID'] = -1
            cca_df.at[ID, 'relationship'] = 'mother'
        else:
            cca_df.loc[ID] = posData.ccaStatus_whenEmerged[ID]

    def annotateIsHistoryKnown(self, ID):
        """
        This function is used for annotating that a cell has unknown or known
        history. Cells with unknown history are for example the cells already
        present in the first frame or cells that appear in the frame from
        outside of the field of view.

        With this function we simply set 'is_history_known' to False.
        When the users saves instead we update the entire staus of the cell
        with unknown history with the function "updateIsHistoryKnown()"
        """
        posData = self.data[self.pos_i]
        is_history_known = posData.cca_df.at[ID, 'is_history_known']
        relID = posData.cca_df.at[ID, 'relative_ID']
        if relID in posData.cca_df.index:
            relID_cca = self.getStatus_RelID_BeforeEmergence(ID, relID)

        if is_history_known:
            # Save status of ID when emerged to allow undoing
            statusID_whenEmerged = self.getStatusKnownHistoryBud(ID)
            if statusID_whenEmerged is None:
                return
            posData.ccaStatus_whenEmerged[ID] = statusID_whenEmerged

        # Store cca_df for undo action
        undoId = uuid.uuid4()
        self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId)

        if ID not in posData.ccaStatus_whenEmerged:
            self.warnSettingHistoryKnownCellsFirstFrame(ID)
            return

        self.setHistoryKnowledge(ID, posData.cca_df)

        if relID in posData.cca_df.index:
            # If the cell with unknown history has a relative ID assigned to it
            # we set the cca of it to the status it had BEFORE the assignment
            posData.cca_df.loc[relID] = relID_cca

        # Update cell cycle info LabelItems
        obj_idx = posData.IDs.index(ID)
        rp_ID = posData.rp[obj_idx]

        if relID in posData.IDs:
            relObj_idx = posData.IDs.index(relID)
            rp_relID = posData.rp[relObj_idx]
        
        self.setAllTextAnnotations()
        self.drawAllMothBudLines()

        self.store_cca_df()

        if self.ccaTableWin is not None:
            zoomIDs = self.getZoomIDs()
            self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs)

        # Correct future frames
        for i in range(posData.frame_i+1, posData.SizeT):
            cca_df_i = self.get_cca_df(frame_i=i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break

            self.storeUndoRedoCca(i, cca_df_i, undoId)
            IDs = cca_df_i.index
            if ID not in IDs:
                # For some reason ID disappeared from this frame
                continue
            else:
                self.setHistoryKnowledge(ID, cca_df_i)
                if relID in IDs:
                    cca_df_i.loc[relID] = relID_cca
                self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False)


        # Correct past frames
        for i in range(posData.frame_i-1, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break

            self.storeUndoRedoCca(i, cca_df_i, undoId)
            IDs = cca_df_i.index
            if ID not in IDs:
                # we reached frame where ID was not existing yet
                break
            else:
                relID = cca_df_i.at[ID, 'relative_ID']
                self.setHistoryKnowledge(ID, cca_df_i)
                if relID in IDs:
                    cca_df_i.loc[relID] = relID_cca
                self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False)
        
        self.enqAutosave()
    
    def annotateWillDivide(self, ID, relID, frame_i=None):
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i

        # Store in the past frames that division has been annotated
        for past_frame_i in range(frame_i-1, -1, -1):
            past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True)
            if past_cca_df is None:
                return
            
            if ID not in past_cca_df.index:
                # ID is a bud and is not emerged yet here
                return
            
            if frame_i-1 == past_frame_i:
                # Get generation number at first iteration
                gen_num = past_cca_df.at[ID, 'generation_num']
                
            if past_cca_df.at[ID, 'generation_num'] != gen_num:
                # ID is a mother and the cell cycle is finished here
                return
            
            past_cca_df.at[ID, 'will_divide'] = 1
            past_cca_df.at[relID, 'will_divide'] = 1

            self.store_cca_df(
                cca_df=past_cca_df, frame_i=past_frame_i, autosave=False
            )

    def annotateDivisionFutureFramesSwapMothers(
            self, cca_df_at_future_division, mothIDofDisappearedBud, frame_i
        ):
        """This method is called as part of `guiWin.swapMothers`. 
        
        It annotates cell division and propagates that to future frames to the 
        mother cell that stops having the correct bud because division between 
        wrong bud and other wrong mother was annotated in the future. 

        Parameters
        ----------
        cca_df_at_future_division : pd.DataFrame
            _description_
        mothIDofDisappearedBud : int
            Mother ID of the disappeared bud
        frame_i : int
            Frame since when the mother ID stops having the correct bud because 
            the correct bud was assigned as divided from the wrong mother
        """        
        posData = self.data[self.pos_i]
        
        relativeIDofMothID = cca_df_at_future_division.at[
            mothIDofDisappearedBud, 'relative_ID'
        ]
        if relativeIDofMothID not in cca_df_at_future_division.index:
            # Also wrong bud ID disappeared
            return
        
        relativeIDofMothIDrelationship = cca_df_at_future_division.at[
            relativeIDofMothID, 'relationship'
        ]
        if relativeIDofMothIDrelationship != 'bud':
            # The wrong bud ID is a cell in G1 from future cycle --> 
            # the actual wrong bud ID disappeared too.
            return
        
        wrongBudID = relativeIDofMothID
        
        self.annotateDivision(
            cca_df_at_future_division, mothIDofDisappearedBud, wrongBudID, 
            frame_i=frame_i
        )
        cca_df_at_future_division.at[
            mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i
        self.store_cca_df(
            frame_i=frame_i, cca_df=cca_df_at_future_division, autosave=False
        )
        
        ccaStatusToRestore = cca_df_at_future_division.loc[mothIDofDisappearedBud]
        for future_i in range(frame_i+1, posData.SizeT):            
            # Get cca_df for ith frame from allData_li
            cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break
            
            ccs = cca_df_i.at[mothIDofDisappearedBud, 'cell_cycle_stage']
            if ccs == 'G1':
                # Mother cell in G1 again, stop correcting
                break
            
            cca_df_i.loc[mothIDofDisappearedBud] = ccaStatusToRestore
            cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i
            
            self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False)            
    
    def annotateDivision(self, cca_df, ID, relID, frame_i=None):
        # Correct as follows:
        # For frame_i > 0 --> assign to G1 and +1 on generation number
        # For frame == 0 --> reinitialize to unknown cells
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i

        self.annotateWillDivide(ID, relID)

        store = False
        cca_df.at[ID, 'cell_cycle_stage'] = 'G1'
        cca_df.at[relID, 'cell_cycle_stage'] = 'G1'
        
        if frame_i > 0:
            gen_num_clickedID = cca_df.at[ID, 'generation_num']
            cca_df.at[ID, 'generation_num'] += 1
            cca_df.at[ID, 'division_frame_i'] = frame_i    
            gen_num_relID = cca_df.at[relID, 'generation_num']
            cca_df.at[relID, 'generation_num'] = gen_num_relID+1
            cca_df.at[relID, 'division_frame_i'] = frame_i
            if gen_num_clickedID < gen_num_relID:
                cca_df.at[ID, 'relationship'] = 'mother'
            else:
                cca_df.at[relID, 'relationship'] = 'mother'
        else:
            cca_df.at[ID, 'generation_num'] = 2
            cca_df.at[relID, 'generation_num'] = 2

            cca_df.at[ID, 'division_frame_i'] = -1
            cca_df.at[relID, 'division_frame_i'] = -1

            cca_df.at[ID, 'relationship'] = 'mother' 
            cca_df.at[relID, 'relationship'] = 'mother'
        
        store = True
        return store

    def undoDivisionAnnotation(self, cca_df, ID, relID):
        # Correct as follows:
        # If G1 then correct to S and -1 on generation number
        store = False
        cca_df.at[ID, 'cell_cycle_stage'] = 'S'
        gen_num_clickedID = cca_df.at[ID, 'generation_num']
        cca_df.at[ID, 'generation_num'] -= 1
        cca_df.at[ID, 'division_frame_i'] = -1
        cca_df.at[relID, 'cell_cycle_stage'] = 'S'
        gen_num_relID = cca_df.at[relID, 'generation_num']
        cca_df.at[relID, 'generation_num'] -= 1
        cca_df.at[relID, 'division_frame_i'] = -1
        if gen_num_clickedID < gen_num_relID:
            cca_df.at[ID, 'relationship'] = 'bud'
        else:
            cca_df.at[relID, 'relationship'] = 'bud'
        cca_df.at[ID, 'will_divide'] = 0
        cca_df.at[relID, 'will_divide'] = 0
        store = True
        return store

    def undoBudMothAssignment(self, ID):
        posData = self.data[self.pos_i]
        relID = posData.cca_df.at[ID, 'relative_ID']
        ccs = posData.cca_df.at[ID, 'cell_cycle_stage']
        if ccs == 'G1':
            return
        posData.cca_df.at[ID, 'relative_ID'] = -1
        posData.cca_df.at[ID, 'generation_num'] = 2
        posData.cca_df.at[ID, 'cell_cycle_stage'] = 'G1'
        posData.cca_df.at[ID, 'relationship'] = 'mother'
        if relID in posData.cca_df.index:
            posData.cca_df.at[relID, 'relative_ID'] = -1
            posData.cca_df.at[relID, 'generation_num'] = 2
            posData.cca_df.at[relID, 'cell_cycle_stage'] = 'G1'
            posData.cca_df.at[relID, 'relationship'] = 'mother'

        obj_idx = posData.IDs.index(ID)
        relObj_idx = posData.IDs.index(relID)
        rp_ID = posData.rp[obj_idx]
        rp_relID = posData.rp[relObj_idx]

        self.store_cca_df()

        # Update cell cycle info LabelItems
        self.setAllTextAnnotations()

        if self.ccaTableWin is not None:
            zoomIDs = self.getZoomIDs()
            self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs)

    @exception_handler
    def manualCellCycleAnnotation(self, ID):
        """
        This function is used for both annotating division or undoing the
        annotation. It can be called on any frame.

        If we annotate division (right click on a cell in S) then it will
        check if there are future frames to correct.
        Frames to correct are those frames where both the mother and the bud
        are annotated as S phase cells.
        In this case we assign all those frames to G1, relationship to mother,
        and +1 generation number

        If we undo the annotation (right click on a cell in G1) then it will
        correct both past and future annotated frames (if present).
        Frames to correct are those frames where both the mother and the bud
        are annotated as G1 phase cells.
        In this case we assign all those frames to G1, relationship back to
        bud, and -1 generation number
        """
        posData = self.data[self.pos_i]

        # Store cca_df for undo action
        undoId = uuid.uuid4()
        self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId)

        # Correct current frame
        clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage']
        relID = posData.cca_df.at[ID, 'relative_ID']

        if relID not in posData.IDs:
            return
        
        if clicked_ccs == 'G1' and posData.frame_i == 0:
            # We do not allow undoing division annotation on first frame
            return

        if clicked_ccs == 'G1':
            issue_frame_i = self.checkDivisionCanBeUndone(ID, relID)
            if issue_frame_i is not None:
                _warnings.warnDivisionAnnotationCannotBeUndone(
                    ID, relID, issue_frame_i, qparent=self
                )
                return
        
        if clicked_ccs == 'S':
            self.annotateDivision(posData.cca_df, ID, relID)
            self.store_cca_df()
        else:
            self.undoDivisionAnnotation(posData.cca_df, ID, relID)
            self.store_cca_df()

        # Update cell cycle info LabelItems
        self.ax1_newMothBudLinesItem.setData([], [])
        self.ax1_oldMothBudLinesItem.setData([], [])
        self.ax2_newMothBudLinesItem.setData([], [])
        self.ax2_oldMothBudLinesItem.setData([], [])
        self.drawAllMothBudLines()
        self.setAllTextAnnotations()

        if self.ccaTableWin is not None:
            zoomIDs = self.getZoomIDs()
            self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs)
        
        # Correct future frames
        for future_i in range(posData.frame_i+1, posData.SizeT):
            cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break

            self.storeUndoRedoCca(future_i, cca_df_i, undoId)
            IDs = cca_df_i.index
            if ID not in IDs:
                # For some reason ID disappeared from this frame
                continue

            ccs = cca_df_i.at[ID, 'cell_cycle_stage']
            relID = cca_df_i.at[ID, 'relative_ID']
            if clicked_ccs == 'S':
                if ccs == 'G1':
                    # Cell is in G1 in the future again so stop annotating
                    break
                self.annotateDivision(cca_df_i, ID, relID)
                self.store_cca_df(
                    frame_i=future_i, cca_df=cca_df_i, autosave=False
                )
            elif ccs == 'S':
                # Cell is in S in the future again so stop undoing (break)
                # also leave a 1 frame duration G1 to avoid a continuous
                # S phase
                self.annotateDivision(cca_df_i, ID, relID)
                self.store_cca_df(
                    frame_i=future_i, cca_df=cca_df_i, autosave=False
                )
                break
            else:
                self.undoDivisionAnnotation(cca_df_i, ID, relID)
                self.store_cca_df(
                    frame_i=future_i, cca_df=cca_df_i, autosave=False
                )
        
        # Correct past frames
        for past_i in range(posData.frame_i-1, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True)
            if ID not in cca_df_i.index or relID not in cca_df_i.index:
                # Bud did not exist at frame_i = i
                break

            self.storeUndoRedoCca(past_i, cca_df_i, undoId)
            ccs = cca_df_i.at[ID, 'cell_cycle_stage']
            relID = cca_df_i.at[ID, 'relative_ID']
            if ccs == 'S':
                # We correct only those frames in which the ID was in 'G1'
                break
            else:
                store = self.undoDivisionAnnotation(cca_df_i, ID, relID)
                self.store_cca_df(
                    frame_i=past_i, cca_df=cca_df_i, autosave=False
                )
        
        self.enqAutosave()

    def warnMotherNotEligible(self, new_mothID, budID, i, why):
        if why == 'not_G1_in_the_future':
            err_msg = html_utils.paragraph(f"""
                The requested cell in G1 (ID={new_mothID})
                at future frame {i+1} has a bud assigned to it,
                therefore it cannot be assigned as the mother
                of bud ID {budID}.<br><br>
                You can assign a cell as the mother of bud ID {budID}
                only if this cell is in G1 for the
                entire life of the bud.<br><br>
                One possible solution is to click on "cancel", go to
                frame {i+1} and  assign the bud of cell {new_mothID}
                to another cell.\n'
                A second solution is to assign bud ID {budID} to cell
                {new_mothID} anyway by clicking "Apply".<br><br>
                However to ensure correctness of
                future assignments Cell-ACDC will delete any cell cycle
                information from frame {i+1} to the end. Therefore, you
                will have to visit those frames again.<br><br>
                The deletion of cell cycle information
                <b>CANNOT BE UNDONE!</b>
                Saved data is not changed of course.<br><br>
                Apply assignment or cancel process?
            """)
            applyButton = widgets.okPushButton(isDefault=False)
            applyButton.setText('Apply and remove future annotations')
            msg = widgets.myMessageBox()
            _, applyButton = msg.warning(
               self, 'Cell not eligible', err_msg, 
               buttonsTexts=('Cancel', applyButton)
            )
            cancel = msg.cancel
            apply = msg.clickedButton == applyButton
        elif why == 'not_G1_in_the_past':
            err_msg = html_utils.paragraph(f"""
                The requested cell in G1
                (ID={new_mothID}) at past frame {i+1}
                has a bud assigned to it, therefore it cannot be
                assigned as mother of bud ID {budID}.<br>
                You can assign a cell as the mother of bud ID {budID}
                only if this cell is in G1 for the entire life of the bud.<br>
                One possible solution is to first go to frame {i+1} and
                assign the bud of cell {new_mothID} to another cell.
            """)
            msg = widgets.myMessageBox()
            msg.warning(
               self, 'Cell not eligible', err_msg
            )
            cancel = msg.cancel
            apply = False
        elif why == 'single_frame_G1_duration':
            err_msg = html_utils.paragraph(f"""
                Assigning bud ID {budID} to cell ID {new_mothID} would result 
                in <b>no G1 phase at all</b> between previous cell cycle and 
                current cell cycle (see frame n. {i+1}).<br><br>
                
                The solution is to annotate division on cell ID {new_mothID} 
                on any frame before the frame number {i+1}, and then 
                proceed to correcting the bud assignment.<br><br>
                
                This will gurantee a G1 duration for the cell {new_mothID}
                of <b>at least 1 frame</b>.<br><br>
                Thank you for your patience!
            """)
            msg = widgets.myMessageBox()
            msg.warning(
               self, 'Cell not eligible', err_msg
            )
            cancel = msg.cancel
            apply = False
        return cancel, apply
    
    def warnSettingHistoryKnownCellsFirstFrame(self, ID):
        txt = html_utils.paragraph(f"""
            Cell ID {ID} is a cell that is <b>present since the first 
            frame.</b><br><br>
            These cells already have history UNKNOWN assigned and the 
            history status <b>cannot be changed.</b>
        """)
        msg = widgets.myMessageBox(wrapText=False)
        msg.warning(
            self, 'First frame cells', txt
        )

    def checkMothEligibility(self, budID, new_mothID):
        """
        Check that the new mother is in G1 for the entire life of the bud
        and that the G1 duration is > than 1 frame
        """
        last_cca_frame_i = self.navigateScrollBar.maximum()-1
        posData = self.data[self.pos_i]
        eligible = True

        # Check future frames
        G1_duration_future = 0
        for future_i in range(posData.frame_i, posData.SizeT):
            cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True)

            if cca_df_i is None:
                # ith frame was not visited yet
                break
            
            if budID not in cca_df_i.index:
                # Bud disappeared
                break

            is_still_bud = cca_df_i.at[budID, 'relationship'] == 'bud'
            if not is_still_bud:
                break

            ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage']
            if ccs != 'G1':
                cancel, apply = self.warnMotherNotEligible(
                    new_mothID, budID, future_i, 'not_G1_in_the_future'
                )
                if apply:
                    self.resetCcaFuture(future_i)
                    break
                isG1singleFrame = G1_duration_future == 1
                isFutureFrameNotLastAnnot = future_i != last_cca_frame_i
                if cancel or (isG1singleFrame and isFutureFrameNotLastAnnot):
                    eligible = False
                    return eligible
            
            G1_duration_future += 1

        # Check past frames
        for past_i in range(posData.frame_i-1, -1, -1):
            # Get cca_df for ith frame from allData_li
            cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True)

            is_bud_existing = budID in cca_df_i.index
            is_moth_existing = new_mothID in cca_df_i.index

            if not is_moth_existing:
                # Mother not existing because it appeared from outside FOV
                break

            ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage']
            if ccs != 'G1' and is_bud_existing:
                # Requested mother not in G1 in the past
                # during the life of the bud (is_bud_existing = True)
                self.warnMotherNotEligible(
                    new_mothID, budID, past_i, 'not_G1_in_the_past'
                )
                eligible = False
                return eligible

            if not is_bud_existing:
                # Bud stop existing --> check that mother is still in G1
                if ccs != 'G1':
                    eligible = False
                    self.warnMotherNotEligible(
                        new_mothID, budID, past_i, 'single_frame_G1_duration'
                    )
                break
            
        return eligible
    
    def checkMothersExcludedOrDead(self):
        try:
            posData = self.data[self.pos_i]
            buds_df = posData.cca_df[
                (posData.cca_df.relationship == 'bud')
                & (posData.cca_df.emerg_frame_i == posData.frame_i)
            ]
            acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df']
            moth_df = acdc_df_i.loc[buds_df.relative_ID.to_list()]
            excluded_df = moth_df[
                (moth_df.is_cell_dead > 0) | (moth_df.is_cell_excluded > 0)
            ]
            excludedMothIDs = excluded_df.index.to_list()
            if not excludedMothIDs:
                self.stopBlinkingPairItem()
                return True
            budIDsOfExcludedMoth = excluded_df.relative_ID.to_list()
            proceed = self.warnDeadOrExcludedMothers(
                budIDsOfExcludedMoth, excludedMothIDs
            )
            return proceed
        except Exception as e:
            self.logger.info(traceback.format_exc())
            print('-'*100)
            self.logger.warning(
                'Checking if mother cell is excluded or dead failed.'
            )
            print('^'*100)
            return False
    
    def checkDivisionCanBeUndone(self, ID, relID):
        """Check that division annotation can be undone (see Notes section)

        Parameters
        ----------
        ID : int
            Cell ID of the clicked cell in G1
        relID : _type_
            Relative ID of the cell that was clicked
        
        Notes
        -----
        Division annotation can be undone only if `relID` is also in G1 for the
        entire duration of the correction
        """        
        posData = self.data[self.pos_i]
        
        ccs_relID = posData.cca_df.at[relID, 'cell_cycle_stage']
        if ccs_relID == 'S':
            return posData.frame_i
        
        # Check future frames
        for future_i in range(posData.frame_i+1, posData.SizeT):
            cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break 
            
            ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage']
            if ccs_relID == 'S':
                return future_i
        
        # Check past frames
        for past_i in range(posData.frame_i-1, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True)
            if ID not in cca_df_i.index or relID not in cca_df_i.index:
                # Bud did not exist at frame_i = i
                break
            
            ccs = cca_df_i.at[ID, 'cell_cycle_stage']
            if ccs == 'S':
                break
            
            ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage']
            if ccs_relID == 'S':
                return future_i           
            
    
    def stopBlinkingPairItem(self):
        self.ax1_newMothBudLinesItem.setOpacity(1.0)
        self.ax1_oldMothBudLinesItem.setOpacity(1.0)
        
        self.warnPairingItem.setData([], [])
        try:
            self.blinkPairingItemTimer.stop()
        except Exception as e:
            pass
    
    def warnDeadOrExcludedMothers(self, budIDs, mothIDs):
        self.startBlinkingPairingItem(budIDs, mothIDs)
        msg = widgets.myMessageBox(wrapText=False)
        pairings = [
            f'Mother ID {mID} --> bud ID {bID}' 
            for mID, bID in zip(mothIDs, budIDs)
        ]
        txt = html_utils.paragraph(f"""
            The <b>mother</b> cell in the following mother-bud pairings
            (blinking line on the image) is<br>
            <b>excluded from the analysis or dead</b>:
            {html_utils.to_list(pairings)}
        """)
        msg.warning(
            self, 'Mother cell is excluded or dead', txt, 
            buttonsTexts=('Cancel', 'Ok')
        )
        return not msg.cancel
    
    def startBlinkingPairingItem(self, budIDs, mothIDs):
        self.ax1_newMothBudLinesItem.setOpacity(0.2)
        self.ax1_oldMothBudLinesItem.setOpacity(0.2)
        
        posData = self.data[self.pos_i]
        acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df']
        
        # Blink one pairing at the time (the first found)
        xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid']
        yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid']
        
        xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid']
        yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid']
        
        self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m])
        
        self.blinkPairingItemTimer = QTimer()
        self.blinkPairingItemTimer.flag = True
        self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem)
        self.blinkPairingItemTimer.start(300)
    
    def blinkPairingItem(self):
        if self.blinkPairingItemTimer.flag:
            opacity = 0.3
            self.blinkPairingItemTimer.flag = False
        else:
            opacity = 1.0
            self.blinkPairingItemTimer.flag = True
        self.warnPairingItem.setOpacity(opacity)

    def getStatus_RelID_BeforeEmergence(self, budID, curr_mothID):
        posData = self.data[self.pos_i]
        # Get status of the current mother before it had budID assigned to it
        cca_status_before_bud_emerg = None
        for i in range(posData.frame_i-1, -1, -1):
            # Get cca_df for ith frame from allData_li
            cca_df_i = self.get_cca_df(frame_i=i, return_df=True)

            is_bud_existing = budID in cca_df_i.index
            if not is_bud_existing:
                # Bud was not emerged yet
                if curr_mothID in cca_df_i.index:
                    cca_status_before_bud_emerg = cca_df_i.loc[curr_mothID]
                    return cca_status_before_bud_emerg
                else:
                    # The bud emerged together with the mother because
                    # they appeared together from outside of the fov
                    # and they were trated as new IDs bud in S0
                    bud_cca_dict = base_cca_dict.copy()
                    bud_cca_dict['cell_cycle_stage'] = 'S'
                    bud_cca_dict['generation_num'] = 0
                    bud_cca_dict['relationship'] = 'bud'
                    bud_cca_dict['emerg_frame_i'] = i+1
                    bud_cca_dict['is_history_known'] = True
                    cca_status_before_bud_emerg = pd.Series(bud_cca_dict)
                    return cca_status_before_bud_emerg
        
        # Mother did not have a status before bud emergence because it was
        # already paired with bud at first frame --> reinit to default
        cca_status_before_bud_emerg = (
            core.getBaseCca_df([curr_mothID]).loc[curr_mothID]
        )
        return cca_status_before_bud_emerg
        

    def annotateBudToDifferentMother(self):
        """
        This function is used for correcting automatic mother-bud assignment.

        It can be called at any frame of the bud life.

        There are three cells involved: bud, current mother, new mother.

        Eligibility:
            - User clicked first on a bud (checked at click time)
            - User released mouse button on a cell in G1 (checked at release time)
            - The new mother MUST be in G1 for all the frames of the bud life
              --> if not warn
            - The new mother MUST have appeared in current frame OR be already 
              in G1 in previous frame, otherwise there would be no G1 cycle 

        Result:
            - The bud only changes relative ID to the new mother
            - The new mother changes relative ID and stage to 'S'
            - The old mother changes its entire status to the status it had
              before being assigned to the clicked bud
        """
        posData = self.data[self.pos_i]
        lab2D = self.get_2Dlab(posData.lab)
        budID = lab2D[self.yClickBud, self.xClickBud]
        new_mothID = lab2D[self.yClickMoth, self.xClickMoth]

        if budID == new_mothID:
            return

        if not self.isSnapshot:
            eligible = self.checkMothEligibility(budID, new_mothID)
            if not eligible:
                return

            budEligible = self.checkChangeMotherBudEligible(
                budID, posData.frame_i
            )
            if not budEligible:
                return        
        
        # Allow partial initialization of cca_df with mouse      
        if  posData.frame_i == 0:
            newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage']
            if not newMothCcs == 'G1':
                err_msg = (
                    'You are assigning the bud to a cell that is not in G1!'
                )
                msg = QMessageBox()
                msg.critical(
                   self, 'New mother not in G1!', err_msg, msg.Ok
                )
                return
            # Store cca_df for undo action
            undoId = uuid.uuid4()
            self.storeUndoRedoCca(0, posData.cca_df, undoId)
            currentRelID = posData.cca_df.at[budID, 'relative_ID']
            if currentRelID in posData.cca_df.index:
                posData.cca_df.at[currentRelID, 'relative_ID'] = -1
                posData.cca_df.at[currentRelID, 'generation_num'] = 2
                posData.cca_df.at[currentRelID, 'cell_cycle_stage'] = 'G1'
            posData.cca_df.at[budID, 'relationship'] = 'bud'
            posData.cca_df.at[budID, 'generation_num'] = 0
            posData.cca_df.at[budID, 'relative_ID'] = new_mothID
            posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S'
            posData.cca_df.at[new_mothID, 'relative_ID'] = budID
            posData.cca_df.at[new_mothID, 'generation_num'] = 2
            posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S'
            self.updateAllImages()
            self.store_cca_df()
            return

        curr_mothID = posData.cca_df.at[budID, 'relative_ID']        
        if curr_mothID in posData.cca_df.index:
            curr_moth_cca = self.getStatus_RelID_BeforeEmergence(
                budID, curr_mothID
            )

        # Store cca_df for undo action
        undoId = uuid.uuid4()
        self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId)
        
        # Correct current frames and update LabelItems
        posData.cca_df.at[budID, 'relative_ID'] = new_mothID
        posData.cca_df.at[budID, 'generation_num'] = 0
        posData.cca_df.at[budID, 'relative_ID'] = new_mothID
        posData.cca_df.at[budID, 'relationship'] = 'bud'
        posData.cca_df.at[budID, 'corrected_on_frame_i'] = posData.frame_i
        posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S'

        posData.cca_df.at[new_mothID, 'relative_ID'] = budID
        posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S'
        posData.cca_df.at[new_mothID, 'relationship'] = 'mother'

        
        if curr_mothID in posData.cca_df.index:
            # Cells with UNKNOWN history has relative's ID = -1
            # which is not an existing cell
            posData.cca_df.loc[curr_mothID] = curr_moth_cca

        self.updateAllImages()

        # self.checkMultiBudMoth(draw=True)
        self.store_cca_df()
        proceed = self.checkMothersExcludedOrDead()
        if not proceed:
            # User clicked on cancel in the message box
            self.UndoCca()
            return

        if self.ccaTableWin is not None:
            zoomIDs = self.getZoomIDs()
            self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs)

        # Correct future frames
        for i in range(posData.frame_i+1, posData.SizeT):
            # Get cca_df for ith frame from allData_li
            cca_df_i = self.get_cca_df(frame_i=i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break

            IDs = cca_df_i.index
            if budID not in IDs or new_mothID not in IDs:
                # For some reason ID disappeared from this frame
                continue

            self.storeUndoRedoCca(i, cca_df_i, undoId)
            bud_relationship = cca_df_i.at[budID, 'relationship']
            bud_ccs = cca_df_i.at[budID, 'cell_cycle_stage']

            if bud_relationship == 'mother' and bud_ccs == 'S':
                # The bud at the ith frame budded itself --> stop
                break

            cca_df_i.at[budID, 'relative_ID'] = new_mothID
            cca_df_i.at[budID, 'generation_num'] = 0
            cca_df_i.at[budID, 'relative_ID'] = new_mothID
            cca_df_i.at[budID, 'relationship'] = 'bud'
            cca_df_i.at[budID, 'cell_cycle_stage'] = 'S'

            newMoth_bud_ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage']
            if newMoth_bud_ccs == 'G1':
                # Assign bud to new mother only if the new mother is in G1
                # This can happen if the bud already has a G1 annotated
                cca_df_i.at[new_mothID, 'relative_ID'] = budID
                cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S'
                cca_df_i.at[new_mothID, 'relationship'] = 'mother'

            if curr_mothID in cca_df_i.index:
                # Cells with UNKNOWN history has relative's ID = -1
                # which is not an existing cell
                cca_df_i.loc[curr_mothID] = curr_moth_cca

            self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False)

        # Correct past frames
        for i in range(posData.frame_i-1, -1, -1):
            # Get cca_df for ith frame from allData_li
            cca_df_i = self.get_cca_df(frame_i=i, return_df=True)

            is_bud_existing = budID in cca_df_i.index
            if not is_bud_existing:
                # Bud was not emerged yet
                break

            self.storeUndoRedoCca(i, cca_df_i, undoId)
            cca_df_i.at[budID, 'relative_ID'] = new_mothID
            cca_df_i.at[budID, 'generation_num'] = 0
            cca_df_i.at[budID, 'relative_ID'] = new_mothID
            cca_df_i.at[budID, 'relationship'] = 'bud'
            cca_df_i.at[budID, 'cell_cycle_stage'] = 'S'

            cca_df_i.at[new_mothID, 'relative_ID'] = budID
            cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S'
            cca_df_i.at[new_mothID, 'relationship'] = 'mother'

            if curr_mothID in cca_df_i.index:
                # Cells with UNKNOWN history has relative's ID = -1
                # which is not an existing cell
                cca_df_i.loc[curr_mothID] = curr_moth_cca

            self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False)
        
        self.enqAutosave()
    
    def onMotherNotInG1(self, mothID):
        txt = html_utils.paragraph(
            f'You clicked on <b>ID={mothID}</b> which is <b>NOT in G1</b><br><br>'
            'Do you want to proceed with <b>swapping the mother cells</b>?<br><br>'
            'NOTE: To assign a bud <b>start by clicking on the bud</b> '
            'and release on a cell in G1'
        )
        msg = widgets.myMessageBox()
        swapMothersButton = widgets.reloadPushButton('Swap mother cells')
        _, swapMothersButton = msg.warning(
            self, 'Released on a cell NOT in G1', txt,
            buttonsTexts=('Cancel', swapMothersButton)
        )
        if msg.cancel:
            return
        
        pairings = self.checkSwapMothersEligibility()
        if pairings is None:
            self.logger.info('Swapping mothers is not possible.')
            return
        
        self.swapMothers(*pairings)
    
    def _checkBudFutureNoDivision(self, budID, start_frame_i):
        posData = self.data[self.pos_i]
        
        future_i = start_frame_i
        for future_i in range(start_frame_i, posData.SizeT):
            if future_i == 0:
                continue
            
            # Get cca_df for ith frame from allData_li
            cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                return
            
            if budID not in cca_df_i.index:
                # Bud disappears in the future --> fine
                return
            
            ccs = cca_df_i.at[budID, 'cell_cycle_stage']
            if ccs == 'G1':
                return future_i, cca_df_i.at[budID, 'relative_ID']
    
    def warnBudAnnotatedDividedInFuture(
            self, budID, motherID, future_division_frame_i, 
            action='swap mother cells'
        ):
        posData = self.data[self.pos_i]
        
        txt = html_utils.paragraph(f"""
            Bud ID {budID} is annotated as divided from mother ID {motherID} 
            at frame n. {future_division_frame_i+1},<br>
            therefore it is not possible to {action}.<br><br>
            We <b>recommend reinitializing cell cycle annotations</b> on any 
            frame<br> between frames number {posData.frame_i+1} and 
            {future_division_frame_i} before attempting to {action}.<br><br>
            Thank you for your patience!
        """)
        msg = widgets.myMessageBox(wrapText=False)
        msg.warning(self, f'{action} not possible'.title(), txt)
        return
    
    def _checkMothInG1beforeBudEmergence(
            self, motherID, budID, wrongBudID, start_frame_i
        ):
        """Check that mother is in G1 on the frame before bud emergence

        Parameters
        ----------
        motherID : int
            ID of mother cell
        budID : int
            ID of bud
        start_frame_i : int
            Frame index from which to start checking in the past
        """        
        for past_i in range(start_frame_i, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True)            
            if budID not in cca_df_i.index:
                if cca_df_i.at[motherID, 'cell_cycle_stage'] == 'G1':
                    return
                
                budID_prev_cycle = cca_df_i.at[motherID, 'relative_ID']
                if budID_prev_cycle != wrongBudID:
                    return past_i + 1
                
                break
    
    def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1):
        posData = self.data[self.pos_i]
        
        txt = html_utils.paragraph(f"""
            Assigning bud ID {budID} to cell ID {motherID} cannot be 
            done because cell ID {motherID} is not in G1 at frame n. 
            {frame_no_G1}.<br><br>
            This would result in no G1 phase between previous cell cycle of 
            cell ID {motherID} and current one. 
            This is unfortunately not allowed.<br><br>
            One possible solution is to annotate division on cell ID 
            {motherID} on any frame before frame n. {frame_no_G1}.<br><br>
            Thank you for your patience!
        """)
        msg = widgets.myMessageBox(wrapText=False)
        msg.warning(self, 'Swap mothers not possible', txt)
        return
    
    def checkChangeMotherBudEligible(self, budID, frame_i):
        result = self._checkBudFutureNoDivision(budID, frame_i)
        if result is None:
            return True
        
        self.warnBudAnnotatedDividedInFuture(
            budID, *result, action='change mother cell'
        )
        return False
    
    def checkSwapMothersEligibility(self):
        posData = self.data[self.pos_i]
        
        lab2D = self.get_2Dlab(posData.lab)
        budID = lab2D[self.yClickBud, self.xClickBud]
        otherMothID = lab2D[self.yClickMoth, self.xClickMoth]
        mothID = posData.cca_df.at[budID, 'relative_ID']
        otherBudID = posData.cca_df.at[otherMothID, 'relative_ID']
        
        for _budID in (budID, otherBudID):
            result = self._checkBudFutureNoDivision(
                _budID, posData.frame_i
            )
            if result is None:
                continue
            
            self.warnBudAnnotatedDividedInFuture(_budID, *result)
            return
        
        correct_pairings = {
            otherBudID: mothID, budID: otherMothID
        }
        wrong_pairings = {
            mothID: budID, otherMothID: otherBudID
        }
        for correctBudID, correctMothID in correct_pairings.items():
            wrongBudID = wrong_pairings[correctMothID]
            frame_no_G1 = self._checkMothInG1beforeBudEmergence(
                correctMothID, correctBudID, wrongBudID, posData.frame_i
            )
            if frame_no_G1 is None:
                continue
            
            self.warnMotherNotAtLeastOneFrameG1(
                correctBudID, correctMothID, frame_no_G1
            )
            return
        
        return budID, otherBudID, otherMothID, mothID
    
    @exception_handler
    def swapMothers(self, budID, otherBudID, otherMothID, mothID):
        posData = self.data[self.pos_i]
        
        # Store cca_df for undo action
        undoId = uuid.uuid4()
        self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId)
        
        self.logger.info(
            f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n'
            f'  * Bud ID {budID} --> mother ID {otherMothID}\n'
            f'  * Bud ID {otherBudID} --> mother ID {mothID}'
        )
        
        correct_pairings = {
            otherBudID: mothID,
            budID: otherMothID
        }
        
        for correct_budID, correct_mothID in correct_pairings.items():
            posData.cca_df.at[correct_budID, 'relative_ID'] = correct_mothID
            posData.cca_df.at[correct_mothID, 'relative_ID'] = correct_budID
            posData.cca_df.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i
            posData.cca_df.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i
        self.store_cca_df()
        
        # Correct past frames
        corrected_budIDs_past = set()
        for past_i in range(posData.frame_i-1, -1, -1):
            if len(corrected_budIDs_past) == 2:
                break
            
            for correct_budID, correct_mothID in correct_pairings.items():
                # Get cca_df for ith frame from allData_li
                cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True)
            
                if correct_budID in corrected_budIDs_past:
                    continue

                if correct_budID not in cca_df_i.index:
                    # Bud does not exist anymore in the past
                    corrected_budIDs_past.add(correct_budID)
                    
                    if len(corrected_budIDs_past) < 2:
                        self.restoreMotherToBeforeWrongBudWasAssignedToIt(
                            correct_mothID, cca_df_i, past_i
                        )
                    continue
                
                cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID
                cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID
                cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i
                cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i
                
                # Set mother cell cycle stage to S in case it is not
                if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1':
                    cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S'
                    # cca_df_i.at[correct_mothID, 'generation_num'] -= 1
            
                self.store_cca_df(
                    frame_i=past_i, cca_df=cca_df_i, autosave=False
                )
        
        # Correct future frames
        corrected_budIDs_future = set()
        for future_i in range(posData.frame_i+1, posData.SizeT):
            if len(corrected_budIDs_future) == 2:
                break
            
            # Get cca_df for ith frame from allData_li
            cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break
            
            for correct_budID, correct_mothID in correct_pairings.items():
                if correct_budID in corrected_budIDs_future:
                    # Bud already corrected in the future
                    continue
                
                if correct_budID not in cca_df_i.index:
                    # Bud disappeared in the future
                    corrected_budIDs_future.add(correct_budID)
                    continue
                
                ccs_bud = cca_df_i.at[correct_budID, 'cell_cycle_stage']
                if ccs_bud == 'G1':
                    # Bud divided in the future, annotate division between 
                    # correct mother and wrong bud and then stop correcting
                    if correct_budID not in corrected_budIDs_future:
                        corrected_budIDs_future.add(correct_budID)
                    
                    if len(corrected_budIDs_future) < 2: 
                        self.annotateDivisionFutureFramesSwapMothers(
                            cca_df_i, correct_mothID, future_i
                        )
                    continue
                
                cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID
                cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID
                cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i
                cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i
                
                # Set mother cell cycle stage to S in case it is not
                if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1':
                    cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S'
                    # cca_df_i.at[correct_mothID, 'generation_num'] -= 1
            
            self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False)
        
        self.updateAllImages()
    
    def restoreMotherToBeforeWrongBudWasAssignedToIt(
            self, mothIDofDisappearedBud, 
            cca_df_at_correct_bud_ID_disappearance, 
            frame_i
        ):
        """This method is called as part of `guiWin.swapMothers`. 

        Parameters
        ----------
        mothIDofDisappearedBud : int
            Mother ID of the disappeared bud
        cca_df_at_correct_bud_ID_disappearance : pd.DataFrame
            Cell cycle annotations DataFrame when the correct bud ID stopped 
            existing (before emergence)
        frame_i : int
            Frame index when the correct bud ID stopped existing 
            (before emergence)
        
        Note
        ----
        It restores the mother cell cycle annotations to the status it had 
        before the wrong bud was assigned to it. 
        
        We need to do it only if the swapMothers past frames loop is still 
        iterating to correct the other bud.
        
        We also need to do this only if the wrong bud ID is actually a bud.
        
        When we swap mothers in the past frames it can be that the correct bud 
        ID stops existing (before emergence). In this case the correct mother 
        still has the wrong bud assigned to ID so we need to restore the status 
        it had before the wrong bud was assigned to it. 
        
        To determine the status we go back until the wrong bud disappear. That 
        is the frame before the wrong bud was assigned to the mother we want to 
        correct. This is the status we want to restore.
        
        When we go back in time it could be that the wrong bud never disappears 
        becuase it is already emerged at frame 0. In this case the status we 
        want to restore at is the default G1 status at frame 0. 
        """      
        relativeIDofMothID = cca_df_at_correct_bud_ID_disappearance.at[
            mothIDofDisappearedBud, 'relative_ID'
        ]
        if relativeIDofMothID not in cca_df_at_correct_bud_ID_disappearance.index:
            # Also wrong bud ID disappeared
            return
        
        relativeIDofMothIDrelationship = cca_df_at_correct_bud_ID_disappearance.at[
            relativeIDofMothID, 'relationship'
        ]
        if relativeIDofMothIDrelationship != 'bud':
            # The wrong bud ID is a cell in G1 from previous cycle --> 
            # the actual wrong bud ID disappeared too.
            return
        
        wrongBudID = relativeIDofMothID
        
        mothCcaBeforeWrongBudID = base_cca_dict
        # Search in the past for status of mother before wrong bud emerged
        for past_i in range(frame_i, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True)
            if wrongBudID not in cca_df_i.index:
                mothCcaBeforeWrongBudID = cca_df_i.loc[mothIDofDisappearedBud]
                break
               
        # Restore in past frames the correct mother status
        for past_i in range(frame_i, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True)
            if wrongBudID in cca_df_i.index:
                cca_df_i.loc[mothIDofDisappearedBud] = mothCcaBeforeWrongBudID
                cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i
                self.store_cca_df(
                    frame_i=past_i, cca_df=cca_df_i, autosave=False
                )
            else:
                break
    
    def getClosedSplineCoords(self):
        xxS, yyS = self.curvPlotItem.getData()
        bbox_area = (xxS.max()-xxS.min())*(yyS.max()-yyS.min())
        if bbox_area < 26_000:
            # Using 1000 is fast enough according to profiling
            return xxS, yyS 
        
        optimalSpaceSize = self.splineToObjModel.predict(
            bbox_area, max_exec_time=150
        )
        if optimalSpaceSize >= 1000:
            # Using 1000 is fast enough according to model
            return xxS, yyS
        
        if optimalSpaceSize < 100:
            # Do not allow a rough spline
            optimalSpaceSize = 100
        
        # Get spline with optimal space size so that exec time 
        # or skimage.draw.polygon is less than 150 ms
        xx, yy = self.curvAnchors.getData()
        resolutionSpace = np.linspace(0, 1, int(optimalSpaceSize))
        xxS, yyS = self.getSpline(
            xx, yy, resolutionSpace=resolutionSpace, per=True
        )
        return xxS, yyS


    def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False):
        # Remove duplicates
        valid = np.where(np.abs(np.diff(xx)) + np.abs(np.diff(yy)) > 0)
        xx = np.r_[xx[valid], xx[-1]]
        yy = np.r_[yy[valid], yy[-1]]
        if appendFirst:
            xx = np.r_[xx, xx[0]]
            yy = np.r_[yy, yy[0]]
            per = True

        # Interpolate splice
        if resolutionSpace is None:
            resolutionSpace = self.hoverLinSpace
        k = 2 if len(xx) == 3 else 3

        try:
            tck, u = scipy.interpolate.splprep(
                [xx, yy], s=0, k=k, per=per
            )
            xi, yi = scipy.interpolate.splev(resolutionSpace, tck)
            return xi, yi
        except (ValueError, TypeError):
            # Catch errors where we know why splprep fails
            return [], []

    def uncheckQButton(self, button):
        # Manual exclusive where we allow to uncheck all buttons
        for b in self.checkableQButtonsGroup.buttons():
            if b != button:
                b.setChecked(False)

    def delBorderObj(self, checked):
        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False)

        posData = self.data[self.pos_i]
        posData.lab = skimage.segmentation.clear_border(
            posData.lab, buffer_size=1
        )
        oldIDs = posData.IDs.copy()
        self.update_rp()
        removedIDs = [ID for ID in oldIDs if ID not in posData.IDs]
        if posData.cca_df is not None:
            posData.cca_df = posData.cca_df.drop(index=removedIDs)
        self.store_data()
        self.updateAllImages()
    
    def brushAutoFillToggled(self, checked):
        val = 'Yes' if checked else 'No'
        self.df_settings.at['brushAutoFill', 'value'] = val
        self.df_settings.to_csv(self.settings_csv_path)
    
    def brushAutoHideToggled(self, checked):
        val = 'Yes' if checked else 'No'
        self.df_settings.at['brushAutoHide', 'value'] = val
        self.df_settings.to_csv(self.settings_csv_path)

    def addDelROI(self, event):       
        roi, key = self.createDelROI()
        self.addRoiToDelRoiInfo(roi)
        if not self.labelsGrad.showLabelsImgAction.isChecked():
            self.ax1.addDelRoiItem(roi, key)
        else:
            self.ax2.addDelRoiItem(roi, key)
        self.applyDelROIimg1(roi, init=True)
        self.applyDelROIimg1(roi, init=True, ax=1)

        if self.isSnapshot:
            self.fixCcaDfAfterEdit('Delete IDs using ROI')
            self.updateAllImages()
        else:
            self.warnEditingWithCca_df(
                'Delete IDs using ROI', get_cancelled=True
            )
    
    def replacePolyLineRoiWithLineRoi(self, roi):
        x0, y0 = roi.pos().x(), roi.pos().y()
        (_, point1), (_, point2) = roi.getLocalHandlePositions()
        xr1, yr1 = point1.x(), point1.y()
        xr2, yr2 = point2.x(), point2.y()
        x1, y1 = xr1+x0, yr1+y0
        x2, y2 = xr2+x0, yr2+x0
        lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5)
        lineRoi.handleSize = 7
        self.ax1.removeItem(self.polyLineRoi)
        self.ax1.addItem(lineRoi)
        lineRoi.removeHandle(2)
        # Connect closed ROI
        lineRoi.sigRegionChanged.connect(self.delROImoving)
        lineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished)
        return lineRoi
    
    def addRoiToDelRoiInfo(self, roi: pg.ROI):
        posData = self.data[self.pos_i]
        for i in range(posData.frame_i, posData.SizeT):
            delROIs_info = posData.allData_li[i]['delROIs_info']
            delROIs_info['rois'].append(roi)
            delROIs_info['state'].append(roi.getState())
            delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D))
            delROIs_info['delIDsROI'].append(set())
    
    def addDelPolyLineRoi_cb(self, checked):
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton)
            self.connectLeftClickButtons()
            if self.isSnapshot:
                self.fixCcaDfAfterEdit('Delete IDs using ROI')
                self.updateAllImages()
            else:
                self.warnEditingWithCca_df('Delete IDs using ROI')
        else:
            self.tempSegmentON = False
            self.ax1_rulerPlotItem.setData([], [])
            self.ax1_rulerAnchorsItem.setData([], [])
            self.startPointPolyLineItem.setData([], [])
            while self.app.overrideCursor() is not None:
                self.app.restoreOverrideCursor()
         
    def createDelPolyLineRoi(self):
        Y, X = self.currentLab2D.shape
        self.polyLineRoi = pg.PolyLineROI(
            [], rotatable=False,
            removable=True,
            pen=pg.mkPen(color='r')
        )
        self.polyLineRoi.handleSize = 7
        self.polyLineRoi.points = []
        self.ax1.addItem(self.polyLineRoi)
    
    def addPointsPolyLineRoi(self, closed=False):
        self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed)
        if not closed:
            return

        # Connect closed ROI
        self.polyLineRoi.sigRegionChanged.connect(self.delROImoving)
        self.polyLineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished)
    
    def getViewRange(self):
        Y, X = self.img1.image.shape[:2]
        xRange, yRange = self.ax1.viewRange()
        xmin = 0 if xRange[0] < 0 else xRange[0]
        ymin = 0 if yRange[0] < 0 else yRange[0]
        
        xmax = X if xRange[1] >= X else xRange[1]
        ymax = Y if yRange[1] >= Y else yRange[1]
        return int(ymin), int(ymax), int(xmin), int(xmax)

    def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None):
        posData = self.data[self.pos_i]
        if xl is None:
            xRange, yRange = self.ax1.viewRange()
            xl = 0 if xRange[0] < 0 else xRange[0]
            yb = 0 if yRange[0] < 0 else yRange[0]
        Y, X = self.currentLab2D.shape
        if anchors is None:
            roi = widgets.DelROI(
                [xl, yb], [w, h],
                rotatable=False,
                removable=True,
                pen=pg.mkPen(color='r'),
                maxBounds=QRectF(QRect(0,0,X,Y))
            )
            ## handles scaling horizontally around center
            roi.addScaleHandle([1, 0.5], [0, 0.5])
            roi.addScaleHandle([0, 0.5], [1, 0.5])

            ## handles scaling vertically from opposite edge
            roi.addScaleHandle([0.5, 0], [0.5, 1])
            roi.addScaleHandle([0.5, 1], [0.5, 0])

            ## handles scaling both vertically and horizontally
            roi.addScaleHandle([1, 1], [0, 0])
            roi.addScaleHandle([0, 0], [1, 1])
            roi.addScaleHandle([0, 1], [1, 0])
            roi.addScaleHandle([1, 0], [0, 1])

        roi.handleSize = 7
        roi.sigRegionChanged.connect(self.delROImoving)
        roi.sigRegionChanged.connect(self.delROIstartedMoving)
        roi.sigRegionChangeFinished.connect(self.delROImovingFinished)
        
        key = uuid.uuid4()
        
        return roi, key
    
    def delROIstartedMoving(self, roi):
        self.clearLostObjContoursItems()
    
    def clearLostObjContoursItems(self):
        self.ax1_lostObjScatterItem.setData([], [])
        self.ax2_lostObjScatterItem.setData([], [])

        self.ax1_lostTrackedScatterItem.setData([], [])
        self.ax2_lostTrackedScatterItem.setData([], [])
        
        self.ax2_lostObjImageItem.clear()
        self.ax2_lostTrackedObjImageItem.clear()
        
        self.ax1_lostObjImageItem.clear()
        self.ax1_lostTrackedObjImageItem.clear()
    
    def delROImoving(self, roi):
        roi.setPen(color=(255,255,0))
        # First bring back IDs if the ROI moved away
        self.restoreAnnotDelROI(roi)
        self.setImageImg2()
        self.applyDelROIimg1(roi)
        self.applyDelROIimg1(roi, ax=1)

    def delROImovingFinished(self, roi: pg.ROI):
        roi.setPen(color='r')
        self.update_rp()
        self.updateAllImages()
        QTimer.singleShot(
            300, partial(self.updateDelROIinFutureFrames, roi)
        )

    def restoreAnnotDelROI(self, roi, enforce=True, draw=True):
        posData = self.data[self.pos_i]
        ROImask = self.getDelRoiMask(roi)
        delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
        try:
            idx = delROIs_info['rois'].index(roi)
        except Exception as err:
            return 
        
        delMask = delROIs_info['delMasks'][idx]
        delIDs = delROIs_info['delIDsROI'][idx]
        overlapROIdelIDs = np.unique(delMask[ROImask])
        lab2D = self.get_2Dlab(posData.lab)
        restoredIDs = set()
        for ID in delIDs:
            if ID in overlapROIdelIDs and not enforce:
                continue
            
            restoredIDs.add(ID)
            
            delMaskID = delMask==ID
            self.currentLab2D[delMaskID] = ID
            lab2D[delMaskID] = ID
            
            if draw:
                self.restoreDelROIimg1(delMaskID, ID, ax=0)
                self.restoreDelROIimg1(delMaskID, ID, ax=1)
                
            delMask[delMaskID] = 0
            
        delROIs_info['delIDsROI'][idx] = delIDs - restoredIDs
        self.set_2Dlab(lab2D)
        self.update_rp()

    def restoreDelROIimg1(self, delMaskID, delID, ax=0):
        if ax == 0:
            how = self.drawIDsContComboBox.currentText()
        else:
            how = self.getAnnotateHowRightImage()

        if how.find('nothing') != -1:
            return
        
        if how.find('contours') != -1:
            rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8))
            if len(rp_delmask) > 0:
                obj = rp_delmask[0]
                self.addObjContourToContoursImage(obj=obj, ax=ax)  
        elif how.find('overlay segm. masks') != -1:
            if ax == 0:
                self.labelsLayerImg1.setImage(
                    self.currentLab2D, autoLevels=False
                )
            else:
                self.labelsLayerRightImg.setImage(
                    self.currentLab2D, autoLevels=False
                )

    def getDelRoisIDs(self):
        posData = self.data[self.pos_i]
        if posData.frame_i > 0:
            prev_lab = posData.allData_li[posData.frame_i-1]['labels']
        allDelIDs = set()
        for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']:
            if (
                    not self.ax1.isDelRoiItemPresent(roi) 
                    and not self.ax2.isDelRoiItemPresent(roi)
                ):
                continue
            
            ROImask = self.getDelRoiMask(roi)
            delIDs = posData.lab[ROImask]
            allDelIDs.update(delIDs)
            if posData.frame_i > 0:
                delIDsPrevFrame = prev_lab[ROImask]
                allDelIDs.update(delIDsPrevFrame)
        return allDelIDs
    
    def getStoredDelRoiIDs(self, frame_i=None):
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i
        allDelIDs = set()
        delROIs_info = posData.allData_li[frame_i]['delROIs_info']
        delIDs_rois = delROIs_info['delIDsROI']
        for delIDs in delIDs_rois:
            allDelIDs.update(delIDs)
        return allDelIDs
    
    # @exec_time
    def getDelROIlab(self):
        posData = self.data[self.pos_i]
        if not hasattr(self, 'delRoiLab'):
            self.initDelRoiLab()
            
        self.delRoiLab[:] = self.get_2Dlab(posData.lab, force_z=False)
        allDelIDs = set()
        # Iterate rois and delete IDs
        for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']:
            if (
                    not self.ax1.isDelRoiItemPresent(roi) 
                    and not self.ax2.isDelRoiItemPresent(roi)
                ):
                continue     
            ROImask = self.getDelRoiMask(roi)
            delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
            idx = delROIs_info['rois'].index(roi)
            delObjROImask = delROIs_info['delMasks'][idx]
            delIDsROI = delROIs_info['delIDsROI'][idx]   
            delROIlabRp = skimage.measure.regionprops(self.delRoiLab)
            for delObj in delROIlabRp:
                isDelObj = np.any(ROImask[delObj.slice][delObj.image])
                if not isDelObj:
                    continue
                
                delObjROImask[delObj.slice][delObj.image] = delObj.label
                self.delRoiLab[delObj.slice][delObj.image] = 0
            
                delIDsROI.add(delObj.label)
                allDelIDs.add(delObj.label)

            # Keep a mask of deleted IDs to bring them back when roi moves
            delROIs_info['delMasks'][idx] = delObjROImask
            delROIs_info['delIDsROI'][idx] = delIDsROI
        
        # printl(
        #     f't1-t0: {(t1-t0)*1000:.3f} ms,',
        #     f't2-t1: {(t2-t1)*1000:.3f} ms,',
        #     f't3-t2: {(t3-t2)*1000:.3f} ms,',
        #     # f't4-t3: {(t4-t3)*1000:.3f} ms,',
        #     # f't5-t4: {(t5-t4)*1000:.3f} ms,',
        #     # f't6-t5: {(t6-t5)*1000:.3f} ms',
        #     sep='\n'
        # )
        
        return allDelIDs, self.delRoiLab
    
    def getDelRoiMask(self, roi, posData=None, z_slice=None):
        if posData is None:
            posData = self.data[self.pos_i]
        if z_slice is None:
            z_slice = self.z_lab()
        ROImask = np.zeros(posData.lab.shape, bool)
        if isinstance(roi, pg.PolyLineROI):
            r, c = [], []
            x0, y0 = roi.pos().x(), roi.pos().y()
            for _, point in roi.getLocalHandlePositions():
                xr, yr = point.x(), point.y()
                r.append(int(yr+y0))
                c.append(int(xr+x0))
            if not r or not c:
                return ROImask
            
            if len(r) == 2:
                rr, cc, val = skimage.draw.line_aa(r[0], c[0], r[1], c[1])
            else:
                rr, cc = skimage.draw.polygon(r, c, shape=self.currentLab2D.shape)
            
            Y, X = self.currentLab2D.shape
            rr = rr[(rr>=0) & (rr<Y)]
            cc = cc[(cc>=0) & (cc<X)]
            
            if self.isSegm3D:
                ROImask[z_slice, rr, cc] = True
            else:
                ROImask[rr, cc] = True
        elif isinstance(roi, pg.LineROI):
            (_, point1), (_, point2) = roi.getSceneHandlePositions()
            point1 = self.ax1.vb.mapSceneToView(point1)
            point2 = self.ax1.vb.mapSceneToView(point2)
            x1, y1 = int(point1.x()), int(point1.y())
            x2, y2 = int(point2.x()), int(point2.y())
            rr, cc, val = skimage.draw.line_aa(y1, x1, y2, x2)
            if self.isSegm3D:
                ROImask[z_slice, rr, cc] = True
            else:
                ROImask[rr, cc] = True
        else: 
            x0, y0 = [int(c) for c in roi.pos()]
            w, h = [int(c) for c in roi.size()]
            if self.isSegm3D:
                ROImask[z_slice, y0:y0+h, x0:x0+w] = True
            else:
                ROImask[y0:y0+h, x0:x0+w] = True
        return ROImask

    def enableSmartTrack(self, checked):
        posData = self.data[self.pos_i]
        # Disable tracking for already visited frames

        if posData.allData_li[posData.frame_i]['labels'] is not None:
            trackingEnabled = True
        else:
            trackingEnabled = False

        if checked:
            self.UserEnforced_DisabledTracking = False
            self.UserEnforced_Tracking = False
        else:
            if trackingEnabled:
                self.UserEnforced_DisabledTracking = True
                self.UserEnforced_Tracking = False
            else:
                self.UserEnforced_DisabledTracking = False
                self.UserEnforced_Tracking = True

    def addTimestamp(self, checked):
        if checked:
            posData = self.data[self.pos_i]
            Y, X = self.img1.image.shape
            viewRange = self.ax1.viewRange()
            self.timestampDialog = apps.TimestampPropertiesDialog(parent=self)
            self.timestampDialog.show()
            self.timestamp = widgets.TimestampItem(
                Y, X, viewRange, 
                secondsPerFrame=posData.TimeIncrement,
                start_timedelta=self.timestampStartTimedelta
            )
            self.timestamp.sigEditProperties.connect(
                self.editTimestampProperties
            )
            self.timestamp.addToAxis(self.ax1)
            self.timestamp.draw(
                posData.frame_i, **self.timestampDialog.kwargs()
            )
            self.timestampDialog.sigValueChanged.connect(self.updateTimestamp)
            self.timestampDialog.exec_()
        else:
            self.timestamp.removeFromAxis(self.ax1)
        
        self.timestampDialog = None
        self.imgGrad.addTimestampAction.setChecked(checked)
    
    def addScaleBar(self, checked):
        if checked:
            posData = self.data[self.pos_i]
            Y, X = self.img1.image.shape
            viewRange = self.ax1.viewRange()
            self.scaleBarDialog = apps.ScaleBarPropertiesDialog(
                X, Y, posData.PhysicalSizeX, parent=self
            )
            self.scaleBarDialog.show()
            self.scaleBar = widgets.ScaleBar((Y, X), viewRange, parent=self.ax1)
            self.scaleBar.sigEditProperties.connect(self.editScaleBarProperties)
            self.scaleBar.addToAxis(self.ax1)
            self.scaleBar.draw(**self.scaleBarDialog.kwargs())
            self.scaleBarDialog.sigValueChanged.connect(self.updateScaleBar)
            self.scaleBarDialog.exec_()
            if self.scaleBarDialog.cancel:
                self.addScaleBarAction.setChecked(False)
                return
        else:
            self.scaleBar.removeFromAxis(self.ax1)

        self.scaleBarDialog = None
        self.imgGrad.addScaleBarAction.setChecked(checked)
    
    def updateScaleBar(self, scaleBarKwargs):
        self.scaleBar.draw(**scaleBarKwargs)
    
    def updateTimestamp(self, timeStampKwargs):
        posData = self.data[self.pos_i]
        self.timestamp.draw(posData.frame_i, **timeStampKwargs)
    
    def editScaleBarProperties(self, properties):
        Y, X = self.img1.image.shape
        posData = self.data[self.pos_i]
        win = apps.ScaleBarPropertiesDialog(
            X, Y, posData.PhysicalSizeX, parent=self, **properties
        )
        win.sigValueChanged.connect(self.updateScaleBar)
        win.exec_()
    
    def editTimestampProperties(self, properties):
        win = apps.TimestampPropertiesDialog(parent=self, **properties)
        win.sigValueChanged.connect(self.updateTimestamp)
        win.show()
    
    def invertBw(self, checked, update=True):
        self.invertBwAlreadyCalledOnce = True
        
        self.labelsGrad.invertBwAction.toggled.disconnect()
        self.labelsGrad.invertBwAction.setChecked(checked)
        self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW)

        self.imgGrad.invertBwAction.toggled.disconnect()
        self.imgGrad.invertBwAction.setChecked(checked)
        self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW)

        self.imgGrad.setInvertedColorMaps(checked)
        self.imgGrad.invertCurrentColormap(checked)

        self.imgGradRight.setInvertedColorMaps(checked)
        self.imgGradRight.invertCurrentColormap(checked)

        for items in self.overlayLayersItems.values():
            lutItem = items[1]
            lutItem.invertBwAction.toggled.disconnect()
            lutItem.invertBwAction.setChecked(checked)
            lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW)
            lutItem.setInvertedColorMaps(checked)

        if self.slideshowWin is not None:
            self.slideshowWin.is_bw_inverted = checked
            self.slideshowWin.update_img()
        self.df_settings.at['is_bw_inverted', 'value'] = 'Yes' if checked else 'No'
        self.df_settings.to_csv(self.settings_csv_path)
        if checked:
            # Light mode
            self.equalizeHistPushButton.setStyleSheet('')
            self.graphLayout.setBackground(graphLayoutBkgrColor)
            self.ax2_BrushCirclePen = pg.mkPen((150,150,150), width=2)
            self.ax2_BrushCircleBrush = pg.mkBrush((200,200,200,150))
            self.titleColor = 'black'    
        else:
            # Dark mode
            self.equalizeHistPushButton.setStyleSheet(
                'QPushButton {background-color: #282828; color: #F0F0F0;}'
            )
            self.graphLayout.setBackground(darkBkgrColor)
            self.ax2_BrushCirclePen = pg.mkPen(width=2)
            self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50))
            self.titleColor = 'white'
        
        self.textAnnot[0].invertBlackAndWhite()
        self.textAnnot[1].invertBlackAndWhite()

        self.objLabelAnnotRgb = tuple(
            self.textAnnot[0].item.colors()['label'][:3]
        )
        self.textIDsColorButton.setColor(self.objLabelAnnotRgb)
        self.imgGrad.textColorButton.setColor(self.objLabelAnnotRgb)
        for items in self.overlayLayersItems.values():
            lutItem = items[1]
            lutItem.textColorButton.setColor(self.objLabelAnnotRgb)
        
        if update:
            self.updateAllImages()
    
    def _channelHoverValues(self, descr, channel, value, ff=None):
        if ff is None:
            n_digits = len(str(int(value)))
            ff = myutils.get_number_fstring_formatter(
                type(value), precision=abs(n_digits-5)
            )
        txt = (
            f'<b>{descr} {channel}</b>: value={value:{ff}}'
        )
        return txt
    
    def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata):
        posData = self.data[self.pos_i]
        if posData.ol_data is None:
            return txt
        
        for filename in posData.ol_data:
            chName = myutils.get_chname_from_basename(
                filename, posData.basename, remove_ext=False
            )
            if chName not in self.checkedOverlayChannels:
                continue
            
            raw_overlay_img = self.getRawImage(filename=filename)
            raw_overlay_value = raw_overlay_img[ydata, xdata]
            # raw_overlay_max_value = raw_overlay_img.max()

            raw_txt = self._channelHoverValues('Raw', chName, raw_overlay_value)

            txt = f'{txt} | {raw_txt}'
        return txt
    
    def getActiveToolButton(self):
        for button in self.LeftClickButtons:
            if button.isChecked():
                return button
    
    def getConcatAcdcDf(self):
        acdc_dfs = []
        keys = []
        posData = self.data[self.pos_i]
        for frame_i, data_dict in enumerate(posData.allData_li):
            lab = data_dict['labels']
            if lab is None:
                break
            
            acdc_df = data_dict['acdc_df']
            if acdc_df is None:
                break
            
            acdc_dfs.append(acdc_df)
            keys.append(frame_i)
        
        if not acdc_dfs:
            return
        
        return pd.concat(acdc_dfs, keys=keys, names=['frame_i'])
            
    
    def checkHighlightTimestamp(self, x, y, activeToolButton):
        if not hasattr(self, 'timestamp'):
            return
        
        if not self.addTimestampAction.isChecked():
            return
        
        if activeToolButton is not None:
            return
        
        if hasattr(self, 'scaleBar'):
            if self.scaleBar.isHighlighted():
                return
        
        ymin, xmin, ymax, xmax = self.timestamp.bbox()
        if x < xmin:
            self.timestamp.setHighlighted(False)
            return
        
        if x > xmax:
            self.timestamp.setHighlighted(False)
            return
        
        if y < ymin:
            self.timestamp.setHighlighted(False)
            return
        
        if y > ymax:
            self.timestamp.setHighlighted(False)
            return

        self.timestamp.setHighlighted(True)
    
    def checkHighlightScaleBar(self, x, y, activeToolButton):
        if not hasattr(self, 'scaleBar'):
            return
        
        if not self.addScaleBarAction.isChecked():
            return
        
        if activeToolButton is not None:
            return
        
        ymin, xmin, ymax, xmax = self.scaleBar.bbox()
        if x < xmin:
            self.scaleBar.setHighlighted(False)
            return
        
        if x > xmax:
            self.scaleBar.setHighlighted(False)
            return
        
        if y < ymin:
            self.scaleBar.setHighlighted(False)
            return
        
        if y > ymax:
            self.scaleBar.setHighlighted(False)
            return

        self.scaleBar.setHighlighted(True)
    
    def getMouseDataCoordsRightImage(self):
        text = self.wcLabel.text()
        if not text:
            return
        
        ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0])
        if ax_idx == 0:
            return
        
        coords = re.findall(r'x=(\d+), y=(\d+) \(ax', text)[0]
        
        return tuple([int(val) for val in coords])
    
    def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0):     
        ax_idx = 0 if is_ax0 else 1
        txt = f'x={xdata:d}, y={ydata:d} (ax{ax_idx})'
        if activeToolButton == self.rulerButton:
            txt = self._addRulerMeasurementText(txt)
            return txt
        elif activeToolButton is not None:
            return txt

        posData = self.data[self.pos_i]

        raw_img = self.getRawImage()
        raw_value = raw_img[ydata, xdata]
        # raw_max_value = raw_img.max()

        ch = self.user_ch_name
        raw_txt = self._channelHoverValues('Raw', ch, raw_value)

        txt = f'{txt} | {raw_txt}'

        txt = self._addOverlayHoverValuesFormatted(txt, xdata, ydata)
        
        ID = self.currentLab2D[ydata, xdata]
        maxID = max(posData.IDs, default=0)

        num_obj = len(posData.IDs)
        lab_txt = (
            f'<b>Objects</b>: ID={ID}, <i>max ID={maxID}, '
            f'num. of objects={num_obj}</i>'
        )
        txt = f'{txt} | {lab_txt}'

        txt = self._addRulerMeasurementText(txt)
        return txt
    
    def getRulerLengthText(self):
        text = self.wcLabel.text()
        lengthText = re.findall(r'length = (.*)\)', text)[0]
        lengthText = lengthText.replace('pxl', 'pixels')
        return f'{lengthText})'
    
    def _addRulerMeasurementText(self, txt):
        posData = self.data[self.pos_i]
        xx, yy = self.ax1_rulerPlotItem.getData()
        if xx is None:
            return txt

        lenPxl = math.sqrt((xx[0]-xx[1])**2 + (yy[0]-yy[1])**2)
        depthAxes = self.switchPlaneCombobox.depthAxes()
        if depthAxes != 'z':
            pxlToUm = posData.PhysicalSizeZ
        else:
            pxlToUm = posData.PhysicalSizeX
        
        length_txt = (
            f'length = {int(lenPxl)} pxl ({lenPxl*pxlToUm:.2f} μm)'
        )
        txt = f'{txt} | <b>Measurement</b>: {length_txt}'
        return txt

    def updateImageValueFormatter(self):
        if self.img1.image is not None:
            dtype = self.img1.image.dtype
            n_digits = len(str(int(self.img1.image.max())))
            self.imgValueFormatter = myutils.get_number_fstring_formatter(
                dtype, precision=abs(n_digits-5)
            )

        rawImgData = self.data[self.pos_i].img_data
        dtype = rawImgData.dtype
        n_digits = len(str(int(rawImgData.max())))
        self.rawValueFormatter = myutils.get_number_fstring_formatter(
            dtype, precision=abs(n_digits-5)
        )

    def normaliseIntensitiesActionTriggered(self, action):
        how = action.text()
        self.df_settings.at['how_normIntensities', 'value'] = how
        self.df_settings.to_csv(self.settings_csv_path)
        self.updateAllImages()
        self.updateImageValueFormatter()

    def setLastUserNormAction(self):
        how = self.df_settings.at['how_normIntensities', 'value']
        for action in self.normalizeQActionGroup.actions():
            if action.text() == how:
                action.setChecked(True)
                break

    def saveLabelsColormap(self):
        self.labelsGrad.saveColormap()
    
    def addFontSizeActions(self, menu, slot):
        fontActionGroup = QActionGroup(self)
        fontActionGroup.setExclusive(True)
        for fontSize in range(4,27):
            action = QAction(self)
            action.setText(str(fontSize))
            action.setCheckable(True)
            if fontSize == self.fontSize:
                action.setChecked(True)
            fontActionGroup.addAction(action)
            menu.addAction(action)
            action.triggered.connect(slot)
        return fontActionGroup

    @exception_handler
    def changeFontSize(self):
        fontSize = self.fontSizeSpinBox.value()
        if fontSize == self.fontSize:
            return
        
        self.fontSize = fontSize

        self.df_settings.at['fontSize', 'value'] = self.fontSize
        self.df_settings.to_csv(self.settings_csv_path)
        
        self.setAllIDs()
        posData = self.data[self.pos_i]
        for ax in range(2):
            self.textAnnot[ax].changeFontSize(self.fontSize)
        if self.highLowResAction.isChecked():
            self.setAllTextAnnotations()
        else:
            self.updateAllImages()

    def enableZstackWidgets(self, enabled):
        if enabled:
            myutils.setRetainSizePolicy(self.zSliceScrollBar)
            myutils.setRetainSizePolicy(self.zProjComboBox)
            myutils.setRetainSizePolicy(self.zSliceOverlay_SB)
            myutils.setRetainSizePolicy(self.zProjOverlay_CB)
            myutils.setRetainSizePolicy(self.overlay_z_label)
            self.zSliceScrollBar.setDisabled(False)
            self.zProjComboBox.show()
            if self.data[self.pos_i].SizeT > 1:
                self.zProjLockViewButton.show()
            self.zSliceScrollBar.show()
            self.zSliceCheckbox.show()
            self.zSliceSpinbox.show()
            self.switchPlaneCombobox.show()
            self.switchPlaneCombobox.setDisabled(False)
            self.SizeZlabel.show()
        else:
            myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False)
            myutils.setRetainSizePolicy(self.zProjComboBox, retain=False)
            myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False)
            myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False)
            myutils.setRetainSizePolicy(self.overlay_z_label, retain=False)
            self.zSliceScrollBar.setDisabled(True)
            self.zProjComboBox.hide()
            self.zProjComboBox.hide()
            self.zSliceScrollBar.hide()
            self.zSliceCheckbox.hide()
            self.zSliceSpinbox.hide()
            self.SizeZlabel.hide()
            self.switchPlaneCombobox.hide()
            self.switchPlaneCombobox.setDisabled(True)
        
        self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled)
        for ch, overlayItems in self.overlayLayersItems.items():
            imageItem, lutItem, alphaScrollBar = overlayItems
            lutItem.rescaleAcrossZstackAction.setDisabled(not enabled)

    def reInitCca(self):
        if not self.isSnapshot:
            txt = html_utils.paragraph(
                'If you decide to continue <b>ALL cell cycle annotations</b> from '
                'this frame to the end will be <b>erased from current session</b> '
                '(saved data is not touched of course).<br><br>'
                'To annotate future frames again you will have to revisit them.<br><br>'
                'Do you want to continue?'
            )
            msg = widgets.myMessageBox()
            msg.warning(
               self, 'Re-initialize annnotations?', txt, 
               buttonsTexts=('Cancel', 'Yes')
            )
            posData = self.data[self.pos_i]
            if msg.cancel:
                return
            
            # Reset all future frames
            self.resetCcaFuture(posData.frame_i+1)
            if posData.frame_i == 0:
                # Reset everything since we are on first frame
                posData.cca_df = self.getBaseCca_df()
                self.store_data()
            self.updateAllImages()
            self.navigateScrollBar.setMaximum(posData.frame_i+1)
            self.navSpinBox.setMaximum(posData.frame_i+1)
        else:
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)

            posData = self.data[self.pos_i]
            posData.cca_df = self.getBaseCca_df()
            self.store_data()
            self.updateAllImages()        


    def repeatAutoCca(self):
        # Do not allow automatic bud assignment if there are future
        # frames that already contain anotations
        posData = self.data[self.pos_i]
        next_df = posData.allData_li[posData.frame_i+1]['acdc_df']
        if next_df is not None:
            if 'cell_cycle_stage' in next_df.columns:
                msg = QMessageBox()
                warn_cca = msg.critical(
                    self, 'Future visited frames detected!',
                    'Automatic bud assignment CANNOT be performed becasue '
                    'there are future frames that already contain cell cycle '
                    'annotations. The behaviour in this case cannot be predicted.\n\n'
                    'We suggest assigning the bud manually OR use the '
                    '"Re-initialize cell cycle annotations" button which properly '
                    're-initialize future frames.',
                    msg.Ok
                )
                return

        correctedAssignIDs = (
            posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index
        )
        NeverCorrectedAssignIDs = [
            ID for ID in posData.new_IDs if ID not in correctedAssignIDs
        ]

        # Store cca_df temporarily if attempt_auto_cca fails
        posData.cca_df_beforeRepeat = posData.cca_df.copy()

        if not all(NeverCorrectedAssignIDs):
            notEnoughG1Cells, proceed = self.attempt_auto_cca()
            if notEnoughG1Cells or not proceed:
                posData.cca_df = posData.cca_df_beforeRepeat
            else:
                self.updateAllImages()
            return

        msg = QMessageBox()
        msg.setIcon(msg.Question)
        msg.setText(
            'Do you want to automatically assign buds to mother cells for '
            'ALL the new cells in this frame (excluding cells with unknown history) '
            'OR only the cells where you never clicked on?'
        )
        msg.setDetailedText(
            f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}')
        enforceAllButton = QPushButton('ALL new cells')
        b = QPushButton('Only cells that I never corrected assignment')
        msg.addButton(b, msg.YesRole)
        msg.addButton(enforceAllButton, msg.NoRole)
        msg.exec_()
        if msg.clickedButton() == enforceAllButton:
            notEnoughG1Cells, proceed = self.attempt_auto_cca(enforceAll=True)
        else:
            notEnoughG1Cells, proceed = self.attempt_auto_cca()
        if notEnoughG1Cells or not proceed:
            posData.cca_df = posData.cca_df_beforeRepeat
        else:
            self.updateAllImages()

    def manualEditCcaToolbarActionTriggered(self):
        self.manualEditCca()
    
    def askGet2Dor3Dimage(self):
        txt = html_utils.paragraph("""
            Do you want to test the denoising on the visualized 2D image or 
            on the entire 3D z-stack?                           
        """)
        msg = widgets.myMessageBox(wrapText=False)
        _, use3Dbutton, use2Dbutton = msg.question(
            self, '3D denoising?', txt, 
            buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image')
        )
        if msg.cancel:
            return 
        
        if msg.clickedButton == use3Dbutton:
            posData = self.data[self.pos_i]
            zslice = self.zSliceScrollBar.sliderPosition()
            return posData.img_data[posData.frame_i, zslice]
        else:
            return self.getDisplayedImg1()
        
    def manualEditCca(self, checked=True):
        posData = self.data[self.pos_i]
        editCcaWidget = apps.editCcaTableWidget(
            posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i,
            parent=self
        )
        editCcaWidget.sigApplyChangesFutureFrames.connect(
            self.applyManualCcaChangesFutureFrames
        )
        editCcaWidget.exec_()
        if editCcaWidget.cancel:
            return
        posData.cca_df = editCcaWidget.cca_df
        self.store_cca_df()
        # self.checkMultiBudMoth()
        self.updateAllImages()
    
    @exception_handler
    def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i):
        self.store_data(autosave=False)
        posData = self.data[self.pos_i]
        undoId = uuid.uuid4()
        for i in range(posData.frame_i, stop_frame_i):
            cca_df_i = self.get_cca_df(frame_i=i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break
            
            self.storeUndoRedoCca(i, cca_df_i, undoId)
            
            for ID, changes_ID in changes.items():
                if ID not in cca_df_i.index:
                    continue
                for col, (oldValue, newValue) in changes_ID.items():
                    cca_df_i.at[ID, col] = newValue
            self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False)
        self.get_data()
        self.updateAllImages()
    
    def annotateRightHowCombobox_cb(self, idx):
        how = self.annotateRightHowCombobox.currentText()
        saveSettings = True
        if hasattr(self.annotateRightHowCombobox, 'saveSettings'):
            saveSettings = self.annotateRightHowCombobox.saveSettings

        if saveSettings:
            self.df_settings.at['how_draw_right_annotations', 'value'] = how
            self.df_settings.to_csv(self.settings_csv_path)

        mode = self.modeComboBox.currentText()
        isCcaAnnot = (
            self.annotCcaInfoCheckboxRight.isChecked() and 
            mode != 'Normal division: Lineage tree'
        )
        isIDAnnot = (self.annotIDsCheckboxRight.isChecked() or (
            self.annotCcaInfoCheckboxRight.isChecked() and
            mode == 'Normal division: Lineage tree'
        ))
        self.textAnnot[1].setCcaAnnot(
            isCcaAnnot
        )

        self.textAnnot[1].setLabelAnnot(
            isIDAnnot
        )
        if not self.isDataLoading:
            self.updateAllImages()

    def drawIDsContComboBox_cb(self, idx):
        how = self.drawIDsContComboBox.currentText()
        saveSettings = True
        if hasattr(self.drawIDsContComboBox, 'saveSettings'):
            saveSettings = self.drawIDsContComboBox.saveSettings
        
        if saveSettings:
            self.df_settings.at['how_draw_annotations', 'value'] = how
            self.df_settings.to_csv(self.settings_csv_path)

        mode = self.modeComboBox.currentText()
        isCcaAnnot = (
            self.annotCcaInfoCheckbox.isChecked() and 
            mode != 'Normal division: Lineage tree'
        )
        isIDAnnot = (self.annotIDsCheckbox.isChecked() or (
            self.annotCcaInfoCheckbox.isChecked() and
            mode == 'Normal division: Lineage tree'
        ))
        self.textAnnot[0].setCcaAnnot(
            isCcaAnnot
        )

        self.textAnnot[0].setLabelAnnot(
            isIDAnnot
        )

        if not self.isDataLoading:
            self.updateAllImages()

        if self.eraserButton.isChecked():
            self.setTempImg1Eraser(None, init=True)

    def mousePressColorButton(self, event):
        posData = self.data[self.pos_i]
        items = list(self.checkedOverlayChannels)
        if len(items)>1:
            selectFluo = widgets.QDialogListbox(
                'Select image',
                'Select which fluorescence image you want to update the color of\n',
                items, multiSelection=False, parent=self
            )
            selectFluo.exec_()
            keys = selectFluo.selectedItemsText
            if selectFluo.cancel or not keys:
                return
            else:
                self.overlayColorButton.channel = keys[0]
        else:
            self.overlayColorButton.channel = items[0]
        self.overlayColorButton.selectColor()

    def setEnabledCcaToolbar(self, enabled=False):
        self.manuallyEditCcaAction.setDisabled(False)
        self.viewCcaTableAction.setDisabled(False)
        self.ccaToolBar.setVisible(enabled)
        for action in self.ccaToolBar.actions():
            button = self.ccaToolBar.widgetForAction(action)
            action.setVisible(enabled)
            button.setEnabled(enabled)

    # def setEnabledCcaToolbar(self, enabled=False):
    #     self.manuallyEditCcaAction.setDisabled(False)
    #     self.viewCcaTableAction.setDisabled(False)
    #     self.ccaToolBar.setVisible(enabled)
    #     for action in self.ccaToolBar.actions():
    #         button = self.ccaToolBar.widgetForAction(action)
    #         action.setVisible(enabled)
    #         button.setEnabled(enabled)

    def setEnabledEditToolbarButton(self, enabled=False):
        for action in self.segmActions:
            action.setEnabled(enabled)

        for action in self.segmActionsVideo:
            action.setEnabled(enabled)

        self.relabelSequentialAction.setEnabled(enabled)
        self.repeatTrackingMenuAction.setEnabled(enabled)
        self.repeatTrackingVideoAction.setEnabled(enabled)
        self.postProcessSegmAction.setEnabled(enabled)
        self.autoSegmAction.setEnabled(enabled)
        self.editToolBar.setVisible(enabled)
        mode = self.modeComboBox.currentText()
        ccaON = mode == 'Cell cycle analysis'
        for action in self.editToolBar.actions():
            button = self.editToolBar.widgetForAction(action)
            # Keep binCellButton active in cca mode
            if button==self.binCellButton and not enabled and ccaON:
                action.setVisible(True)
                button.setEnabled(True)
            else:
                action.setVisible(enabled)
                button.setEnabled(enabled)
        if not enabled:
            self.setUncheckedAllButtons()

    def setEnabledFileToolbar(self, enabled):
        for action in self.fileToolBar.actions():
            button = self.fileToolBar.widgetForAction(action)
            if action == self.openFolderAction or action == self.newAction:
                continue
            if action == self.manageVersionsAction:
                continue
            if action == self.openFileAction:
                continue
            action.setEnabled(enabled)
            button.setEnabled(enabled)

    def reconnectUndoRedo(self):
        try:
            self.undoAction.triggered.disconnect()
            self.redoAction.triggered.disconnect()
        except Exception as e:
            pass
        mode = self.modeComboBox.currentText()
        if mode == 'Segmentation and Tracking' or mode == 'Snapshot':
            self.undoAction.triggered.connect(self.undo)
            self.redoAction.triggered.connect(self.redo)
        elif mode == 'Cell cycle analysis':
            self.undoAction.triggered.connect(self.UndoCca)
        elif mode == 'Custom annotations':
            self.undoAction.triggered.connect(self.undoCustomAnnotation)
        else:
            self.undoAction.setDisabled(True)
            self.redoAction.setDisabled(True)

    def enableSizeSpinbox(self, enabled):
        self.brushSizeLabelAction.setVisible(enabled)
        self.brushSizeAction.setVisible(enabled)
        self.brushAutoFillAction.setVisible(enabled)
        self.brushAutoHideAction.setVisible(enabled)
        self.brushEraserToolBar.setVisible(enabled)        
        self.disableNonFunctionalButtons()

    def reload_cb(self):
        posData = self.data[self.pos_i]
        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False)
        labData = np.load(posData.segm_npz_path)
        # Keep compatibility with .npy and .npz files
        try:
            lab = labData['arr_0'][posData.frame_i]
        except Exception as e:
            lab = labData[posData.frame_i]
        posData.segm_data[posData.frame_i] = lab.copy()
        self.get_data()
        self.tracking()
        self.updateAllImages()

    def clearComboBoxFocus(self, mode):
        # Remove focus from modeComboBox to avoid the key_up changes its value
        self.sender().clearFocus()
        try:
            self.timer.stop()
            self.modeComboBox.setStyleSheet('background-color: none')
        except Exception as e:
            pass
    
    def updateModeMenuAction(self):
        self.modeActionGroup.triggered.disconnect()
        for action in self.modeActionGroup.actions():
            if action.text() != self.modeComboBox.currentText():
                continue
            action.setChecked(True)
            break
        self.modeActionGroup.triggered.connect(self.changeModeFromMenu)

    def changeModeFromMenu(self, action):
        self.modeComboBox.setCurrentText(action.text())

    def restorePrevAnnotOptions(self):
        if self.prevAnnotOptions is None:
            return
        self.restoreAnnotOptions_ax1(options=self.prevAnnotOptions)
        self.setDrawAnnotComboboxText()
        self.prevAnnotOptions = None
    
    @disableWindow
    def changeMode(self, text):
        self.reconnectUndoRedo()
        self.updateModeMenuAction()
        self.clearCustomAnnot()
        posData = self.data[self.pos_i]
        mode = text
        prevMode = self.modeComboBox.previousText()
        self.annotateToolbar.setVisible(False)
        if prevMode != 'Viewer':
            self.store_data(autosave=False)
        self.copyLostObjButton.setChecked(False)
        self.stopCcaIntegrityCheckerWorker()

        if prevMode == 'Normal division: Lineage tree':
            self.lin_tree_ask_changes()
            self.lineage_tree = None
            self.editLin_TreeBar.setVisible(False)

        elif prevMode == 'Cell cycle analysis':
            self.setEnabledCcaToolbar(enabled=False)

        if mode == 'Segmentation and Tracking':
            self.setSwitchViewedPlaneDisabled(True)
            self.trackingMenu.setDisabled(False)
            self.modeToolBar.setVisible(True)
            self.lastTrackedFrameLabel.setText('')
            self.initSegmTrackMode()
            self.setEnabledEditToolbarButton(enabled=True)
            self.addExistingDelROIs()
            self.checkTrackingEnabled()
            self.setEnabledCcaToolbar(enabled=False)
            self.clearComputedContours()
            self.realTimeTrackingToggle.setDisabled(False)
            self.realTimeTrackingToggle.label.setDisabled(False)
            if posData.cca_df is not None:
                self.store_cca_df()
            self.restorePrevAnnotOptions()
        elif mode == 'Cell cycle analysis':
            self.setSwitchViewedPlaneDisabled(True)
            self.startCcaIntegrityCheckerWorker()
            proceed = self.initCca()
            if proceed:
                self.applyDelROIs()
            self.modeToolBar.setVisible(True)
            self.realTimeTrackingToggle.setDisabled(True)
            self.realTimeTrackingToggle.label.setDisabled(True)
            self.computeAllContours()
            # RAWR!!!!!
            # self.computeAllObjToObjCostPairs()
            if proceed:
                self.setEnabledEditToolbarButton(enabled=False)
                if self.isSnapshot:
                    self.editToolBar.setVisible(True)
                self.setEnabledCcaToolbar(enabled=True)
                self.removeAlldelROIsCurrentFrame()
                self.setAnnotOptionsCcaMode()
                self.clearGhost()
        elif mode == 'Viewer':
            self.setSwitchViewedPlaneDisabled(False)
            self.modeToolBar.setVisible(True)
            self.realTimeTrackingToggle.setDisabled(True)
            self.realTimeTrackingToggle.label.setDisabled(True)
            self.setEnabledEditToolbarButton(enabled=False)
            self.setEnabledCcaToolbar(enabled=False)
            self.removeAlldelROIsCurrentFrame()
            # currentMode = self.drawIDsContComboBox.currentText()
            # self.drawIDsContComboBox.clear()
            # self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxCcaItems)
            # self.drawIDsContComboBox.setCurrentText(currentMode)
            self.navigateScrollBar.setMaximum(posData.SizeT)
            self.navSpinBox.setMaximum(posData.SizeT)
            self.clearGhost()
            self.computeAllContours()
        elif mode == 'Custom annotations':
            self.setSwitchViewedPlaneDisabled(True)
            self.modeToolBar.setVisible(True)
            self.realTimeTrackingToggle.setDisabled(True)
            self.realTimeTrackingToggle.label.setDisabled(True)
            self.setEnabledEditToolbarButton(enabled=False)
            self.setEnabledCcaToolbar(enabled=False)
            self.removeAlldelROIsCurrentFrame()
            self.annotateToolbar.setVisible(True)
            self.clearGhost()
            self.doCustomAnnotation(0)
            self.computeAllContours()
        elif mode == 'Snapshot':
            self.setSwitchViewedPlaneDisabled(False)
            self.reconnectUndoRedo()
            self.setEnabledSnapshotMode()
            self.doCustomAnnotation(0)
            self.clearComputedContours()
        elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree
            # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed)
            proceed = self.initLinTree()
            self.setEnabledCcaToolbar(enabled=False)
            self.setNavigateScrollBarMaximum()
            if proceed:
                self.applyDelROIs()
            self.modeToolBar.setVisible(True)
            self.realTimeTrackingToggle.setDisabled(True)
            self.realTimeTrackingToggle.label.setDisabled(True)
            if proceed:
                self.setEnabledEditToolbarButton(enabled=False)
                if self.isSnapshot:
                    self.editToolBar.setVisible(True)
                self.removeAlldelROIsCurrentFrame()
                self.setAnnotOptionsLin_treeMode()
                self.clearGhost()
                self.editLin_TreeBar.setVisible(True)

    def disableEditingViewPlaneNotXY(self):
        posData = self.data[self.pos_i]
        self.manuallyEditCcaAction.setDisabled(True)
        for action in self.segmActions:
            action.setDisabled(True)
        if posData.SizeT == 1:
            self.segmVideoMenu.setDisabled(True)
        self.postProcessSegmAction.setDisabled(True)
        self.autoSegmAction.setDisabled(True)
        self.ccaToolBar.setVisible(False)
        self.editToolBar.setVisible(False)
        for action in self.ccaToolBar.actions():
            button = self.editToolBar.widgetForAction(action)
            if button is not None:
                button.setDisabled(True)
            action.setVisible(False)
        for action in self.editToolBar.actions():
            button = self.editToolBar.widgetForAction(action)
            action.setVisible(False)
            if button is not None:
                button.setDisabled(True)
    
    def setEnabledSnapshotMode(self):
        posData = self.data[self.pos_i]
        self.manuallyEditCcaAction.setDisabled(False)
        self.viewCcaTableAction.setDisabled(False)
        for action in self.segmActions:
            action.setDisabled(False)

        self.segmVideoMenu.setDisabled(True)
        self.trackingMenu.setDisabled(True)
        self.modeToolBar.setVisible(False)
        
        self.relabelSequentialAction.setDisabled(False)
        self.postProcessSegmAction.setDisabled(False)
        self.autoSegmAction.setDisabled(False)
        self.ccaToolBar.setVisible(True)
        self.editToolBar.setVisible(True)
        self.reinitLastSegmFrameAction.setVisible(False)
        for action in self.ccaToolBar.actions():
            button = self.ccaToolBar.widgetForAction(action)
            if button == self.assignBudMothButton:
                button.setDisabled(False)
                action.setVisible(True)
            elif action == self.reInitCcaAction:
                action.setVisible(True)
            elif action == self.assignBudMothAutoAction and posData.SizeT==1:
                action.setVisible(True)
        for action in self.editToolBar.actions():
            button = self.editToolBar.widgetForAction(action)
            action.setVisible(True)
            button.setEnabled(True)
        self.realTimeTrackingToggle.setDisabled(True)
        self.realTimeTrackingToggle.label.setDisabled(True)
        self.repeatTrackingAction.setVisible(False)
        self.manualTrackingAction.setVisible(False)
        button = self.editToolBar.widgetForAction(self.repeatTrackingAction)
        button.setDisabled(True)
        button = self.editToolBar.widgetForAction(self.manualTrackingAction)
        button.setDisabled(True)
        self.disableNonFunctionalButtons()
        self.reinitLastSegmFrameAction.setVisible(False)

    def launchSlideshow(self):
        posData = self.data[self.pos_i]
        self.determineSlideshowWinPos()
        if self.slideshowButton.isChecked():
            self.slideshowWin = apps.imageViewer(
                parent=self,
                button_toUncheck=self.slideshowButton,
                linkWindow=posData.SizeT > 1,
                enableOverlay=True, 
                enableMirroredCursor=True
            )
            self.slideshowWin.img.minMaxValuesMapper = (
                self.img1.minMaxValuesMapper
            )
            self.slideshowWin.img.setCurrentPosIndex(self.pos_i)
            h = self.drawIDsContComboBox.size().height()
            self.slideshowWin.framesScrollBar.setFixedHeight(h)
            self.slideshowWin.overlayButton.setChecked(
                self.overlayButton.isChecked()
            )
            self.slideshowWin.sigHoveringImage.connect(
                self.setMirroredCursorFromSecondWindow
            )
            if posData.SizeZ > 1:
                z_slice = self.zSliceScrollBar.sliderPosition()
                self.slideshowWin.img.setCurrentZsliceIndex(z_slice)
                self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice)
                self.slideshowWin.z_label.setText(
                    f'z-slice  {z_slice+1:02}/{posData.SizeZ}'
                )
            self.slideshowWin.update_img()
            self.slideshowWin.show(
                left=self.slideshowWinLeft, top=self.slideshowWinTop
            )
        else:
            self.slideshowWin.close()
            self.slideshowWin = None
    
    def setMirroredCursorFromSecondWindow(self, x, y):
        if x is None:
            xx, yy = [], []
        else:
            xx, yy = [x], [y]
        self.ax1_cursor.setData(xx, yy)
        if not self.isTwoImageLayout:
            return
        self.ax2_cursor.setData(xx, yy)
    
    def goToZsliceSearchedID(self, obj):
        if not self.isSegm3D:
            return
        
        current_z = self.z_lab()
        nearest_nonzero_z = core.nearest_nonzero_z_idx_from_z_centroid(
            obj, current_z=current_z
        )
        if nearest_nonzero_z == current_z:
            self.drawPointsLayers(computePointsLayers=True)
            return
        
        self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z)
        self.update_z_slice(nearest_nonzero_z)

    def nearest_nonzero(self, a, y, x):
        r, c = np.nonzero(a)
        if r.size == 0:
            return None
        dist = ((r - y)**2 + (c - x)**2)
        min_idx = dist.argmin()
        return a[r[min_idx], c[min_idx]]

    def disconnectLeftClickButtons(self):
        for button in self.LeftClickButtons:
            try:
                button.toggled.disconnect()
            except Exception as e:
                # Not all the LeftClickButtons have toggled connected
                pass

    def uncheckLeftClickButtons(self, sender):
        for button in self.LeftClickButtons:
            if button != sender:
                button.setChecked(False)
        
        if button != self.labelRoiButton:
            # self.labelRoiButton is disconnected so we manually call uncheck
            self.labelRoi_cb(False)
        self.secondLevelToolbar.setVisible(True)
        for toolbar in self.controlToolBars:
            try:
                toolbar.keepVisibleWhenActive
                if toolbar.isVisible():
                    self.secondLevelToolbar.setVisible(False)
                    continue
            except:
                pass
            toolbar.setVisible(False) 
        
        self.enableSizeSpinbox(False)
        if sender is not None:
            self.keepIDsButton.setChecked(False)

    def connectLeftClickButtonsPointsLayersToolbar(self):
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                if not hasattr(action, 'layerTypeIdx'):
                    continue
                if action.layerTypeIdx != 4:
                    continue
                action.button.toggled.connect(
                    self.addPointsByClickingButtonToggled
                )
    
    def connectLeftClickButtons(self):
        self.brushButton.toggled.connect(self.Brush_cb)
        self.curvToolButton.toggled.connect(self.curvTool_cb)
        self.rulerButton.toggled.connect(self.ruler_cb)
        self.eraserButton.toggled.connect(self.Eraser_cb)
        self.wandToolButton.toggled.connect(self.wand_cb)
        self.labelRoiButton.toggled.connect(self.labelRoi_cb)
        self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb)
        self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb)
        self.expandLabelToolButton.toggled.connect(self.expandLabelCallback)
        self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb)
        self.manualBackgroundButton.toggled.connect(self.manualBackground_cb)
        self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb)
        self.connectLeftClickButtonsPointsLayersToolbar()

    def brushSize_cb(self, value):
        self.ax2_EraserCircle.setSize(value*2)
        self.ax1_BrushCircle.setSize(value*2)
        self.ax2_BrushCircle.setSize(value*2)
        self.ax1_EraserCircle.setSize(value*2)
        self.ax2_EraserX.setSize(value)
        self.ax1_EraserX.setSize(value)
        self.setDiskMask()
    
    def autoIDtoggled(self, checked):
        self.editIDspinboxAction.setDisabled(checked)
        self.editIDLabelAction.setDisabled(checked)
        if not checked and self.editIDspinbox.value() == 0:
            newID = self.setBrushID(return_val=True)
            self.editIDspinbox.setValue(newID)

    def wand_cb(self, checked):
        posData = self.data[self.pos_i]
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.wandToolButton)
            self.connectLeftClickButtons()
            self.wandControlsToolbar.setVisible(True)
            # self.secondLevelToolbar.setVisible(False)
        else:
            self.resetCursors()
            # self.secondLevelToolbar.setVisible(True)
            self.wandControlsToolbar.setVisible(False)
    
    def magicPrompts_cb(self, checked):
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.magicPromptsToolButton)
            self.connectLeftClickButtons()
            self.magicPromptsToolbar.setVisible(True)
            self.promptSegmentPointsLayerToolbar.setVisible(True)
            if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit:
                self.addPointsLayerTriggered(
                    toolbar=self.promptSegmentPointsLayerToolbar
                )
        else:
            self.resetCursors()
            self.promptSegmentPointsLayerToolbar.setVisible(False)
            self.magicPromptsToolbar.setVisible(False)
    
    def copyLostObjContour_cb(self, checked):
        self.copyLostObjToolbar.setVisible(checked)
        
        self.ax1_lostObjScatterItem.hoverLostID = 0
        if not checked:
            return
        
        self.lostObjImage = np.zeros_like(self.currentLab2D)
        self.updateLostContoursImage(0)
    
    def copyLostObjectContour(self, ID: int):
        posData = self.data[self.pos_i]
        mask = self.lostObjImage == ID
        lab2D = self.get_2Dlab(posData.lab)
        lab2D[mask] = ID
        self.lostObjImage[mask] = 0
        self.set_2Dlab(lab2D)
    
    def updateLostNewCurrentIDs(self):
        posData = self.data[self.pos_i]
        
        prev_IDs = self.getPrevFrameIDs()  
        tracked_lost_IDs = self.getTrackedLostIDs()
        curr_IDs = posData.IDs
        curr_delRoiIDs = self.getStoredDelRoiIDs()
        prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1)
        lost_IDs = [
            ID for ID in prev_IDs if ID not in curr_IDs
            and ID not in prev_delRoiIDs and ID not in tracked_lost_IDs
        ]
        new_IDs = [
            ID for ID in curr_IDs if ID not in prev_IDs 
            and ID not in curr_delRoiIDs
        ]
        IDs_with_holes = []
        posData.lost_IDs = lost_IDs
        posData.new_IDs = new_IDs
        posData.old_IDs = prev_IDs
        posData.IDs = curr_IDs
        
        out = (
            lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs
        )
        return out
    
    def copyAllLostObjectsWorkerCallback(
            self, posData, for_future_frame_n, max_overlap_perc
        ):
        current_frame_i = posData.frame_i
        last_visited_frame_i = self.get_last_tracked_i()
        last_copied_frame_i = current_frame_i+for_future_frame_n+1
        frames_range = (current_frame_i, last_copied_frame_i)
        for frame_i in range(*frames_range):
            if frame_i == posData.SizeT:
                break
            
            if frame_i > posData.frame_i:
                self.store_data(mainThread=False, autosave=False)
                
                posData.frame_i = frame_i
                self.get_data()
                self.tracking(wl_update=False)
                self.update_rp()
                self.updateLostNewCurrentIDs()
                self.store_data(mainThread=False, autosave=False)
                # delROIsIDs = self.getDelRoisIDs()
                
                self.lostObjContoursImage[:] = 0
                prev_rp = posData.allData_li[frame_i-1]['regionprops']
                prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs']
                for lostID in posData.lost_IDs:
                    obj = prev_rp[prev_IDs_idxs[lostID]]
                    self.addLostObjsToImage(obj, lostID, force=True)
            
            for lostObj in skimage.measure.regionprops(self.lostObjImage):
                overlap = np.count_nonzero(
                    self.currentLab2D[lostObj.slice][lostObj.image]
                )
                overlap_perc = overlap/lostObj.area*100
                if overlap_perc > max_overlap_perc:
                    self.copyAllLostObjectsWorker.overlapWarning = True
                    continue
                
                self.copyLostObjectContour(lostObj.label)
            
            self.copyAllLostObjectsWorker.signals.progressBar.emit(1)           
        
        if for_future_frame_n == 0:
            return
        
        # Back to current frame
        self.store_data(autosave=False, mainThread=False)
        posData.frame_i = current_frame_i
        self.get_data()
        
        if last_visited_frame_i >= last_copied_frame_i:
            return
        
        self.copyAllLostObjectsWorker.output['doReinitLastSegmFrame'] = True
        self.copyAllLostObjectsWorker.output['last_visited_frame_i'] = (
            last_visited_frame_i
        )
    
    @disableWindow
    def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc):
        if not self.copyLostObjButton.isChecked():
            return
        
        posData = self.data[self.pos_i]
        
        desc = (
            'Copying all lost objects...'
        )
        
        self.progressWin = apps.QDialogWorkerProgress(
            title=desc, parent=self.mainWin, pbarDesc=desc
        )
        self.progressWin.mainPbar.setMaximum(for_future_frame_n+1)
        self.progressWin.show(self.app)
                              
        self.copyAllLostObjectsThread = QThread()
        
        self.copyAllLostObjectsWorker = workers.SimpleWorker(
            posData, self.copyAllLostObjectsWorkerCallback, 
            func_args=(for_future_frame_n, max_overlap_perc)
        )
        self.copyAllLostObjectsWorker.overlapWarning = False
        
        self.copyAllLostObjectsWorker.moveToThread(
            self.copyAllLostObjectsThread
        )
        
        self.copyAllLostObjectsWorker.signals.finished.connect(
            self.copyAllLostObjectsThread.quit
        )
        self.copyAllLostObjectsWorker.signals.finished.connect(
            self.copyAllLostObjectsWorker.deleteLater
        )
        self.copyAllLostObjectsThread.finished.connect(
            self.copyAllLostObjectsThread.deleteLater
        )
        
        self.copyAllLostObjectsWorker.signals.critical.connect(
            self.copyAllLostObjectsWorkerCritical
        )
        self.copyAllLostObjectsWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.copyAllLostObjectsWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.copyAllLostObjectsWorker.signals.progress.connect(
            self.workerProgress
        )
        self.copyAllLostObjectsWorker.signals.finished.connect(
            self.copyAllLostObjectsWorkerFinished
        )
        
        self.copyAllLostObjectsThread.started.connect(
            self.copyAllLostObjectsWorker.run
        )
        self.copyAllLostObjectsThread.start()
        
        self.copyAllLostObjectsWorkerLoop = QEventLoop()
        self.copyAllLostObjectsWorkerLoop.exec_()
    
    def copyAllLostObjectsWorkerCritical(self, error):
        self.copyAllLostObjectsWorkerLoop.exit()
        self.workerCritical(error)
    
    def copyAllLostObjectsWorkerFinished(self, output):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        
        if output.get('doReinitLastSegmFrame', False):
            self.reInitLastSegmFrame(
                from_frame_i=output.get('last_visited_frame_i'), 
                updateImages=False, 
                force=True
            )
        
        if self.copyAllLostObjectsWorker.overlapWarning:
            self.blinker = qutils.QControlBlink(
                self.copyLostObjToolbar.maxOverlapNumberControl, 
                qparent=self.mainWin
            )
            self.blinker.start()
        
        self.copyAllLostObjectsWorkerLoop.exit()
        self.update_rp()
        self.updateAllImages()
        self.store_data()
    
    def labelRoiTrangeCheckboxToggled(self, checked):
        disabled = not checked
        self.labelRoiStartFrameNoSpinbox.setDisabled(disabled)
        self.labelRoiStopFrameNoSpinbox.setDisabled(disabled)
        self.labelRoiStartFrameNoSpinbox.label.setDisabled(disabled)
        self.labelRoiStopFrameNoSpinbox.label.setDisabled(disabled)
        self.labelRoiToEndFramesAction.setDisabled(disabled)
        self.labelRoiFromCurrentFrameAction.setDisabled(disabled)

        if disabled:
            return

        posData = self.data[self.pos_i]

        self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1)
        self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT)

    def drawClearRegion_cb(self, checked):
        posData = self.data[self.pos_i]
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.drawClearRegionButton)
            self.connectLeftClickButtons()

        self.drawClearRegionToolbar.setVisible(checked)
        
        if not self.isSegm3D:
            self.drawClearRegionToolbar.setZslicesControlEnabled(False)
            return
        
        if not checked:
            return
        
        self.drawClearRegionToolbar.setZslicesControlEnabled(
            True, SizeZ=posData.SizeZ
        )
    
    def labelRoi_cb(self, checked):
        posData = self.data[self.pos_i]
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.labelRoiButton)
            self.connectLeftClickButtons()

            self.labelRoiStartFrameNoSpinbox.setMaximum(posData.SizeT)
            self.labelRoiStopFrameNoSpinbox.setMaximum(posData.SizeT)

            if self.labelRoiActiveWorkers:
                lastActiveWorker = self.labelRoiActiveWorkers[-1]
                self.labelRoiGarbageWorkers.append(lastActiveWorker)
                lastActiveWorker.finished.emit()
                self.logger.info('Collected garbage w5orker (magic labeller).')
            
            self.labelRoiToolbar.setVisible(True)
            if self.isSegm3D:
                self.labelRoiZdepthSpinbox.setDisabled(False)
            else:
                self.labelRoiZdepthSpinbox.setDisabled(True)

            # Start thread and pause it
            self.labelRoiThread = QThread()
            self.labelRoiMutex = QMutex()
            self.labelRoiWaitCond = QWaitCondition()

            labelRoiWorker = workers.LabelRoiWorker(self)

            labelRoiWorker.moveToThread(self.labelRoiThread)
            labelRoiWorker.finished.connect(self.labelRoiThread.quit)
            labelRoiWorker.finished.connect(labelRoiWorker.deleteLater)
            self.labelRoiThread.finished.connect(
                self.labelRoiThread.deleteLater
            )

            labelRoiWorker.finished.connect(self.labelRoiWorkerFinished)
            labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone)
            labelRoiWorker.sigProgressBar.connect(self.workerUpdateProgressbar)

            labelRoiWorker.progress.connect(self.workerProgress)
            labelRoiWorker.critical.connect(self.workerCritical)

            self.labelRoiActiveWorkers.append(labelRoiWorker)

            self.labelRoiThread.started.connect(labelRoiWorker.run)
            self.labelRoiThread.start()

            # Add the rectROI to ax1
            self.ax1.addItem(self.labelRoiItem)
        else:
            self.labelRoiToolbar.setVisible(False)
            
            for worker in self.labelRoiActiveWorkers:
                worker._stop()
            while self.app.overrideCursor() is not None:
                self.app.restoreOverrideCursor()
            
            self.labelRoiItem.setPos((0,0))
            self.labelRoiItem.setSize((0,0))
            self.freeRoiItem.clear()
            self.ax1.removeItem(self.labelRoiItem)
            self.updateLabelRoiCircularCursor(None, None, False)
    
    def clearObjsFreehandRegion(self):
        self.logger.info('Clearing objects inside freehand region...')
        
        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True)
        
        posData = self.data[self.pos_i]
        zRange = None
        if self.isSegm3D:
            z_slice = self.z_lab()
            zRange = self.drawClearRegionToolbar.zRange(z_slice, posData.SizeZ)
            
        regionSlice = self.freeRoiItem.slice(zRange=zRange)
        mask = self.freeRoiItem.mask()
        
        regionLab = posData.lab[(...,) + regionSlice].copy()
        regionLab[..., ~mask] = 0
        
        clearBorders = (
            self.drawClearRegionToolbar
            .clearOnlyEnclosedObjsRadioButton
            .isChecked()
        )
        if clearBorders:
            regionLab = skimage.segmentation.clear_border(regionLab)
        
        regionRp = skimage.measure.regionprops(regionLab)
        clearIDs = [obj.label for obj in regionRp]
        
        if not clearIDs:
            if clearBorders:
                self.logger.warning(
                    'None of the objects in the freehand region are '
                    'fully enclosed'
                )
            else:
                self.logger.warning(
                    'None of the objects are touching the freehand region'
                )
            return
        
        self.deleteIDmiddleClick(clearIDs, False, False)
        self.update_cca_df_deletedIDs(posData, clearIDs)
        
        self.freeRoiItem.clear()
        
        self.updateAllImages()
    
    def labelRoiWorkerFinished(self):
        self.logger.info('Magic labeller closed.')
        worker = self.labelRoiActiveWorkers.pop(-1)
    
    def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID):
        # Delete only objects touching borders in X and Y not in Z
        if self.labelRoiAutoClearBorderCheckbox.isChecked():
            mask = np.zeros(roiLab.shape, dtype=bool)
            mask[..., 1:-1, 1:-1] = True
            roiLab = skimage.segmentation.clear_border(roiLab, mask=mask)

        roiLabMask = roiLab>0
        roiLab[roiLabMask] += (brushID-1)
        if self.labelRoiReplaceExistingObjectsCheckbox.isChecked():
            IDs_touched_by_new_objects = np.unique(lab[roiLabSlice][roiLabMask])
            for ID in IDs_touched_by_new_objects:
                lab[lab==ID] = 0
        
        lab[roiLabSlice][roiLabMask] = roiLab[roiLabMask]
        return lab

    @exception_handler
    def labelRoiDone(self, roiSegmData, isTimeLapse):
        posData = self.data[self.pos_i]
        self.setBrushID()

        if isTimeLapse:
            self.progressWin.mainPbar.setMaximum(0)
            self.progressWin.mainPbar.setValue(0)
            current_frame_i = posData.frame_i
            start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1
            for i, roiLab in enumerate(roiSegmData):
                frame_i = start_frame_i + i
                lab = posData.allData_li[frame_i]['labels']
                store = True
                if lab is None:
                    if frame_i >= len(posData.segm_data):
                        lab = np.zeros_like(posData.segm_data[0])
                        posData.segm_data = np.append(
                            posData.segm_data, lab[np.newaxis], axis=0
                        )
                    else:
                        lab = posData.segm_data[frame_i]
                    store = False
                roiLabSlice = self.labelRoiSlice[1:]
                lab = self.indexRoiLab(
                    roiLab, roiLabSlice, lab, posData.brushID
                )
                if store:
                    posData.frame_i = frame_i
                    posData.allData_li[frame_i]['labels'] = lab.copy()
                    self.get_data()
                    self.store_data(autosave=False)
            
            # Back to current frame
            posData.frame_i = current_frame_i
            self.get_data()
        else:
            roiLab = roiSegmData
            posData.lab = self.indexRoiLab(
                roiLab, self.labelRoiSlice, posData.lab, posData.brushID
            )

        self.update_rp()
        
        # Repeat tracking
        if self.autoIDcheckbox.isChecked():
            self.tracking(enforce=True, assign_unique_new_IDs=False)
        
        self.store_data()
        self.updateAllImages()
        
        self.labelRoiItem.setPos((0,0))
        self.labelRoiItem.setSize((0,0))
        self.freeRoiItem.clear()
        self.logger.info('Magic labeller done!')
        self.app.restoreOverrideCursor()  

        self.labelRoiRunning = False    
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None  
        
        uncheckLabelRoiTRange = (
            self.labelRoiTrangeCheckbox.isChecked()
            and not self.labelRoiTrangeCheckbox.findChild(QAction).isChecked()
        )
        if uncheckLabelRoiTRange:
            self.labelRoiTrangeCheckbox.setChecked(False)

    def restoreHoverObjBrush(self):
        posData = self.data[self.pos_i]
        if self.ax1BrushHoverID in posData.IDs:
            obj_idx = posData.IDs_idxs[self.ax1BrushHoverID]
            obj = posData.rp[obj_idx]
            self.addObjContourToContoursImage(obj=obj, ax=0)
            self.addObjContourToContoursImage(obj=obj, ax=1)

    def hideItemsHoverBrush(self, xy=None, ID=None, force=False):
        if xy is not None:
            x, y = xy
            if x is None:
                return

            xdata, ydata = int(x), int(y)
            Y, X = self.currentLab2D.shape

            if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y):
                return

        if not self.brushAutoHideCheckbox.isChecked() and not force:
            return
        
        posData = self.data[self.pos_i]
        size = self.brushSizeSpinbox.value()*2

        if xy is not None:
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]

        if self.ax1_lostObjScatterItem.isVisible():
            self.ax1_lostObjScatterItem.setVisible(False)

        if self.ax1_lostTrackedScatterItem.isVisible():
            self.ax1_lostTrackedScatterItem.setVisible(False)
        
        if self.ax2_lostObjScatterItem.isVisible():
            self.ax2_lostObjScatterItem.setVisible(False)

        if self.ax2_lostTrackedScatterItem.isVisible():
            self.ax2_lostTrackedScatterItem.setVisible(False)

        # Restore ID previously hovered
        if ID != self.ax1BrushHoverID and not self.isMouseDragImg1:
            self.restoreHoverObjBrush()

        # Hide items hover ID
        if ID != 0:
            self.clearObjContour(ID=ID, ax=0)
            self.clearObjContour(ID=ID, ax=1)
            # self.setAllTextAnnotations(labelsToSkip={ID:True})
            self.ax1BrushHoverID = ID
        else:
            # self.setAllTextAnnotations()
            self.ax1BrushHoverID = 0

    def updateBrushCursor(self, x, y, isHoverImg1=True):
        if x is None:
            return

        xdata, ydata = int(x), int(y)
        _img = self.currentLab2D
        Y, X = _img.shape

        if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y):
            return

        size = self.brushSizeSpinbox.value()*2
        self.setHoverToolSymbolData(
            [x], [y], self.activeBrushCircleCursors(isHoverImg1),
            size=size
        )
        self.setHoverToolSymbolColor(
            xdata, ydata, self.ax2_BrushCirclePen,
            self.activeBrushCircleCursors(isHoverImg1),
            self.brushButton, brush=self.ax2_BrushCircleBrush
        )
    
    def moveLabelButtonToggled(self, checked):
        if not checked:
            self.hoverLabelID = 0
            self.highlightedID = 0
            self.highLightIDLayerImg1.clear()
            self.highLightIDLayerRightImage.clear()
            self.setHighlightID(False)
    
    def setAllIDs(self, onlyVisited=False):
        for posData in self.data:
            posData.allIDs = set()
            for frame_i in range(len(posData.segm_data)):
                if frame_i >= len(posData.allData_li):
                    break
                lab = posData.allData_li[frame_i]['labels']
                if lab is None and onlyVisited:
                    break
                
                if lab is None:
                    rp = skimage.measure.regionprops(posData.segm_data[frame_i])
                else:
                    rp = posData.allData_li[frame_i]['regionprops']
                posData.allIDs.update([obj.label for obj in rp])
    
    def countObjects(self):
        self.logger.info('Counting objects...')
        if self.countObjsWindow is None:
            activeCategories = {
                'In current frame', 
                'In all visited frames', 
                'In entire video', 
                'Unique objects in all visited frames', 
                'Unique objects in entire video'
            }
        else:
            activeCategories = self.countObjsWindow.activeCategories()
            
        posData = self.data[self.pos_i]
        numObjsCurrentFrame = len(posData.IDs)
        
        uniqueIDsVisited = None
        uniqueIDsAll = None
        numObjsVisitedFrames = None
        numObjsTotal = None
        if 'Unique objects in all visited frames' in activeCategories:
            uniqueIDsVisited = set()
        
        if 'Unique objects in entire video' in activeCategories:
            uniqueIDsAll = set()
        
        if 'In all visited frames' in activeCategories:
            numObjsVisitedFrames = 0
        
        if 'In entire video' in activeCategories:
            numObjsTotal = 0
            
        for frame_i in range(len(posData.segm_data)):
            lab = posData.allData_li[frame_i]['labels']
            if lab is not None:
                IDsFrame = posData.allData_li[frame_i]['IDs']
                
                if uniqueIDsVisited is not None:
                    uniqueIDsVisited.update(IDsFrame)
                
                if uniqueIDsAll is not None:
                    uniqueIDsAll.update(IDsFrame)
                
                numObjsFrame = len(IDsFrame)
                
                if numObjsVisitedFrames is not None:
                    numObjsVisitedFrames += numObjsFrame
                    
                if numObjsTotal is not None:
                    numObjsTotal += numObjsFrame
            else:
                lab = posData.segm_data[frame_i]
                
                if numObjsTotal is not None or numObjsTotal is not None:
                    rp = skimage.measure.regionprops(posData.segm_data[frame_i])
                
                if numObjsTotal is not None:
                    numObjsTotal += len(rp)
                    
                if uniqueIDsAll is not None:
                    uniqueIDsAll.update([obj.label for obj in rp])
        
        numUniqueObjsVisitedFrames = None
        if uniqueIDsVisited is not None:
            numUniqueObjsVisitedFrames = len(uniqueIDsVisited)
        
        numUniqueObjsTotal = None
        if uniqueIDsAll is not None:
            numUniqueObjsTotal = len(uniqueIDsAll)
        
        allCategoryCountMapper = {
            'In current frame': numObjsCurrentFrame, 
            'In all visited frames': numObjsVisitedFrames, 
            'In entire video': numObjsTotal, 
            'Unique objects in all visited frames': numUniqueObjsVisitedFrames, 
            'Unique objects in entire video': numUniqueObjsTotal
        }
        if self.countObjsWindow is None:
            return allCategoryCountMapper 
        
        categoryCountMapper = {}
        for category in activeCategories:
            categoryCountMapper[category] = allCategoryCountMapper[category]
            
        return categoryCountMapper
    
    def updateObjectCounts(self):
        if self.countObjsWindow is None:
            return
        
        if not self.countObjsWindow.isVisible():
            return
        
        if not self.countObjsWindow.livePreviewCheckbox.isChecked():
            return
        
        categoryCountMapper = self.countObjects()
        self.countObjsWindow.updateCounts(categoryCountMapper)
    
    def keepIDs_cb(self, checked):
        if checked:
            self.highlightedLab = np.zeros_like(self.currentLab2D)
            if self.annotCcaInfoCheckbox.isChecked():
                self.annotCcaInfoCheckbox.setChecked(False)
                self.annotIDsCheckbox.setChecked(True)
                self.setDrawAnnotComboboxText()
            self.uncheckLeftClickButtons(None)
            self.initKeepObjLabelsLayers()      
            self.setAllIDs()
        else:
            # restore items to non-grayed out
            self.tempLayerImg1.setImage(self.emptyLab, autoLevels=False)
            self.tempLayerRightImage.setImage(self.emptyLab, autoLevels=False)
            alpha = self.imgGrad.labelsAlphaSlider.value()
            self.labelsLayerImg1.setOpacity(alpha)
            self.labelsLayerRightImg.setOpacity(alpha)
            self.ax1_contoursImageItem.setOpacity(1.0)
            self.ax2_contoursImageItem.setOpacity(1.0)
            self.ax1_lostObjImageItem.setOpacity(1.0)
            self.ax2_lostObjImageItem.setOpacity(1.0)
            self.ax1_lostTrackedObjImageItem.setOpacity(1.0)
            self.ax2_lostTrackedObjImageItem.setOpacity(1.0)
        
        self.keepIDsToolbar.setVisible(checked)
        self.highlightedIDopts = None
        self.keptObjectsIDs = widgets.KeptObjectIDsList(
            self.keptIDsLineEdit, self.keepIDsConfirmAction
        )
        self.updateAllImages()

        # QTimer.singleShot(300, self.autoRange)

    def setFrameNavigationDisabled(self, disable:bool=False, why:str=""):
        """Disables the frame navigation buttons and scrollbar.
        This is used when the user is not allowed to navigate through frames
        Call again to unlock it again. Also sets tooltips to inform the user
        
        Parameters
        ----------
        disable : bool, optional
            if the navigation should be disabled, by default False
        why : str, optional
            set tooltip of scrollbar to tell user why its disabled, by default ""
        """
        self.prevAction.setDisabled(disable)
        self.nextAction.setDisabled(disable)
        self.navigateScrollBar.setDisabled(disable)
        if not disable:
            self.navigateScrollBar.setToolTip(
                'NOTE: The maximum frame number that can be visualized with this '
                'scrollbar\n'
                'is the last visited frame with the selected mode\n'
                '(see "Mode" selector on the top-right).\n\n'
                'If the scrollbar does not move it means that you never visited\n'
                'any frame with current mode.\n\n'
                'Note that the "Viewer" mode allows you to scroll ALL frames.'
            )
            return
        
        if not why:
            stack = traceback.format_stack()
            calling_func = stack[-2].split('in ')[1].split('\n')[0]
            why = f'called from {calling_func}'
        txt = f'Frame navigation disabled: {why}'
        self.logger.info(txt)
        self.navigateScrollBar.setToolTip(txt)

    @disableWindow
    def whitelistTrackOGagainstPreviousFrame_cb(self):
        """Tracks the original labels against the previous frame.
        This is used as a callback for sigTrackOGagainstPreviousFrame signal
        """
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i
        old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i].copy()
        prev_cell_IDs = posData.allData_li[frame_i-1]['IDs']
        self.whitelistTrackOGCurr(against_prev=True)
        new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i]

        new_IDs = new_cell_IDs - old_cell_IDs
        new_IDs = new_IDs & set(prev_cell_IDs)

        self.whitelistUpdateLab(
            track_og_curr=False, IDs_to_add=new_IDs,
        )

    def whitelistLoadOGLabs_cb(self):
        """Generates a dialog to load the original (not whitelisted) labels
        """
        posData = self.data[self.pos_i]
        curr_seg_path = posData.segm_npz_path

        segmFilename = os.path.basename(curr_seg_path)
        custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz"
        images_path = posData.images_path
        existingEndnames = [
            files for files in os.listdir(images_path) if files.endswith('.npz')
        ]
        if custom_first not in existingEndnames:
            custom_first = None

        infoText = html_utils.paragraph(
            'Select the segmentation file containing the original labels '
            'of the objects. Pleae note that the current saved "original" '
            'labels will be replaced with the new ones, but the filtered '
            'labels will be kept.'
        )

        win = apps.SelectSegmFileDialog(
            existingEndnames, images_path, parent=self, 
            basename=posData.basename, infoText=infoText,
            custom_first=custom_first
        )
        win.exec_()
        if win.cancel:
            self.logger.info('Loading original labels canceled.')
            return
        selected = win.selectedItemText
        self.logger.info(f'Loading original labels from {selected}...')
        self.whitelistLoadOGLabs(selected)

    @disableWindow
    def whitelistLoadOGLabs(self, selected:str): # done
        """Loads the original labels from the selected files"

        Parameters
        ----------
        selected : str
            Selected file name from the dialog.
        """
        posData = self.data[self.pos_i]
        images_path = posData.images_path

        selected_path = os.path.join(images_path, selected)
        posData.whitelist.loadOGLabs(selected_path)
        
        self.whitelistIDsToolbar.viewOGToggle.setCheckable(True)


    @exception_handler
    @disableWindow
    def whitelistViewOGIDs(self, checked:bool):
        """Switch between selected and original labels.
        Uses self.viewOriginalLabels to see what has to be done.

        Parameters
        ----------
        checked : bool
            True if the original labels have to be shown, False otherwise.
        """
        switch_to_og = checked and not self.viewOriginalLabels
        switch_to_seg = not checked and self.viewOriginalLabels

        if not switch_to_og and not switch_to_seg:
            return

        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            return
        
        if posData.whitelist._debug:
            printl('whitelistViewOGIDs', checked)
     
        frame_i = posData.frame_i
        if frame_i > 0:
            frames_range = [frame_i-1, frame_i]
        else:
            frames_range = [frame_i]

        self.store_data(autosave=False)
        if switch_to_og:
            self.setFrameNavigationDisabled(True, why='Viewing original labels')
            self.viewOriginalLabels = True

            for i in frames_range:
                posData.frame_i = i
                self.get_data()
                self.whitelistTrackOGCurr(frame_i=i)

                IDs = posData.IDs

                og_frame = posData.whitelist.originalLabs[i].copy()
                IDs_to_uppdate = posData.whitelist.whitelistIDs[i] & posData.whitelist.originalLabsIDs[i]
                if IDs_to_uppdate:
                    mask = np.isin(og_frame, list(IDs_to_uppdate))
                    og_frame[mask] = 0

                    mask = np.isin(posData.lab, list(IDs_to_uppdate))
                    og_frame[mask] = posData.lab[mask]
                
                IDs_to_add = posData.whitelist.whitelistIDs[i] - posData.whitelist.originalLabsIDs[i]
                if IDs_to_add:
                    mask = np.isin(posData.lab, list(IDs_to_add))
                    og_frame[mask] = posData.lab[mask]

                posData.lab = og_frame
                self.update_rp(wl_update=False)
                self.store_data(autosave=False)

            if frame_i > 0:
                missing_IDs = set(posData.IDs) - set(posData.allData_li[frame_i-1]['IDs'])
                self.trackManuallyAddedObject(missing_IDs,isNewID=True, wl_update=False)

            self.setAllTextAnnotations()
            self.updateAllImages()

        elif switch_to_seg:
            self.setFrameNavigationDisabled(False)

            self.viewOriginalLabels = False
            for i in frames_range:
                posData.frame_i = i
                self.get_data()
                try:
                    posData.whitelist.originalLabs[i] = posData.lab.copy()
                    posData.whitelist.originalLabsIDs[i] = set(posData.IDs)
                except AttributeError:
                    lab = posData.segm_data[i].copy()
                    IDs = [obj.label for obj in skimage.measure.regionprops(lab)]
                    posData.whitelist.originalLabs[i] = lab
                    posData.whitelist.originalLabsIDs[i] = set(IDs)

                # self.whitelistTrackCurrOG()
                self.store_data(autosave=False)
                self.whitelistUpdateLab(frame_i=i) #has update_rp and store data
                self.setAllTextAnnotations()
                self.updateAllImages()

    def whitelistSetViewOGIDsToggle(self, checked: bool):
        """Set the view original labels toggle button to checked or unchecked.
        This also updates the self.viewOriginalLabels variable.
        !!! Doesn't change the actually displayed labels, use self.whitelistViewOGIDs
        to do that.!!!
        
        Parameters
        ----------
        checked : bool
            True if the original labels are shown, False otherwise.
        """
        self.viewOriginalLabels = checked
        self.whitelistIDsToolbar.viewOGToggle.setChecked(checked)

    def whitelistAddNewIDsToggled(self, checked: bool):
        """Will set self.addNewIDsWhitelistToggle to checked and call
        whitelistAddNewIDs if checked is True.

        Parameters
        ----------
        checked : bool
            True if the add new IDs toggle is checked, False otherwise.
        """
        self.addNewIDsWhitelistToggle = checked
        if checked:
            self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'Yes'
        else:
            self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'No'
        self.df_settings.to_csv(self.settings_csv_path)
        if checked:
            self.whitelistAddNewIDs(ignore_not_first_time=True)
            self.updateAllImages()
            self.whitelistIDsUpdateText()

    def whitelistAddNewIDs(self, ignore_not_first_time:bool=False):
        """Functionw white adds new IDs to the whitelist, based on the original labels.
        It will check if the frame is visited the first time, unless 
        ignore_not_first_time is True.
        It does nothing if self.addNewIDsWhitelistToggle is False.

        Parameters
        ----------
        ignore_not_first_time : bool, optional
            Weather it should be checked if the frame is visited 
            the first time, by default False
        """
        mode = self.modeComboBox.currentText()
        if mode != 'Segmentation and Tracking':
            return
    
        if not self.addNewIDsWhitelistToggle:
            return
        
        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            return

        debug = posData.whitelist._debug

        if debug:
            printl('whitelistAddNewIDs')

        posData = self.data[self.pos_i]
        frame_i = posData.frame_i
        
        if self.get_last_tracked_i() != frame_i and not ignore_not_first_time:
            return
    
        if frame_i == 0:
            return
        
        if self.whitelistAddNewIDsFrame is None:
            self.whitelistAddNewIDsFrame = frame_i

        if frame_i == self.whitelistAddNewIDsFrame:
            return
        
        self.whitelistAddNewIDsFrame = frame_i
        
        self.store_data(autosave=False)
        self.get_data()

        posData.whitelist.addNewIDs(frame_i=frame_i,
                                    allData_li=posData.allData_li,
                                    IDs_curr=posData.IDs,)


    def whitelistIDsAccepted(self, 
                             whitelistIDs: Set[int] | List[int]):
        """Function which is called when the user accepts a whitelist.
        Also initializes the whitelist if it is not already initialized. (Aka not loaded)

        Parameters
        ----------
        whitelistIDs : set | list
            The accepted IDs from the whitelist dialog.
        """
        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False)

        self.whitelistIDsToolbar.viewOGToggle.setCheckable(True)
        self.whitelistSetViewOGIDsToggle(False)
        self.setFrameNavigationDisabled(False)

        self.store_data(autosave=False)
        self.get_data()
        posData = self.data[self.pos_i]

        if not posData.whitelist:
            posData.whitelist = whitelist.Whitelist(
                total_frames=posData.SizeT,
            )
        
        if posData.whitelist._debug:
            printl('whitelistIDsAccepted', whitelistIDs)

        whitelistIDs = set(whitelistIDs)

        IDs_curr = set(posData.IDs)
        posData.whitelist.IDsAccepted(
            whitelistIDs,
            segm_data=posData.segm_data,
            frame_i=posData.frame_i,
            allData_li=posData.allData_li,
            IDs_curr=IDs_curr,
        )
        self.whitelistPropagateIDs(new_whitelist=whitelistIDs, 
                                   try_create_new_whitelists=True, 
                                   only_future_frames=True, 
                                   force_not_dynamic_update=True,
                                   update_lab=True
                                   )

        self.whitelistIDsUpdateText()
        self.whitelistUpdateLab()
        self.updateAllImages()
        self.keepIDsTempLayerLeft.clear()

    def whitelistUpdateLab(self, frame_i: int=None,
        track_og_curr=True, new_frame:bool=False,
        IDs_to_add:List[int] | Set[int]=None,
        IDs_to_remove:List[int]|Set[int]=None,
        ): 
        # this should also work for 3D i think...
        """Updates the displayed lab based on the whitelist.

        Parameters
        ----------
        frame_i : int, optional
            frame which should be updated. If not provided, 
            uses posData.frame_i, by default None
        track_og_curr : bool, optional
            if True, will track the original current IDs, by default True
        new_frame : bool, optional
            if True, will set the frame to the new frame, by default False
        IDs_to_add : list, optional
            IDs to add to the whitelist, by default None
        IDs_to_remove : list, optional
            IDs to remove from the whitelist, by default None
        """
        benchmark = False
        if benchmark:
            ts = [time.perf_counter()]
            titles = [
                '',
                'store_data',
                'whitelistSetViewOGIDsToggle',
                'get_data',
                'get what to add/remove',
                'track_og_curr',
                'get current lab',
                'add/remove IDs',
                'store data',
                'update images',
                ]
            
        mode = self.modeComboBox.currentText()
        if mode != 'Segmentation and Tracking':
            return
        
        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            return
            
        if frame_i is None:
            frame_i = posData.frame_i
            og_frame_i = frame_i
        else:
            og_frame_i = posData.frame_i
            posData.frame_i = frame_i


        debug = posData.whitelist._debug
        if debug:
            printl('whitelistUpdateLab', frame_i, og_frame_i)
            from . import debugutils
            debugutils.print_call_stack()

        if benchmark:
            ts.append(time.perf_counter())

        self.whitelistSetViewOGIDsToggle(False)

        if benchmark:
            ts.append(time.perf_counter())


        og_lab = posData.whitelist.originalLabs[frame_i]
        if benchmark:
            ts.append(time.perf_counter())

        whitelist = posData.whitelist.get(frame_i=frame_i)
        IDs_to_add_remove_provided = IDs_to_add is not None or IDs_to_remove is not None
        if not IDs_to_add_remove_provided:
            self.get_data()
            current_IDs = set(posData.IDs)
            missing_IDs = list(whitelist - current_IDs)
            to_be_removed_IDs = list(current_IDs - whitelist)
        else:
            missing_IDs = list(IDs_to_add) if IDs_to_add is not None else []
            to_be_removed_IDs = list(IDs_to_remove) if IDs_to_remove is not None else []

        if benchmark:
            ts.append(time.perf_counter())

        if missing_IDs and track_og_curr and not new_frame:
            self.whitelistTrackOGCurr(frame_i=frame_i)

        if benchmark:
            ts.append(time.perf_counter())

        if not missing_IDs and not to_be_removed_IDs:
            if og_frame_i != frame_i:
                posData.frame_i = og_frame_i
                if not IDs_to_add_remove_provided:
                    self.get_data()
                    self.update_rp(wl_update=False)
                    self.store_data(autosave=False)
            if benchmark:
                print('No IDs to add/remove')
                ts.append(time.perf_counter())
                indx = titles.index('track_og_curr')
                titles[indx + 1] = 'store_data'
                time_taken = time.perf_counter() - ts[0]
                print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s')
                for i in range(1, len(ts)):
                    time_taken = ts[i] - ts[i-1]
                    print(f'Time taken for {titles[i]}: {time_taken:.2f}s')
                print('')

            return
        
        missing_IDs = np.array(missing_IDs, dtype=np.int32)
        to_be_removed_IDs = np.array(to_be_removed_IDs, dtype=np.int32)

        if debug:
            printl(current_IDs, missing_IDs, to_be_removed_IDs)

        curr_lab = posData.lab # or curr_lab = posData.lab??? 
        # convert values to int if they are not already
        if curr_lab is None:
            try:
                curr_lab = posData.segm_data[frame_i].copy()
            except:
                pass
            if curr_lab is None:
                printl('No current lab?')
                curr_lab = np.zeros_like(og_lab)
        curr_lab = curr_lab.astype(np.int32)
        if benchmark:
            ts.append(time.perf_counter())

        if missing_IDs.size > 0:
            mask = np.isin(og_lab, missing_IDs) # add missing_IDs
            curr_lab[mask] = og_lab[mask]

        if to_be_removed_IDs.size > 0:
            curr_lab[np.isin(curr_lab, to_be_removed_IDs)] = 0 # remove to_be_removed_IDs

        if benchmark:
            ts.append(time.perf_counter())
        
        posData.lab = curr_lab
        self.update_rp(wl_update=False)
        self.store_data()

        if benchmark:
            ts.append(time.perf_counter())
        if og_frame_i != frame_i:
            posData.frame_i = og_frame_i
            if not IDs_to_add_remove_provided:
                self.get_data()
                self.update_rp(wl_update=False)
                self.store_data(autosave=False)
        
        self.updateAllImages()
        self.setAllTextAnnotations()

        if benchmark:
            ts.append(time.perf_counter())
            time_taken = time.perf_counter() - ts[0]
            print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s')
            for i in range(1, len(ts)):
                time_taken = ts[i] - ts[i-1]
                print(f'Time taken for {titles[i]}: {time_taken:.2f}s')
            print('')


    def whitelistIDsUpdateText(self):
        """Updates the text. Carefull, triggers whitelistLineEdit.textChanged!
        """
        mode = self.modeComboBox.currentText()
        if mode != 'Segmentation and Tracking':
            return

        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            return
        
        if posData.whitelist._debug:
            printl('whitelistIDsUpdateText')
        
        frame_i = posData.frame_i
        whitelist = posData.whitelist.get(frame_i=frame_i)

        self.whitelistIDsToolbar.whitelistLineEdit.setText(whitelist)

    def whitelistTrackOGCurr(self, frame_i:int=None, 
                             against_prev:bool=False,
                             lab:np.ndarray=None,
                             rp:list=None,
                             IDs: Set[int] | List[int] =None):
        """Track the original labels in relation to the current (whitelisted) labels.
        Parameters

        Parameters
        ----------
        frame_i : int, optional
            frame_i to be tracked, posData.frame_i if not provided, by default None
        against_prev : bool, optional
            if the original frame should be tracked against frame_i-1. 
            Cannot be used with rp or lab, by default False
        lab : np.ndarray, optional
            lab to be tracked against, by default None
        rp : list, optional
            regionprops for this lab, by default None
        IDs : Set[int] | List[int], optional
            IDs that should be tracked based on og

        Raises
        ------
        ValueError
            Cannot provide both rp and lab when tracking against previous frame.
            Instead only provide rp and lab, and dont set against_prev.
        """
        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            return

        debug = posData.whitelist._debug

        if debug:
            from . import debugutils
            debugutils.print_call_stack(depth=2)
            printl('whitelistTrackOGCurr', against_prev)

        if against_prev and (rp is not None or lab is not None):
            raise ValueError('Cannot provide both rp and lab when tracking against previous frame.'
            'Instead only provide rp and lab, and dont set against_prev.')

        if frame_i is None:
            frame_i = posData.frame_i

        if against_prev and frame_i == 0:
            return

        og_frame_i = posData.frame_i
        ### against what should I track?

        if lab is not None and not rp:
            rp = skimage.measure.regionprops(lab)
        
        if lab is None:
            if debug:
                printl('No lab and no rp provided.')
            if against_prev:
                rp = posData.allData_li[frame_i-1]['regionprops']
                lab = posData.allData_li[frame_i-1]['labels']
            else:
                self.update_rp()
                self.store_data(autosave=False)
                if frame_i != og_frame_i:
                    posData.frame_i = frame_i
                
                self.get_data()
                rp = posData.rp
                lab = posData.lab

        og_lab = posData.whitelist.originalLabs[frame_i]
        og_rp = skimage.measure.regionprops(og_lab)
        # lab = lab.copy()

        denom_overlap_matrix = 'union' if not against_prev else 'area_prev'

        og_lab = CellACDC_tracker.track_frame(
                lab, rp, og_lab, og_rp,
                denom_overlap_matrix=denom_overlap_matrix,
                posData = posData,
                setBrushID_func=self.setBrushID,
                IDs=IDs,
        )

        posData.whitelist.originalLabs[frame_i] = og_lab
        posData.whitelist.originalLabsIDs[frame_i] = {obj.label for obj in skimage.measure.regionprops(og_lab)}

        if frame_i != og_frame_i:
            posData.frame_i = og_frame_i
            self.get_data()

    def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False):
        """Track the current (whitelisted) labels in relation to the original labels.

        Parameters
        ----------
        frame_i : int, optional
            frame_i to be tracked, posData.frame_i if not provided, by default None
        against_prev : bool, optional
            if the original frame should be tracked against frame_i-1. 
        """
        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            return

        if posData.whitelist._debug:
            printl('whitelistTrackCurrOG', frame_i, against_prev)

        if frame_i is None:
            frame_i = posData.frame_i

        if against_prev and frame_i == 0:
            return

        og_frame = posData.frame_i
        self.store_data(autosave=False)
        if frame_i != og_frame:
            posData.frame_i = frame_i
        
        self.get_data()
        lab = posData.lab
        rp = posData.rp

        if against_prev:
            og_lab = posData.whitelist.originalLabs[frame_i-1]
        else:
            og_lab = posData.whitelist.originalLabs[frame_i]

        og_rp = skimage.measure.regionprops(og_lab)

        denom_overlap_matrix = 'union' if not against_prev else 'area_prev'

        lab = CellACDC_tracker.track_frame(
                og_lab, og_rp, lab, rp,
                denom_overlap_matrix=denom_overlap_matrix,
                posData = posData,
                setBrushID_func=self.setBrushID
        )

        posData.lab = lab

        self.update_rp(wl_update=False)
        self.store_data(autosave=False)

        if frame_i != og_frame:
            posData.frame_i = og_frame
            self.get_data()

    def whitelistSyncIDsOG(self, 
                           frame_is: List[int]=None,
                           against_prev: bool=False,):
        """Interates over the frames and calls whitelistTrackOGCurr for each frame.

        Parameters
        ----------
        frame_is : List[int], optional
            list of frame_i, if None goes through all, by default None
        against_prev : bool, optional
            if the original frame should be tracked against frame_i-1. 
        """
        posData = self.data[self.pos_i]
        if frame_is is None:
            frame_is = range(posData.SizeT)

        for frame_i in frame_is:
            self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev)

    def whitelistInitNewFrames(self, frame_i:int=None, force:bool=False):
        """Initialize the whitelist for a new frame. The class whitelist keeps track
        of the init frames and doesnt try to init them again, unless forced.
        Does not init the class!

        Parameters
        ----------
        frame_i : int, optional
            frame_i to be init, posData.frame_i if not provided, by default None
        force : bool, optional
            if the init should be forced, by default False

        Returns
        -------
        bool
            if the frame was new or not
        list
            list of frames that were updated, and info about added/removed IDs
        """

        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            return False, []

        if frame_i is None:
            frame_i = posData.frame_i
        
        if posData.whitelist._debug:
            printl('whitelistInitNewFrames', frame_i, force)

        

        if frame_i not in posData.whitelist.initialized_i:
            self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True)

        new_frame, update_frames = posData.whitelist.initNewFrames(
            frame_i=frame_i, force=force)

        self.whitelistAddNewIDs()
        return new_frame, update_frames

    def whitelistPropagateIDs(self, 
                              new_whitelist: Set[int] | List[int] = None, 
                              IDs_to_add: Set[int] = None,
                              IDs_to_remove: Set[int] = None,
                              frame_i: int = None,
                              try_create_new_whitelists: bool = False,
                              curr_frame_only: bool = False,
                              force_not_dynamic_update: bool = False,
                              only_future_frames: bool = True,
                              allow_only_current_IDs: bool = False,
                              track_og_curr: bool = True,
                              IDs_curr: Set[int] | List[int] = None,
                              index_lab_combo: Tuple[int, np.ndarray] = None,
                              curr_rp: list = None,
                              curr_lab: np.ndarray = None,
                              store_data: bool = True,
                              update_lab: bool = False,
                              ):
        """
        Propagates whitelist IDs across frames in the dataset. (Doesnt update labs)
        Should also be called when viewing a new frame!

        This function updates whitelist. If curr_frame_only is True, it only updates the
        whitelist of the current frame. If the frame changes, this function should be called 
        again to update the whitelist for the new frame (without this argument).
        It should also handle cases were this is not done, but this is less safe.
        Then, all the additions and removals are propagated to the other frames.
        If force_not_dynamic_update is True, the function will propagate the entire whitelist to 
        frames, and not only the IDs which were added or removed.

        Hierarchy of arguments for current_IDs:
        1. IDs_curr (if provided)
        (2. index_lab_combo (if provided) (is also passed to not current frame only 
        propagation if that propagation is necessary, and used when the frame_i matches))
        3. curr_rp (if provided)
        4. curr_lab (if provided)
        5. allData_li

        Parameters
        ----------
        new_whitelist : Set[int] | List[int], optional
            A new set of whitelist IDs to replace the current whitelist. Cannot be 
            used together with `IDs_to_add` or `IDs_to_remove`, by default None.
        IDs_to_add : Set[int], optional
            A set of IDs to add to the current whitelist, by default None.
        IDs_to_remove : Set[int], optional
            A set of IDs to remove from the current whitelist, by default None.
        frame_i : int, optional
            The frame index for the propagation. 
            If None, uses posData.frame_i, by default None.
        try_create_new_whitelists : bool, optional
            If True, creates new whitelist entries for frames that do not already 
            have them. Should only be necessary when its initialized, by default False.
        curr_frame_only : bool, optional
            If True, only updates the whitelist for the current frame. 
            (See description of function), by default False.
        force_not_dynamic_update : bool, optional
            If True, disables dynamic updates to the whitelist. 
            (See description of function), by default False.
        only_future_frames : bool, optional
            If True, propagates changes only to future frames, by default True.
        allow_only_current_IDs : bool, optional
            If True, only allows IDs that are present in the current frame 
            to be added to the whitelist, by default True.
        track_og_curr : bool, optional
            If True, tracks the original labels in relation to the current
            (whitelisted) labels. This is done by calling whitelistTrackOGCurr.
            If its a new frame, this is done in whitelistInitNewFrames against the 
            previous frame,
            by default True.
        IDs_curr : Set[int] | List[int], optional
            A set of IDs for the current frame, if None, 
            will be calculated from other stuff (see description), by default None.
        index_lab_combo : Tuple[int, np.ndarray], optional
            Combination of frame_i and current frame, 
            Used to get IDs_curr (see description), when the frame_i matches
            (is also passed to not current frame only 
            propagation if that propagation is necessary, 
            and used when the frame_i matches), by default None.
        curr_rp : list, optional
            Region properties for the current frame. For IDs_curr. (see description), 
            by default None.
        curr_lab : np.ndarray, optional
            Labels for the current frame for IDs_curr. (see description),
            by default None.
        store_data : bool, optional
            If True, stores the data before propagating the IDs.
        update_lab : bool, optional
            If True, updates the labels after propagating the IDs.
            Will always update labels for newly init frames, by default False.

        Raises
        ------
        ValueError
            If both `new_whitelistIDs` and `IDs_to_add`/`IDs_to_remove` are provided.

        Example
        -------
        To add IDs 5 and 6 to the whitelist for the current frame:
        ```python
        self.whitelistPropagateIDs(IDs_to_add={5, 6}, curr_frame_only=True)
        ```
        Then when the frame changes:
        ```python
        self.whitelistPropagateIDs()
        ```

        To replace the whitelist for frame 10 with a new set of IDs:
        ```python
        self.whitelistPropagateIDs(new_whitelistIDs={1, 2, 3}, frame_i=10)
        ```
        This would also propagate the changes to all other frames.

        """
        #doesnt update the frame displayed, only wl
        try: # safety XD
            IDs_curr = IDs_curr.copy()
        except AttributeError:
            pass
            
        IDs_curr = set(IDs_curr) if IDs_curr is not None else None

        posData = self.data[self.pos_i]

        debug = posData.whitelist._debug if posData.whitelist is not None else False

        if debug:
            printl('Propagating IDs...')
            from . import debugutils
            debugutils.print_call_stack()
            printl(new_whitelist, IDs_to_add, IDs_to_remove)

        if posData.whitelist is None:
            return

        # og_frame_i = posData.frame_i
        if frame_i is None:
            frame_i = posData.frame_i

        new_frame, update_frames_init = self.whitelistInitNewFrames(frame_i=frame_i)

        # if track_og_curr and not new_frame:
        #     self.whitelistTrackOGCurr(frame_i=frame_i, rp=curr_rp, lab=curr_lab)

        update_frames = posData.whitelist.propagateIDs(
            frame_i,
            posData.allData_li,
            new_whitelist=new_whitelist,
            IDs_to_add=IDs_to_add,
            IDs_to_remove=IDs_to_remove,
            try_create_new_whitelists=try_create_new_whitelists,
            curr_frame_only=curr_frame_only,
            force_not_dynamic_update=force_not_dynamic_update,
            only_future_frames=only_future_frames,
            allow_only_current_IDs=allow_only_current_IDs,
            IDs_curr=IDs_curr,
            index_lab_combo=index_lab_combo,
            curr_rp=curr_rp,
            curr_lab=curr_lab,
        )
        if update_lab:
            update_frames = update_frames_init + update_frames
        else:
            update_frames = update_frames_init
        # printl(posData.whitelistIDs[frame_i])
        # posData.frame_i = og_frame_i
        self.whitelistIDsUpdateText()
        if store_data:
            self.store_data(autosave=False)

        for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames:
            self.whitelistUpdateLab(frame_i=frame_i, track_og_curr=track_og_curr, 
                                    new_frame=new_frame, IDs_to_add=IDs_to_add, 
                                    IDs_to_remove=IDs_to_remove, )

    def whitelistIDs_cb(self, checked:bool):
        """Callback for when the whitelist IDs button is checked or unchecked.
        Initialises the pointlayer and the whitelist IDs toolbar if checked.

        Parameters
        ----------
        checked : bool
            True if the whitelist IDs button is checked, False otherwise.
        """
        self.store_data(autosave=False)

        if checked:
            self.initKeepObjLabelsLayers()
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.whitelistIDsButton)
            self.connectLeftClickButtons()
            
        self.whitelistIDsToolbar.setVisible(checked)
        self.whitelistHighlightIDs(checked)
        self.whitelistIDsUpdateText()
        self.whitelistUpdateTempLayer()

        if not checked:
            self.setLostNewOldPrevIDs()
            self.updateAllImages()

    def whitelistHighlightIDs(self, checked:bool=True):
        """Highlights the IDs in the current frame based on the whitelist.

        Parameters
        ----------
        checked : bool, optional
            If False, will delete all highlights, by default True
        """
        if not checked:
            self.removeHighlightLabelID()
            return
        
        posData = self.data[self.pos_i]

        if posData.whitelist is None:
            if not hasattr(self, 'tempWhitelistIDs'):
                self.tempWhitelistIDs = set() # not updated, only use in this context
                current_whitelist = self.tempWhitelistIDs
            else:
                current_whitelist = self.tempWhitelistIDs
        else:
            current_whitelist = posData.whitelist.get(
                frame_i=posData.frame_i)
        
        for ID in current_whitelist:
            self.highlightLabelID(ID)
        
    def whitelistIDsChanged(self, 
                            whitelistIDs: Set[int] | List[int], 
                            debug: bool=False):
        """Callback for when the whitelist IDs are changed. 
        This is called when the user changed the IDs in the whitelist IDs toolbar
        (or when its programmatically changed, but if its not 
        visible it should return instantly)
        Will update the temp layer and also complain when IDs 
        are not valid/present in the current lab

        Parameters
        ----------
        whitelistIDs : set | list
            The IDs that are currently in the whitelist.
        debug : bool, optional
            debug, by default False
        """
        if not self.whitelistIDsButton.isChecked():
            return

        self.store_data(autosave=False)
        self.get_data()
        
        posData = self.data[self.pos_i]

        if posData.whitelist:
            debug = posData.whitelist._debug
        if debug:
            printl('whitelistIDsChanged', whitelistIDs)

        if posData.whitelist is None:
            wl_init = False
            if not hasattr(self, 'tempWhitelistIDs'):
                self.tempWhitelistIDs = set() # not updated, only use in this context
                current_whitelist = self.tempWhitelistIDs
            else:
                current_whitelist = self.tempWhitelistIDs
        else:
            wl_init = True
            current_whitelist = posData.whitelist.get(
                frame_i=posData.frame_i)

        current_whitelist_copy = current_whitelist.copy()
        if not hasattr(posData, 'originalLabsIDs') or posData.whitelist.originalLabsIDs is None:
            possible_IDs = posData.IDs.copy()
        else:
            possible_IDs = posData.whitelist.originalLabsIDs[posData.frame_i]
            possible_IDs.update(posData.IDs)

        isAnyIDnotExisting = False
        for ID in whitelistIDs:
            if ID not in possible_IDs:
                isAnyIDnotExisting = True
                continue
            if ID not in current_whitelist_copy:
                current_whitelist.add(ID)
                self.highlightLabelID(ID)

        for ID in current_whitelist_copy:
            if ID not in possible_IDs:
                isAnyIDnotExisting = True
                continue
            if ID not in whitelistIDs:
                current_whitelist.remove(ID)
                self.removeHighlightLabelID(IDs=[ID])

        if wl_init:
            posData.whitelist.whitelistIDs[posData.frame_i] = current_whitelist
        else:
            self.tempWhitelistIDs = current_whitelist

        self.whitelistUpdateTempLayer()
        if isAnyIDnotExisting:
            self.whitelistIDsToolbar.whitelistLineEdit.warnNotExistingID()
        else:
            self.whitelistIDsToolbar.whitelistLineEdit.setInstructionsText()

    def whitelistUpdateTempLayer(self):
        """Updates the temp layer with the current whitelist IDs.
        """
        if not self.whitelistIDsButton.isChecked():
            self.keepIDsTempLayerLeft.clear()
            return

        keptLab = np.zeros_like(self.currentLab2D)

        self.store_data(autosave=False)
        self.get_data()

        posData = self.data[self.pos_i]
        if posData.whitelist is None:
            if not hasattr(self, 'tempWhitelistIDs'):
                self.tempWhitelistIDs = set() # not updated, only use in this context
                current_whitelist = self.tempWhitelistIDs
            else:
                current_whitelist = self.tempWhitelistIDs
        else:
            current_whitelist = posData.whitelist.get(posData.frame_i)

        for obj in posData.rp:
            if obj.label not in current_whitelist:
                continue

            if not self.isObjVisible(obj.bbox):
                continue

            _slice = self.getObjSlice(obj.slice)
            _objMask = self.getObjImage(obj.image, obj.bbox)

            keptLab[_slice][_objMask] = obj.label

        self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False)
            
    def delObjsOutSegmMaskActionTriggered(self):
        posData = self.data[self.pos_i]
        segm_files = load.get_segm_files(posData.images_path)
        existingSegmEndnames = load.get_endnames(
            posData.basename, segm_files
        )
        selectSegmWin = widgets.QDialogListbox(
            'Select segmentation file',
            'Select segmentation file to use as ROI:\n',
            existingSegmEndnames, multiSelection=False, parent=self
        )
        selectSegmWin.exec_()
        if selectSegmWin.cancel:
            self.logger.info('Delete objects process cancelled.')
            return
        
        selectedSegmEndname = selectSegmWin.selectedItemsText[0]
        
        self.startDelObjsOutSegmMaskWorker(selectedSegmEndname)
    
    def startDelObjsOutSegmMaskWorker(self, selectedSegmEndname):
        self.store_data(autosave=False)
        posData = self.data[self.pos_i]
        segm_data = np.squeeze(self.getStoredSegmData())
        
        self.progressWin = apps.QDialogWorkerProgress(
            title='Deleting objects outside of ROIs', parent=self,
            pbarDesc='Deleting objects outside of ROIs...'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)
        
        self.thread = QThread()
        self.worker = workers.DelObjectsOutsideSegmROIWorker(
            selectedSegmEndname, segm_data, posData.images_path
        )
        self.worker.moveToThread(self.thread)
        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)

        self.worker.progress.connect(self.workerProgress)
        self.worker.critical.connect(self.workerCritical)
        self.worker.finished.connect(self.delObjsOutSegmMaskWorkerFinished)

        self.worker.debug.connect(self.workerDebug)

        self.thread.started.connect(self.worker.run)
        self.thread.start()
    
    def storeViewRange(self):
        if not hasattr(self, 'isRangeReset'):
            return
        
        if not self.isRangeReset:
            return
        self.ax1_viewRange = self.ax1.viewRange()
        self.isRangeReset = False
    
    def mergeObjs_cb(self, checked):
        if not checked:
            self.mergeObjsTempLine.setData([], [])
    
    def Brush_cb(self, checked):
        if checked:
            self.typingEditID = False
            self.setDiskMask()
            self.setHoverToolSymbolData(
                [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle,
                         self.ax1_EraserX, self.ax2_EraserX)
            )
            self.updateBrushCursor(self.xHoverImg, self.yHoverImg)
            self.setBrushID()

            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.sender())
            c = self.defaultToolBarButtonColor
            self.eraserButton.setStyleSheet(f'background-color: {c}')
            self.connectLeftClickButtons()
            self.enableSizeSpinbox(True)
            self.showEditIDwidgets(True)
            self.setFocusGraphics()
        else:
            self.ax1_lostObjScatterItem.setVisible(True)
            self.ax2_lostObjScatterItem.setVisible(True)
            self.ax1_lostTrackedScatterItem.setVisible(True)
            self.ax2_lostTrackedScatterItem.setVisible(True)

            self.setHoverToolSymbolData(
                [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle),
            )
            self.enableSizeSpinbox(False)
            self.showEditIDwidgets(False)
            self.resetCursors()
    
    def showEditIDwidgets(self, visible):
        self.editIDLabelAction.setVisible(visible)
        self.editIDspinboxAction.setVisible(visible)
        self.autoIDcheckboxAction.setVisible(visible)
    
    def resetCursors(self):
        self.ax1_cursor.setData([], [])
        self.ax2_cursor.setData([], [])
        while self.app.overrideCursor() is not None:
            self.app.restoreOverrideCursor()

    def setDiskMask(self):
        brushSize = self.brushSizeSpinbox.value()
        # diam = brushSize*2
        # center = (brushSize, brushSize)
        # diskShape = (diam+1, diam+1)
        # diskMask = np.zeros(diskShape, bool)
        # rr, cc = skimage.draw.disk(center, brushSize+1, shape=diskShape)
        # diskMask[rr, cc] = True
        self.diskMask = skimage.morphology.disk(brushSize, dtype=bool)

    def getDiskMask(self, xdata, ydata):
        Y, X = self.currentLab2D.shape[-2:]

        brushSize = self.brushSizeSpinbox.value()
        yBottom, xLeft = ydata-brushSize, xdata-brushSize
        yTop, xRight = ydata+brushSize+1, xdata+brushSize+1

        if xLeft<0:
            if yBottom<0:
                # Disk mask out of bounds top-left
                diskMask = self.diskMask.copy()
                diskMask = diskMask[-yBottom:, -xLeft:]
                yBottom = 0
            elif yTop>Y:
                # Disk mask out of bounds bottom-left
                diskMask = self.diskMask.copy()
                diskMask = diskMask[0:Y-yBottom, -xLeft:]
                yTop = Y
            else:
                # Disk mask out of bounds on the left
                diskMask = self.diskMask.copy()
                diskMask = diskMask[:, -xLeft:]
            xLeft = 0

        elif xRight>X:
            if yBottom<0:
                # Disk mask out of bounds top-right
                diskMask = self.diskMask.copy()
                diskMask = diskMask[-yBottom:, 0:X-xLeft]
                yBottom = 0
            elif yTop>Y:
                # Disk mask out of bounds bottom-right
                diskMask = self.diskMask.copy()
                diskMask = diskMask[0:Y-yBottom, 0:X-xLeft]
                yTop = Y
            else:
                # Disk mask out of bounds on the right
                diskMask = self.diskMask.copy()
                diskMask = diskMask[:, 0:X-xLeft]
            xRight = X

        elif yBottom<0:
            # Disk mask out of bounds on top
            diskMask = self.diskMask.copy()
            diskMask = diskMask[-yBottom:]
            yBottom = 0

        elif yTop>Y:
            # Disk mask out of bounds on bottom
            diskMask = self.diskMask.copy()
            diskMask = diskMask[0:Y-yBottom]
            yTop = Y

        else:
            # Disk mask fully inside the image
            diskMask = self.diskMask

        return yBottom, xLeft, yTop, xRight, diskMask

    def setBrushID(self, useCurrentLab=True, return_val=False):
        # Make sure that the brushed ID is always a new one based on
        # already visited frames
        posData = self.data[self.pos_i]
        wl_init = posData.whitelist and posData.whitelist.whitelistIDs
        if useCurrentLab:
            IDs_tot = set(posData.IDs)
            if wl_init:
                IDs_tot.update(posData.whitelist.originalLabsIDs[posData.frame_i])
                if posData.whitelist.whitelistIDs[posData.frame_i]:
                    IDs_tot.update(posData.whitelist.whitelistIDs[posData.frame_i])
            newID = max(IDs_tot, default=0)
        else:
            newID = 0
        for frame_i, storedData in enumerate(posData.allData_li):
            if frame_i == posData.frame_i:
                continue
            lab = storedData['labels']
            if lab is not None:
                rp = storedData['regionprops']
                IDs_tot = {obj.label for obj in rp}
                if wl_init:
                    IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i])
                    if posData.whitelist.whitelistIDs[frame_i]:
                        IDs_tot.update(posData.whitelist.whitelistIDs[frame_i])
                _max = max(IDs_tot, default=0)
                if _max > newID:
                    newID = _max
            else:
                break

        for y, x, manual_ID in posData.editID_info:
            if manual_ID > newID:
                newID = manual_ID
        posData.brushID = newID+1
        if return_val:
            return posData.brushID

    def equalizeHist(self):
        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False, storeImage=True)
        self.updateAllImages()

    def curvTool_cb(self, checked):
        posData = self.data[self.pos_i]
        if checked:
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.curvToolButton)
            self.connectLeftClickButtons()
            self.hoverLinSpace = np.linspace(0, 1, 1000)
            self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen)
            self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen)
            self.curvAnchors = pg.ScatterPlotItem(
                symbol='o', size=9,
                brush=pg.mkBrush((255,0,0,50)),
                pen=pg.mkPen((255,0,0), width=2),
                hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3),
                hoverBrush=pg.mkBrush((255,0,0)), tip=None
            )
            self.ax1.addItem(self.curvAnchors)
            self.ax1.addItem(self.curvPlotItem)
            self.ax1.addItem(self.curvHoverPlotItem)
            self.splineHoverON = True
            posData.curvPlotItems.append(self.curvPlotItem)
            posData.curvAnchorsItems.append(self.curvAnchors)
            posData.curvHoverItems.append(self.curvHoverPlotItem)
        else:
            self.splineHoverON = False
            self.isRightClickDragImg1 = False
            self.clearCurvItems()
            while self.app.overrideCursor() is not None:
                self.app.restoreOverrideCursor()

    def updateHoverLabelCursor(self, x, y):
        if x is None:
            self.hoverLabelID = 0
            return

        xdata, ydata = int(x), int(y)
        Y, X = self.currentLab2D.shape
        if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y):
            return

        ID = self.currentLab2D[ydata, xdata]
        self.hoverLabelID = ID

        if ID == 0:
            if self.highlightedID != 0:
                self.updateAllImages()
                self.highlightedID = 0
            return

        if self.app.overrideCursor() != Qt.SizeAllCursor:
            self.app.setOverrideCursor(Qt.SizeAllCursor)
        
        if not self.isMovingLabel:
            self.highlightSearchedID(ID)

    def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True):
        if x is None:
            return

        xdata, ydata = int(x), int(y)
        _img = self.currentLab2D
        Y, X = _img.shape

        if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y):
            return

        size = self.brushSizeSpinbox.value()*2
        self.setHoverToolSymbolData(
            [x], [y], self.activeEraserCircleCursors(isHoverImg1),
            size=size
        )
        self.setHoverToolSymbolData(
            [x], [y], self.activeEraserXCursors(isHoverImg1),
            size=int(size/2)
        )

        isMouseDrag = (
            self.isMouseDragImg1 or self.isMouseDragImg2
        )
        if isMouseDrag:
            return
        
        if xyLocked is not None:
            xdata, ydata = xyLocked

        self.setHoverToolSymbolColor(
            xdata, ydata, self.eraserCirclePen,
            self.activeEraserCircleCursors(isHoverImg1),
            self.eraserButton, hoverRGB=None
        )

    def Eraser_cb(self, checked):
        if checked:
            self.setDiskMask()
            self.setHoverToolSymbolData(
                [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle),
            )
            self.updateEraserCursor(self.xHoverImg, self.yHoverImg)
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.sender())
            c = self.defaultToolBarButtonColor
            self.brushButton.setStyleSheet(f'background-color: {c}')
            self.connectLeftClickButtons()
            self.enableSizeSpinbox(True)
        else:
            self.setHoverToolSymbolData(
                [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle,
                         self.ax1_EraserX, self.ax2_EraserX)
            )
            self.enableSizeSpinbox(False)
            self.resetCursors()
            self.updateAllImages()
    
    def storeCurrentAnnotOptions_ax1(self, return_value=False):
        if self.annotOptionsToRestore is not None:
            return
        
        checkboxes = [
            'annotIDsCheckbox',
            'annotCcaInfoCheckbox',
            'annotContourCheckbox',
            'annotSegmMasksCheckbox',
            'drawMothBudLinesCheckbox',
            'annotNumZslicesCheckbox',
            'drawNothingCheckbox',
        ]
        annotOptions = {}
        for checkboxName in checkboxes:
            checkbox = getattr(self, checkboxName)
            annotOptions[checkboxName] = checkbox.isChecked()
        if return_value:
            return annotOptions
        self.annotOptionsToRestore = annotOptions
        
    def storeCurrentAnnotOptions_ax2(self):
        if self.annotOptionsToRestoreRight is not None:
            return
        
        checkboxes = [
            'annotIDsCheckboxRight',
            'annotCcaInfoCheckboxRight',
            'annotContourCheckboxRight',
            'annotSegmMasksCheckboxRight',
            'drawMothBudLinesCheckboxRight',
            'annotNumZslicesCheckboxRight',
            'drawNothingCheckboxRight',
        ]
        self.annotOptionsToRestoreRight = {}
        for checkboxName in checkboxes:
            checkbox = getattr(self, checkboxName)
            self.annotOptionsToRestoreRight[checkboxName] = checkbox.isChecked()
    
    def restoreAnnotOptions_ax1(self, options=None):
        if options is None and not hasattr(self, 'annotOptionsToRestore'):
            return

        if options is None:
            options = self.annotOptionsToRestore
        
        if options is None:
            return
            
        for option, state in options.items():
            checkbox = getattr(self, option)
            checkbox.setChecked(state)
        
        self.setDrawAnnotComboboxText()
        self.annotOptionsToRestore = None
    
    def restoreAnnotOptions_ax2(self):
        if not hasattr(self, 'annotOptionsToRestoreRight'):
            return

        if self.annotOptionsToRestoreRight is None:
            return

        for option, state in self.annotOptionsToRestoreRight.items():
            checkbox = getattr(self, option)
            checkbox.setChecked(state)
        
        self.setDrawAnnotComboboxTextRight()
        self.annotOptionsToRestoreRight = None

    def setDrawNothingAnnotations(self):
        self.storeCurrentAnnotOptions_ax1()
        self.storeCurrentAnnotOptions_ax2()
        self.drawNothingCheckbox.setChecked(True)
        self.annotOptionClicked(
            sender=self.drawNothingCheckbox, saveSettings=False)
        self.drawNothingCheckboxRight.setChecked(True)
        self.annotOptionClickedRight(
            sender=self.drawNothingCheckboxRight, saveSettings=False
        )
    
    def restoreAnnotationsOptions(self):
        self.restoreAnnotOptions_ax1()
        self.restoreAnnotOptions_ax2()
    
    def onDoubleSpaceBar(self):
        how = self.drawIDsContComboBox.currentText()
        if how.find('nothing') == -1:
            self.storeCurrentAnnotOptions_ax1()
            self.drawNothingCheckbox.setChecked(True)
            self.annotOptionClicked(
                sender=self.drawNothingCheckbox, saveSettings=False
            )
        else:
            self.restoreAnnotOptions_ax1()
        
        how = self.annotateRightHowCombobox.currentText()
        if how.find('nothing') == -1:
            self.storeCurrentAnnotOptions_ax2()
            self.drawNothingCheckboxRight.setChecked(True)
            self.annotOptionClickedRight(
                sender=self.drawNothingCheckboxRight, saveSettings=False
            )
        else:
            self.restoreAnnotOptions_ax2()


    def resizeBottomLayoutLineClicked(self, event):
        pass
        
    def resizeBottomLayoutLineDragged(self, event):
        if not self.img1BottomGroupbox.isVisible():
            return
        newBottomLayoutHeight = self.bottomScrollArea.minimumHeight() - event.y()
        self.bottomScrollArea.setFixedHeight(newBottomLayoutHeight)
    
    def resizeBottomLayoutLineReleased(self):
        QTimer.singleShot(100, self.autoRange)
    
    def mousePressEvent(self, event) -> None:
        if event.button() == Qt.MouseButton.RightButton:
            pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos())
            if pos.y()>=0:
                self.gui_raiseBottomLayoutContextMenu(event)
        return super().mousePressEvent(event)
        
    def zoomBottomLayoutActionTriggered(self, checked):
        if not checked:
            return
        perc = int(re.findall(r'(\d+)%', self.sender().text())[0])
        if perc != 100:
            fontSizeFactor = perc/100
            heightFactor = perc/100
            self.resizeSlidersArea(
                fontSizeFactor=fontSizeFactor, heightFactor=heightFactor
            )
        else:
            self.gui_resetBottomLayoutHeight()
        self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc
        self.df_settings.to_csv(self.settings_csv_path)
        QTimer.singleShot(150, self.resizeGui)
    
    def defaultRescaleIntensLutActionToggled(self, action):
        how = action.text()
        for rescaleIntensAction in self.imgGrad.rescaleActionGroup.actions():
            if how == rescaleIntensAction.text():
                rescaleIntensAction.setChecked(True)
                rescaleIntensAction.trigger()
                break
        
        for channel, items in self.overlayLayersItems.items():
            _, lutItem, _ = items
            for rescaleIntensAction in lutItem.rescaleActionGroup.actions():
                if how == rescaleIntensAction.text():
                    rescaleIntensAction.setChecked(True)
                    rescaleIntensAction.trigger()
                    break
        
        self.df_settings.at['default_rescale_intens_how', 'value'] = how
        self.df_settings.to_csv(self.settings_csv_path)
    
    def retainSpaceSlidersToggled(self, checked):
        if checked:
            self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'Yes'
        else:
            self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'No'
        self.df_settings.to_csv(self.settings_csv_path)
        if not self.zSliceScrollBar.isEnabled():
            retainSpaceZ = False
        else:
            retainSpaceZ = checked
        myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ)
        myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ)
        myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ)
        myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ)
        myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ)
        
        # for overlayItems in self.overlayLayersItems.values():
        #     alphaScrollBar = overlayItems[2]
        #     myutils.setRetainSizePolicy(alphaScrollBar, retain=checked)
        #     myutils.setRetainSizePolicy(alphaScrollBar.label, retain=checked)
        
        QTimer.singleShot(200, self.resizeGui)
        
    def resizeLeaveSpaceTerminalBelow(self):
        self.setWindowState(Qt.WindowMaximized)
        QTimer.singleShot(200, self._resizeLeaveSpaceTerminalBelow)
    
    def _resizeLeaveSpaceTerminalBelow(self):
        geometry = self.geometry()
        left = geometry.left()
        top = geometry.top()
        width = geometry.width()
        height = geometry.height()
        self.setGeometry(left, top+10, width, height-200)
    
    def checkSetDelObjActionActive(self, event):
        if self.delObjAction is None and self.is_win:
            return
        
        if self.delObjAction is None:
            # On mac we check for Key_Control
            if event.key() == Qt.Key_Control:
                self.delObjToolAction.setChecked(True)
            return

        delObjKeySequence, delObjQtButton = self.delObjAction
        keySequenceText = widgets.QKeyEventToString(event).rstrip('+')

        if delObjKeySequence is None:
            # self.delObjToolAction.setChecked(True)
            return

        if keySequenceText == delObjKeySequence.toString():
            self.delObjToolAction.setChecked(True)
    
    def checkTriggerKeyPressShortcuts(self, event: QKeyEvent):
        isBrushKey = event.key() == self.brushButton.keyPressShortcut
        isEraserKey = event.key() == self.eraserButton.keyPressShortcut
        if isBrushKey or isEraserKey:
            return isBrushKey, isEraserKey
        
        modifierText = widgets.modifierKeyToText(event.modifiers())
        for widget in self.widgetsWithShortcut.values():
            if not hasattr(widget, 'keyPressShortcut'):
                continue
            
            if event.key() == widget.keyPressShortcut:
                if widget.isCheckable():
                    widget.setChecked(True)
                else:
                    widget.trigger()       
                continue
            
            shortcutText = widget.keyPressShortcut.toString()
            try:
                mod, key = shortcutText.split('+')
                if modifierText == mod and event.key() == QKeySequence(key):
                    widget.trigger()
                    
            except Exception as e:
                pass
        
        return isBrushKey, isEraserKey
    
    def _temp_debug(self, id=None):
        posData = self.data[self.pos_i]
        imshow(posData.lab, annotate_labels_idxs=[0])
    
    def keyPressCheckSetSpinboxValue(self, event, spinbox):
        """Check if the key pressed is a digit and set the spinbox value 
        accordingly."""
        try:
            n = int(event.text())
            if self.typingEditID:
                value = int(f'{spinbox.value()}{n}')
            else:
                value = n
                self.typingEditID = True
            spinbox.setValue(value)
            
            try:
                spinbox.timer.stop()
            except Exception as err:
                pass
            
            spinbox.timer = QTimer(spinbox)
            spinbox.timer.timeout.connect(
                self.editingSpinboxValueTimerCallback
            )
            spinbox.timer.start(2000)
            spinbox.timer.setSingleShot(True)
            success = True
        except Exception as e:
            # printl(traceback.format_exc())
            success = False        
        return success
    
    def editingSpinboxValueTimerCallback(self):
        self.typingEditID = False
    
    @exception_handler
    def keyPressEvent(self, ev):        
        ctrl = ev.modifiers() == Qt.ControlModifier
        if ctrl and ev.key() == Qt.Key_D:
            self.resizeLeaveSpaceTerminalBelow()
            return

        if ev.key() == Qt.Key_Q and self.debug:
            # posData = self.data[self.pos_i]
            # frame_i = posData.frame_i
            # import pandasgui
            # df_li = [posData.allData_li[i]['acdc_df'] for i in range(len(posData.allData_li))] 
            # pandasgui.show(
            #     self.lineage_tree.lineage_list, df_li, self.lineage_tree.lineage_list[frame_i-1], df_li[frame_i-1]
            # )
            
            # pass
        
            QTimer.singleShot(100, self._do_nothing)


        if not self.isDataLoaded:
            self.logger.warning(
                'Data not loaded yet. Key pressing events are not connected.'
            )
            return

        if ev.key() == Qt.Key_Control:
            if not self.isCtrlDown:
                self.wasCtrlPressedFirstTime = True
                self.onCtrlPressedFirstTime()
            self.isCtrlDown = True
        
        if ev.key() == Qt.Key_PageDown:
            self.onKeyPageDown()
        
        if ev.key() == Qt.Key_PageUp:
            self.onKeyPageUp()
        
        if ev.key() == Qt.Key_Home:
            self.onKeyHome()
        
        if ev.key() == Qt.Key_End:
            self.onKeyEnd()
        
        modifiers = ev.modifiers()
        isAltModifier = modifiers == Qt.AltModifier
        isCtrlModifier = modifiers == Qt.ControlModifier
        isShiftModifier = modifiers == Qt.ShiftModifier
        
        self.checkSetDelObjActionActive(ev)
        
        self.isZmodifier = (
            ev.key()== Qt.Key_Z and not isAltModifier
            and not isCtrlModifier and not isShiftModifier
        )
        if isShiftModifier:
            self.isShiftDown = True
            if self.brushButton.isChecked():
                # Force default brush symbol with shift down
                self.setHoverToolSymbolColor(
                    1, 1, self.ax2_BrushCirclePen,
                    (self.ax2_BrushCircle, self.ax1_BrushCircle),
                    self.brushButton, brush=self.ax2_BrushCircleBrush,
                    ID=0
                )
            if self.isSegm3D:
                self.changeBrushID()        
        isBrushActive = (
            self.brushButton.isChecked() or self.eraserButton.isChecked()
        )
        isManualTrackingActive = self.manualTrackingButton.isChecked()
        isManualBackgroundActive = self.manualBackgroundButton.isChecked()
        isTypingIDFunctionChecked = False
        if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked():
            success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox)
            isTypingIDFunctionChecked = True
        
        if isManualTrackingActive:
            isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue(
                ev, self.manualTrackingToolbar.spinboxID
            )
        
        elif isManualBackgroundActive:
            isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue(
                ev, self.manualBackgroundToolbar.spinboxID
            )
        
        addPointsByClickingButton = self.buttonAddPointsByClickingActive()
        if (
                addPointsByClickingButton is not None 
                and addPointsByClickingButton.toolbar.isVisible()
            ):
            isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue(
                ev, addPointsByClickingButton.rightClickIDSpinbox
            )
        
        isBrushKey, isEraserKey = self.checkTriggerKeyPressShortcuts(ev)
        isExpandLabelActive = self.expandLabelToolButton.isChecked()
        isWandActive = self.wandToolButton.isChecked()
        isLabelRoiCircActive = (
            self.labelRoiButton.isChecked() 
            and self.labelRoiIsCircularRadioButton.isChecked()
        )
        how = self.drawIDsContComboBox.currentText()
        isOverlaySegm = how.find('overlay segm. masks') != -1
        if ev.key()==Qt.Key_Up and not isCtrlModifier:
            self.keyUpCallback(
                isBrushActive, isWandActive, isExpandLabelActive, 
                isLabelRoiCircActive
            )
        elif ev.key()==Qt.Key_Down and not isCtrlModifier:
            self.keyDownCallback(
                isBrushActive, isWandActive, isExpandLabelActive, 
                isLabelRoiCircActive
            )
        elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return:
            if isTypingIDFunctionChecked:
                self.typingEditID = False
            elif self.keepIDsButton.isChecked():
                self.keepIDsConfirmAction.trigger()
        elif ev.key() == Qt.Key_Escape:            
            self.onEscape(isTypingIDFunctionChecked=isTypingIDFunctionChecked)
        elif isAltModifier:
            isCursorSizeAll = self.app.overrideCursor() == Qt.SizeAllCursor
            # Alt is pressed while cursor is on images --> set SizeAllCursor
            if self.xHoverImg is not None and not isCursorSizeAll:
                self.app.setOverrideCursor(Qt.SizeAllCursor)
        elif isCtrlModifier and isOverlaySegm:
            if ev.key() == Qt.Key_Up:
                val = self.imgGrad.labelsAlphaSlider.value()
                delta = 5/self.imgGrad.labelsAlphaSlider.maximum()
                val = val+delta
                self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True)
            elif ev.key() == Qt.Key_Down:
                val = self.imgGrad.labelsAlphaSlider.value()
                delta = 5/self.imgGrad.labelsAlphaSlider.maximum()
                val = val-delta
                self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True)
        elif ev.key() == self.zoomOutKeyValue:
            self.zoomToCells(enforce=True)
            if self.countKeyPress == 0:
                self.isKeyDoublePress = False
                self.countKeyPress = 1
                self.doubleKeyTimeElapsed = False
                self.Button = None
                QTimer.singleShot(400, self.doubleKeyTimerCallBack)
            elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed:
                self.ax1.autoRange()
                self.isKeyDoublePress = True
                self.countKeyPress = 0
        elif ev.key() == Qt.Key_Space:
            if self.countKeyPress == 0:
                # Single press --> wait that it's not double press
                self.isKeyDoublePress = False
                self.countKeyPress = 1
                self.doubleKeyTimeElapsed = False
                QTimer.singleShot(300, self.doubleKeySpacebarTimerCallback)
            elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed:
                self.isKeyDoublePress = True
                # Double press --> toggle draw nothing
                self.onDoubleSpaceBar()
                self.countKeyPress = 0
        elif isBrushKey or isEraserKey:
            mode = self.modeComboBox.currentText()
            if mode == 'Cell cycle analysis' or mode == 'Viewer':
                return
            if isBrushKey:
                self.Button = self.brushButton
            else:
                self.Button = self.eraserButton

            if self.countKeyPress == 0:
                # If first time clicking B activate brush and start timer
                # to catch double press of B
                if not self.Button.isChecked():
                    self.uncheck = False
                    self.Button.setChecked(True)
                else:
                    self.uncheck = True
                self.countKeyPress = 1
                self.isKeyDoublePress = False
                self.doubleKeyTimeElapsed = False

                QTimer.singleShot(400, self.doubleKeyTimerCallBack)
            elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed:
                self.isKeyDoublePress = True
                color = self.Button.palette().button().color().name()
                if color == self.doublePressKeyButtonColor:
                    c = self.defaultToolBarButtonColor
                else:
                    c = self.doublePressKeyButtonColor
                self.Button.setStyleSheet(f'background-color: {c}')
                self.countKeyPress = 0
                if self.xHoverImg is not None:
                    xdata, ydata = int(self.xHoverImg), int(self.yHoverImg)
                    if isBrushKey:
                        self.setHoverToolSymbolColor(
                            xdata, ydata, self.ax2_BrushCirclePen,
                            (self.ax2_BrushCircle, self.ax1_BrushCircle),
                            self.brushButton, brush=self.ax2_BrushCircleBrush
                        )
                    elif isEraserKey:
                        self.setHoverToolSymbolColor(
                            xdata, ydata, self.eraserCirclePen,
                            (self.ax2_EraserCircle, self.ax1_EraserCircle),
                            self.eraserButton
                        )

    def doubleRightClickTimerCallBack(self):
        if self.isDoubleRightClick:
            self.doubleRightClickTimeElapsed = False
            return
        self.doubleRightClickTimeElapsed = True
        self.countRightClicks = 0
        
        # Time to double right click on img1 expired --> single right-click
        self.gui_imgGradShowContextMenu(*self._img1_click_xy)        
    
    def doubleKeyTimerCallBack(self):
        if self.isKeyDoublePress:
            self.doubleKeyTimeElapsed = False
            return
        self.doubleKeyTimeElapsed = True
        self.countKeyPress = 0
        if self.Button is None:
            return

        isBrushChecked = self.Button.isChecked()
        if isBrushChecked and self.uncheck:
            self.Button.setChecked(False)
        c = self.defaultToolBarButtonColor
        self.Button.setStyleSheet(f'background-color: {c}')

    def doubleKeySpacebarTimerCallback(self):
        if self.isKeyDoublePress:
            self.doubleKeyTimeElapsed = False
            return
        self.doubleKeyTimeElapsed = True
        self.countKeyPress = 0

        # # Spacebar single press --> toggle next visualization
        # currentIndex = self.drawIDsContComboBox.currentIndex()
        # nItems = self.drawIDsContComboBox.count()
        # nextIndex = currentIndex+1
        # if nextIndex < nItems:
        #     self.drawIDsContComboBox.setCurrentIndex(nextIndex)
        # else:
        #     self.drawIDsContComboBox.setCurrentIndex(0)

    def keyReleaseEvent(self, ev):
        if self.app.overrideCursor() == Qt.SizeAllCursor:
            self.app.restoreOverrideCursor()
        if ev.key() == Qt.Key_Control:
            self.onCtrlReleased()
        elif ev.key() == Qt.Key_Shift:
            if self.isSegm3D and self.xHoverImg is not None:
                # Restore normal brush cursor when releasing shift
                xdata, ydata = int(self.xHoverImg), int(self.yHoverImg)
                self.setHoverToolSymbolColor(
                    xdata, ydata, self.ax2_BrushCirclePen,
                    (self.ax2_BrushCircle, self.ax1_BrushCircle),
                    self.brushButton, brush=self.ax2_BrushCircleBrush
                )
                self.changeBrushID()
            self.isShiftDown = False
        canRepeat = (
            ev.key() == Qt.Key_Left
            or ev.key() == Qt.Key_Right
            or ev.key() == Qt.Key_Up
            or ev.key() == Qt.Key_Down
            or ev.key() == Qt.Key_Control
            or ev.key() == Qt.Key_Backspace
            or self.delObjToolAction.isChecked()
        )      
        
        if canRepeat and ev.isAutoRepeat():
            return
        
        self.delObjToolAction.setChecked(False)
        
        if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z:
            if self.warnKeyPressedMsg is not None:
                return
            self.warnKeyPressedMsg = widgets.myMessageBox(
                showCentered=False, wrapText=False
            )
            txt = html_utils.paragraph(f"""
            Please, <b>do not keep the key "{ev.text().upper()}" 
            pressed.</b><br><br>
            It confuses me :)<br><br>
            Thanks!
            """)
            self.warnKeyPressedMsg.warning(self, 'Release the key, please', txt)
            self.warnKeyPressedMsg = None
        elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier:
            self.zKeptDown = True
        elif ev.key() == Qt.Key_Z and self.isZmodifier:
            posData = self.data[self.pos_i]
            self.isZmodifier = False
            if not self.zKeptDown and posData.SizeZ > 1:
                self.zSliceCheckbox.setChecked(not self.zSliceCheckbox.isChecked())
            self.zKeptDown = False

    def setUncheckedAllButtons(self):
        self.clickedOnBud = False
        try:
            self.BudMothTempLine.setData([], [])
        except Exception as e:
            pass
        for button in self.checkableButtons:
            button.setChecked(False)
        
        self.countObjsButton.setChecked(False)
        self.splineHoverON = False
        self.tempSegmentON = False
        self.isRightClickDragImg1 = False
        self.clearCurvItems(removeItems=False)
    
    def setUncheckedAllCustomAnnotButtons(self):
        for button in self.customAnnotDict.keys():
            button.setChecked(False)

    def askPropagateChangePast(self, change_txt):
        txt = html_utils.paragraph(f"""
            Do you want to propagate the change "{change_txt}" to the past frames?
        """)
        msg = widgets.myMessageBox(wrapText=False)
        yesButton, _ = msg.question(
            self, 'Propagate change to past frames', txt, 
            buttonsTexts=('Yes', 'No')
        )
        return msg.clickedButton == yesButton

    def propagateMergeObjsPast(self, IDs_to_merge):
        self.store_data(autosave=False)
        posData = self.data[self.pos_i]
        current_frame_i = posData.frame_i
        for past_frame_i in range(posData.frame_i-1, -1, -1):
            posData.frame_i = past_frame_i
            self.get_data()
            
            IDs = posData.allData_li[past_frame_i]['IDs']
            stop_loop = False
            for ID in IDs_to_merge:
                if ID not in IDs:
                    stop_loop = True
                    break

                if ID == 0:
                    continue
                posData.lab[posData.lab==ID] = self.firstID
                self.update_rp()
                
                self.store_data(autosave=False)
            
            if stop_loop:
                break
        
        posData.frame_i = current_frame_i
        self.get_data()

    def propagateChange(
            self, modID, modTxt, doNotShow, UndoFutFrames,
            applyFutFrames, applyTrackingB=False, force=False
        ):
        """
        This function determines whether there are already visited future frames
        that contains "modID". If so, it triggers a pop-up asking the user
        what to do (propagate change to future frames o not)
        """
        posData = self.data[self.pos_i]
        # Do not check the future for the last frame
        if posData.frame_i+1 == posData.SizeT:
            # No future frames to propagate the change to
            return False, False, None, doNotShow

        includeUnvisited = posData.includeUnvisitedInfo.get(modTxt, False)
        areFutureIDs_affected = []
        # Get number of future frames already visited and check if future
        # frames has an ID affected by the change
        last_tracked_i_found = False
        segmSizeT = len(posData.segm_data)
        for i in range(posData.frame_i+1, segmSizeT):
            if posData.allData_li[i]['labels'] is None:
                if not last_tracked_i_found:
                    # We set last tracked frame at -1 first None found
                    last_tracked_i = i - 1
                    last_tracked_i_found = True
                if not includeUnvisited:
                    # Stop at last visited frame since includeUnvisited = False
                    break
                else:
                    lab = posData.segm_data[i]
            else:
                lab = posData.allData_li[i]['labels']
            
            if modID in lab:
                areFutureIDs_affected.append(True)
        
        if not last_tracked_i_found:
            # All frames have been visited in segm&track mode
            last_tracked_i = posData.SizeT - 1

        if last_tracked_i == posData.frame_i and not includeUnvisited:
            # No future frames to propagate the change to
            return False, False, None, doNotShow

        if not areFutureIDs_affected and not force:
            # There are future frames but they are not affected by the change
            return UndoFutFrames, False, None, doNotShow

        # Ask what to do unless the user has previously checked doNotShowAgain
        if doNotShow:
            endFrame_i = last_tracked_i
            if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID':
                self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1))
            return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow
        else:
            addApplyAllButton = (
                modTxt == 'Delete ID' or modTxt == 'Edit ID' 
                or modTxt == 'Assign new ID'
            )
            ffa = apps.FutureFramesAction_QDialog(
                posData.frame_i+1, last_tracked_i, modTxt, 
                applyTrackingB=applyTrackingB, parent=self, 
                addApplyAllButton=addApplyAllButton
            )
            ffa.exec_()
            decision = ffa.decision

            if decision is None:
                return None, None, None, doNotShow

            endFrame_i = ffa.endFrame_i
            doNotShowAgain = ffa.doNotShowCheckbox.isChecked()
            askAction = self.askHowFutureFramesActions[modTxt]
            askAction.setChecked( not doNotShowAgain)
            askAction.setDisabled(False)

            self.onlyTracking = False
            if decision == 'apply_and_reinit':
                UndoFutFrames = True
                applyFutFrames = False
            elif decision == 'apply_and_NOTreinit':
                UndoFutFrames = False
                applyFutFrames = False
            elif decision == 'apply_to_all_visited':
                UndoFutFrames = False
                applyFutFrames = True
            elif decision == 'only_tracking':
                UndoFutFrames = False
                applyFutFrames = True
                self.onlyTracking = True
            elif decision == 'apply_to_all':
                UndoFutFrames = False
                applyFutFrames = True
                posData.includeUnvisitedInfo[modTxt] = True

            if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID':
                self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1))
        return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain

    def addCcaState(self, frame_i, cca_df, undoId):
        posData = self.data[self.pos_i]
        posData.UndoRedoCcaStates[frame_i].insert(
            0, {'id': undoId, 'cca_df': cca_df.copy()}
        )

    def addCurrentState(self, storeImage=False, storeOnlyZoom=False):
        posData = self.data[self.pos_i]
        if posData.cca_df is not None:
            cca_df = posData.cca_df.copy()
        else:
            cca_df = None

        if storeImage:
            image = self.img1.image.copy()
        else:
            image = None

        if storeOnlyZoom:
            labels, crop_slice = transformation.crop_2D(
                self.currentLab2D, self.ax1.viewRange(), tolerance=10,
                return_copy=False
            )
            if self.isSegm3D:
                z = self.z_lab(checkIfProj=True)
                if z is None:
                    z_slice = slice(0, len(posData.lab))
                    crop_slice = (z_slice, *crop_slice)
                    labels = posData.lab[crop_slice].copy()
                else:
                    z_slice = z
                    crop_slice = (z_slice, *crop_slice)
                    labels = labels.copy()
            else:
                labels = labels.copy()
        else:
            labels = posData.lab.copy()
            crop_slice = None
        
        state = {
            'image': image,
            'labels': labels,
            'editID_info': posData.editID_info.copy(),
            'binnedIDs': posData.binnedIDs.copy(),
            'keptObejctsIDs': self.keptObjectsIDs.copy(),
            'ripIDs': posData.ripIDs.copy(),
            'cca_df': cca_df,
            'crop_slice': crop_slice
        }
        posData.UndoRedoStates[posData.frame_i].insert(0, state)
        
        # posData.storedLab = np.array(posData.lab, order='K', copy=True)
        # self.storeStateWorker.callbackOnDone = callbackOnDone
        # self.storeStateWorker.enqueue(posData, self.img1.image)

    def getCurrentState(self):
        posData = self.data[self.pos_i]
        i = posData.frame_i
        c = self.UndoCount
        state = posData.UndoRedoStates[i][c]
        if state['image'] is None:
            image_left = None
        else:
            image_left = state['image'].copy()
        
        crop_slice = state['crop_slice']
        if crop_slice is None:
            posData.lab = state['labels'].copy()
        elif self.isSegm3D:
            z_slice, slice_y, slice_x = crop_slice
            posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy()
        else:
            slice_y, slice_x = crop_slice
            posData.lab[..., slice_y, slice_x] = state['labels'].copy()
        
        posData.editID_info = state['editID_info'].copy()
        posData.binnedIDs = state['binnedIDs'].copy()
        posData.ripIDs = state['ripIDs'].copy()
        self.keptObjectsIDs = state['keptObejctsIDs'].copy()
        cca_df = state['cca_df']
        if cca_df is not None:
            posData.cca_df = state['cca_df'].copy()
        else:
            posData.cca_df = None
        return image_left
    
    def storeLabelRoiParams(self, value=None, checked=True):
        checkedRoiType = self.labelRoiTypesGroup.checkedButton().text()
        circRoiRadius = self.labelRoiCircularRadiusSpinbox.value()
        roiZdepth = self.labelRoiZdepthSpinbox.value()
        autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked()
        clearBorder = 'Yes' if autoClearBorder else 'No'
        self.df_settings.at['labelRoi_checkedRoiType', 'value'] = checkedRoiType
        self.df_settings.at['labelRoi_circRoiRadius', 'value'] = circRoiRadius
        self.df_settings.at['labelRoi_roiZdepth', 'value'] = roiZdepth
        self.df_settings.at['labelRoi_autoClearBorder', 'value'] = clearBorder
        self.df_settings.at['labelRoi_replaceExistingObjects', 'value'] = (
            'Yes' if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() 
            else 'No'
        )
        self.df_settings.to_csv(self.settings_csv_path)
    
    def loadLabelRoiLastParams(self):
        idx = 'labelRoi_checkedRoiType'
        if idx in self.df_settings.index:
            checkedRoiType = self.df_settings.at[idx, 'value']
            for button in self.labelRoiTypesGroup.buttons():
                if button.text() == checkedRoiType:
                    button.setChecked(True)
                    break
        
        idx = 'labelRoi_circRoiRadius'
        if idx in self.df_settings.index:
            circRoiRadius = self.df_settings.at[idx, 'value']
            self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius))
        
        idx = 'labelRoi_roiZdepth'
        if idx in self.df_settings.index:
            roiZdepth = self.df_settings.at[idx, 'value']
            self.labelRoiZdepthSpinbox.setValue(int(roiZdepth))
        
        idx = 'labelRoi_autoClearBorder'
        if idx in self.df_settings.index:
            clearBorder = self.df_settings.at[idx, 'value']
            checked = clearBorder == 'Yes'
            self.labelRoiAutoClearBorderCheckbox.setChecked(checked)
        
        idx = 'labelRoi_replaceExistingObjects'
        if idx in self.df_settings.index:
            val = self.df_settings.at[idx, 'value']
            checked = val == 'Yes'
            self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked)
        
        if self.labelRoiIsCircularRadioButton.isChecked():
            self.labelRoiCircularRadiusSpinbox.setDisabled(False)

    # @exec_time
    def storeUndoRedoStates(
            self, UndoFutFrames, storeImage=False, storeOnlyZoom=False
        ):
        posData = self.data[self.pos_i]
        if UndoFutFrames:
            # Since we modified current frame all future frames that were already
            # visited are not valid anymore. Undo changes there
            self.reInitLastSegmFrame(updateImages=False)
        
        # Keep only 5 Undo/Redo states
        if len(posData.UndoRedoStates[posData.frame_i]) > 5:
            posData.UndoRedoStates[posData.frame_i].pop(-1)

        # Restart count from the most recent state (index 0)
        # NOTE: index 0 is most recent state before doing last change
        self.UndoCount = 0
        self.undoAction.setEnabled(True)
        self.addCurrentState(
            storeImage=storeImage, storeOnlyZoom=storeOnlyZoom
        )
        
    def storeUndoRedoCca(self, frame_i, cca_df, undoId):
        if self.isSnapshot:
            # For snapshot mode we don't store anything because we have only
            # segmentation undo action active
            return
        """
        Store current cca_df along with a unique id to know which cca_df needs
        to be restored
        """

        posData = self.data[self.pos_i]

        # Restart count from the most recent state (index 0)
        # NOTE: index 0 is most recent state before doing last change
        self.UndoCcaCount = 0
        self.undoAction.setEnabled(True)

        self.addCcaState(frame_i, cca_df, undoId)

        # Keep only 10 Undo/Redo states
        if len(posData.UndoRedoCcaStates[frame_i]) > 10:
            posData.UndoRedoCcaStates[frame_i].pop(-1)

    def undoCustomAnnotation(self):
        pass

    def UndoCca(self):
        posData = self.data[self.pos_i]
        # Undo current ccaState
        storeState = False
        if self.UndoCount == 0:
            undoId = uuid.uuid4()
            self.addCcaState(posData.frame_i, posData.cca_df, undoId)
            storeState = True


        # Get previously stored state
        self.UndoCount += 1
        currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i]
        prevCcaState = currentCcaStates[self.UndoCount]
        posData.cca_df = prevCcaState['cca_df']
        self.store_cca_df()
        self.updateAllImages()

        # Check if we have undone all states
        if len(currentCcaStates) > self.UndoCount:
            # There are no states left to undo for current frame_i
            self.undoAction.setEnabled(False)

        # Undo all past and future frames that has a last status inserted
        # when modyfing current frame
        prevStateId = prevCcaState['id']
        for frame_i in range(0, posData.SizeT):
            if storeState:
                cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True)
                if cca_df_i is None:
                    break
                # Store current state to enable redoing it
                self.addCcaState(frame_i, cca_df_i, undoId)

            CcaStates_i = posData.UndoRedoCcaStates[frame_i]
            if len(CcaStates_i) <= self.UndoCount:
                # There are no states to undo for frame_i
                continue

            CcaState_i = CcaStates_i[self.UndoCount]
            id_i = CcaState_i['id']
            if id_i != prevStateId:
                # The id of the state in frame_i is different from current frame
                continue

            cca_df_i = CcaState_i['cca_df']
            self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False)
        
        self.resetWillDivideInfo()
        self.enqAutosave()

    def undo(self):
        addPointsByClickingButton = self.buttonAddPointsByClickingActive()
        if addPointsByClickingButton is not None:
            done = self.undoAddPoint(addPointsByClickingButton.action)
            if done:
                return
        
        if self.UndoCount == 0:
            # Store current state to enable redoing it
            self.addCurrentState()
    
        posData = self.data[self.pos_i]
        # Get previously stored state
        if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1:
            self.UndoCount += 1
            # Since we have undone then it is possible to redo
            self.redoAction.setEnabled(True)

            # Restore state
            image_left = self.getCurrentState()
            self.update_rp()
            self.updateAllImages(image=image_left)
            self.store_data()

        if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1:
            # We have undone all available states
            self.undoAction.setEnabled(False)
        
        if self.whitelistIDsButton.isChecked():
            self.whitelistHighlightIDs()

    def redo(self):
        posData = self.data[self.pos_i]
        # Get previously stored state
        if self.UndoCount > 0:
            self.UndoCount -= 1
            # Since we have redone then it is possible to undo
            self.undoAction.setEnabled(True)

            # Restore state
            image_left = self.getCurrentState()
            self.update_rp()
            self.updateAllImages(image=image_left)
            self.store_data()

        if not self.UndoCount > 0:
            # We have redone all available states
            self.redoAction.setEnabled(False)

        if self.whitelistIDsButton.isChecked():
            self.whitelistHighlightIDs()

    def realTimeTrackingClicked(self, checked):
        # Event called ONLY if the user click on Disable tracking
        # NOT called if setChecked is called. This allows to keep track
        # of the user choice. This way user con enforce tracking
        # NOTE: I know two booleans doing the same thing is overkill
        # but the code is more readable when we actually need them

        posData = self.data[self.pos_i]
        isRealTimeTrackingDisabled = not checked

        # Turn off smart tracking
        self.enableSmartTrackAction.toggled.disconnect()
        self.enableSmartTrackAction.setChecked(False)
        if isRealTimeTrackingDisabled:
            self.UserEnforced_DisabledTracking = True
            self.UserEnforced_Tracking = False
        else:
            txt = html_utils.paragraph("""

            Do you want to keep <b>tracking always active</b> including on already 
            visited frames?<br><br>
            Note: To re-activate automatic handling of tracking go to<br> 
            <code>Edit --> Smart handling of enabling/disabling tracking</code>.

            """)
            msg = widgets.myMessageBox(showCentered=False, wrapText=False)
            yesButton, noButton = msg.question(
                self, 'Keep tracking always active?', txt, 
                buttonsTexts=('Yes', 'No')
            )
            if msg.clickedButton == yesButton:
                self.repeatTracking()
                self.UserEnforced_DisabledTracking = False
                self.UserEnforced_Tracking = True
            else:
                self.enableSmartTrackAction.setChecked(True)

    @exception_handler
    def repeatTrackingVideo(self):
        posData = self.data[self.pos_i]
        win = widgets.selectTrackerGUI(
            posData.SizeT, currentFrameNo=posData.frame_i+1
        )
        win.exec_()
        if win.cancel:
            self.logger.info('Tracking aborted.')
            return

        trackerName = win.selectedItemsText[0]
        start_n = win.startFrame
        stop_n = win.stopFrame
        video_to_track = posData.segm_data
        for frame_i in range(start_n-1, stop_n):
            data_dict = posData.allData_li[frame_i]
            lab = data_dict['labels']
            if lab is None:
                break

            video_to_track[frame_i] = lab
        video_to_track = video_to_track[start_n-1:stop_n]        
        
        self.logger.info(f'Importing {trackerName} tracker...')
        self.tracker, self.track_params, init_params = myutils.init_tracker(
            posData, trackerName, qparent=self, return_init_params=True
        )
        if self.track_params is None:
            self.logger.info('Tracking aborted.')
            return
        
        warningText = myutils.validate_tracker_input(
            self.tracker, video_to_track
        )
        if warningText is not None:
            self.logger.info(warningText)
            self.warnTrackerInputNotValid(trackerName, warningText)
            return        
        
        if 'image_channel_name' in self.track_params:
            # Remove the channel name since it was already loaded in init_tracker
            del self.track_params['image_channel_name']
        
        track_params_log = {
            key: value for key, value in self.track_params.items()
            if key != 'image'
        }
        self.logger.info(
            'Tracking parameters:\n\n'
            f'Initialization parameters: {init_params}\n'
            f'Track parameters: {track_params_log}'
        )

        last_tracked_i = self.get_last_tracked_i()
        if start_n-1 <= last_tracked_i and start_n>1:
            proceed = self.warnRepeatTrackingVideoWithAnnotations(
                last_tracked_i, start_n
            )
            if not proceed:
                self.logger.info('Tracking aborted.')
                return
            
            self.logger.info(f'Removing annotations from frame n. {start_n}.')
            self.resetCcaFuture(start_n-1)

        self.start_n = start_n
        self.stop_n = stop_n
        
        info_txt = f'Tracking from frame n. {start_n} to {stop_n}...'
        self.logger.info(info_txt)

        self.progressWin = apps.QDialogWorkerProgress(
            title='Tracking', parent=self, pbarDesc=info_txt
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(stop_n-start_n)
        self.startTrackingWorker(posData, video_to_track)

    def warnTrackerInputNotValid(self, trackerName, warningText):
        msg = widgets.myMessageBox(wrapText=False)
        txt = warningText.replace('\n', '<br>')
        txt = html_utils.paragraph(
            f'{txt}<br><br>'
            'Tracking process will be cancelled. Thank you for your patience!'
        )
        msg.warning(self, 'Invalid input for tracker', txt)
    
    def repeatTracking(self):
        posData = self.data[self.pos_i]
        prev_lab = self.get_2Dlab(posData.lab).copy()
        self.tracking(enforce=True, DoManualEdit=False)
        if posData.editID_info:
            editedIDsInfo = {
                posData.lab[y,x]:newID 
                for y, x, newID in posData.editID_info
                if posData.lab[y,x] != newID
            }
            editedIDsInfoItems = [
                f'ID {oldID} --> {newID}'
                for oldID, newID in editedIDsInfo.items()
            ]
            editIDul = html_utils.to_list(editedIDsInfoItems)
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(f"""
                You requested to repeat tracking but <b>there are manually 
                edited IDs</b> (see edited IDs in the details section below)
                <br><br>
                Do you want to keep these edits or ignore them?
            """)
            keepManualEditButton = widgets.okPushButton(
                'Keep manually edited IDs'
            )
            ignoreButton = widgets.noPushButton(
                'Ignore manually edited IDs'
            )
            msg.question(
                self, 'Repeat tracking mode', txt, 
                buttonsTexts=(keepManualEditButton, ignoreButton), 
                detailsText=editIDul
            )
            if msg.cancel:
                return
            if msg.clickedButton == keepManualEditButton:
                allIDs = [obj.label for obj in posData.rp]
                lab2D = self.get_2Dlab(posData.lab)
                self.manuallyEditTracking(lab2D, allIDs)
                self.update_rp()
                self.setAllTextAnnotations()
                self.highlightLostNew()
                # self.checkIDsMultiContour()
            else:
                posData.editID_info = []
        if np.any(posData.lab != prev_lab):
            if self.isSnapshot:
                self.fixCcaDfAfterEdit('Repeat tracking')
                self.updateAllImages()
            else:
                self.warnEditingWithCca_df('Repeat tracking')
        else:
            self.updateAllImages()

    def updateGhostMaskOpacity(self, alpha_percentage=None):
        if alpha_percentage is None:
            alpha_percentage = (
                self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value()
            )
        alpha = alpha_percentage/100
        self.ghostMaskItemLeft.setOpacity(alpha)
        self.ghostMaskItemRight.setOpacity(alpha)

    def addManualTrackingItems(self):
        self.ghostContourItemLeft.addToPlotItem()
        self.ghostContourItemRight.addToPlotItem()

        self.ghostMaskItemLeft.addToPlotItem()
        self.ghostMaskItemRight.addToPlotItem()

        Y, X = self.img1.image.shape[:2]
        self.ghostMaskItemLeft.initImage((Y, X))
        self.ghostMaskItemRight.initImage((Y, X))

        self.updateGhostMaskOpacity()
    
    def removeManualTrackingItems(self):        
        self.ghostContourItemLeft.removeFromPlotItem()
        self.ghostContourItemRight.removeFromPlotItem()

        self.ghostMaskItemLeft.removeFromPlotItem()
        self.ghostMaskItemRight.removeFromPlotItem()
    
    def addManualBackgroundItems(self):
        self.manualBackgroundObjItem.addToPlotItem()
        self.ax1.addItem(self.manualBackgroundImageItem)
    
    def removeManualBackgroundItems(self):
        self.manualBackgroundObjItem.removeFromPlotItem()
        self.ax1.removeItem(self.manualBackgroundImageItem)
    
    def resetManualBackgroundSpinboxID(self):
        if not self.manualBackgroundButton.isChecked():
            self.manualBackgroundObj = None
            return
        
        posData = self.data[self.pos_i]
        minID = min(posData.IDs, default=0)
        self.manualBackgroundToolbar.spinboxID.setValue(minID)
    
    def initManualBackgroundObject(self, ID=None):
        if not self.manualBackgroundButton.isChecked():
            self.manualBackgroundObj = None
            return
        
        if ID is None:
            ID = self.manualBackgroundToolbar.spinboxID.value()
        
        posData = self.data[self.pos_i]
        if ID not in posData.IDs:
            self.manualTrackingButton = None
            self.manualBackgroundToolbar.showWarning(
                f'The ID {ID} does not exist'
            )
            return
        
        ID_idx = posData.IDs_idxs[ID]
        self.manualBackgroundObj = posData.rp[ID_idx]
        
        self.manualBackgroundToolbar.clearInfoText()
        self.manualBackgroundObj.contour = self.getObjContours(
            self.manualBackgroundObj, local=True
        )
        xx_contour = self.manualBackgroundObj.contour[:,0]
        yy_contour = self.manualBackgroundObj.contour[:,1]
        self.manualBackgroundObj.xx_contour = xx_contour
        self.manualBackgroundObj.yy_contour = yy_contour
    
    def initGhostObject(self, ID=None):
        mode = self.modeComboBox.currentText()
        if mode != 'Segmentation and Tracking':
            self.ghostObject = None
            return
        
        if not self.manualTrackingButton.isChecked():
            self.ghostObject = None
            return
        
        if not self.manualTrackingToolbar.showGhostCheckbox.isChecked():
            self.ghostObject = None
            return
        
        if ID is None:
            ID = self.manualTrackingToolbar.spinboxID.value()
        
        posData = self.data[self.pos_i]
        if posData.frame_i == 0:
            self.ghostObject = None
            return
        
        prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops']
        if prevFrameRp is None:
            self.ghostObject = None
            return
        
        for obj in prevFrameRp:
            if obj.label != ID:
                continue
            self.ghostObject = obj
            break
        else:
            self.ghostObject = None
            self.manualTrackingToolbar.showWarning(
                f'The ID {ID} does not exist in previous frame '
                '--> starting a new track.'
            )
            return
        
        self.manualTrackingToolbar.clearInfoText()

        self.ghostObject.contour = self.getObjContours(
            self.ghostObject, local=True
        )
        self.ghostObject.xx_contour = self.ghostObject.contour[:,0]
        self.ghostObject.yy_contour = self.ghostObject.contour[:,1]

        self.ghostMaskItemLeft.initLookupTable(self.lut[ID])
        self.ghostMaskItemRight.initLookupTable(self.lut[ID])
    
    def clearGhost(self):
        self.clearGhostContour()
        self.clearGhostMask()
    
    def clearManualBackgroundAnnotations(self):
        try:
            for textItem in self.manualBackgroundTextItems.values():
                textItem.setText('')
        except Exception as error:
            pass
    
    def clearGhostContour(self):
        self.ghostContourItemLeft.clear()
        self.ghostContourItemRight.clear()
        self.manualBackgroundObjItem.clear()
    
    def clearGhostMask(self):
        self.ghostMaskItemLeft.clear()
        self.ghostMaskItemRight.clear()

    @disableWindow
    def _importInitMagicPromptModel(
            self, model_name, posData, win, acdcPromptSegment, toolbar
        ):
        self.logger.info(f'Initializing promptable model {model_name}...')
        init_kwargs = win.init_kwargs
        model = myutils.init_prompt_segm_model(
            acdcPromptSegment, posData, win.init_kwargs
        )
        toolbar.model = model
        toolbar.model_segment_kwargs = win.model_kwargs
        toolbar.model_name = model_name
        toolbar.viewModelParamsAction.setDisabled(False)
        
        self.magicPromptsToolbar.setInitializedModel(
            init_kwargs, toolbar.model_segment_kwargs
        )
        
        self.logger.info(
            f'Promptable model {model_name} successfully initialised!'
        )
    
    @exception_handler
    def magicPromptsInitModel(
            self, 
            model_name, 
            acdcPromptSegment,
            init_argspecs, 
            segment_argspecs,
            help_url,
            toolbar,
        ):
        posData = self.data[self.pos_i]
        
        out = prompts.init_prompt_model_params(
            posData, model_name, init_argspecs, segment_argspecs, 
            help_url=help_url, qparent=self, init_last_params=True
        )
        win = out.get('win')
        if win.cancel:
            self.logger.info(
                f'Initialization of {model_name} promptable model cancelled.'
            )
            return
        
        self._importInitMagicPromptModel(
            model_name, posData, win, acdcPromptSegment, toolbar
        )
    
    def viewSetMagicPromptModelParams(
            self, 
            model_name, 
            acdcPromptSegment,
            init_argspecs, 
            segment_argspecs,
            help_url,
            init_kwargs,
            segment_kwargs,
            toolbar
        ):
        posData = self.data[self.pos_i]
        
        init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs(
            init_argspecs, init_kwargs
        )
        segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs(
            segment_argspecs, segment_kwargs
        )
        
        out = prompts.init_prompt_model_params(
            posData, model_name, init_argspecs, segment_argspecs, 
            help_url=help_url, qparent=self, init_last_params=False
        )
        win = out.get('win')
        if win.cancel:
            return
        
        if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs:
            self._importInitMagicPromptModel(
                model_name, posData, win, acdcPromptSegment, toolbar
            )
    
    def getMagicPromptsInputs(self, toolbar):
        if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit:
            _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self)
            return
        
        if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled():
            self.blinker = qutils.QControlBlink(
                self.magicPromptsToolbar.selectModelAction, qparent=self
            )
            self.blinker.start()
            _warnings.warnPromptSegmentModelNotInit(qparent=self)
            return
        
        posData = self.data[self.pos_i]
        image = self.getDisplayedZstack()
        df_points = self.promptSegmentPointsLayerToolbar.pointsLayerDf(
            posData, isSegm3D=self.isSegm3D
        )
        
        self.logger.info(
            f'Starting {toolbar.model_name} promptable segmentation with the '
            f'following prompts:\n\n{df_points}'
        )
        
        return image, df_points
    
    @disableWindow
    def magicPromptsComputeOnZoomTriggered(self, toolbar):
        inputs = self.getMagicPromptsInputs(toolbar)
        if inputs is None:
            self.logger.info(
                '"Computing promptable segmentation on zoom" process cancelled.'
            )
            return
        
        posData = self.data[self.pos_i]
        image, df_points = inputs
        
        ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange()
        Y, X = image.shape[-2:]
        
        xmin = int(max(0, xmin))
        xmax = int(min(X, xmax))
        ymin = int(max(0, ymin))
        ymax = int(min(Y, ymax))
        
        self.logger.info(
            f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}'
        )
        
        zoom_slice = (slice(ymin, ymax), slice(xmin, xmax))
        
        image = image[..., ymin:ymax, xmin:xmax]
        image_origin = (0, ymin, xmin)
        
        df_points['y'] -= ymin
        df_points['x'] -= xmin
        
        df_points = df_points[ df_points['frame_i'] == posData.frame_i]
        
        self.logger.info(
            f'Image origin = {image_origin}\n'
            f'Image shape = {image.shape}'
        )
        
        self.startMagicPromptsWorkerAndWait(
            image, df_points, toolbar.model, toolbar.model_segment_kwargs, 
            image_origin=image_origin, zoom_slice=zoom_slice
        )
    
    def magicPromptsClearPoints(self):
        self.promptSegmentPointsLayerToolbar.scatterItem().clear()
    
    @disableWindow
    def magicPromptsComputeOnImageTriggered(self, toolbar):
        inputs = self.getMagicPromptsInputs(toolbar)
        if inputs is None:
            self.logger.info(
                '"Computing promptable segmentation on entire image" '
                'process cancelled.'
            )
            return

        image, df_points = inputs
        
        self.startMagicPromptsWorkerAndWait(
            image, df_points, toolbar.model
        )
    
    def startMagicPromptsWorkerAndWait(
            self, image, df_points, model, model_segment_kwargs, 
            image_origin=(0, 0, 0), zoom_slice=None
        ):
        desc = (
            'Running promptable segmentation model...'
        )
        self.logger.info(desc)
        posData = self.data[self.pos_i]
        
        self.progressWin = apps.QDialogWorkerProgress(
            title=desc, parent=self, pbarDesc=desc
        )
        self.progressWin.mainPbar.setMaximum(0)
        self.progressWin.show(self.app)
        
        self.magicPromptsThread = QThread()
        self.magicPromptsWorker = workers.MagicPromptsWorker(
            posData, image, df_points, model, model_segment_kwargs, 
            image_origin=image_origin,
            global_image=posData.img_data[posData.frame_i]
        )
        
        self.magicPromptsWorker.moveToThread(
            self.magicPromptsThread
        )
        
        self.magicPromptsWorker.signals.finished.connect(
            self.magicPromptsThread.quit
        )
        self.magicPromptsWorker.signals.finished.connect(
            self.magicPromptsWorker.deleteLater
        )
        self.magicPromptsThread.finished.connect(
            self.magicPromptsThread.deleteLater
        )
        
        self.magicPromptsWorker.signals.critical.connect(
            self.magicPromptsWorkerCritical
        )
        self.magicPromptsWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.magicPromptsWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.magicPromptsWorker.signals.progress.connect(
            self.workerProgress
        )
        self.magicPromptsWorker.signals.finished.connect(
            partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice)
        )
        
        self.magicPromptsThread.started.connect(
            self.magicPromptsWorker.run
        )
        self.magicPromptsThread.start()
        
        self.magicPromptsWorkerLoop = QEventLoop()
        self.magicPromptsWorkerLoop.exec_()
    
    def magicPromptsWorkerCritical(self, error):
        self.magicPromptsWorkerLoop.exit()
        self.workerCritical(error)
    
    def magicPromptsWorkerFinished(self, output, zoom_slice=None):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        self.magicPromptsWorkerLoop.exit()
        
        lab_new, lab_union, lab_interesection = output
        
        posData = self.data[self.pos_i]
        
        if zoom_slice is None:
            zoom_slice = slice(None)
        

        images = (
            posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]], 
            posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]], 
            posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]], 
            posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]], 
        )
        labels_overlays = (
            posData.lab[..., zoom_slice[0], zoom_slice[1]], 
            lab_new[..., zoom_slice[0], zoom_slice[1]], 
            lab_union[..., zoom_slice[0], zoom_slice[1]], 
            lab_interesection[..., zoom_slice[0], zoom_slice[1]],
        )
        labels_overlays_lut = self.getLabelsImageLut()
        labels_overlays_luts = (
            labels_overlays_lut, 
            labels_overlays_lut, 
            labels_overlays_lut,
            labels_overlays_lut,
        )
        
        axis_titles = (
            'Original masks', 
            'New masks', 
            'Union of original and new masks',
            'Intersection of original and new masks'
        )
        from cellacdc.plot import imshow
        promptSegmResultsWindow = imshow(
            *images, 
            labels_overlays=labels_overlays,
            labels_overlays_luts=labels_overlays_luts,
            axis_titles=axis_titles,
            window_title='Promptable segmentation results',
            figure_title='Ctrl+Click to select the result to use',
            annotate_labels_idxs=[0, 1, 2, 3],
            selectable_images=True, 
            max_ncols=2,
            lut='gray',
            infer_rgb=False
        )
        if promptSegmResultsWindow.selected_idx is None:
            self.logger.info(
                'Selection of the promptable model segmentation '
                'result cancelled.'
            )
            return
        
        if promptSegmResultsWindow.selected_idx == 0:
            self.logger.info(
                'No selection of a promptable model segmentation '
                'result was made'
            )
            return

        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False)
        
        results = (None, lab_new, lab_union, lab_interesection)
        selected_idx = promptSegmResultsWindow.selected_idx
        posData.allData_li[posData.frame_i]['labels'] = results[selected_idx]
        self.get_data()
        self.store_data(autosave=False)
        self.updateAllImages()
        
    def manualTracking_cb(self, checked):
        self.manualTrackingToolbar.setVisible(checked)
        if checked:
            self.realTimeTrackingToggle.previousStatus = (
                self.realTimeTrackingToggle.isChecked()
            )
            self.realTimeTrackingToggle.setChecked(False)
            self.UserEnforced_DisabledTracking_previousStatus = (
                self.UserEnforced_DisabledTracking
            )
            self.UserEnforced_Tracking_previousStatus = (
                self.UserEnforced_Tracking
            )

            self.UserEnforced_DisabledTracking = True
            self.UserEnforced_Tracking = False
            self.initGhostObject()
            self.addManualTrackingItems()
        else:
            self.realTimeTrackingToggle.setChecked(
                self.realTimeTrackingToggle.previousStatus
            )
            self.UserEnforced_DisabledTracking = (
                self.UserEnforced_DisabledTracking_previousStatus
            )
            self.UserEnforced_Tracking = (
                self.UserEnforced_Tracking_previousStatus
            )
            self.removeManualTrackingItems()
            self.clearGhost()
    
    def manualBackground_cb(self, checked):
        if checked:
            posData = self.data[self.pos_i]
            minID = min(posData.IDs, default=0)
            if minID == self.manualBackgroundToolbar.spinboxID.value():
                self.initManualBackgroundObject()
            else:
                self.manualBackgroundToolbar.spinboxID.setValue(minID)
            # self.initManualBackgroundObject()
            # self.initManualBackgroundImage()
            self.addManualBackgroundItems()
            self.disconnectLeftClickButtons()
            self.uncheckLeftClickButtons(self.manualBackgroundButton)
            self.connectLeftClickButtons()
            self.updateAllImages()
        else:
            self.removeManualTrackingItems()
            self.clearGhost()
            self.clearManualBackgroundAnnotations()
        self.manualBackgroundToolbar.setVisible(checked)

    def autoSegm_cb(self, checked):
        if checked:
            self.askSegmParam = True
            # Ask which model
            models = myutils.get_list_of_models()
            win = widgets.QDialogListbox(
                'Select model',
                'Select model to use for segmentation: ',
                models,
                multiSelection=False,
                parent=self
            )
            win.exec_()
            if win.cancel:
                return
            model_name = win.selectedItemsText[0]
            self.segmModelName = model_name
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)
            self.updateAllImages()
            self.computeSegm()
            self.askSegmParam = False
        else:
            self.segmModelName = None

    def postProcessSegm(self, checked):
        if self.isSegm3D:
            SizeZ = max([posData.SizeZ for posData in self.data])
        else:
            SizeZ = None
        if checked:
            posData = self.data[self.pos_i]
            self.postProcessSegmWin = apps.PostProcessSegmDialog(
                posData, mainWin=self
            )
            self.postProcessSegmWin.sigClosed.connect(
                self.postProcessSegmWinClosed
            )
            self.postProcessSegmWin.sigValueChanged.connect(
                self.postProcessSegmValueChanged
            )
            self.postProcessSegmWin.sigEditingFinished.connect(
                self.postProcessSegmEditingFinished
            )
            self.postProcessSegmWin.sigApplyToAllFutureFrames.connect(
                self.postProcessSegmApplyToAllFutureFrames
            )
            self.postProcessSegmWin.show()
            self.postProcessSegmWin.valueChanged(None)
        else:
            self.postProcessSegmWin.close()
            self.postProcessSegmWin = None
    
    def postProcessSegmApplyToAllFutureFrames(
            self, postProcessKwargs, 
            customPostProcessGroupedFeatures, 
            customPostProcessFeatures
        ):
        proceed = self.warnEditingWithCca_df(
            'post-processing segmentation', update_images=False
        )
        if not proceed:
            self.logger.info('Post-processing segmentation cancelled.')
            return

        self.progressWin = apps.QDialogWorkerProgress(
            title='Post-processing segmentation', parent=self,
            pbarDesc=f'Post-processing segmentation masks...'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)

        self.startPostProcessSegmWorker(
            postProcessKwargs, customPostProcessGroupedFeatures, 
            customPostProcessFeatures
        )
    
    def postProcessSegmEditingFinished(self):
        self.update_rp()
        self.store_data()
        self.updateAllImages()
    
    def postProcessSegmWorkerFinished(self):
        self.progressWin.workerFinished = True
        self.progressWin.close()
        self.progressWin = None
        self.get_data()
        self.updateAllImages()
        self.titleLabel.setText('Post-processing segmentation done!', color='w')
        self.logger.info('Post-processing segmentation done!')

    def postProcessSegmWinClosed(self):
        self.postProcessSegmWin = None
        self.postProcessSegmAction.toggled.disconnect()
        self.postProcessSegmAction.setChecked(False)
        self.postProcessSegmAction.toggled.connect(self.postProcessSegm)
    
    def postProcessSegmValueChanged(self, lab, delObjs: dict):
        for delObj in delObjs.values():
            self.clearObjContour(obj=delObj, ax=0)
            self.clearObjContour(obj=delObj, ax=1)
            
        posData = self.data[self.pos_i]
        
        labelsToSkip = {}
        for ID in posData.IDs:
            if ID in delObjs:
                labelsToSkip[ID] = True
                continue
            
            restoreObj = self.postProcessSegmWin.origObjs[ID]
            self.addObjContourToContoursImage(obj=restoreObj, ax=0)
            self.addObjContourToContoursImage(obj=restoreObj, ax=1)
 
        # self.setAllTextAnnotations(labelsToSkip=labelsToSkip)

        posData.lab = lab
        self.setImageImg2()
        if self.annotSegmMasksCheckbox.isChecked():
            self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False)
        if self.annotSegmMasksCheckboxRight.isChecked():
            self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False)

    def readSavedCustomAnnot(self):
        tempAnnot = {}
        if os.path.exists(custom_annot_path):
            self.logger.info('Loading saved custom annotations...')
            tempAnnot = load.read_json(
                custom_annot_path, logger_func=self.logger.info
            )

        posData = self.data[self.pos_i]
        self.savedCustomAnnot = tempAnnot
        for pos_i, posData in enumerate(self.data):
            self.savedCustomAnnot = {
                **self.savedCustomAnnot, **posData.customAnnot
            }
    
    def addCustomAnnotButtonAllLoadedPos(self):
        allPosCustomAnnot = {}
        for pos_i, posData in enumerate(self.data):
            self.addCustomAnnotationSavedPos(pos_i=pos_i)
            allPosCustomAnnot = {**allPosCustomAnnot, **posData.customAnnot}
        for posData in self.data:
            posData.customAnnot = allPosCustomAnnot

    def addCustomAnnotationSavedPos(self, pos_i=None):
        if pos_i is None:
            pos_i = self.pos_i
        
        posData = self.data[pos_i]
        for name, annotState in posData.customAnnot.items():
            # Check if button is already present and update only annotated IDs
            buttons = [b for b in self.customAnnotDict.keys() if b.name==name]
            if buttons:
                toolButton = buttons[0]
                allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs']
                allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {})
                continue

            try:
                symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0]
            except Exception as e:
                self.logger.info(traceback.format_exc())
                symbol = 'o'
            
            symbolColor = QColor(*annotState['symbolColor'])
            shortcut = annotState['shortcut']
            if shortcut is not None:
                keySequence = widgets.macShortcutToWindows(shortcut)
                keySequence = widgets.KeySequenceFromText(keySequence)
            else:
                keySequence = None
            toolTip = myutils.getCustomAnnotTooltip(annotState)
            keepActive = annotState.get('keepActive', True)
            isHideChecked = annotState.get('isHideChecked', True)

            toolButton, action = self.addCustomAnnotationButton(
                symbol, symbolColor, keySequence, toolTip, name,
                keepActive, isHideChecked
            )
            allPosAnnotIDs = [
                pos.customAnnotIDs.get(name, defaultdict(list)) 
                for pos in self.data
            ]
            self.customAnnotDict[toolButton] = {
                'action': action,
                'state': annotState,
                'annotatedIDs': allPosAnnotIDs
            }

            self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton)

    def addCustomAnnotationButton(
            self, symbol, symbolColor, keySequence, toolTip, annotName,
            keepActive, isHideChecked
        ):
        toolButton = widgets.customAnnotToolButton(
            symbol, symbolColor, parent=self, keepToolActive=keepActive,
            isHideChecked=isHideChecked
        )
        toolButton.setCheckable(True)
        self.checkableQButtonsGroup.addButton(toolButton)
        if keySequence is not None:
            toolButton.setShortcut(keySequence)
        toolButton.setToolTip(toolTip)
        toolButton.name = annotName
        toolButton.toggled.connect(self.customAnnotButtonToggled)
        toolButton.sigRemoveAction.connect(self.removeCustomAnnotButton)
        toolButton.sigKeepActiveAction.connect(self.customAnnotKeepActive)
        toolButton.sigHideAction.connect(self.customAnnotHide)
        toolButton.sigModifyAction.connect(self.customAnnotModify)
        action = self.annotateToolbar.addWidget(toolButton)
        return toolButton, action

    def addCustomAnnnotScatterPlot(
            self, symbolColor, symbol, toolButton
        ):
        # Add scatter plot item
        symbolColorBrush = [0, 0, 0, 50]
        symbolColorBrush[:3] = symbolColor.getRgb()[:3]
        scatterPlotItem = widgets.CustomAnnotationScatterPlotItem()
        scatterPlotItem.setData(
            [], [], symbol=symbol, pxMode=False,
            brush=pg.mkBrush(symbolColorBrush), size=15,
            pen=pg.mkPen(width=3, color=symbolColor),
            hoverable=True, hoverBrush=pg.mkBrush(symbolColor),
            tip=None
        )
        scatterPlotItem.sigHovered.connect(self.customAnnotHovered)
        scatterPlotItem.button = toolButton
        self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem
        self.ax1.addItem(scatterPlotItem)
    
    def addCustomAnnotationItems(
            self, symbol, symbolColor, keySequence, toolTip, name,
            keepActive, isHideChecked, state
        ):
        toolButton, action = self.addCustomAnnotationButton(
            symbol, symbolColor, keySequence, toolTip, name,
            keepActive, isHideChecked
        )

        self.customAnnotDict[toolButton] = {
            'action': action,
            'state': state,
            'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))]
        }

        # Save custom annotation to cellacdc/temp/custom_annotations.json
        state_to_save = state.copy()
        state_to_save['symbolColor'] = tuple(symbolColor.getRgb())
        self.savedCustomAnnot[name] = state_to_save
        for posData in self.data:
            posData.customAnnot[name] = state_to_save

        # Add scatter plot item
        self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton)

        customAnnotButton = self.customAnnotDict[toolButton]
        allPosAnnotatedIDs = customAnnotButton['annotatedIDs']
        # Add 0s column to acdc_df
        for pos_i, posData in enumerate(self.data):
            for frame_i, data_dict in enumerate(posData.allData_li):
                acdc_df = data_dict['acdc_df']
                if acdc_df is None:
                    continue
                if name not in acdc_df.columns:
                    acdc_df[name] = 0
                else:
                    acdc_df[name] = acdc_df[name].astype(int)
                    acdc_df_annot = acdc_df[acdc_df[name] == 1].reset_index()
                    annot_IDs = acdc_df_annot['Cell_ID'].to_list()
                    allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs)
                    
            if posData.acdc_df is not None:
                if name not in posData.acdc_df.columns:
                    posData.acdc_df[name] = 0
                else:
                    posData.acdc_df[name] = posData.acdc_df[name].astype(int)
                    acdc_df_annot = (
                        posData.acdc_df[posData.acdc_df[name] == 1]
                        .reset_index()
                    )
                    annot_IDs = acdc_df_annot['Cell_ID'].to_list()
                    allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs)
        
    def customAnnotHovered(self, scatterPlotItem, points, event):
        # Show tool tip when hovering an annotation with annotation name and ID
        vb = scatterPlotItem.getViewBox()
        if vb is None:
            return
        if len(points) > 0:
            posData = self.data[self.pos_i]
            point = points[0]
            x, y = point.pos().x(), point.pos().y()
            xdata, ydata = int(x), int(y)
            ID = self.get_2Dlab(posData.lab)[ydata, xdata]
            vb.setToolTip(
                f'Annotation name: {scatterPlotItem.button.name}\n'
                f'ID = {ID}'
            )
        else:
            vb.setToolTip('')
    
    def loadCustomAnnotations(self):
        items = list(self.savedCustomAnnot.keys())
        if len(items) == 0:
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph("""
            There are no custom annotations saved.<br><br>
            Click on "Add custom annotation" button to start adding new 
            annotations.
            """)
            msg.warning(self, 'No annotations saved', txt)
            return
        
        self.selectAnnotWin = widgets.QDialogListbox(
            'Load previously used custom annotation(s)',
            'Select annotations to load:', items,
            additionalButtons=('Delete selected annnotations', ),
            parent=self, multiSelection=True
        )
        for button in self.selectAnnotWin._additionalButtons:
            button.disconnect()
            button.clicked.connect(self.deleteSavedAnnotation)
        self.selectAnnotWin.exec_()
        if self.selectAnnotWin.cancel:
            return
        
        for selectedAnnotName in self.selectAnnotWin.selectedItemsText:
            selectedAnnot = self.savedCustomAnnot[selectedAnnotName]

            symbol = selectedAnnot['symbol']
            symbol = re.findall(r"\'(.+)\'", symbol)[0]
            symbolColor = selectedAnnot['symbolColor']
            symbolColor = pg.mkColor(symbolColor)
            keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut'])
            Type = selectedAnnot['type']
            toolTip = (
                f'Name: {selectedAnnotName}\n\n'
                f'Type: {Type}\n\n'
                f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n'
                f'Description: {selectedAnnot["description"]}\n\n'
                f'Shortcut: "{keySequence}"'
            )
            keepActive = selectedAnnot['keepActive']
            isHideChecked = selectedAnnot['isHideChecked']
            state = {
                'type': Type,
                'name': selectedAnnotName,
                'symbol':  selectedAnnot['symbol'],
                'shortcut': selectedAnnot['shortcut'],
                'description': selectedAnnot["description"],
                'keepActive': keepActive,
                'isHideChecked': isHideChecked,
                'symbolColor': symbolColor
            }
            self.addCustomAnnotationItems(
                symbol, symbolColor, keySequence, toolTip, selectedAnnotName,
                keepActive, isHideChecked, state
            )
            for pos_i, posData in enumerate(self.data):
                posData.customAnnot[selectedAnnotName] = selectedAnnot
            
        self.saveCustomAnnot()
    
    def deleteSavedAnnotation(self):
        for item in self.selectAnnotWin.listBox.selectedItems():
            name = item.text()
            self.savedCustomAnnot.pop(name)
        self.deleteSelectedAnnot(self.selectAnnotWin.listBox.selectedItems())
        items = list(self.savedCustomAnnot.keys())
        self.selectAnnotWin.listBox.clear()
        self.selectAnnotWin.listBox.addItems(items)

    def addCustomAnnotation(self):
        self.readSavedCustomAnnot()

        self.addAnnotWin = apps.customAnnotationDialog(
            self.savedCustomAnnot, parent=self
        )
        self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot)
        self.addAnnotWin.exec_()
        if self.addAnnotWin.cancel:
            self.logger.info('Custom annotation process cancelled.')
            return

        symbol = self.addAnnotWin.symbol
        symbolColor = self.addAnnotWin.state['symbolColor']
        keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence
        toolTip = self.addAnnotWin.toolTip
        name = self.addAnnotWin.state['name']
        keepActive = self.addAnnotWin.state.get('keepActive', True)
        isHideChecked = self.addAnnotWin.state.get('isHideChecked', True)
        
        proceed = self.checkNameExists(name)
        if not proceed:
            self.logger.info('Custom annotation process cancelled.')
            return

        self.addCustomAnnotationItems(
            symbol, symbolColor, keySequence, toolTip, name,
            keepActive, isHideChecked, self.addAnnotWin.state
        )
        self.saveCustomAnnot()
        self.doCustomAnnotation(0)

    def askCustomAnnotationNameExists(self, name):
        msg = widgets.myMessageBox(wrapText=False)
        txt = html_utils.paragraph(f"""
            The annotationa called <code>{name}</code> already exists in the 
            acdc_output CSV file.<br><br>
            If you continue, this column will be used to initialize 
            pre-annotated objects.<br><br>
            Do you want to continue?
        """
        )
        noButton, yesButton = msg.question(
            self, 'Custom annotation name already exists', txt,
            buttonsTexts=('No, stop process', 'Yes, use existing column')
        )
        return msg.clickedButton == yesButton
        
    
    def checkNameExists(self, name):
        posData = self.data[self.pos_i]
        for frame_i, data_dict in enumerate(posData.allData_li):
            acdc_df = data_dict['acdc_df']
            if acdc_df is None:
                continue
            if name in acdc_df.columns:
                return self.askCustomAnnotationNameExists(name)
        
        if posData.acdc_df is not None and name in posData.acdc_df.columns:
            return self.askCustomAnnotationNameExists(name)
         
        return True
            
    
    def viewAllCustomAnnot(self, checked):
        if not checked:
            # Clear all annotations before showing only checked
            for button in self.customAnnotDict.keys():
                self.clearScatterPlotCustomAnnotButton(button)
        self.doCustomAnnotation(0)

    def clearScatterPlotCustomAnnotButton(self, button):
        scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem']
        scatterPlotItem.setData([], [])

    def saveCustomAnnot(self, only_temp=False):
        if not hasattr(self, 'savedCustomAnnot'):
            return

        if not self.savedCustomAnnot:
            return

        self.logger.info('Saving custom annotations parameters...')
        # Save to cell acdc temp path
        with open(custom_annot_path, mode='w') as file:
            json.dump(self.savedCustomAnnot, file, indent=2)

        if only_temp:
            return
        
        # Save to pos path
        for posData in self.data:
            if not posData.customAnnot:
                continue
            with open(posData.custom_annot_json_path, mode='w') as file:
                json.dump(posData.customAnnot, file, indent=2)

    def customAnnotKeepActive(self, button):
        self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive

    def customAnnotHide(self, button):
        self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked
        clearAnnot = (
            not button.isChecked() and button.isHideChecked
            and not self.viewAllCustomAnnotAction.isChecked()
        )
        if clearAnnot:
            # User checked hide annot with the button not active --> clear
            self.clearScatterPlotCustomAnnotButton(button)
        elif not button.isChecked():
            # User uncheked hide annot with the button not active --> show
            self.doCustomAnnotation(0)

    def deleteSelectedAnnot(self, itemsToDelete):
        self.saveCustomAnnot(only_temp=True)

    def customAnnotModify(self, button):
        state = self.customAnnotDict[button]['state']
        self.addAnnotWin = apps.customAnnotationDialog(
            self.savedCustomAnnot, state=state
        )
        self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot)
        self.addAnnotWin.exec_()
        if self.addAnnotWin.cancel:
            return

        # Rename column if existing
        posData = self.data[self.pos_i]
        acdc_df = posData.allData_li[posData.frame_i]['acdc_df']
        if acdc_df is not None:
            old_name = self.customAnnotDict[button]['state']['name']
            new_name = self.addAnnotWin.state['name']
            acdc_df = acdc_df.rename(columns={old_name: new_name})
            posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df

        self.customAnnotDict[button]['state'] = self.addAnnotWin.state

        name = self.addAnnotWin.state['name']
        state_to_save = self.addAnnotWin.state.copy()
        symbolColor = self.addAnnotWin.state['symbolColor']
        state_to_save['symbolColor'] = tuple(symbolColor.getRgb())
        self.savedCustomAnnot[name] = self.addAnnotWin.state
        self.saveCustomAnnot()

        symbol = self.addAnnotWin.symbol
        symbolColor = self.customAnnotDict[button]['state']['symbolColor']
        button.setColor(symbolColor)
        button.update()
        symbolColorBrush = [0, 0, 0, 50]
        symbolColorBrush[:3] = symbolColor.getRgb()[:3]
        scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem']
        xx, yy = scatterPlotItem.getData()
        if xx is None:
            xx, yy = [], []
        scatterPlotItem.setData(
            xx, yy, symbol=symbol, pxMode=False,
            brush=pg.mkBrush(symbolColorBrush), size=15,
            pen=pg.mkPen(width=3, color=symbolColor)
        )

    def doCustomAnnotation(self, ID):
        mode = self.modeComboBox.currentText()
        if not self.isSnapshot and mode != 'Custom annotations':
            # Do not show annotations if timelapse and mode not annotations
            return
        
        if self.switchPlaneCombobox.depthAxes() != 'z': 
            return
        
        # NOTE: pass 0 for ID to not add
        posData = self.data[self.pos_i]
        if self.viewAllCustomAnnotAction.isChecked():
            # User requested to show all annotations --> iterate all buttons
            # Unless it actively clicked to annotate --> avoid annotating object
            # with all the annotations present
            buttons = list(self.customAnnotDict.keys())
        else:
            # Annotate if the button is active or isHideChecked is False
            buttons = [
                b for b in self.customAnnotDict.keys()
                if (b.isChecked() or not b.isHideChecked)
            ]
            if not buttons:
                return

        for button in buttons:
            annotatedIDs = (
                self.customAnnotDict[button]['annotatedIDs'][self.pos_i]
            )
            annotIDs_frame_i = annotatedIDs.get(posData.frame_i, [])
            state = self.customAnnotDict[button]['state']
            acdc_df = posData.allData_li[posData.frame_i]['acdc_df']
            
            if button.isChecked() and ID > 0:
                # Annotate only if existing ID and the button is checked
                if ID in annotIDs_frame_i:
                    annotIDs_frame_i.remove(ID)
                    acdc_df.at[ID, state['name']] = 0
                elif ID != 0:
                    annotIDs_frame_i.append(ID)
            
            annotPerButton = self.customAnnotDict[button]
            allAnnotedIDs = annotPerButton['annotatedIDs']
            posAnnotedIDs = allAnnotedIDs[self.pos_i]
            posAnnotedIDs[posData.frame_i] = annotIDs_frame_i
            
            if acdc_df is None:
                self.store_data(autosave=False)
            acdc_df = posData.allData_li[posData.frame_i]['acdc_df']

            xx, yy = [], []
            for annotID in annotIDs_frame_i:
                obj_idx = posData.IDs_idxs[annotID]
                obj = posData.rp[obj_idx]
                acdc_df.at[annotID, state['name']] = 1
                if not self.isObjVisible(obj.bbox):
                    continue
                y, x = self.getObjCentroid(obj.centroid)
                xx.append(x)
                yy.append(y)
                
            scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem']
            scatterPlotItem.setData(xx, yy)

            posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df
        
        # if self.highlightedID != 0:
        #     self.highlightedID = 0
        #     self.setHighlightID(False)

        if buttons:
            return buttons[0]

    def removeCustomAnnotButton(self, button, askHow=True, save=True):
        if askHow:
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph("""
                Do you want to <b>remove also the column with annotations</b> or 
                only the annotation button?<br>
            """)
            _, removeOnlyButton, removeColButton = msg.question(
                self, 'Remove only button?', txt, 
                buttonsTexts=(
                    'Cancel', 'Remove only button', 
                    ' Remove also column with annotations '
                )
            )
            if msg.cancel:
                return
            removeOnlyButton = msg.clickedButton == removeOnlyButton
        else:
            removeOnlyButton = True
        
        name = self.customAnnotDict[button]['state']['name']
        # remove annotation from position
        for posData in self.data:
            try:
                posData.customAnnot.pop(name)
                posData.saveCustomAnnotationParams()
            except KeyError as e:
                # Current pos doesn't have any annotation button. Continue
                continue

            if posData.acdc_df is None:
                continue
            
            if removeOnlyButton:
                continue

            posData.acdc_df = posData.acdc_df.drop(
                columns=name, errors='ignore'
            )
            for frame_i, data_dict in enumerate(posData.allData_li):
                acdc_df = data_dict['acdc_df']
                if acdc_df is None:
                    continue
                acdc_df = acdc_df.drop(columns=name, errors='ignore')
                posData.allData_li[frame_i]['acdc_df'] = acdc_df

        self.clearScatterPlotCustomAnnotButton(button)

        action = self.customAnnotDict[button]['action']
        self.annotateToolbar.removeAction(action)
        self.checkableQButtonsGroup.removeButton(button)
        self.customAnnotDict.pop(button)
        # self.savedCustomAnnot.pop(name)

        self.saveCustomAnnot(only_temp=True)

    def customAnnotButtonToggled(self, checked):
        if checked:
            self.customAnnotButton = self.sender()
            # Uncheck the other buttons
            for button in self.customAnnotDict.keys():
                if button == self.sender():
                    continue

                button.toggled.disconnect()
                self.clearScatterPlotCustomAnnotButton(button)
                button.setChecked(False)                
                button.toggled.connect(self.customAnnotButtonToggled)
            self.doCustomAnnotation(0)
        else:
            self.customAnnotButton = None
            button = self.sender()
            clearAnnotation = (
                button.isHideChecked 
                or not self.viewAllCustomAnnotAction.isChecked()
            )
            if clearAnnotation:    
                self.clearScatterPlotCustomAnnotButton(button)
            self.setHighlightID(False)
            self.resetCursor()
    
    def resetCursor(self):
        if self.app.overrideCursor() is not None:
            while self.app.overrideCursor() is not None:
                self.app.restoreOverrideCursor()

    def segmFrameCallback(self, action):
        if action == self.addCustomModelFrameAction:
            return
        
        idx = self.segmActions.index(action)
        model_name = self.modelNames[idx]
        self.repeatSegm(model_name=model_name, askSegmParams=True)

    def segmVideoCallback(self, action):
        if action == self.addCustomModelVideoAction:
            return

        posData = self.data[self.pos_i]
        win = apps.startStopFramesDialog(
            posData.SizeT, currentFrameNum=posData.frame_i+1
        )
        win.exec_()
        if win.cancel:
            self.logger.info('Segmentation on multiple frames aborted.')
            return

        idx = self.segmActionsVideo.index(action)
        model_name = self.modelNames[idx]
        self.repeatSegmVideo(model_name, win.startFrame, win.stopFrame)
    
    def segmentToolActionTriggered(self):
        if self.segmModelName is None:
            win = apps.QDialogSelectModel(parent=self)
            win.exec_()
            if win.cancel:
                self.logger.info('Repeat segmentation cancelled.')
                return
            model_name = win.selectedModel
            self.repeatSegm(
                model_name=model_name, askSegmParams=True
            )
        else:
            self.repeatSegm(model_name=self.segmModelName)        
    
    def initSegmModelParams(
            self, model_name, acdcSegment, init_params, segment_params, 
            is_label_roi=False, initLastParams=False,
            extraParams=None, extraParamsTitle=None,ini_filename=None

        ):
        posData = self.data[self.pos_i]        
        try:
            url = acdcSegment.url_help()
        except AttributeError:
            url = None
        
        text_if_cancelled = 'Segmentation process cancelled.'
        out = prompts.init_segm_model_params(
            posData, model_name, init_params, segment_params, 
            help_url=url, qparent=self, init_last_params=initLastParams, 
            check_sam_embeddings=not is_label_roi, is_gui_caller=True,
            extraParams=extraParams,extraParamsTitle=extraParamsTitle,
            ini_filename=ini_filename,
        )
        if out.get('load_sam_embeddings', False):
            self.logger.info('Loading Segment Anything image embeddings...')
            for _posData in self.data:
                _posData.loadSamEmbeddings(logger_func=None)
            text_if_cancelled = 'SAM embeddings loaded.'
            
        win = out.get('win')
        if win is None:
            self.logger.info(text_if_cancelled)
            self.titleLabel.setText(text_if_cancelled)
            return
        
        if win.cancel:
            self.logger.info(text_if_cancelled)
            self.titleLabel.setText(text_if_cancelled)
            return
        
        if model_name != 'thresholding':
            self.model_kwargs = win.model_kwargs
        
        return win
    
    @exception_handler
    def repeatSegm(
            self, model_name='', askSegmParams=False, is_label_roi=False
        ):
        if model_name == 'thresholding':
            # thresholding model is stored as 'Automatic thresholding'
            # at line of code `models.append('Automatic thresholding')`
            model_name = 'Automatic thresholding'
        
        idx = self.modelNames.index(model_name)
        # Ask segm parameters if not already set
        # and not called by segmSingleFrameMenu (askSegmParams=False)
        if not askSegmParams:
            askSegmParams = self.model_kwargs is None

        self.downloadWin = apps.downloadModel(model_name, parent=self)
        self.downloadWin.download()

        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False)

        if model_name == 'Automatic thresholding':
            # Automatic thresholding is the name of the models as stored 
            # in self.modelNames, but the actual model is called thresholding
            # (see cellacdc/models/thresholding)
            model_name = 'thresholding'

        posData = self.data[self.pos_i]
        # Check if model needs to be imported
        acdcSegment = self.acdcSegment_li[idx]
        if acdcSegment is None:
            self.logger.info(f'Importing {model_name}...')
            acdcSegment = myutils.import_segment_module(model_name)
            self.acdcSegment_li[idx] = acdcSegment

        # Ask parameters if the user clicked on the action
        # Otherwise this function is called by "computeSegm" function and
        # we use loaded parameters
        if askSegmParams:
            if self.app.overrideCursor() == Qt.WaitCursor:
                self.app.restoreOverrideCursor()
            self.segmModelName = model_name
            # Read all models parameters
            init_params, segment_params = myutils.getModelArgSpec(acdcSegment)
            # Prompt user to enter the model parameters
            try:
                url = acdcSegment.url_help()
            except AttributeError:
                url = None
            
            self.preproc_recipe = None
            initLastParams = True
            if model_name == 'thresholding':
                win = apps.QDialogAutomaticThresholding(
                    parent=self, isSegm3D=self.isSegm3D
                )
                win.exec_()
                if win.cancel:
                    return
                self.model_kwargs = win.segment_kwargs
                thresh_method = self.model_kwargs['threshold_method']
                gauss_sigma = self.model_kwargs['gauss_sigma']
                segment_params = myutils.insertModelArgSpec(
                    segment_params, 'threshold_method', thresh_method
                )
                segment_params = myutils.insertModelArgSpec(
                    segment_params, 'gauss_sigma', gauss_sigma
                )
                initLastParams = False
            
            win = self.initSegmModelParams(
                model_name, acdcSegment, init_params, segment_params, 
                is_label_roi=is_label_roi, 
                initLastParams=initLastParams
            )
            if win is None:
                return
            
            self.standardPostProcessKwargs = win.standardPostProcessKwargs
            self.customPostProcessFeatures = win.customPostProcessFeatures
            self.customPostProcessGroupedFeatures = (
                win.customPostProcessGroupedFeatures
            )
            self.applyPostProcessing = win.applyPostProcessing
            self.secondChannelName = win.secondChannelName
            self.preproc_recipe = win.preproc_recipe
            
            myutils.log_segm_params(
                model_name, win.init_kwargs, win.model_kwargs, 
                logger_func=self.logger.info, 
                preproc_recipe=win.preproc_recipe, 
                apply_post_process=self.applyPostProcessing, 
                standard_postprocess_kwargs=self.standardPostProcessKwargs, 
                custom_postprocess_features=self.customPostProcessFeatures
            )

            use_gpu = win.init_kwargs.get('gpu', False)
            proceed = myutils.check_gpu_availible(model_name, use_gpu, qparent=self)
            if not proceed:
                self.logger.info('Segmentation process cancelled.')
                self.titleLabel.setText('Segmentation process cancelled.')
                return
            
            model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs)            
            try:
                model.setupLogger(self.logger)
            except Exception as e:
                pass
            self.models[idx] = model
            model.model_name = model_name
        else:
            model = self.models[idx]
        
        if is_label_roi:
            return model

        self.titleLabel.setText(
            f'Labelling with {model_name}... '
            '(check progress in terminal/console)', color=self.titleColor
        )

        if self.askRepeatSegment3D:
            self.segment3D = False
        if self.isSegm3D and self.askRepeatSegment3D:
            msg = widgets.myMessageBox(showCentered=False)
            msg.addDoNotShowAgainCheckbox(text='Do not ask again')
            txt = html_utils.paragraph(
                'Do you want to segment the <b>entire z-stack</b> or only the '
                '<b>current z-slice</b>?'
            )
            _, segment3DButton, _ = msg.question(
                self, '3D segmentation?', txt,
                buttonsTexts=(
                    'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice'
                )
            )
            if msg.cancel:
                self.titleLabel.setText('Segmentation process aborted.')
                self.logger.info('Segmentation process aborted.')
                return
            self.segment3D = msg.clickedButton == segment3DButton
            if msg.doNotShowAgainCheckbox.isChecked():
                self.askRepeatSegment3D = False
        
        if self.askZrangeSegm3D:
            self.z_range = None
        if self.isSegm3D and self.segment3D and self.askZrangeSegm3D:
            idx = (posData.filename, posData.frame_i)
            try:
                orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui']
            except ValueError as e:
                orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] 
            selectZtool = apps.QCropZtool(
                posData.SizeZ, parent=self, cropButtonText='Ok',
                addDoNotShowAgain=True, title='Select z-slice range to segment'
            )
            selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged)
            selectZtool.sigCrop.connect(selectZtool.close)
            selectZtool.exec_()
            self.update_z_slice(orignal_z)
            if selectZtool.cancel:
                self.titleLabel.setText('Segmentation process aborted.')
                self.logger.info('Segmentation process aborted.')
                return
            startZ = selectZtool.lowerZscrollbar.value()
            stopZ = selectZtool.upperZscrollbar.value()
            self.z_range = (startZ, stopZ)
            if selectZtool.doNotShowAgainCheckbox.isChecked():
                self.askZrangeSegm3D = False
        
        secondChannelData = None
        if self.secondChannelName is not None:
            secondChannelData = self.getSecondChannelData()
        
        self.titleLabel.setText(
            f'{model_name} is thinking... '
            '(check progress in terminal/console)', color=self.titleColor
        )

        self.model = model
        
        self.segmWorkerMutex = QMutex()
        self.segmWorkerWaitCond = QWaitCondition()
        self.thread = QThread()
        self.worker = workers.segmWorker(
            self, secondChannelData=secondChannelData,
            mutex=self.segmWorkerMutex, waitCond=self.segmWorkerWaitCond
        )
        self.worker.z_range = self.z_range
        self.worker.moveToThread(self.thread)
        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        if self.debug:
            self.worker.debug.connect(self.debugSegmWorker)
        self.thread.finished.connect(self.thread.deleteLater)

        # Custom signals
        self.worker.critical.connect(self.workerCritical)
        self.worker.finished.connect(self.segmWorkerFinished)

        self.thread.started.connect(self.worker.run)
        self.thread.start()
    
    def debugSegmWorker(self, to_debug):
        img, secondChImg = to_debug
        printl(img.shape, secondChImg.shape)
        imshow(img, secondChImg)
        self.segmWorkerWaitCond.wakeAll()
    
    def selectZtoolZvalueChanged(self, whichZ, z):
        self.update_z_slice(z)

    @exception_handler
    def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum):
        if model_name == 'thresholding':
            # thresholding model is stored as 'Automatic thresholding'
            # at line of code `models.append('Automatic thresholding')`
            model_name = 'Automatic thresholding'

        idx = self.modelNames.index(model_name)

        self.downloadWin = apps.downloadModel(model_name, parent=self)
        self.downloadWin.download()

        if model_name == 'Automatic thresholding':
            # Automatic thresholding is the name of the models as stored 
            # in self.modelNames, but the actual model is called thresholding
            # (see cellacdc/models/thresholding)
            model_name = 'thresholding'

        posData = self.data[self.pos_i]
        # Check if model needs to be imported
        acdcSegment = self.acdcSegment_li[idx]
        if acdcSegment is None:
            self.logger.info(f'Importing {model_name}...')
            acdcSegment = myutils.import_segment_module(model_name)
            self.acdcSegment_li[idx] = acdcSegment

        # Read all models parameters
        init_params, segment_params = myutils.getModelArgSpec(acdcSegment)
        # Prompt user to enter the model parameters
        try:
            url = acdcSegment.url_help()
        except AttributeError:
            url = None
        
        if model_name == 'thresholding':
            autoThreshWin = apps.QDialogAutomaticThresholding(
                parent=self, isSegm3D=self.isSegm3D
            )
            autoThreshWin.exec_()
            if autoThreshWin.cancel:
                return
        
        win = self.initSegmModelParams(
            model_name, acdcSegment, init_params, segment_params
        )
        if win is None:
            return

        self.standardPostProcessKwargs = win.standardPostProcessKwargs
        self.customPostProcessFeatures = win.customPostProcessFeatures
        self.customPostProcessGroupedFeatures = (
            win.customPostProcessGroupedFeatures
        )
        self.applyPostProcessing = win.applyPostProcessing
        self.preproc_recipe = win.preproc_recipe
        
        myutils.log_segm_params(
            model_name, win.init_kwargs, win.model_kwargs, 
            logger_func=self.logger.info, 
            preproc_recipe=win.preproc_recipe, 
            apply_post_process=self.applyPostProcessing, 
            standard_postprocess_kwargs=self.standardPostProcessKwargs, 
            custom_postprocess_features=self.customPostProcessFeatures
        )
        
        secondChannelData = None
        if win.secondChannelName is not None:
            secondChannelData = self.getSecondChannelData()

        use_gpu = win.init_kwargs.get('gpu', False)
        proceed = myutils.check_gpu_availible(model_name, use_gpu, qparent=self)
        if not proceed:
            self.logger.info('Segmentation process cancelled.')
            self.titleLabel.setText('Segmentation process cancelled.')
            return

        model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) 
        try:
            model.setupLogger(self.logger)
        except Exception as e:
            pass
        
        self.extendSegmDataIfNeeded(stopFrameNum)
        self.reInitLastSegmFrame(
            from_frame_i=startFrameNum-1, updateImages=False
        )

        self.titleLabel.setText(
            f'{model_name} is thinking... '
            '(check progress in terminal/console)', color=self.titleColor
        )

        self.progressWin = apps.QDialogWorkerProgress(
            title='Segmenting video', parent=self,
            pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum)

        self.thread = QThread()
        self.worker = workers.segmVideoWorker(
            posData, win, model, startFrameNum, stopFrameNum
        )
        self.worker.secondChannelData = secondChannelData
        self.worker.moveToThread(self.thread)
        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)

        # Custom signals
        self.worker.critical.connect(self.workerCritical)
        self.worker.finished.connect(self.segmVideoWorkerFinished)
        self.worker.progressBar.connect(self.workerUpdateProgressbar)
        self.worker.progress.connect(self.workerProgress)

        self.thread.started.connect(self.worker.run)
        self.thread.start()

    def segmVideoWorkerFinished(self, exec_time):
        self.progressWin.workerFinished = True
        self.progressWin.close()
        self.progressWin = None

        self.activateAnnotations()

        self.get_data()
        self.tracking(enforce=True)
        self.updateAllImages()

        txt = f'Done. Segmentation computed in {exec_time:.3f} s'
        self.logger.info('-----------------')
        self.logger.info(txt)
        self.logger.info('=================')
        self.titleLabel.setText(txt, color='g')

    @exception_handler
    def lazyLoaderCritical(self, error):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
            self.lazyLoader.pause()
        raise error
    
    def ccaIntegrityWorkerCritical(self, error):
        try:
            raise error
        except Exception as err:
            self.logger.exception(traceback.format_exc())
        
        href = f'<a href="{issues_url}">GitHub page</a>'
        txt = html_utils.paragraph(f"""
            Unfortunately the experimental feature 
            <code>check cell cycle annotations integrity</code> raised a 
            critical error.<br><br>
            Cell-ACDC will now disable this feature to allow you to keep 
            using the software.<br><br>
            However, <b>we kindly ask you to report the issue</b> on our 
            {href}, thank you very much!<br><br>
            Please, <b>include the log file when reporting the issue</b>.<br><br>
            Log file location:
        """)
        msg = widgets.myMessageBox(wrapText=False)
        msg.warning(
            self, 'Experimental feature error', txt,
            commands=(self.log_path,),
            path_to_browse=self.logs_path
        )
        self.disableCcaIntegrityChecker()
        
    @exception_handler
    def workerCritical(self, out: Tuple[QObject, Exception]):
        worker, error = out
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        self.logger.info(error)
        worker.thread().quit()
        worker.deleteLater()
        worker.thread().deleteLater()
        raise error
    
    def workerLog(self, text):
        self.logger.info(text)
    
    def saveDataWorkerCritical(self, error):
        self.logger.warning(
            'Saving process stopped because of critical error.'
        )
        self.saveWin.aborted = True
        self.worker.finished.emit()
        self.workerCritical(error)
    
    def lazyLoaderWorkerClosed(self):
        if self.lazyLoader.salute:
            self.logger.info('Cell-ACDC GUI closed.')     
            self.sigClosed.emit(self)
        
        self.lazyLoader = None

    def segmWorkerFinished(self, lab, exec_time):
        posData = self.data[self.pos_i]

        if posData.segmInfo_df is not None and posData.SizeZ>1:
            idx = (posData.filename, posData.frame_i)
            posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True

        if lab.ndim == 2 and self.isSegm3D:
            self.set_2Dlab(lab)
        else:
            posData.lab = lab.copy()

        self.activateAnnotations()
        
        self.update_rp(wl_update=False)
        self.tracking(enforce=True)
        
        if self.isSnapshot:
            self.fixCcaDfAfterEdit('Repeat segmentation')
            self.updateAllImages()
        else:
            self.warnEditingWithCca_df('Repeat segmentation')

        txt = f'Done. Segmentation computed in {exec_time:.3f} s'
        self.logger.info('-----------------')
        self.logger.info(txt)
        self.logger.info('=================')
        self.titleLabel.setText(txt, color='g')
        self.checkIfAutoSegm()
        
        QTimer.singleShot(200, self.resizeGui)
    
    def activateAnnotations(self):
        if self.annotContourCheckbox.isChecked():
            return
        if self.annotSegmMasksCheckbox.isChecked():
            return
        
        self.annotSegmMasksCheckbox.setChecked(True)
        self.setDrawAnnotComboboxText()

    # @exec_time
    def getDisplayedImg1(self):
        return self.img1.image
    
    def getDisplayedZstack(self):
        posData = self.data[self.pos_i]
        return posData.img_data[posData.frame_i]

    def autoAssignBud_YeastMate(self):
        if not self.is_win:
            txt = (
                'YeastMate is available only on Windows OS.'
                'We are working on expading support also on macOS and Linux.\n\n'
                'Thank you for your patience!'
            )
            msg = QMessageBox()
            msg.critical(
                self, 'Supported only on Windows', txt, msg.Ok
            )
            return


        model_name = 'YeastMate'
        idx = self.modelNames.index(model_name)

        self.titleLabel.setText(
            f'{model_name} is thinking... '
            '(check progress in terminal/console)', color=self.titleColor
        )

        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False)

        posData = self.data[self.pos_i]
        # Check if model needs to be imported
        acdcSegment = self.acdcSegment_li[idx]
        if acdcSegment is None:
            acdcSegment = myutils.import_segment_module(model_name)
            self.acdcSegment_li[idx] = acdcSegment

        # Read all models parameters
        init_params, segment_params = myutils.getModelArgSpec(acdcSegment)
        # Prompt user to enter the model parameters
        try:
            url = acdcSegment.url_help()
        except AttributeError:
            url = None

        _SizeZ = None
        if self.isSegm3D:
            _SizeZ = posData.SizeZ 
        win = apps.QDialogModelParams(
            init_params,
            segment_params,
            model_name, 
            url=url, 
            posData=posData,
            df_metadata=posData.metadata_df
        )
        win.exec_()
        if win.cancel:
            self.titleLabel.setText('Segmentation aborted.')
            return

        use_gpu = win.init_kwargs.get('gpu', False)
        proceed = myutils.check_gpu_availible(model_name, use_gpu, qparent=self)
        if not proceed:
            self.logger.info('Segmentation process cancelled.')
            self.titleLabel.setText('Segmentation process cancelled.')
            return
            
        self.model_kwargs = win.model_kwargs
        model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) 
        try:
            model.setupLogger(self.logger)
        except Exception as e:
            pass

        self.models[idx] = model

        img = self.getDisplayedImg1()

        posData.cca_df = model.predictCcaState(img, posData.lab)
        self.store_data()
        self.updateAllImages()

        self.titleLabel.setText('Budding event prediction done.', color='g')
    
    def isNavigateActionOnNextFrame(self):
        posData = self.data[self.pos_i]
        if posData.SizeT == 1:
            return False
        
        ax1_coords = self.getMouseDataCoordsRightImage()
        if ax1_coords is None:
            return False
        
        if not self.labelsGrad.showNextFrameAction.isEnabled():
            return False
        
        if not self.labelsGrad.showNextFrameAction.isChecked():
            return
        
        # Mouse is on right image and next frame action is checked
        return True 
    
    def rightImageFramesScrollbarValueChanged(self, value):
        img = self.nextFrameImage(current_frame_i=value-2)
        self.img1.linkedImageItem.frame_i = value
        self.img1.linkedImageItem.setImage(img)
    
    def nextActionTriggered(self):
        if self.isNavigateActionOnNextFrame():
            self.rightImageFramesScrollbar.setValue(
                self.rightImageFramesScrollbar.value()+1 
            )
            return
        
        stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd
        if self.zKeptDown or self.zSliceCheckbox.isChecked():
            self.zSliceScrollBar.triggerAction(stepAddAction)
        else:
            self.navigateScrollBar.triggerAction(stepAddAction)
    
    def prevActionTriggered(self):
        if self.isNavigateActionOnNextFrame():
            self.rightImageFramesScrollbar.setValue(
                self.rightImageFramesScrollbar.value()-1 
            )
            return
        
        stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub
        if self.zKeptDown or self.zSliceCheckbox.isChecked():
            self.zSliceScrollBar.triggerAction(stepSubAction)
        else:
            self.navigateScrollBar.triggerAction(stepSubAction)

    def resetNavigateScrollbar(self):
        try:
            self.navigateScrollBar.blockSignals(True)
            self.navigateScrollBar.actionTriggered.disconnect()
            self.navigateScrollBar.sliderReleased.disconnect()
            self.navigateScrollBar.sliderMoved.disconnect()
            # self.navigateScrollBar.valueChanged.disconnect()
            self.navigateScrollBar.setSliderPosition(self.navSpinBox.value())
        except Exception as e:
            if "disconnect()" not in str(e):
                printl(e)
            pass

        self.navigateScrollBar.blockSignals(False)
        self.navigateScrollBar.actionTriggered.connect(self.framesScrollBarActionTriggered)
        self.navigateScrollBar.sliderReleased.connect(self.framesScrollBarReleased)
        self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved)

    @exception_handler
    def next_cb(self):
        if self.isSnapshot:
            self.next_pos()
        else:
            self.next_frame()
        if self.curvToolButton.isChecked():
            self.curvTool_cb(True)
        
        self.updatePropsWidget('')

    @exception_handler
    def prev_cb(self):
        if self.isSnapshot:
            self.prev_pos()
        else:
            self.prev_frame()
        if self.curvToolButton.isChecked():
            self.curvTool_cb(True)
        
        self.updatePropsWidget('')

    def zoomOut(self):
        self.ax1.autoRange()

    def preprocessActionTriggered(self):
        self.preprocessDialog.show()
        self.preprocessDialog.raise_()
        self.preprocessDialog.activateWindow()
        self.preprocessDialog.emitSigPreviewToggled()

    
    def combineChannelsActionTriggered(self):
        self.combineDialog.show()
        self.combineDialog.raise_()
        self.combineDialog.activateWindow()
        self.combineDialog.emitSigPreviewToggled()
    
    def zoomToObjsActionCallback(self):
        self.zoomToCells(enforce=True)

    def zoomToCells(self, enforce=False):
        if not self.enableAutoZoomToCellsAction.isChecked() and not enforce:
            return

        posData = self.data[self.pos_i]
        lab_mask = (self.currentLab2D>0).astype(np.uint8)
        rp = skimage.measure.regionprops(lab_mask)
        if not rp:
            Y, X = lab_mask.shape
            xRange = -0.5, X+0.5
            yRange = -0.5, Y+0.5
        else:
            obj = rp[0]
            min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox)
            xRange = min_col-10, max_col+10
            yRange = max_row+10, min_row-10

        self.ax1.setRange(xRange=xRange, yRange=yRange)

    def viewCcaTable(self):
        posData = self.data[self.pos_i]
        zoomIDs = self.getZoomIDs()
        
        df = posData.allData_li[posData.frame_i]['acdc_df']        
        current_cca_df = posData.cca_df
        if zoomIDs is not None:
            df = df.loc[zoomIDs]
            current_cca_df = current_cca_df.loc[zoomIDs]
            
        for column in current_cca_df.columns:
            header = (
                '================================================\n'
                f'CURRENT vs STORED `{column}` column'
                f'for frame number {posData.frame_i+1}:\n'
            )
            df_compare = current_cca_df[[column]].copy()
            df_compare[f'STORED_{column}'] = df[column]
            text = f'{header}{df_compare}'
            self.logger.info(text)
        
        if 'cell_cycle_stage' in df.columns:
            cca_df = df[self.cca_df_colnames]
            cca_df = cca_df.merge(
                current_cca_df, how='outer', left_index=True, right_index=True,
                suffixes=('_STORED', '_CURRENT')
            )
            cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1)
            num_cols = len(cca_df.columns)
            for j in range(0,num_cols,2):
                df_j_x = cca_df.iloc[:,j]
                df_j_y = cca_df.iloc[:,j+1]
                if any(df_j_x!=df_j_y):
                    self.logger.info('------------------------')
                    self.logger.info('DIFFERENCES:')
                    diff_df = cca_df.iloc[:,j:j+2]
                    diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1]
                    self.logger.info(diff_df[diff_mask])
        else:
            cca_df = None
            self.logger.info(cca_df)
        self.logger.info('========================')
        if current_cca_df is None:
            return
        if current_cca_df.empty:
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(
                'Cell cycle annotations\' table is <b>empty</b>.<br>'
            )
            msg.warning(self, 'Table empty', txt)
            return
        
        df = posData.add_tree_cols_to_cca_df(
            current_cca_df, frame_i=posData.frame_i
        )
        if self.ccaTableWin is None:
            self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self)
            self.ccaTableWin.show()
            self.ccaTableWin.setGeometryWindow()
            self.ccaTableWin.sigUpdateCcaTable.connect(
                self.onSigUpdateCcaTableWindow
            )
        else:
            self.ccaTableWin.setFocus()
            self.ccaTableWin.activateWindow()
            self.ccaTableWin.updateTable(current_cca_df)

    def updateScrollbars(self):
        self.updateItemsMousePos()
        self.updateFramePosLabel()
        posData = self.data[self.pos_i]
        navPos = self.pos_i+1 if self.isSnapshot else posData.frame_i+1
        self.navigateScrollBar.setSliderPosition(navPos)
        if posData.SizeZ > 1:
            self.updateZsliceScrollbar(posData.frame_i)
            idx = (posData.filename, posData.frame_i)
            self.zSliceScrollBar.setMaximum(posData.SizeZ-1)
            self.zSliceSpinbox.setMaximum(posData.SizeZ)
            self.SizeZlabel.setText(f'/{posData.SizeZ}')

    def updateItemsMousePos(self):
        if self.brushButton.isChecked():
            self.updateBrushCursor(self.xHoverImg, self.yHoverImg)

        if self.eraserButton.isChecked():
            self.updateEraserCursor(self.xHoverImg, self.yHoverImg)

    @exception_handler
    def postProcessing(self):
        if self.postProcessSegmWin is None:
            return
        
        self.postProcessSegmWin.setPosData()
        posData = self.data[self.pos_i]
        lab, delIDs = self.postProcessSegmWin.apply()
        if posData.allData_li[posData.frame_i]['labels'] is None:
            posData.lab = lab.copy()
            self.update_rp()
        else:
            posData.allData_li[posData.frame_i]['labels'] = lab
            self.get_data()

    def preprocessDialogRecipeChanged(self, recipe):# why does this need the recepie as an arg
        recipe = self.preprocessDialog.recipe()
        if recipe is None:
            self.logger.warning('Pre-processing recipe not initialized yet.')
            return
        
        self.updatePreprocessPreview(recipe=recipe)
        
    def combineDialogStepsChanged(self):
        steps, keep_input_type = self.combineDialog.steps(return_keepInputDataType=True)
        if steps is None:
            self.logger.warning('Combine channels steps not initialized yet.')
            return
        
        self.updateCombineChannelsPreview(steps=steps, keep_input_type=keep_input_type)
    
    def combineDialogSaveCombinedData(self, dialog):
        # here check if all data has been processed?
        posData = self.data[self.pos_i]
        
        try:
            posData.combinedChannelsDataArray()
        except TypeError as e:
            if 'Not all frames have been processed.' in str(e):
                msg = widgets.myMessageBox()
                txt = html_utils.paragraph(
                    'Not all frames have been processed.<br>'
                    'Please process all frames before saving.'
                )
                msg.warning(self, 'Process all data before saving', txt)
                return

        helpText = (
            """
            The combined channels file will be saved with a different 
            file name.<br><br>
            Insert a name to append to the end of the new file name. The rest of 
            the name will be the same as the original file base.
            """
        )
        win = apps.filenameDialog(
            basename=f'{posData.basename}',
            ext='.tif',
            hintText='Insert a name for the <b>combined channels</b> file:',
            defaultEntry='combined',
            helpText=helpText, 
            allowEmpty=False,
            parent=dialog
        )
        win.exec_()
        if win.cancel:
            return

        appendedText = win.entryText

        self.progressWin = apps.QDialogWorkerProgress(
            title='Saving combined channels(s)', 
            parent=self,
            pbarDesc='Saving combined channels(s)'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)
        
        self.statusBarLabel.setText('Saving combined channels...')
        
        self.saveCombinedChannelsWorker = workers.SaveCombinedChannelsWorker(
            self.data, appendedText, 
        )
        
        self.saveCombinedChannelsThread = QThread()
        self.saveCombinedChannelsWorker.moveToThread(self.saveCombinedChannelsThread)
        self.saveCombinedChannelsWorker.signals.finished.connect(
            self.saveCombinedChannelsThread.quit
        )
        self.saveCombinedChannelsWorker.signals.finished.connect(
            self.saveCombinedChannelsWorker.deleteLater
        )
        self.saveCombinedChannelsThread.finished.connect(
            self.saveCombinedChannelsThread.deleteLater
        )
        
        self.saveCombinedChannelsWorker.signals.critical.connect(
            self.workerCritical
        )
        self.saveCombinedChannelsWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.saveCombinedChannelsWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.saveCombinedChannelsWorker.signals.progress.connect(
            self.workerProgress
        )
        self.saveCombinedChannelsWorker.signals.finished.connect(
            self.saveCombinedChannelsWorkerFinished
        )
        
        self.saveCombinedChannelsThread.started.connect(
            self.saveCombinedChannelsWorker.run
        )

        self.saveCombinedChannelsWorker.sigDebugShowImg.connect(self.debugShowImg)

        self.saveCombinedChannelsThread.start()

    def debugShowImg(self, img):
        imshow(img)

    def preprocessDialogSavePreprocessedData(self, dialog):
        posData = self.data[self.pos_i]
        
        try:
            posData.preprocessedDataArray()
        except TypeError as e:
            if 'Not all frames have been processed.' in str(e):
                msg = widgets.myMessageBox()
                txt = html_utils.paragraph(
                    'Not all frames have been processed.<br>'
                    'Please process all frames before saving.'
                )
                msg.warning(self, 'Process all data before saving', txt)
                return


        helpText = (
            """
            The preprocessed image file will be saved with a different 
            file name.<br><br>
            Insert a name to append to the end of the new file name. The rest of 
            the name will be the same as the original file.
            """
        )
        
        
        win = apps.filenameDialog(
            basename=f'{posData.basename}{self.user_ch_name}',
            ext=posData.ext,
            hintText='Insert a name for the <b>preprocessed image</b> file:',
            defaultEntry='preprocessed',
            helpText=helpText, 
            allowEmpty=False,
            parent=dialog
        )
        win.exec_()
        if win.cancel:
            return

        appendedText = win.entryText
        
        self.progressWin = apps.QDialogWorkerProgress(
            title='Saving pre-processed image(s)', 
            parent=self,
            pbarDesc='Saving pre-processed image(s)'
        )
        self.progressWin.show(self.app)
        self.progressWin.mainPbar.setMaximum(0)
        
        self.statusBarLabel.setText('Saving pre-processed data...')
        
        self.savePreprocWorker = workers.SaveProcessedDataWorker(
            self.data, appendedText
        )
        
        self.savePreprocThread = QThread()
        self.savePreprocWorker.moveToThread(self.savePreprocThread)
        self.savePreprocWorker.signals.finished.connect(
            self.savePreprocThread.quit
        )
        self.savePreprocWorker.signals.finished.connect(
            self.savePreprocWorker.deleteLater
        )
        self.savePreprocThread.finished.connect(
            self.savePreprocThread.deleteLater
        )
        
        self.savePreprocWorker.signals.critical.connect(
            self.workerCritical
        )
        self.savePreprocWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.savePreprocWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.savePreprocWorker.signals.progress.connect(
            self.workerProgress
        )
        self.savePreprocWorker.signals.finished.connect(
            self.savePreprocWorkerFinished
        )
        
        self.savePreprocThread.started.connect(
            self.savePreprocWorker.run
        )
        self.savePreprocThread.start()
        
    
    def preprocessEnqueueCurrentImage(self, recipe):
        posData = self.data[self.pos_i]
        func = core.preprocess_image_from_recipe
        image_data = self.getImage(raw=True)
        if posData.SizeZ > 1:
            z_slice = self.z_slice_index()
        else:
            z_slice = 0
        
        recipe = core.validate_multidimensional_recipe(recipe)
        
        key = (self.pos_i, posData.frame_i, z_slice)
        self.preprocWorker.enqueue(
            func, 
            image_data, 
            recipe,
            key
        )

    def combineEnqueueCurrentImage(self, steps, keep_input_type):
        posData = self.data[self.pos_i]

        selected_channel = core.get_selected_channels(steps)
        self.getChData(requ_ch=selected_channel)

        if posData.SizeZ > 1:
            z_slice = self.z_slice_index()
        else:
            z_slice = 0
        
        key = (self.pos_i, posData.frame_i, z_slice)
        self.combineWorker.enqueue(
            self.data,
            steps, 
            key,
            keep_input_type
        )
    
    def getChData(self, requ_ch=None, pos_i=None):
        if not pos_i:
            pos_i = self.pos_i

        posData = self.data[pos_i]

        if not requ_ch:
            requ_ch = set(self.ch_names)
        else:
            requ_ch = set(requ_ch)

        posData.setLoadedChannelNames()

        loaded_channels = set(posData.loadedChNames)
        missing_channels = requ_ch - loaded_channels

        self.loadFluo_cb(fluo_channels=missing_channels)

    def updatePreprocessPreview(self, *args, **kwargs):
        force = kwargs.get('force', False)
        
        if not self.preprocessDialog.isVisible() and not force:
            return
        
        if not self.preprocessDialog.previewCheckbox.isChecked() and not force:
            return
        
        if kwargs.get('recipe') is None:
            recipe = self.preprocessDialog.recipe()
        else:
            recipe = kwargs.get('recipe')

        if recipe is None:
            self.logger.warning('Pre-processing recipe not initialized yet.')
            return
        
        txt = 'Pre-processing current image...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        self.preprocessEnqueueCurrentImage(recipe)

    def updateCombineChannelsPreview(self, *args, **kwargs):
        force = kwargs.get('force', False)
        
        if not self.combineDialog.isVisible() and not force:
            return
        
        if not self.combineDialog.previewCheckbox.isChecked() and not force:
            return
        
        if kwargs.get('steps') is None:
            steps, keep_input_type = self.combineDialog.steps(return_keepInputDataType=True)
        else:
            steps = kwargs.get('steps')
            keep_input_type = kwargs.get('keep_input_type')

        if steps is None:
            self.logger.warning('Combine channels steps not initialized yet.')
            return
        
        txt = 'Combining...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        self.combineEnqueueCurrentImage(steps, keep_input_type)
    
    def next_pos(self):
        self.store_data(debug=True, autosave=False)
        prev_pos_i = self.pos_i
        if self.pos_i < self.num_pos-1:
            self.pos_i += 1
            self.updateSegmDataAutoSaveWorker()
        else:
            self.logger.info('You reached last position.')
            self.pos_i = 0
        self.updatePos()
    
    def resetManualBackgroundItems(self):
        self.initManualBackgroundImage()
        self.resetManualBackgroundSpinboxID()
        self.drawManualTrackingGhost(self.xHoverImg, self.yHoverImg)
        self.drawManualBackgroundObj(self.xHoverImg, self.yHoverImg)
    
    def updatePos(self):
        self.setStatusBarLabel()
        self.checkManageVersions()
        self.removeAlldelROIsCurrentFrame()
        self.resetManualBackgroundItems()
        proceed_cca, never_visited = self.get_data(debug=True)
        self.pointsLayerLoadedDfsToData()
        self.initContoursImage()
        self.initDelRoiLab()
        self.initTextAnnot()
        self.postProcessing()
        self.updateScrollbars()
        self.updatePreprocessPreview()
        self.updateCombineChannelsPreview()
        self.updateAllImages()
        self.computeSegm()
        self.zoomOut()
        self.restartZoomAutoPilot()
        self.initManualBackgroundObject()
        self.updateObjectCounts()

    def prev_pos(self):
        self.store_data(debug=False, autosave=False)
        prev_pos_i = self.pos_i
        if self.pos_i > 0:
            self.pos_i -= 1
            self.updateSegmDataAutoSaveWorker()
        else:
            self.logger.info('You reached first position.')
            self.pos_i = self.num_pos-1
        self.updatePos()

    def updateViewerWindow(self):
        if self.slideshowWin is None:
            return

        if self.slideshowWin.linkWindow is None:
            return

        if not self.slideshowWin.linkWindowCheckbox.isChecked():
            return

        posData = self.data[self.pos_i]
        self.slideshowWin.frame_i = posData.frame_i
        self.slideshowWin.update_img()
    
    def warnLostObjects(self, do_warn=True):
        if not do_warn:
            return True
        
        if not self.warnLostCellsAction.isChecked():
            return True
        
        mode = str(self.modeComboBox.currentText())
        if not mode == 'Segmentation and Tracking':
            return True
        
        posData = self.data[self.pos_i]
        if not posData.lost_IDs:
            return True
        
        frame_i = posData.frame_i
        try:
            accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, [])
            already_accepted_lost = (
                Counter(accepted_lost_IDs) == Counter(posData.lost_IDs)
            )
        except AttributeError as err:
            already_accepted_lost = False
        
        if already_accepted_lost:
            return True

        self.nextAction.setDisabled(True)
        self.prevAction.setDisabled(True)
        self.navigateScrollBar.setDisabled(True)
        
        msg = widgets.myMessageBox()
        warn_msg = html_utils.paragraph(
            'Current frame (compared to previous frame) '
            'has <b>lost the following cells</b>:<br><br>'
            f'{posData.lost_IDs}<br><br>'
            'Are you <b>sure</b> you want to continue?<br>'
        )
        checkBox = QCheckBox('Do not show again')
        noButton, yesButton = msg.warning(
            self, 'Lost cells!', warn_msg,
            buttonsTexts=('No', 'Yes'),
            widgets=checkBox
        )
        doNotWarnLostCells = not checkBox.isChecked()
        self.warnLostCellsAction.setChecked(doNotWarnLostCells)
        if msg.clickedButton == noButton:
            self.nextAction.setDisabled(False)
            self.prevAction.setDisabled(False)
            self.navigateScrollBar.setDisabled(False)
            return False
        
        self.nextAction.setDisabled(False)
        self.prevAction.setDisabled(False)
        self.navigateScrollBar.setDisabled(False)
        if not hasattr(posData, 'accepted_lost_IDs'):
            posData.accepted_lost_IDs = {}
        if frame_i not in posData.accepted_lost_IDs:
            posData.accepted_lost_IDs[frame_i] = []
        
        posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs)
        # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place
        prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
        prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs']
        accepted_lost_centroids = {
            tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) 
            for ID in posData.lost_IDs
        }
        try:
            posData.tracked_lost_centroids[frame_i] = (
                posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids)
            )
        except KeyError:
            posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids
            printl('KeyError, need to initialize posData.tracked_lost_centroids[frame_i] properly')
        return True
    
    def next_frame(self, warn=True):
        # ok = self.warnOGIDs()
        # if not ok:
        #     self.resetNavigateScrollbar()
        #     return
        benchmark=False
        if benchmark:
            ts = [time.perf_counter()]
            titles = ['']
        mode = str(self.modeComboBox.currentText())
        posData = self.data[self.pos_i]
        if posData.frame_i < posData.SizeT-1:
            proceed = self.warnLostObjects()
            if not proceed:
                self.resetNavigateScrollbar()
                return

            if posData.frame_i <= 0:
                if mode == 'Cell cycle analysis':
                    # posData.IDs = [obj.label for obj in posData.rp]
                    editCcaWidget = apps.editCcaTableWidget(
                        posData.cca_df, posData.SizeT, parent=self,
                        title='Initialize cell cycle annotations'
                    )
                    editCcaWidget.sigApplyChangesFutureFrames.connect(
                        self.applyManualCcaChangesFutureFrames
                    )
                    editCcaWidget.exec_()
                    if editCcaWidget.cancel:
                        self.resetNavigateFramesScrollbar()
                        return
                    if posData.cca_df is not None:
                        is_cca_same_as_stored = (
                            (posData.cca_df == editCcaWidget.cca_df).all(axis=None)
                        )
                        if not is_cca_same_as_stored:
                            reinit_cca = self.warnEditingWithCca_df(
                                'Re-initialize cell cyle annotations first frame',
                                return_answer=True
                            )
                            if reinit_cca:
                                self.resetCcaFuture(0)
                    posData.cca_df = editCcaWidget.cca_df
                    self.store_cca_df()
                elif mode == 'Normal division: Lineage tree':
                    if self.lineage_tree is None:
                        self.initLinTree()         

            # Store data for current frame
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('Innit stuff')
            if mode != 'Viewer':
                self.store_data(debug=False)
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('store_data')
            # Go to next frame

            self.lin_tree_ask_changes()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('lin_tree_ask_changes')
            posData.frame_i += 1
            self.removeAlldelROIsCurrentFrame()
            proceed_cca, never_visited = self.get_data()
            if not proceed_cca:
                posData.frame_i -= 1
                self.get_data()
                self.logger.info(
                    'No data for current frame. '
                )
                return
            
            if mode == 'Segmentation and Tracking' or self.isSnapshot:
                self.addExistingDelROIs()
            
            # printl('here')
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('get_data')
            self.whitelistPropagateIDs(update_lab=True)
            self.whitelistAddNewIDs()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('whitelist stuff')
            self.updatePreprocessPreview()
            self.updateCombineChannelsPreview()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('update preview')

            self.postProcessing()
            self.tracking(storeUndo=True, wl_update=False)

            if benchmark:
                ts.append(time.perf_counter())
                titles.append('postprocessing tracking')

            notEnoughG1Cells, proceed = self.attempt_auto_cca()
            if notEnoughG1Cells or not proceed:
                posData.frame_i -= 1
                self.get_data()
                self.setAllTextAnnotations()
                self.logger.info(
                    'Not enough G1 cells to compute cell cycle annotations.'
                )
                return

            if benchmark:
                ts.append(time.perf_counter())
                titles.append('cca stuff')
            
            self.store_zslices_rp()

            if benchmark:
                ts.append(time.perf_counter())
                titles.append('store_zslices_rp')
            self.resetExpandLabel()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('resetExpandLabel')
            self.updateAllImages()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('updateAllImages')
            self.updateViewerWindow()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('updateViewerWindow')
            self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1)
            self.setNavigateScrollBarMaximum()
            self.updateScrollbars()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('scrollbar and last visited frame update')
            self.computeSegm()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('computeSegm')
            self.initGhostObject()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('initGhostObject')
            self.zoomToCells()
            self.updateObjectCounts()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('zoomToCells')
        else:
            # Store data for current frame
            if mode != 'Viewer':
                self.store_data(debug=False)
            msg = 'You reached the last segmented frame!'
            self.logger.info(msg)
            self.titleLabel.setText(msg, color=self.titleColor)
    
        if benchmark:
            time_taken = time.perf_counter() - ts[0]
            print(f'\nTotal time for next_frame: {time_taken:.2f}s')
            for i in range(1, len(ts)):
                time_taken = ts[i] - ts[i-1]
                print(f'Time taken for {titles[i]}: {time_taken:.2f}s')
            print('')

    @disableWindow
    def get_difference_table(self, return_css_separated=False, return_differece=False):

        if self.original_df_lin_tree is None:
            return

        posData = self.data[self.pos_i]
        
        new_df = self.lineage_tree.lineage_list[posData.frame_i].copy()
        original_df = self.original_df_lin_tree.copy()

        if original_df.equals(new_df):
            return
        
        compare_columns = ['parent_ID_tree']

        new_df = new_df[original_df.columns]
        new_df = myutils.checked_reset_index_Cell_ID(new_df)
        new_df = new_df[compare_columns]
        new_df = new_df.sort_index()
        original_df = myutils.checked_reset_index_Cell_ID(original_df)
        original_df = original_df[compare_columns]
        original_df = original_df.sort_index()

        differences = original_df.compare(new_df)
        if differences.empty:
            return
        
        differences = myutils.checked_reset_index_Cell_ID(differences)
 
        differences = differences['parent_ID_tree']
        differences = differences.reset_index()

        txt = """<table>
                    <tr>
                        <th>ID</th>
                        <th>old parent --></th>
                        <th>new parent</th>
                    </tr>"""

        for diff in differences.itertuples():
            ID = str(int(diff.Cell_ID))
            old_parent = str(int(diff.self))
            new_parent = str(int(diff.other))
            
            txt += f'''<tr>
                            <td>{ID}</td>
                            <td>{old_parent}</td>
                            <td>{new_parent}</td>
                        </tr>'''
        txt += '</table>'

        css = r'''
            <style>
                table, th, td {
                    border: 1px solid grey;
                    border-collapse: collapse;
                }
                th, td {
                    padding: 5px;
                }
            </style>
        '''
        if return_css_separated and not return_differece:
            return css, txt
        elif return_css_separated and return_differece:
            return css, txt, differences
        elif not return_css_separated and return_differece:
            return txt, differences
        else:
            txt = css + html_utils.paragraph(txt)
            return txt

    def viewLinTreeInfoAction(self):
        mode = str(self.modeComboBox.currentText())
        if mode != 'Normal division: Lineage tree':
            self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.')
            return
        
        if not self.lineage_tree:
            self.logger.info('No lineage tree found.')
            return
        
        posData = self.data[self.pos_i]

        if self.original_df_lin_tree_i != posData.frame_i:
            # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though!
            txt_changes = '<br>No changes were made in this frame.<br><br>'
        
        else:
            result = self.get_difference_table(return_css_separated=True)

            if result is None:
                txt_changes = 'No changes were made in this frame.'
            else:
                css, txt_changes = result

        txt_changes = '<b>Changes made in this frame</b>:' + txt_changes + '<br><br>'
        
        cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i)

        if orphan_cells == []:
            txt_orphan_cells = 'No orphan Cells!'
        else:
            txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells])
        txt_orphan = f'<b>Orphan cells</b>:<br>{txt_orphan_cells}<br><br>'

        lost_cells = list(lost_cells)
        if lost_cells == []:
            txt_lost_cells = 'No lost Cells!'
        else:
            txt_lost_cells = ', '.join([str(cell) for cell in lost_cells])
        txt_lost = f'<b>Lost cells</b>:<br>{txt_lost_cells}<br><br>'

        if cells_with_parent == []:
            table_cells_with_parent = '<br>No cells with parents!'
        else:
            table_cells_with_parent = """<table>
                        <tr>
                            <th>Parent ID</th>
                            <th>ID</th>
                        </tr>"""

            for cell, parent in cells_with_parent:
                table_cells_with_parent += f'''<tr>
                                <td>{parent}</td>
                                <td>{cell}</td>
                            </tr>'''
            table_cells_with_parent += '</table>'

        txt_cells_with_parents = f'<b>Cells with parents</b>:{table_cells_with_parent} <br><br>'

        css = r'''
                <style>
                    table, th, td {
                        border: 1px solid grey;
                        border-collapse: collapse;
                    }
                    th, td {
                        padding: 5px;
                    }
                </style>
            '''

        txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents)

        msg = widgets.myMessageBox()
        msg.information(self,
                'lineage tree information', 
                txt
                )

    @disableWindow
    def lin_tree_ask_changes(self):
        """
        Asks the user for changes in the lineage tree.

        This method is called when the user selects the 'Normal division: Lineage tree' mode.
        It compared the backed up df (self.original_df from repeat_click_and_backup) with the current df (self.lineage_tree.export_df(posData.frame_i)) and propts the user to keep, propagate or discard the changes.

        """
        mode = str(self.modeComboBox.currentText())
        if mode != 'Normal division: Lineage tree':
            return
        
        if not self.lineage_tree:
            return
        
        posData = self.data[self.pos_i]
        
        if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i:
            printl("!This should not happen!")
            self.store_data(autosave=False)
            og_frame = posData.frame_i
            posData.frame_i = self.original_df_lin_tree_i
            self.get_data()
            self.logger.info('Lineage tree changes were not propagated, going back to original frame.')
            self.lin_tree_ask_changes()
            self.store_data(autosave=False)
            posData.frame_i = og_frame
            self.get_data()
            return

        result = self.get_difference_table(return_css_separated=True, return_differece=True)
        if result is None:
            self.original_df_lin_tree = None
            self.original_df_lin_tree_i = None
            return

        css, txt, differences = result
        changed_IDs = differences['Cell_ID'].unique()

        if posData.frame_i == max(self.lineage_tree.frames_for_dfs):
            # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents
            self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs)
            self.original_df_lin_tree = None
            self.original_df_lin_tree_i = None
            return

        txt = txt + 'Do you want to keep, propgagte or discard the changes?'
        txt = css + html_utils.paragraph('<b>Changes made in this frame</b><br>' + txt)

        msg = widgets.myMessageBox()

        propagate_btn, discard_btn, _ = msg.question(self,
                      'Changes in lineage tree', 
                      txt,
                      buttonsTexts=('Propagate', 'Discard', 'Cancel'),)

        if msg.clickedButton == propagate_btn:
            self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs)
            self.original_df_lin_tree = None
            self.original_df_lin_tree_i = None
            self.lin_tree_to_acdc_df(force_all=True)
            self.logger.info('Lineage tree propagated.')

        elif msg.clickedButton == discard_btn:
            self.lineage_tree.lineage_list[self.original_df_lin_tree_i] = self.original_df_lin_tree.copy()
            self.lin_tree_to_acdc_df(specific={posData.frame_i}) # probably not necessary but just in case
            self.original_df_lin_tree = None
            self.original_df_lin_tree_i = None
            self.logger.info('Lineage tree changes discarded.')
            

        elif msg.cancel:
            # Go back to current frame
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph('''
            Changes were kept but not propagated!
            Please make sure to come back and propagate them,
            otherwise your table might be inconsistent!
            There is a button for this next to the edit buttons.
            <b>Please also do not visit new frames!</b>
            
            ''')
            msg.warning(self, 'Changes kept but not propagated!', txt)
            self.lin_tree_to_acdc_df(specific={posData.frame_i})
            self.original_df_lin_tree = None
            self.original_df_lin_tree_i = None
            self.logger.info('Lineage tree changes discarded.')

    def setNavigateScrollBarMaximum(self):
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        if mode == 'Segmentation and Tracking':
            if posData.last_tracked_i is not None:
                if posData.frame_i > posData.last_tracked_i:
                    self.navigateScrollBar.setMaximum(posData.frame_i+1)
                    self.navSpinBox.setMaximum(posData.frame_i+1)
                else:
                    self.navigateScrollBar.setMaximum(posData.last_tracked_i+1)
                    self.navSpinBox.setMaximum(posData.last_tracked_i+1)
            else:
                self.navigateScrollBar.setMaximum(posData.frame_i+1)
                self.navSpinBox.setMaximum(posData.frame_i+1)
            
            self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum()-1)
        elif mode == 'Cell cycle analysis':
            if posData.frame_i > self.last_cca_frame_i:
                self.navigateScrollBar.setMaximum(posData.frame_i+1)
                self.navSpinBox.setMaximum(posData.frame_i+1)
            else:
                self.navigateScrollBar.setMaximum(self.last_cca_frame_i+1)
                self.navSpinBox.setMaximum(self.last_cca_frame_i+1)
            self.lastTrackedFrameLabel.setText(
                f'Last cc annot. frame n. = {self.last_cca_frame_i+1}'
            )
        elif mode == 'Normal division: Lineage tree':
            if self.lineage_tree is None:
                self.navigateScrollBar.setMaximum(posData.frame_i+1)
                self.navSpinBox.setMaximum(posData.frame_i+1)
            else:
                if self.lineage_tree.frames_for_dfs:
                    i = max(self.lineage_tree.frames_for_dfs)
                else:
                    i = 0
                self.navigateScrollBar.setMaximum(i+1)
                self.navSpinBox.setMaximum(i+1)

    def prev_frame(self,):
        benchmark = False
        if benchmark:
            ts = [time.perf_counter()]
            titles = ['']
        posData = self.data[self.pos_i]    
        if posData.frame_i > 0:
            # Store data for current frame
            mode = str(self.modeComboBox.currentText())
            if mode != 'Viewer':
                self.store_data(debug=False)
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('store_data')
            self.removeAlldelROIsCurrentFrame()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('removeAlldelROIsCurrentFrame')
            

            self.lin_tree_ask_changes()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('lin_tree_ask_changes')
            posData.frame_i -= 1
            _, never_visited = self.get_data()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('get_data')
            
            if mode == 'Segmentation and Tracking' or self.isSnapshot:
                self.addExistingDelROIs()
            
            self.resetExpandLabel()
            self.updatePreprocessPreview()
            self.updateCombineChannelsPreview()
            self.postProcessing()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('preview updated, reset expand labels, prost processing')
            self.tracking()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('tracking')
            self.whitelistPropagateIDs(update_lab=True)
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('whitelist stuff')
            self.updateAllImages()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('updateAllImages')
            self.updateScrollbars()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('updateScrollbars')
            self.zoomToCells()
            self.initGhostObject()
            self.updateViewerWindow()
            self.updateObjectCounts()
            if benchmark:
                ts.append(time.perf_counter())
                titles.append('zoomToCells, initGhostObject, updateViewerWindow')
        else:
            msg = 'You reached the first frame!'
            self.logger.info(msg)
            self.titleLabel.setText(msg, color=self.titleColor)
        
        if benchmark:
            time_taken = time.perf_counter() - ts[0]
            print(f'\nTotal time for prev_frame: {time_taken:.2f}s')
            for i in range(1, len(ts)):
                time_taken = ts[i] - ts[i-1]
                print(f'Time taken for {titles[i]}: {time_taken:.2f}s')
            print('')

    def loadSelectedData(self, user_ch_file_paths, user_ch_name):
        data = []
        numPos = len(user_ch_file_paths)
        self.user_ch_file_paths = user_ch_file_paths
        
        self.logger.info(f'Reading {user_ch_name} channel metadata...')
        # Get information from first loaded position
        posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info)
        posData.getBasenameAndChNames()
        posData.buildPaths()

        if posData.ext != '.h5':
            self.lazyLoader.salute = False
            self.lazyLoader.exit = True
            self.lazyLoaderWaitCond.wakeAll()
            self.waitReadH5cond.wakeAll()

        # Get end name of every existing segmentation file
        existingSegmEndNames = set()
        for filePath in user_ch_file_paths:
            _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info)
            _posData.getBasenameAndChNames()
            segm_files = load.get_segm_files(_posData.images_path)
            _existingEndnames = load.get_endnames(
                _posData.basename, segm_files
            )
            existingSegmEndNames.update(_existingEndnames)

        selectedSegmEndName = ''
        self.newSegmEndName = ''
        if self.isNewFile or not existingSegmEndNames:
            self.isNewFile = True
            # Remove the 'segm_' part to allow filenameDialog to check if
            # a new file is existing (since we only ask for the part after
            # 'segm_')
            existingEndNames = [
                n.replace('segm', '', 1).replace('_', '', 1)
                for n in existingSegmEndNames
            ]
            if posData.basename.endswith('_'):
                basename = f'{posData.basename}segm'
            else:
                basename = f'{posData.basename}_segm'
            win = apps.filenameDialog(
                basename=basename,
                hintText='Insert a <b>filename</b> for the segmentation file:',
                existingNames=existingEndNames
            )
            win.exec_()
            if win.cancel:
                self.loadingDataAborted()
                return
            self.newSegmEndName = win.entryText
        else:
            if len(existingSegmEndNames) > 0:
                win = apps.SelectSegmFileDialog(
                    existingSegmEndNames, self.exp_path, parent=self,
                    addNewFileButton=True, basename=posData.basename
                )
                win.exec_()
                if win.cancel:
                    self.loadingDataAborted()
                    return
                if win.newSegmEndName is None:
                    selectedSegmEndName = win.selectedItemText
                    self.AutoPilotProfile.storeSelectedSegmFile(
                        selectedSegmEndName
                    )
                else:
                    self.newSegmEndName = win.newSegmEndName
                    self.isNewFile = True
            elif len(existingSegmEndNames) == 1:
                selectedSegmEndName = list(existingSegmEndNames)[0]

        posData.loadImgData()
        
        required_ram = posData.getBytesImageData()
        if required_ram >= 5e8:
            # Disable autosave for data > 500MB
            self.autoSaveToggle.setChecked(False)

        proceed = self.checkMemoryRequirements(required_ram)
        if not proceed:
            self.loadingDataAborted()
            return
        
        posData.loadOtherFiles(
            load_segm_data=True,
            load_metadata=True,
            create_new_segm=self.isNewFile,
            new_endname=self.newSegmEndName,
            end_filename_segm=selectedSegmEndName,
        )
        self.selectedSegmEndName = selectedSegmEndName
        self.labelBoolSegm = posData.labelBoolSegm
        posData.labelSegmData()

        print('')
        self.logger.info(
            f'Segmentation filename: {posData.segm_npz_path}'
        )

        proceed = posData.askInputMetadata(
            self.num_pos,
            ask_SizeT=self.num_pos==1,
            ask_TimeIncrement=True,
            ask_PhysicalSizes=True,
            singlePos=False,
            save=True, 
            warnMultiPos=True
        )
        if not proceed:
            self.loadingDataAborted()
            return
        
        self.AutoPilotProfile.storeOkAskInputMetadata()

        if posData.isSegm3D is None:
            self.isSegm3D = False
        else:
            self.isSegm3D = posData.isSegm3D
        self.SizeT = posData.SizeT
        self.SizeZ = posData.SizeZ
        self.TimeIncrement = posData.TimeIncrement
        self.PhysicalSizeZ = posData.PhysicalSizeZ
        self.PhysicalSizeY = posData.PhysicalSizeY
        self.PhysicalSizeX = posData.PhysicalSizeX
        self.loadSizeS = posData.loadSizeS
        self.loadSizeT = posData.loadSizeT
        self.loadSizeZ = posData.loadSizeZ

        self.overlayLabelsItems = {}
        self.drawModeOverlayLabelsChannels = {}

        self.existingSegmEndNames = existingSegmEndNames
        self.createOverlayLabelsContextMenu(existingSegmEndNames)
        self.overlayLabelsButtonAction.setVisible(True)
        self.createOverlayLabelsItems(existingSegmEndNames)
        self.disableNonFunctionalButtons()

        self.isH5chunk = (
            posData.ext == '.h5'
            and (self.loadSizeT != self.SizeT
                or self.loadSizeZ != self.SizeZ)
        )

        required_ram = posData.checkH5memoryFootprint()*self.loadSizeS
        if required_ram > 0:
            proceed = self.checkMemoryRequirements(required_ram)
            if not proceed:
                self.loadingDataAborted()
                return

        if posData.SizeT == 1:
            self.isSnapshot = True
        else:
            self.isSnapshot = False

        self.progressWin = apps.QDialogWorkerProgress(
            title='Loading data...', parent=self,
            pbarDesc=f'Loading "{user_ch_file_paths[0]}"...'
        )
        self.progressWin.show(self.app)

        func = partial(
            self.startLoadDataWorker, user_ch_file_paths, user_ch_name,
            posData
        )


        QTimer.singleShot(150, func)
    
    def disableNonFunctionalButtons(self):
        if not self.isSegm3D:
            return 

        for item in self.functionsNotTested3D:
            if hasattr(item, 'action'):
                toolButton = item
                action = toolButton.action
                toolButton.setDisabled(True)
            elif hasattr(item, 'toolbar'):
                toolbar = item.toolbar
                action = item
                toolButton = toolbar.widgetForAction(action)
                toolButton.setDisabled(True)    
            else: 
                action = item
            action.setDisabled(True)
            

    @exception_handler
    def startLoadDataWorker(self, user_ch_file_paths, user_ch_name, firstPosData):
        self.funcDescription = 'loading data'
        
        self.guiTabControl.propsQGBox.idSB.setValue(0)

        self.thread = QThread()
        self.loadDataMutex = QMutex()
        self.loadDataWaitCond = QWaitCondition()

        self.loadDataWorker = workers.loadDataWorker(
            self, user_ch_file_paths, user_ch_name, firstPosData
        )

        self.loadDataWorker.moveToThread(self.thread)
        self.loadDataWorker.signals.finished.connect(self.thread.quit)
        self.loadDataWorker.signals.finished.connect(
            self.loadDataWorker.deleteLater
        )
        self.thread.finished.connect(self.thread.deleteLater)

        self.loadDataWorker.signals.finished.connect(
            self.loadDataWorkerFinished
        )
        self.loadDataWorker.signals.progress.connect(self.workerProgress)
        self.loadDataWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.loadDataWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.loadDataWorker.signals.critical.connect(
            self.workerCritical
        )
        self.loadDataWorker.signals.dataIntegrityCritical.connect(
            self.loadDataWorkerDataIntegrityCritical
        )
        self.loadDataWorker.signals.dataIntegrityWarning.connect(
            self.loadDataWorkerDataIntegrityWarning
        )
        self.loadDataWorker.signals.sigPermissionError.connect(
            self.workerPermissionError
        )
        self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect(
            self.askMismatchSegmDataShape
        )
        self.loadDataWorker.signals.sigRecovery.connect(
            self.askRecoverNotSavedData
        )

        self.thread.started.connect(self.loadDataWorker.run)
        self.thread.start()
    
    def askRecoverNotSavedData(self, posData):
        last_modified_time_unsaved = 'NEVER'
        if os.path.exists(posData.segm_npz_temp_path):
            recovered_file_path = posData.segm_npz_temp_path
            if os.path.exists(posData.segm_npz_path):
                last_modified_time_unsaved = (
                    datetime.fromtimestamp(
                        os.path.getmtime(posData.segm_npz_path)
                    ).strftime("%a %d. %b. %y - %H:%M:%S")
                )
        else:
            posData.setTempPaths()
            if os.path.exists(posData.unsaved_acdc_df_autosave_path):
                zip_path = posData.unsaved_acdc_df_autosave_path
                with zipfile.ZipFile(zip_path, mode='r') as zip:
                    csv_names = natsorted(set(zip.namelist()))
                iso_key = csv_names[-1][:-4]
                most_recent_unsaved_acdc_df_datetime = datetime.strptime(
                    iso_key, load.ISO_TIMESTAMP_FORMAT
                )
                last_modified_time_unsaved = (
                    most_recent_unsaved_acdc_df_datetime
                ).strftime("%a %d. %b. %y - %H:%M:%S")
        
        if os.path.exists(posData.acdc_output_csv_path):
            acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path)
            timestamp = datetime.fromtimestamp(acdc_df_mtime)
            last_modified_time_saved = timestamp.strftime(
                "%a %d. %b. %y - %H:%M:%S"
            )
        else:
            last_modified_time_saved = 'Null'
        
        msg = widgets.myMessageBox(showCentered=False, wrapText=False)
        txt = html_utils.paragraph("""
            Cell-ACDC detected <b>unsaved data</b>.<br><br>
            Do you want to <b>load and recover</b> the unsaved data or 
            load the data that was <b>last saved by the user</b>?
        """)
        details = (f"""
            The unsaved data was created on {last_modified_time_unsaved}\n\n
            The user saved the data last time on {last_modified_time_saved}
        """)
        msg.setDetailedText(details)
        loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data')
        loadSavedButton = widgets.savePushButton('Load saved data')
        infoButton = widgets.infoPushButton('More info...')
        loadSafeNpzButton = ''
        if posData.isSafeNpzOverwritePresent():
            loadSafeNpzButton = widgets.reloadPushButton(
                'Load .safe.npz file from crash'
            )
            buttons = (
                loadSavedButton, loadUnsavedButton, loadSafeNpzButton, 
                infoButton
            )
        else:
            buttons = (loadSavedButton, loadUnsavedButton, infoButton)
        msg.question(
            self.progressWin, 'Recover unsaved data?', txt, 
            buttonsTexts=('Cancel', *buttons), 
            showDialog=False
        )
        infoButton.disconnect()
        infoButton.clicked.connect(partial(self.showInfoAutosave, posData))
        msg.exec_()
        if msg.cancel:
            self.loadDataWorker.abort = True
        elif msg.clickedButton == loadUnsavedButton:
            self.loadDataWorker.loadUnsaved = True
        elif msg.clickedButton == loadSafeNpzButton:
            self.loadDataWorker.loadSafeOverwriteNpz = True
            
        self.loadDataWorker.waitCond.wakeAll()
        # self.AutoPilotProfile.storeLoadSavedData()
    
    def showInfoAutosave(self, posData):
        msg = widgets.myMessageBox(showCentered=False, wrapText=False)
        txt = (f"""
            Cell-ACDC either detected unsaved data in a previous session and it 
            stored it because the <b>Autosave</b><br>
            function was active, or it crashed during saving.<br><br>
            You can toggle Autosave ON and OFF from the menu on the top menubar 
            <code>File --> Autosave</code>.
        """)
        txt = (f"""
            {txt}<br><br>
            If Cell-ACDC crashed during saving, the segmentation file ending 
            with <code>.new.npz</code><br>
            is present and you might be able to recover the data from there. 
        """) 
        
        txt = (f"""
            {txt}<br><br>
            You can find additional recovered data in the following folder:
        """)  
        txt = html_utils.paragraph(txt)
        msg.information(
            self, 'Autosave info', txt, 
            path_to_browse=posData.recoveryFolderPath, 
            commands=(posData.recoveryFolderPath,)
        )
    
    def askMismatchSegmDataShape(self, posData):
        msg = widgets.myMessageBox(wrapText=False)
        title = 'Segm. data shape mismatch'
        f = '3D' if self.isSegm3D else '2D'
        f = f'{f} over time' if posData.SizeT > 1 else f
        r = '2D' if self.isSegm3D else '3D'
        r = f'{r} over time' if posData.SizeT > 1 else r
        text = html_utils.paragraph(f"""
            The segmentation masks of the first Position that you loaded is 
            <b>{f}</b>,<br>
            while {posData.pos_foldername} is <b>{r}</b>.<br><br>
            The loaded segmentation masks <b>must be</b> either <b>all 3D</b> 
            or <b>all 2D</b>.<br><br>
            Do you want to skip loading this position or cancel the process?
        """)
        _, skipPosButton = msg.warning(
            self, title, text, buttonsTexts=('Cancel', 'Skip this Position')
        )
        if skipPosButton == msg.clickedButton:
            self.loadDataWorker.skipPos = True
        self.loadDataWorker.waitCond.wakeAll()

    def workerPermissionError(self, txt, waitCond):
        msg = widgets.myMessageBox(parent=self)
        msg.setIcon(iconName='SP_MessageBoxCritical')
        msg.setWindowTitle('Permission denied')
        msg.addText(txt)
        msg.addButton('  Ok  ')
        msg.exec_()
        waitCond.wakeAll()

    def loadDataWorkerDataIntegrityCritical(self):
        errTitle = 'All loaded positions contains frames over time!'
        self.titleLabel.setText(errTitle, color='r')

        msg = widgets.myMessageBox(parent=self)

        err_msg = html_utils.paragraph(f"""
            {errTitle}.<br><br>
            To load data that contains frames over time you have to select
            only ONE position.
        """)
        msg.setIcon(iconName='SP_MessageBoxCritical')
        msg.setWindowTitle('Loaded multiple positions with frames!')
        msg.addText(err_msg)
        msg.addButton('Ok')
        msg.show(block=True)

    @exception_handler
    def loadDataWorkerFinished(self, data):
        self.funcDescription = 'loading data worker finished'
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None

        if data is None or data=='abort':
            self.loadingDataAborted()
            return
        
        if data[0].onlyEditMetadata:
            self.loadingDataAborted()
            return

        self.pos_i = 0
        self.data = data
        self.gui_createGraphicsItems()
        return True
    
    def checkManageVersions(self):
        posData = self.data[self.pos_i]
        posData.setTempPaths(createFolder=False)
        loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path)
        
        if os.path.exists(posData.recoveryFolderpath()):
            self.manageVersionsAction.setDisabled(False)
            self.manageVersionsAction.setToolTip(
                f'Load an older version of the `{loaded_acdc_df_filename}` file '
                '(table with annotations and measurements).'
            )
        else:
            self.manageVersionsAction.setDisabled(True)

    def preprocessPreviewToggled(self, checked):
        self.viewPreprocDataToggle.setChecked(checked)
        self.updatePreprocessPreview()
    
    def combinePreviewToggled(self, checked):
        self.viewCombineChannelDataToggle.setChecked(checked)
        self.updateCombineChannelsPreview()
    
    def combineCurrentImage(self, 
                            steps: List[Dict[str, Any]]=None,
                            keep_input_data_type:bool=None,
                            ):

        if steps and keep_input_data_type is None:
            raise ValueError('keep_input_data_type must be set if steps is set')
        
        if steps is None:
            steps, keep_input_data_type = self.combineDialog.steps(return_keepInputDataType=True)

        txt = 'Combining current image...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        selected_channel = core.get_selected_channels(steps)
        self.getChData(requ_ch=selected_channel)

        z_slice = self.zSliceScrollBar.sliderPosition()
        pos_i = self.pos_i

        key = (pos_i, self.data[pos_i].frame_i, z_slice)

        self.combineWorker.setupJob(
            self.data, 
            steps, 
            keep_input_data_type,
            key
        )
        
        self.combineWorker.wakeUp()
    
    def combineZStack(self, 
                      steps: List[Dict[str, Any]]=None,
                      keep_input_data_type:bool=None,):

        if steps and not keep_input_data_type:
            raise ValueError('keep_input_data_type must be set if steps is set')
        
        if steps is None:
            steps, keep_input_data_type = self.combineDialog.steps(return_keepInputDataType=True)

        txt = 'Combining z-stack...'
        self.statusBarLabel.setText(txt)
        self.logger.info(txt)
        
        selected_channel = core.get_selected_channels(steps)
        self.getChData(requ_ch=selected_channel)

        posData = self.data[self.pos_i]
        key = (self.pos_i, posData.frame_i, None)
        self.combineWorker.setupJob(
            self.data, 
            steps, 
            keep_input_data_type,
            key
        )

        self.combineWorker.wakeUp()
    
    def combineAllFrames(self, 
                         steps: List[Dict[str, Any]]=None,
                         keep_input_data_type:bool=None,):
        if steps and not keep_input_data_type:
            raise ValueError('keep_input_data_type must be set if steps is set')
        
        if steps is None:
            steps, keep_input_data_type = self.combineDialog.steps(return_keepInputDataType=True)

        txt = 'Combining all frames...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        selected_channel = core.get_selected_channels(steps)
        self.getChData(requ_ch=selected_channel)

        key = (self.pos_i, None, None)
        self.combineWorker.setupJob(
            self.data, 
            steps, 
            keep_input_data_type,
            key
        )

        self.combineWorker.wakeUp()
    
    def combineAllPos(self, 
                      steps: List[Dict[str, Any]]=None,
                      keep_input_data_type:bool=None,):
        if steps and not keep_input_data_type:
            raise ValueError('keep_input_data_type must be set if steps is set')
        
        if steps is None:
            steps, keep_input_data_type = self.combineDialog.steps(return_keepInputDataType=True)

        txt = 'Combining all Positions...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        selected_channel = core.get_selected_channels(steps)
        
        for pos_i in range(len(self.data)):
            self.getChData(requ_ch=selected_channel, pos_i=pos_i)


        key = (None, None, None)
        self.combineWorker.setupJob(
            self.data, 
            steps, 
            keep_input_data_type,
            key
        )

        self.combineWorker.wakeUp()

    def preprocessCurrentImage(self, recipe: List[Dict[str, Any]]):
        txt = 'Pre-processing current image...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        func = core.preprocess_image_from_recipe
        recipe = core.validate_multidimensional_recipe(recipe)
        
        image_data = self.getImage(raw=True)
        self.preprocWorker.setupJob(
            func, 
            image_data, 
            recipe, 
            'current_image'
        )
        
        self.preprocWorker.wakeUp()
    
    def preprocessZStack(self, recipe: List[Dict[str, Any]]):
        txt = 'Pre-processing z-stack...'
        self.statusBarLabel.setText(txt)
        self.logger.info(txt)
        
        posData = self.data[self.pos_i]
        func = core.preprocess_zstack_from_recipe
        recipe = core.validate_multidimensional_recipe(
            recipe, apply_to_all_frames=False
        )
        image_data = posData.img_data[posData.frame_i]
        self.preprocWorker.setupJob(
            func, 
            image_data, 
            recipe, 
            'z_stack'
        )
        
        self.preprocWorker.wakeUp()
    
    def preprocessAllFrames(self, recipe: List[Dict[str, Any]]):
        txt = 'Pre-processing all frames...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        posData = self.data[self.pos_i]
        func = core.preprocess_video_from_recipe
        image_data = posData.img_data
        self.preprocWorker.setupJob(
            func, 
            image_data, 
            recipe, 
            'all_frames'
        )
        self.preprocWorker.wakeUp()
    
    def preprocessAllPos(self, recipe: List[Dict[str, Any]]):
        txt = 'Pre-processing all Positions...'
        self.logger.info(txt)
        self.statusBarLabel.setText(txt)
        
        func = core.preprocess_multi_pos_from_recipe
        recipe = core.validate_multidimensional_recipe(
            recipe, apply_to_all_frames=False
        )
        image_data = [posData.img_data[0] for posData in self.data]
        self.preprocWorker.setupJob(
            func, 
            image_data, 
            recipe, 
            'all_pos'
        )
        
        self.preprocWorker.wakeUp()

    def setupPreprocessing(self):
        posData = self.data[self.pos_i]
        if self.preprocessDialog is not None:
            self.preprocessDialog.close()
        
        self.preprocessDialog = apps.PreProcessRecipeDialog(
            isTimelapse=posData.SizeT>1, 
            isZstack=posData.SizeZ>1,
            isMultiPos=len(self.data)>1,
            df_metadata=posData.metadata_df,
            hideOnClosing=True, 
            addApplyButton=True,
            parent=self
        )
        self.doPreviewPreprocImage = False
        self.preprocessDialog.sigApplyImage.connect(
            self.preprocessCurrentImage
        )
        self.preprocessDialog.sigApplyZstack.connect(
            self.preprocessZStack
        )
        self.preprocessDialog.sigApplyAllFrames.connect(
            self.preprocessAllFrames
        )
        self.preprocessDialog.sigApplyAllPos.connect(
            self.preprocessAllPos
        )
        self.preprocessDialog.sigPreviewToggled.connect(
            self.preprocessPreviewToggled
        )
        self.preprocessDialog.sigValuesChanged.connect(
            self.preprocessDialogRecipeChanged
        )
        self.preprocessDialog.sigSavePreprocData.connect(
            self.preprocessDialogSavePreprocessedData
        )
        
        if self.preprocWorker is not None:
            return
        
        self.preprocThread = QThread()
        self.preprocMutex = QMutex()
        self.preprocWaitCond = QWaitCondition()
        
        self.preprocWorker = workers.CustomPreprocessWorkerGUI(
            self.preprocMutex, self.preprocWaitCond
        )
        
        self.preprocWorker.moveToThread(self.preprocThread)
        self.preprocWorker.signals.finished.connect(self.preprocThread.quit)
        self.preprocWorker.signals.finished.connect(
            self.preprocWorker.deleteLater
        )
        self.preprocThread.finished.connect(self.preprocThread.deleteLater)

        self.preprocWorker.sigDone.connect(self.preprocWorkerDone)
        self.preprocWorker.sigIsQueueEmpty.connect(
            self.preprocWorkerIsQueueEmpty
        )
        self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone)
        self.preprocWorker.signals.progress.connect(self.workerProgress)
        self.preprocWorker.signals.critical.connect(self.workerCritical)
        self.preprocWorker.signals.finished.connect(self.preprocWorkerClosed)
        
        self.preprocThread.started.connect(self.preprocWorker.run)
        self.preprocThread.start()
        
        self.logger.info('Pre-processing worker started.')

    def preprocWorkerCritical(self, error):
        self.preprocessDialog.appliedFinished()
        self.workerCritical(error)

    def setupCombiningChannels(self):
        posData = self.data[self.pos_i]
        if self.combineDialog is not None:
            self.preprocessDialog.close()
        
        self.combineDialog = apps.CombineChannelsSetupDialogGUI(
            posData.chNames,
            isTimelapse=posData.SizeT>1, 
            isZstack=posData.SizeZ>1,
            isMultiPos=len(self.data)>1,
            df_metadata=posData.metadata_df,
            hideOnClosing=True, 
            # addApplyButton=True,
            parent=self
        )
        self.doPreviewPreprocImage = False #to do
        self.combineDialog.sigApplyImage.connect(
            self.combineCurrentImage
            )
        self.combineDialog.sigApplyZstack.connect(
            self.combineZStack
        )
        self.combineDialog.sigApplyAllFrames.connect(
            self.combineAllFrames
        )
        self.combineDialog.sigApplyAllPos.connect(
            self.combineAllPos
        )
        self.combineDialog.sigPreviewToggled.connect(
            self.combinePreviewToggled
        )
        self.combineDialog.sigValuesChanged.connect(
            self.combineDialogStepsChanged
        )
        self.combineDialog.sigSavePreprocData.connect(
            self.combineDialogSaveCombinedData
        )

        if self.combineWorker is not None:
            return
        
        self.combineThread = QThread()
        self.combineMutex = QMutex()
        self.combineWaitCond = QWaitCondition()
        
        self.combineWorker = workers.CombineWorkerGUI(
            self.combineMutex, self.combineWaitCond,
            logger_func=self.logger.info,
            # signals=self.signals # what are the singals for gui???
        )
        
        self.combineWorker.moveToThread(self.combineThread)
        self.combineWorker.signals.finished.connect(self.combineThread.quit)
        self.combineWorker.signals.finished.connect(
            self.combineWorker.deleteLater
        )
        self.combineThread.finished.connect(self.combineWorker.deleteLater)

        self.combineWorker.sigDone.connect(self.combineWorkerDone)
        self.combineWorker.sigIsQueueEmpty.connect(
            self.combineWorkerIsQueueEmpty
        )
        self.combineWorker.sigPreviewDone.connect(self.combineWorkerPreviewDone)
        self.combineWorker.signals.progress.connect(self.workerProgress)
        self.combineWorker.signals.critical.connect(self.workerCritical)
        self.combineWorker.signals.finished.connect(self.combineWorkerClosed)

        self.combineWorker.sigAskLoadFluoChannels.connect(self.combineWorkerAskLoadFluoChannels)
        
        self.combineThread.started.connect(self.combineWorker.run)
        self.combineThread.start()
        
        self.logger.info('Combine channels worker started.')

    def combineWorkerCritical(self, error):
        self.combineDialog.appliedFinished()
        self.workerCritical(error)

    @exception_handler
    def loadingDataCompleted(self):
        self.isDataLoading = True
        posData = self.data[self.pos_i]
        
        files_format = '\n'.join([
            f'  - {file}' for file in posData.images_folder_files
        ])
        sep = '-'*100
        self.logger.info(
            f'{sep}\nFiles present in the first Position folder loaded:\n\n'
            f'{files_format}\n{sep}'
        )
        self.logger.info(f'Basename of the first Position: {posData.basename}')
        self.secondLevelToolbar.setVisible(True)
        self.updateImageValueFormatter()
        self.checkManageVersions()
        self.initManualBackgroundImage()
        self.initPixelSizePropsDockWidget()

        self.setWindowTitle(f'Cell-ACDC - GUI - "{posData.exp_path}"')
        
        self.setupPreprocessing()
        self.setupCombiningChannels()

        if self.isSegm3D:
            self.segmNdimIndicator.setText('3D')
        else:
            self.segmNdimIndicator.setText('2D')
            
        self.segmNdimIndicatorAction.setVisible(True)

        self.guiTabControl.addChannels([posData.user_ch_name])
        self.showPropsDockButton.setDisabled(False)

        self.bottomScrollArea.show()
        self.gui_createStoreStateWorker()
        self.init_segmInfo_df()
        self.connectScrollbars()
        self.initPosAttr()
        
        self.logger.info('Pre-computing min and max values of the images...')
        self.img1.preComputedMinMaxValues(self.data)
        self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper
        
        self.initMetrics()
        self.initFluoData()
        self.createChannelNamesActions()
        self.addActionsLutItemContextMenu(self.imgGrad)
        
        # Scrollbar for opacity of img1 (when overlaying)
        self.img1.alphaScrollbar = self.addAlphaScrollbar(
            self.user_ch_name, self.img1
        )

        self.navigateScrollBar.setSliderPosition(posData.frame_i+1)

        # Connect events at the end of loading data process
        self.gui_connectGraphicsEvents()
        if not self.isEditActionsConnected:
            self.gui_connectEditActions()
            self.normalizeToFloatAction.setChecked(True)
            
        self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged)

        self.setFramesSnapshotMode()
        if self.isSnapshot:
            self.navSizeLabel.setText(f'/{len(self.data)}') 
        else:
            self.navSizeLabel.setText(f'/{posData.SizeT}')

        self.enableZstackWidgets(posData.SizeZ > 1)
        # self.showHighlightZneighCheckbox()
        
        self.exportToVideoAction.setDisabled(
            posData.SizeZ == 1 and posData.SizeT == 1
        )

        self.img1BottomGroupbox.show()

        isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes'
        isRightImgVisible = (
            self.df_settings.at['isRightImageVisible', 'value'] == 'Yes'
        )
        isNextFrameVisible = (
            self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes'
        )
        isNextFrameActive = (
            isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled()
        )
        self.updateScrollbars()
        self.openFolderAction.setEnabled(True)
        self.editTextIDsColorAction.setDisabled(False)
        self.imgPropertiesAction.setEnabled(True)
        self.navigateToolBar.setVisible(True)
        self.labelsGrad.showLabelsImgAction.setChecked(isLabVisible)
        self.labelsGrad.showRightImgAction.setChecked(isRightImgVisible)
        self.labelsGrad.showNextFrameAction.setChecked(isNextFrameActive)
        if isRightImgVisible or isNextFrameActive:
            self.rightBottomGroupbox.setChecked(True)
        
        isTwoImagesLayout = (
            isRightImgVisible or isLabVisible or isNextFrameActive
        )
        self.setTwoImagesLayout(isTwoImagesLayout)
        
        self.setBottomLayoutStretch()
        
        if isNextFrameActive:
            self.rightBottomGroupbox.show()
            self.rightBottomGroupbox.setChecked(True)
            self.drawNothingCheckboxRight.click()  

        self.readSavedCustomAnnot()
        self.addCustomAnnotButtonAllLoadedPos()
        self.setStatusBarLabel()

        self.initLookupTableLab()
        if self.invertBwAction.isChecked() and not self.invertBwAlreadyCalledOnce:
            self.invertBw(True)
        self.restoreSavedSettings()

        self.initContoursImage()
        self.initTextAnnot()
        self.initDelRoiLab()

        self.update_rp()
        self.updateAllImages()
        if posData.SizeT > 1:
            self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2)
        self.setMetricsFunc()

        self.gui_createLabelRoiItem()

        self.titleLabel.setText(
            'Data successfully loaded.',
            color=self.titleColor
        )

        self.disableNonFunctionalButtons()
        self.setVisible3DsegmWidgets()

        if len(self.data) == 1 and posData.SizeZ > 1 and posData.SizeT == 1:
            self.zSliceCheckbox.setChecked(True)
        else:
            self.zSliceCheckbox.setChecked(False)

        self.labelRoiCircItemLeft.setImageShape(self.currentLab2D.shape)
        self.labelRoiCircItemRight.setImageShape(self.currentLab2D.shape)

        self.retainSpaceSlidersToggled(self.retainSpaceSlidersAction.isChecked())

        self.stopAutomaticLoadingPos()
        self.viewAllCustomAnnotAction.setChecked(True)

        self.updateImageValueFormatter()

        posData.loadWhitelist()

        self.setFocusGraphics()
        self.setFocusMain()

        # Overwrite axes viewbox context menu
        self.ax1.vb.menu = self.imgGrad.gradient.menu
        self.ax2.vb.menu = self.labelsGrad.menu

        QTimer.singleShot(200, self.resizeGui)

        self.isDataLoaded = True
        self.isDataLoading = False
        
        self.rescaleIntensitiesLut(setImage=False)
        
        self.gui_createAutoSaveWorker()
    
    def removeAxLimits(self):
        self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307]
        self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307]
    
    def resizeGui(self):
        self.ax1.vb.state['limits']['xRange'] = [None, None]
        self.ax1.vb.state['limits']['yRange'] = [None, None]
        self.autoRange()
        if self.ax1.getViewBox().state['limits']['xRange'][0] is not None:
            self.bottomScrollArea._resizeVertical()
            return
        (xmin, xmax), (ymin, ymax) = self.ax1.viewRange()
        maxYRange = int((ymax-ymin)*1.5)
        maxXRange = int((xmax-xmin)*1.5)
        self.ax1.setLimits(
            maxYRange=maxYRange, 
            maxXRange=maxXRange
        )
        self.bottomScrollArea._resizeVertical()
        QTimer.singleShot(200, self.autoRange)
    
    def setVisible3DsegmWidgets(self):
        self.annotNumZslicesCheckbox.setVisible(self.isSegm3D)
        self.annotNumZslicesCheckboxRight.setVisible(self.isSegm3D)
        if not self.isSegm3D:
            self.annotNumZslicesCheckbox.setChecked(False)
            self.annotNumZslicesCheckboxRight.setChecked(False)
    
    def showHighlightZneighCheckbox(self):
        if self.isSegm3D:
            layout = self.bottomLeftLayout
            # layout.addWidget(self.annotOptionsWidget, 0, 1, 1, 2)
            # # layout.removeWidget(self.drawIDsContComboBox)
            # # layout.addWidget(self.drawIDsContComboBox, 0, 1, 1, 1,
            # #     alignment=Qt.AlignCenter
            # # )
            # layout.addWidget(self.highlightZneighObjCheckbox, 0, 2, 1, 2,
            #     alignment=Qt.AlignRight
            # )
            self.highlightZneighObjCheckbox.show()
            self.highlightZneighObjCheckbox.setChecked(True)
            self.highlightZneighObjCheckbox.toggled.connect(
                self.highlightZneighLabels_cb
            )
            
    def restoreSavedSettings(self):
        if 'how_draw_annotations' in self.df_settings.index:
            how = self.df_settings.at['how_draw_annotations', 'value']
            self.drawIDsContComboBox.setCurrentText(how)
        else:
            self.drawIDsContComboBox.setCurrentText('Draw IDs and contours')
        
        if 'how_draw_right_annotations' in self.df_settings.index:
            how = self.df_settings.at['how_draw_right_annotations', 'value']
            self.annotateRightHowCombobox.setCurrentText(how)
        else:
            self.annotateRightHowCombobox.setCurrentText(
                'Draw IDs and overlay segm. masks'
            )
        
        if 'addNewIDsWhitelistToggle' in self.df_settings.index:
            self.addNewIDsWhitelistToggle = (
                self.df_settings.at['addNewIDsWhitelistToggle', 'value']
                ) == 'Yes'
        else:
            self.addNewIDsWhitelistToggle = True
        
        self.drawAnnotCombobox_to_options()
        self.drawIDsContComboBox_cb(0)
        self.annotateRightHowCombobox_cb(0)
    
    def uncheckAnnotOptions(self, left=True, right=True):
        # Left
        if left:
            self.annotIDsCheckbox.setChecked(False)
            self.annotCcaInfoCheckbox.setChecked(False)
            self.annotContourCheckbox.setChecked(False)
            self.annotSegmMasksCheckbox.setChecked(False)
            self.drawMothBudLinesCheckbox.setChecked(False)
            self.drawNothingCheckbox.setChecked(False)

        # Right 
        if right:
            self.annotIDsCheckboxRight.setChecked(False)
            self.annotCcaInfoCheckboxRight.setChecked(False)
            self.annotContourCheckboxRight.setChecked(False)
            self.annotSegmMasksCheckboxRight.setChecked(False)
            self.drawMothBudLinesCheckboxRight.setChecked(False)
            self.drawNothingCheckboxRight.setChecked(False)

    def setDisabledAnnotOptions(self, disabled):
        # Left
        self.annotIDsCheckbox.setDisabled(disabled)
        self.annotCcaInfoCheckbox.setDisabled(disabled)
        self.annotContourCheckbox.setDisabled(disabled)
        # self.annotSegmMasksCheckbox.setDisabled(disabled)
        self.drawMothBudLinesCheckbox.setDisabled(disabled)
        # self.drawNothingCheckbox.setDisabled(disabled)

        # Right 
        self.annotIDsCheckboxRight.setDisabled(disabled)
        self.annotCcaInfoCheckboxRight.setDisabled(disabled)
        self.annotContourCheckboxRight.setDisabled(disabled)
        # self.annotSegmMasksCheckboxRight.setDisabled(disabled)
        self.drawMothBudLinesCheckboxRight.setDisabled(disabled)
        # self.drawNothingCheckboxRight.setDisabled(disabled)
        
    def drawAnnotCombobox_to_options(self):
        self.uncheckAnnotOptions()

        # Left
        how = self.drawIDsContComboBox.currentText()
        if how.find('IDs') != -1:
            self.annotIDsCheckbox.setChecked(True)
        if how.find('cell cycle info') != -1:
            self.annotCcaInfoCheckbox.setChecked(True) 
        if how.find('contours') != -1:
            self.annotContourCheckbox.setChecked(True) 
        if how.find('segm. masks') != -1:
            self.annotSegmMasksCheckbox.setChecked(True) 
        if how.find('mother-bud lines') != -1:
            self.drawMothBudLinesCheckbox.setChecked(True) 
        if how.find('nothing') != -1:
            self.drawNothingCheckbox.setChecked(True)
        
        # Right
        how = self.annotateRightHowCombobox.currentText()
        if how.find('IDs') != -1:
            self.annotIDsCheckboxRight.setChecked(True)
        if how.find('cell cycle info') != -1:
            self.annotCcaInfoCheckboxRight.setChecked(True) 
        if how.find('contours') != -1:
            self.annotContourCheckboxRight.setChecked(True) 
        if how.find('segm. masks') != -1:
            self.annotSegmMasksCheckboxRight.setChecked(True) 
        if how.find('mother-bud lines') != -1:
            self.drawMothBudLinesCheckboxRight.setChecked(True) 
        if how.find('nothing') != -1:
            self.drawNothingCheckboxRight.setChecked(True)

    def setStatusBarLabel(self, log=True):
        self.statusbar.clearMessage()
        posData = self.data[self.pos_i]
        segmentedChannelname = posData.filename[len(posData.basename):]
        segmFilename = os.path.basename(posData.segm_npz_path)
        segmEndName = segmFilename[len(posData.basename):]
        txt = (
            f'{posData.pos_foldername} || '
            f'Basename: {posData.basename} || '
            f'Segmented channel: {segmentedChannelname} || '
            f'Segmentation file name: {segmEndName}'
        )
        mode = str(self.modeComboBox.currentText())
        if log:
            self.logger.info(txt)
        self.statusBarLabel.setText(txt)

    def autoRange(self):
        if self.labelsGrad.showLabelsImgAction.isChecked():
            self.ax2.autoRange()
        self.ax1.autoRange()
        
    def resetRange(self):
        if self.ax1_viewRange is None:
            return
        xRange, yRange = self.ax1_viewRange
        if self.labelsGrad.showLabelsImgAction.isChecked():
            self.ax2.vb.setRange(xRange=xRange, yRange=yRange)
        self.ax1.vb.setRange(xRange=xRange, yRange=yRange)
        self.ax1_viewRange = None
        self.isRangeReset = True

    def setFramesSnapshotMode(self):
        self.measurementsMenu.setDisabled(False)
        if self.isSnapshot:
            self.realTimeTrackingToggle.setDisabled(True)
            self.realTimeTrackingToggle.label.setDisabled(True)
            try:
                self.drawIDsContComboBox.currentIndexChanged.disconnect()
            except Exception as e:
                pass
            
            self.imgGrad.rescaleAcrossTimeAction.setDisabled(True)
            self.repeatTrackingAction.setDisabled(True)
            self.manualTrackingAction.setDisabled(True)
            self.logger.info('Setting GUI mode to "Snapshots"...')
            self.modeComboBox.clear()
            self.modeComboBox.addItems(['Snapshot'])
            self.modeComboBox.setDisabled(True)
            self.modeMenu.menuAction().setVisible(False)
            self.drawIDsContComboBox.clear()
            self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems)
            self.drawIDsContComboBox.setCurrentIndex(1)
            self.modeToolBar.setVisible(False)
            self.skipToNewIdAction.setVisible(False)
            self.skipToNewIdAction.setDisabled(True)
            self.modeComboBox.setCurrentText('Snapshot')
            self.annotateToolbar.setVisible(True)
            self.labelsGrad.showNextFrameAction.setDisabled(True)
            self.drawIDsContComboBox.currentIndexChanged.connect(
                self.drawIDsContComboBox_cb
            )
            self.showTreeInfoCheckbox.hide()
            self.rightImageFramesScrollbar.setVisible(False)
            self.rightImageFramesScrollbar.setDisabled(True)
            if not self.isSegm3D:
                self.manualBackgroundAction.setVisible(True)
                self.manualBackgroundAction.setDisabled(False)
            else:
                self.manualBackgroundAction.setVisible(False)
                self.manualBackgroundAction.setDisabled(True)
            self.segForLostIDsAction.setVisible(False)
            self.segForLostIDsAction.setDisabled(True)
        else:
            self.imgGrad.rescaleAcrossTimeAction.setDisabled(False)
            self.annotateToolbar.setVisible(False)
            self.realTimeTrackingToggle.setDisabled(False)
            self.repeatTrackingAction.setDisabled(False)
            self.manualTrackingAction.setDisabled(False)
            self.modeComboBox.setDisabled(False)
            self.modeMenu.menuAction().setVisible(True)
            self.skipToNewIdAction.setVisible(True)
            self.skipToNewIdAction.setDisabled(False)
            try:
                self.modeComboBox.activated.disconnect()
                self.modeComboBox.sigTextChanged.disconnect()
                self.drawIDsContComboBox.currentIndexChanged.disconnect()
            except Exception as e:
                pass
                # traceback.print_exc()
            self.modeComboBox.clear()
            self.modeComboBox.addItems(self.modeItems)
            self.drawIDsContComboBox.clear()
            self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems)
            self.modeComboBox.sigTextChanged.connect(self.changeMode)
            self.modeComboBox.activated.connect(self.clearComboBoxFocus)
            self.drawIDsContComboBox.currentIndexChanged.connect(
                                                    self.drawIDsContComboBox_cb)
            self.modeComboBox.setCurrentText('Viewer')
            self.showTreeInfoCheckbox.show()
            self.manualBackgroundAction.setVisible(False)
            self.manualBackgroundAction.setDisabled(True)
            self.labelsGrad.showNextFrameAction.setDisabled(False)  
            self.segForLostIDsAction.setVisible(True)
            self.segForLostIDsAction.setDisabled(False)
        
        for ch, overlayItems in self.overlayLayersItems.items():
            imageItem, lutItem, alphaScrollBar = overlayItems
            lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot)      

    def checkIfAutoSegm(self):
        """
        If there are any frame or position with empty segmentation mask
        ask whether automatic segmentation should be turned ON
        """
        if self.autoSegmAction.isChecked():
            return
        if self.autoSegmDoNotAskAgain:
            return

        ask = False
        for posData in self.data:
            if posData.SizeT > 1:
                for lab in posData.segm_data:
                    if not np.any(lab):
                        ask = True
                        txt = 'frames'
                        break
            else:
                if not np.any(posData.segm_data):
                    ask = True
                    txt = 'positions'
                    break

        if not ask:
            return

        questionTxt = html_utils.paragraph(
            f'Some or all loaded {txt} contain <b>empty segmentation masks</b>.<br><br>'
            'Do you want to <b>activate automatic segmentation</b><sup>*</sup> '
            f'when visiting these {txt}?<br><br>'
            '<i>* Automatic segmentation can always be turned ON/OFF from the menu<br>'
            '  <code>Edit --> Segmentation --> Enable automatic segmentation</code><br><br></i>'
            f'NOTE: you can automatically segment all {txt} using the<br>'
            '    segmentation module.'
        )
        msg = widgets.myMessageBox(wrapText=False)
        noButton, yesButton = msg.question(
            self, 'Automatic segmentation?', questionTxt,
            buttonsTexts=('No', 'Yes')
        )
        if msg.clickedButton == yesButton:
            self.autoSegmAction.setChecked(True)
        else:
            self.autoSegmDoNotAskAgain = True
            self.autoSegmAction.setChecked(False)

    def init_segmInfo_df(self):
        for posData in self.data:
            if posData is None:
                # posData is None when computing measurements with the utility
                # and with timelapse data
                continue
            if posData.SizeZ > 1 and posData.segmInfo_df is not None:
                if 'z_slice_used_gui' not in posData.segmInfo_df.columns:
                    posData.segmInfo_df['z_slice_used_gui'] = (
                        posData.segmInfo_df['z_slice_used_dataPrep']
                    )
                if 'which_z_proj_gui' not in posData.segmInfo_df.columns:
                    posData.segmInfo_df['which_z_proj_gui'] = (
                        posData.segmInfo_df['which_z_proj']
                    )
                posData.segmInfo_df['resegmented_in_gui'] = False
                posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path)

            NO_segmInfo = (
                posData.segmInfo_df is None
                or posData.filename not in posData.segmInfo_df.index
            )
            if NO_segmInfo and posData.SizeZ > 1:
                filename = posData.filename
                df = myutils.getDefault_SegmInfo_df(posData, filename)
                if posData.segmInfo_df is None:
                    posData.segmInfo_df = df
                else:
                    posData.segmInfo_df = pd.concat([df, posData.segmInfo_df])
                    unique_idx = ~posData.segmInfo_df.index.duplicated()
                    posData.segmInfo_df = posData.segmInfo_df[unique_idx]
                posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path)

    def connectScrollbars(self):
        self.t_label.show()
        self.navigateScrollBar.show()
        self.navigateScrollBar.setDisabled(False)

        if self.data[0].SizeZ > 1:
            self.enableZstackWidgets(True)
            self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1)
            self.zSliceSpinbox.setMaximum(self.data[0].SizeZ)
            self.SizeZlabel.setText(f'/{self.data[0].SizeZ}')
            try:
                self.zSliceScrollBar.actionTriggered.disconnect()
                self.zSliceScrollBar.sliderReleased.disconnect()
                self.zProjComboBox.currentTextChanged.disconnect()
                self.zProjComboBox.activated.disconnect()
                self.switchPlaneCombobox.sigPlaneChanged.disconnect()
                self.zProjLockViewButton.toggled.disconnect()
            except Exception as e:
                pass
            self.zSliceScrollBar.actionTriggered.connect(
                self.zSliceScrollBarActionTriggered
            )
            self.zSliceScrollBar.sliderReleased.connect(
                self.zSliceScrollBarReleased
            )
            self.zProjComboBox.currentTextChanged.connect(self.updateZproj)
            self.zProjComboBox.activated.connect(self.clearComboBoxFocus)
            self.switchPlaneCombobox.sigPlaneChanged.connect(
                self.switchViewedPlane
            )
            self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled)

        posData = self.data[self.pos_i]
        if posData.SizeT == 1:
            self.t_label.setText('Position n.')
            self.navigateScrollBar.setMinimum(1)
            self.navigateScrollBar.setMaximum(len(self.data))
            self.navigateScrollBar.setAbsoluteMaximum(len(self.data))
            self.navSpinBox.setMaximum(len(self.data))
            self.navigateScrollBar.connectEvents({
                'sliderMoved': self.PosScrollBarMoved,
                'sliderReleased': self.PosScrollBarReleased,
                'actionTriggered': self.PosScrollBarAction
            })
        else:
            self.navigateScrollBar.setMinimum(1)
            self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT)
            self.rightImageFramesScrollbar.setMinimum(1)
            self.rightImageFramesScrollbar.setMaximum(posData.SizeT)
            if posData.last_tracked_i is not None:
                self.navigateScrollBar.setMaximum(posData.last_tracked_i+1)
                self.navSpinBox.setMaximum(posData.last_tracked_i+1)
            self.t_label.setText('Frame n.')
            self.navigateScrollBar.connectEvents({
                'sliderMoved': self.framesScrollBarMoved,
                'sliderReleased': self.framesScrollBarReleased,
                'actionTriggered': self.framesScrollBarActionTriggered
            })
            self.rightImageFramesScrollbar.connectValueChanged(
                self.rightImageFramesScrollbarValueChanged
            )

    def zSliceScrollBarActionTriggered(self, action):
        singleMove = (
            action == SliderSingleStepAdd
            or action == SliderSingleStepSub
            or action == SliderPageStepAdd
            or action == SliderPageStepSub
        )
        if singleMove:
            self.update_z_slice(self.zSliceScrollBar.sliderPosition())
        elif action == SliderMove:
            if self.zSliceScrollBarStartedMoving and self.isSegm3D:
                self.clearAx1Items(onlyHideText=True)
                self.clearAx2Items(onlyHideText=True)
            posData = self.data[self.pos_i]
            idx = (posData.filename, posData.frame_i)
            z = self.zSliceScrollBar.sliderPosition()
            if self.switchPlaneCombobox.depthAxes() == 'z': 
                posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z
            self.zSliceSpinbox.setValueNoEmit(z+1)
            img = self._getImageupdateAllImages(None)
            self.img1.setCurrentZsliceIndex(z)
            self.img1.setImage(
                img, next_frame_image=self.nextFrameImage(),
                scrollbar_value=posData.frame_i+2
            )
            try:
                self.setOverlayImages()
            except Exception as err:
                pass
            
            if self.labelsGrad.showLabelsImgAction.isChecked():
                self.img2.setImage(posData.lab, z=z, autoLevels=False)
            self.updateViewerWindow()
            self.setTextAnnotZsliceScrolling()
            self.setGraphicalAnnotZsliceScrolling()
            self.setOverlayLabelsItems()
            self.drawPointsLayers(computePointsLayers=False)
            self.zSliceScrollBarStartedMoving = False
            self.highlightSearchedID(self.highlightedID, force=True) 

    def zSliceScrollBarReleased(self):
        self.tempLayerImg1.setImage(self.emptyLab)
        self.tempLayerRightImage.setImage(self.emptyLab)
        self.zSliceScrollBarStartedMoving = True
        self.update_z_slice(self.zSliceScrollBar.sliderPosition())
    
    def setSwitchViewedPlaneDisabled(self, disabled):
        posData = self.data[self.pos_i]
        if posData.SizeZ == 1:
            return

        self.switchPlaneCombobox.setDisabled(disabled)
        if disabled:
            self.switchPlaneCombobox.setCurrentIndex(0)
    
    def _setViewRangeSwitchPlane(self, previousPlane):
        posData = self.data[self.pos_i]
        SizeZ = posData.SizeZ
        SizeY, SizeX = self.img1.image.shape[:2]
        currentPlane = self.switchPlaneCombobox.plane()
        if previousPlane == 'xy':
            if currentPlane == 'zy':
                self.ax1.setRange(xRange=self.yRangePrev)
                unusedRange = np.clip(self.xRangePrev, 0, SizeX)
            elif currentPlane == 'zx':
                self.ax1.setRange(xRange=self.xRangePrev)
                unusedRange = np.clip(self.yRangePrev, 0, SizeY)
        elif previousPlane == 'zy':
            if currentPlane == 'xy':
                self.ax1.setRange(yRange=self.xRangePrev)
                unusedRange = np.clip(self.yRangePrev, 0, SizeZ)
            elif currentPlane == 'zx':
                self.ax1.setRange(yRange=self.yRangePrev)
                unusedRange = np.clip(self.xRangePrev, 0, SizeY)
        elif previousPlane == 'zx':
            if currentPlane == 'xy':
                self.ax1.setRange(xRange=self.xRangePrev)
                unusedRange = np.clip(self.yRangePrev, 0, SizeZ)
            elif currentPlane == 'zy':
                self.ax1.setRange(yRange=self.yRangePrev)
                unusedRange = np.clip(self.xRangePrev, 0, SizeX)
        
        sliceValue = round((unusedRange[0] + unusedRange[1])/2)
        self.zSliceScrollBar.setSliderPosition(sliceValue)
        self.update_z_slice(self.zSliceScrollBar.sliderPosition())
    
    def setViewRangeSwitchPlane(self, previousPlane):
        self.autoRange()
        QTimer.singleShot(
            100, partial(self._setViewRangeSwitchPlane, previousPlane)
        )
        
    def switchViewedPlane(self, previousPlane, currentPlane):
        posData = self.data[self.pos_i]
        self.xRangePrev, self.yRangePrev = self.ax1.viewRange()
        self.zSlicePrev = self.zSliceScrollBar.sliderPosition()
        
        self.zProjComboBox.setCurrentText('single z-slice')
        depthAxes = self.switchPlaneCombobox.depthAxes()
        self.onEscape()
        if depthAxes != 'z':
            # Disable projections on plane that is not xy
            self.zProjComboBox.setCurrentText('single z-slice')
            self.zProjComboBox.setDisabled(True)
            
            # Clear annotations
            self.clearAllItems()
            self.setHighlightID(False)
            
            # Disable annotations on a plane that is not yz
            self.setDrawNothingAnnotations()
            self.setDisabledAnnotCheckBoxesLeft(True)
            self.setDisabledAnnotCheckBoxesRight(True)
            self.setEnabledAnnotCheckBoxesLeftZdepthAxes()
            self.overlayButtonPrevState = self.overlayButton.isChecked()
            self.overlayButton.setChecked(False)
            self.overlayButton.setDisabled(True)
        else:
            self.zProjComboBox.setDisabled(False)
            self.restoreAnnotationsOptions()
            self.setDisabledAnnotCheckBoxesLeft(False)
            self.setDisabledAnnotCheckBoxesRight(False)
            self.overlayButton.setDisabled(False)
            if self.overlayButtonPrevState:
                self.overlayButton.setChecked(self.overlayButtonPrevState)
            self.updateZsliceScrollbar(posData.frame_i)
        
        SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:]
        
        if depthAxes != 'z' and self.isSnapshot:
            # Disable editing when the plane is not xy
            self.disableEditingViewPlaneNotXY()
        elif self.isSnapshot:
            # Re-enable editing in snapshot mode when the plane is xy
            self.setEnabledSnapshotMode()
        
        if depthAxes == 'z':
            maxSliceNum = posData.SizeZ
        elif depthAxes == 'y':
            maxSliceNum = SizeY
        else:
            maxSliceNum = SizeX
        
        maxSliceText = f'/{maxSliceNum}'
        self.SizeZlabel.setText(maxSliceText)
        self.zSliceCheckbox.setText(f'{depthAxes}-slice')
        self.zSliceScrollBar.setMaximum(maxSliceNum-1)
        self.zSliceSpinbox.setMaximum(maxSliceNum)
        
        self.initContoursImage()
        self.updateAllImages()
        QTimer.singleShot(
            200, partial(self.setViewRangeSwitchPlane, previousPlane)
        )
    
    def onZsliceSpinboxValueChange(self, value):
        self.zSliceScrollBar.setSliderPosition(value-1)

    def update_z_slice(self, z):
        posData = self.data[self.pos_i]
        if self.switchPlaneCombobox.depthAxes() == 'z': 
            if self.zProjLockViewButton.isChecked():
                idx = [
                    (posData.filename, frame_i) 
                    for frame_i in range(posData.SizeT)
                ]
            else:
                idx = [
                    (posData.filename, frame_i) 
                    for frame_i in range(posData.frame_i, posData.SizeT)
                ]
            posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z
                
        self.updatePreprocessPreview()
        self.updateCombineChannelsPreview()
        self.highlightedID = self.getHighlightedID()
        self.updateAllImages(computePointsLayers=False, computeContours=False)

    def updateOverlayZslice(self, z):
        self.setOverlayImages()

    def updateOverlayZproj(self, how):
        if how.find('max') != -1 or how == 'same as above':
            self.overlay_z_label.setDisabled(True)
            self.zSliceOverlay_SB.setDisabled(True)
        else:
            self.overlay_z_label.setDisabled(False)
            self.zSliceOverlay_SB.setDisabled(False)
        self.setOverlayImages()

    def updateZproj(self, how):
        for p, posData in enumerate(self.data[self.pos_i:]):
            if self.zProjLockViewButton.isChecked():
                idx = [
                    (posData.filename, frame_i) 
                    for frame_i in range(posData.SizeT)
                ]
            else:
                idx = [(posData.filename, posData.frame_i)]
            posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how
            posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path)
            
        posData = self.data[self.pos_i]
        if how == 'single z-slice':
            self.zSliceScrollBar.setDisabled(False)
            self.zSliceSpinbox.setDisabled(False)
            self.zSliceCheckbox.setDisabled(False)
            self.setZprojDisabled(False)
            self.update_z_slice(self.zSliceScrollBar.sliderPosition())
        else:
            self.zSliceScrollBar.setDisabled(True)
            self.zSliceSpinbox.setDisabled(True)
            self.zSliceCheckbox.setDisabled(True)
            self.setZprojDisabled(self.isSegm3D)
            self.updateAllImages()
    
    def setZprojDisabled(self, disabled, storePrevState=False):
        for action in self.editToolBar.actions():
            button = self.editToolBar.widgetForAction(action)
            if button == self.eraserButton:
                continue
            action.setDisabled(disabled)
            try:
                button.setChecked(False)
            except Exception as err:
                pass
        
    def clearAx2Items(self, onlyHideText=False):
        self.ax2_binnedIDs_ScatterPlot.clear()
        self.ax2_ripIDs_ScatterPlot.clear()
        self.ax2_contoursImageItem.clear()
        self.ax2_lostObjImageItem.clear()
        self.ax2_lostTrackedObjImageItem.clear()
        self.textAnnot[1].clear()
        self.ax2_newMothBudLinesItem.setData([], [])
        self.ax2_oldMothBudLinesItem.setData([], [])
        self.ax2_lostObjScatterItem.setData([], [])
    
    def clearAx1Items(self, onlyHideText=False):
        self.ax1_binnedIDs_ScatterPlot.clear()
        self.ax1_ripIDs_ScatterPlot.clear()
        self.labelsLayerImg1.clear()
        self.labelsLayerRightImg.clear()
        self.keepIDsTempLayerLeft.clear()
        self.keepIDsTempLayerRight.clear()
        self.highLightIDLayerImg1.clear()
        self.highLightIDLayerRightImage.clear()
        self.searchedIDitemLeft.clear()
        self.searchedIDitemRight.clear()
        self.ax1_contoursImageItem.clear()
        self.ax1_lostObjImageItem.clear()
        self.ax1_lostTrackedObjImageItem.clear()
        self.textAnnot[0].clear()
        self.ax1_newMothBudLinesItem.setData([], [])
        self.ax1_oldMothBudLinesItem.setData([], [])
        self.ax1_lostObjScatterItem.setData([], [])
        self.ax1_lostTrackedScatterItem.setData([], [])
        self.ccaFailedScatterItem.setData([], [])
        self.yellowContourScatterItem.setData([], [])
        
        self.clearPointsLayers()

        self.clearOverlayLabelsItems()
        self.clearManualBackgroundAnnotations()
        self.clearCustomAnnot()
    
    def clearPointsLayers(self):
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                try:
                    action.scatterItem.clear()
                    # action.pointsData = {}
                except Exception as e:
                    continue

    def clearOverlayLabelsItems(self):
        for segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items():
            items = self.overlayLabelsItems[segmEndname]
            imageItem, contoursItem, gradItem = items
            imageItem.clear()
            contoursItem.clear()

    def clearAllItems(self):
        self.clearAx1Items()
        self.clearAx2Items()
        
    def clearCustomAnnot(self):
        for button in self.customAnnotDict.keys():
            scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem']
            scatterPlotItem.setData([], [])

    def clearCurvItems(self, removeItems=True):
        try:
            posData = self.data[self.pos_i]
            curvItems = zip(posData.curvPlotItems,
                            posData.curvAnchorsItems,
                            posData.curvHoverItems)
            for plotItem, curvAnchors, hoverItem in curvItems:
                plotItem.setData([], [])
                curvAnchors.setData([], [])
                hoverItem.setData([], [])
                if removeItems:
                    self.ax1.removeItem(plotItem)
                    self.ax1.removeItem(curvAnchors)
                    self.ax1.removeItem(hoverItem)

            if removeItems:
                posData.curvPlotItems = []
                posData.curvAnchorsItems = []
                posData.curvHoverItems = []
        except AttributeError:
            # traceback.print_exc()
            pass
    
    # @exec_time
    def splineToObj(self, xxA=None, yyA=None, isRightClick=False):
        posData = self.data[self.pos_i]
        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False, storeOnlyZoom=True)

        if isRightClick:
            xxS, yyS = self.curvPlotItem.getData()
            if xxS is None:
                self.setUncheckedAllButtons()
                return
            N = len(xxS)
            self.smoothAutoContWithSpline(n=int(N*0.05))

        xxS, yyS = self.getClosedSplineCoords()

        self.setBrushID()
        newIDMask = np.zeros(self.currentLab2D.shape, bool)
        rr, cc = skimage.draw.polygon(yyS, xxS)
        newIDMask[rr, cc] = True
        newIDMask[self.currentLab2D!=0] = False
        self.currentLab2D[newIDMask] = posData.brushID
        self.set_2Dlab(self.currentLab2D)

    def addFluoChNameContextMenuAction(self, ch_name):
        posData = self.data[self.pos_i]
        allTexts = [
            action.text() for action in self.chNamesQActionGroup.actions()
        ]
        if ch_name not in allTexts:
            action = QAction(self)
            action.setText(ch_name)
            action.setCheckable(True)
            self.chNamesQActionGroup.addAction(action)
            action.setChecked(True)
            self.fluoDataChNameActions.append(action)

    def computeSegm(self, force=False):
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer' or mode == 'Cell cycle analysis':
            return

        if np.any(posData.lab) and not force:
            # Do not compute segm if there is already a mask
            return

        if not self.autoSegmAction.isChecked():
            return

        self.repeatSegm(model_name=self.segmModelName)

    def initImgCmap(self):
        if not 'img_cmap' in self.df_settings.index:
            self.df_settings.at['img_cmap', 'value'] = 'grey'
        self.imgCmapName = self.df_settings.at['img_cmap', 'value']
        self.imgCmap = self.imgGrad.cmaps[self.imgCmapName]
        if self.imgCmapName != 'grey':
            # To ensure mapping to colors we need to normalize image
            self.normalizeByMaxAction.setChecked(True)
    
    def initGlobalAttr(self):
        self.setOverlayColors()

        self.initImgCmap()

        # Colormap
        self.setLut()

        self.fluoDataChNameActions = []

        self.splineHoverON = False
        self.tempSegmentON = False
        self.isCtrlDown = False
        self.xyOnCtrlPressedFirstTime = None
        self.typingEditID = False
        self.isShiftDown = False
        self.prevAnnotOptions = None
        self.ghostObject = None
        self.autoContourHoverON = False
        self.navigateScrollBarStartedMoving = True
        self.zSliceScrollBarStartedMoving = True
        self.labelRoiRunning = False
        self.isRangeReset = True
        self.lastManualSeparateState = None
        self.editIDmergeIDs = True
        self.doNotAskAgainExistingID = False
        self.doubleRightClickTimeElapsed = False
        self.isRealTimeTrackerInitialized = False
        self.isWarningCcaIntegrity = False
        self.isDoubleRightClick = False
        self.isExportingVideo = False
        self.pointsLayersNeverToggled = True
        self.highlightedIDopts = None
        self.timestampStartTimedelta = timedelta(seconds=0)
        self.keptObjectsIDs = widgets.KeptObjectIDsList(
            self.keptIDsLineEdit, self.keepIDsConfirmAction
        )
        self._ZprojWidgersEnabledState = None
        self.imgValueFormatter = 'd'
        self.rawValueFormatter = 'd'
        self.lastHoverID = -1
        self.annotOptionsToRestore = None
        self.annotOptionsToRestoreRight = None
        self.rescaleIntensChannelHowMapper = {
            self.user_ch_name: 'Rescale each 2D image'
        }
        self.timestampDialog = None
        self.scaleBarDialog = None
        self.countObjsWindow = None

        # Second channel used by cellpose
        self.secondChannelName = None

        self.ax1_viewRange = None
        self.measurementsWin = None

        self.model_kwargs = None
        self.segmModelName = None
        self.labelRoiModel = None
        self.autoSegmDoNotAskAgain = False
        self.labelRoiGarbageWorkers = []
        self.labelRoiActiveWorkers = []

        self.clickedOnBud = False
        self.postProcessSegmWin = None

        self.UserEnforced_DisabledTracking = False
        self.UserEnforced_Tracking = False

        self.ax1BrushHoverID = 0
        
        self.disabled_cca_warnings = set()

        self.last_pos_i = -1
        self.last_frame_i = -1

        # Plots items
        self.isMouseDragImg2 = False
        self.isMouseDragImg1 = False
        self.isMovingLabel = False
        self.isRightClickDragImg1 = False
        self.clickObjYc, self.clickObjXc = None, None

        self.cca_df_colnames = cca_df_colnames
        self.cca_df_dtypes = [
            str, int, int, str, int, int, bool, bool, int
        ]
        self.cca_df_default_values = list(base_cca_dict.values())
        self.cca_df_int_cols = [
            col for col in cca_df_colnames if type(base_cca_dict[col]) == int
        ]
        self.lin_tree_df_bool_col = [
            col for col in cca_df_colnames 
            if isinstance(base_cca_dict[col], bool)
        ]

        self.lin_tree_col_checks = [
            'generation_num',
        ]

        # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols)
        # # self.lin_tree_df_dtypes = [ #dk if i need this, for now ignored
        # #     str, int, int, str, int, int, bool, bool, int
        # # ]
        # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val
        self.lin_tree_df_int_cols = [
            'generation_num',
            'relative_ID',
            'emerg_frame_i',
            'division_frame_i',
            'corrected_on_frame_i'
        ]
        self.lin_tree_df_bool_col = [
            'is_history_known',
        ]

        self.lin_tree_col_checks = [
            'generation_num',
        ]

        self.lin_tree_df_colnames = self.lin_tree_df_int_cols + self.lin_tree_df_bool_col + self.lin_tree_col_checks
        self.SegForLostIDsSettings =  {}
    
    def initMetricsToSave(self, posData):
        posData.setLoadedChannelNames()

        if self.metricsToSave is None:
            # self.metricsToSave means that the user did not set 
            # through setMeasurements dialog --> save all measurements
            self.metricsToSave = {chName:[] for chName in posData.loadedChNames}
            isManualBackgrPresent = posData.manualBackgroundLab is not None
            for chName in posData.loadedChNames:
                metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc(
                    posData.SizeZ>1, chName, isSegm3D=self.isSegm3D,
                    isManualBackgrPresent=isManualBackgrPresent
                )
                self.metricsToSave[chName].extend(metrics_desc.keys())
                self.metricsToSave[chName].extend(bkgr_val_desc.keys())

                custom_metrics_desc = measurements.custom_metrics_desc(
                    posData.SizeZ>1, chName, posData=posData, 
                    isSegm3D=self.isSegm3D, return_combine=False
                )
                self.metricsToSave[chName].extend(
                    custom_metrics_desc.keys()
                )
        
        # Get metrics parameters --> function name, how etc
        self.metrics_func, _ = measurements.standard_metrics_func()
        self.custom_func_dict = measurements.get_custom_metrics_func()
        params = measurements.get_metrics_params(
            self.metricsToSave, self.metrics_func, self.custom_func_dict
        )
        (bkgr_metrics_params, foregr_metrics_params, 
        concentration_metrics_params, custom_metrics_params) = params
        self.bkgr_metrics_params = bkgr_metrics_params
        self.foregr_metrics_params = foregr_metrics_params
        self.concentration_metrics_params = concentration_metrics_params
        self.custom_metrics_params = custom_metrics_params
        
        self.ch_indipend_custom_func_dict = (
            measurements.get_channel_indipendent_custom_metrics_func()
        )
        self.ch_indipend_custom_func_params = (
            measurements.get_channel_indipend_custom_metrics_params(
                self.ch_indipend_custom_func_dict,
                self.chIndipendCustomMetricsToSave
            )
        )

    def initMetrics(self):
        self.logger.info('Initializing measurements...')
        self.chNamesToSkip = []
        self.metricsToSkip = {}
        self.calc_for_each_zslice_mapper = {}
        self.calc_size_for_each_zslice = False
        # At the moment we don't know how many channels the user will load -->
        # we set the measurements to save either at setMeasurements dialog
        # or at initMetricsToSave
        self.metricsToSave = None
        self.regionPropsToSave = measurements.get_props_names()
        if self.isSegm3D:
            self.regionPropsToSave = measurements.get_props_names_3D()
        else:
            self.regionPropsToSave = measurements.get_props_names()  
        
        posData = self.data[self.pos_i]
        self.mixedChCombineMetricsToSkip = []
        self.chIndipendCustomMetricsToSave = list(
            measurements.ch_indipend_custom_metrics_desc(
                posData.SizeZ>1, isSegm3D=self.isSegm3D,
            ).keys()
        )
        
        self.sizeMetricsToSave = list(
            measurements.get_size_metrics_desc(
                self.isSegm3D, posData.SizeT>1
            ).keys()
        )
        exp_path = posData.exp_path
        posFoldernames = myutils.get_pos_foldernames(exp_path)
        for pos in posFoldernames:
            images_path = os.path.join(exp_path, pos, 'Images')
            for file in myutils.listdir(images_path):
                if not file.endswith('custom_combine_metrics.ini'):
                    continue
                filePath = os.path.join(images_path, file)
                configPars = load.read_config_metrics(filePath)

                posData.combineMetricsConfig = load.add_configPars_metrics(
                    configPars, posData.combineMetricsConfig
                )
    
    def initPosAttr(self):
        exp_path = self.data[self.pos_i].exp_path
        pos_foldernames = myutils.get_pos_foldernames(exp_path)
        if len(pos_foldernames) == 1:
            self.loadPosAction.setDisabled(True)
        else:
            self.loadPosAction.setDisabled(False)

        for p, posData in enumerate(self.data):
            self.pos_i = p
            posData.curvPlotItems = []
            posData.curvAnchorsItems = []
            posData.curvHoverItems = []
            posData.trackedLostIDs = set()

            posData.HDDmaxID = np.max(posData.segm_data)

            # Decision on what to do with changes to future frames attr
            posData.doNotShowAgain_EditID = False
            posData.UndoFutFrames_EditID = False
            posData.applyFutFrames_EditID = False

            posData.doNotShowAgain_RipID = False
            posData.UndoFutFrames_RipID = False
            posData.applyFutFrames_RipID = False

            posData.doNotShowAgain_DelID = False
            posData.UndoFutFrames_DelID = False
            posData.applyFutFrames_DelID = False

            posData.doNotShowAgain_keepID = False
            posData.UndoFutFrames_keepID = False
            posData.applyFutFrames_keepID = False
            
            posData.doNotShowAgainAssignNewID = False
            posData.UndoFutFramesAssignNewID = False
            posData.applyFutFramesAssignNewID = False

            posData.includeUnvisitedInfo = {
                'Delete ID': False, 'Edit ID': False, 'Keep ID': False
            }
            
            posData.loadTrackedLostCentroids()
            posData.acdcTracker2stepsAnnotInfo = {}

            posData.doNotShowAgain_BinID = False
            posData.UndoFutFrames_BinID = False
            posData.applyFutFrames_BinID = False

            posData.disableAutoActivateViewerWindow = False
            posData.new_IDs = []
            posData.lost_IDs = []
            posData.multiBud_mothIDs = [2]
            posData.UndoRedoStates = [[] for _ in range(posData.SizeT)]
            posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)]

            posData.ol_data_dict = {}
            posData.ol_data = None

            posData.ol_labels_data = None       
            
            missing_frames = posData.SizeT - len(posData.allData_li)
            if missing_frames > 0:
                posData.allData_li.extend([None] * missing_frames)
            for i in range(posData.SizeT):
                if posData.allData_li[i] is None:
                    posData.allData_li[i] = (
                        myutils.get_empty_stored_data_dict()
                    )
            
            posData.lutLevels = {channel: {} for channel in self.ch_names}

            posData.ccaStatus_whenEmerged = {}

            posData.frame_i = 0
            posData.brushID = 0
            posData.binnedIDs = set()
            posData.ripIDs = set()
            posData.cca_df = None
            if posData.last_tracked_i is not None:
                last_tracked_num = posData.last_tracked_i+1
                # Load previous session data
                # Keep track of which ROIs have already been added
                # in previous frame
                delROIshapes = [[] for _ in range(posData.SizeT)]
                for i in range(last_tracked_num):
                    posData.frame_i = i
                    self.get_data()
                    self.store_data(
                        enforce=True, autosave=False, store_cca_df_copy=True
                    )

                # Ask whether to resume from last frame
                if last_tracked_num>1:
                    msg = widgets.myMessageBox()
                    txt = html_utils.paragraph(
                        'Cell-ACDC detected a previous session ended '
                        f'at frame {last_tracked_num}.<br><br>'
                        f'Do you want to <b>resume from frame '
                        f'{last_tracked_num}?</b>'
                    )
                    noButton, yesButton = msg.question(
                        self, 'Start from last session?', txt,
                        buttonsTexts=(' No ', 'Yes')
                    )
                    self.AutoPilotProfile.storeClickMessageBox(
                        'Start from last session?', msg.clickedButton.text()
                    )
                    if msg.clickedButton == yesButton:
                        posData.frame_i = posData.last_tracked_i
                    else:
                        posData.frame_i = 0

        # Back to first position
        self.pos_i = 0
        self.get_data(debug=False)
        self.store_data(autosave=False)
        # self.updateAllImages()

        # Link Y and X axis of both plots to scroll zoom and pan together
        self.ax2.vb.setYLink(self.ax1.vb)
        self.ax2.vb.setXLink(self.ax1.vb)

        self.setAllIDs()

    def navigateSpinboxValueChanged(self, value):
        self.navigateScrollBar.setSliderPosition(value)
        if self.isSnapshot:
            self.PosScrollBarMoved(value)
        else:
            self.navigateScrollBarStartedMoving = True
            self.framesScrollBarMoved(value)
    
    def navigateSpinboxEditingFinished(self):
        if self.isSnapshot:
            self.PosScrollBarReleased()
        else:
            self.framesScrollBarReleased()

    def PosScrollBarAction(self, action):
        if action == SliderSingleStepAdd:
            self.next_cb()
        elif action == SliderSingleStepSub:
            self.prev_cb()
        elif action == SliderPageStepAdd:
            self.PosScrollBarReleased()
        elif action == SliderPageStepSub:
            self.PosScrollBarReleased()

    def PosScrollBarMoved(self, pos_n):
        self.pos_i = pos_n-1
        self.updateFramePosLabel()
        proceed_cca, never_visited = self.get_data()
        self.updateAllImages()
        self.setStatusBarLabel()

    def PosScrollBarReleased(self):
        self.pos_i = self.navigateScrollBar.sliderPosition()-1
        self.updateFramePosLabel()
        self.updatePos()

    def resetNavigateFramesScrollbar(self, frame_i=None):
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i
            
        self.navigateScrollBar.setValueNoSignal(frame_i+1)
    
    def framesScrollBarActionTriggered(self, action):
        if action == SliderSingleStepAdd:
            # Clicking on dialogs triggered by next_cb might trigger
            # pressEvent of navigateQScrollBar, avoid that
            self.navigateScrollBar.disableCustomPressEvent()
            self.next_cb()
            QTimer.singleShot(100, self.navigateScrollBar.enableCustomPressEvent)
        elif action == SliderSingleStepSub:
            self.prev_cb()
        elif action == SliderPageStepAdd:
            self.framesScrollBarReleased()
        elif action == SliderPageStepSub:
            self.framesScrollBarReleased()

    def framesScrollBarMoved(self, frame_n):
        posData = self.data[self.pos_i]
        posData.frame_i = frame_n-1
        if posData.allData_li[posData.frame_i]['labels'] is None:
            if posData.frame_i < len(posData.segm_data):
                posData.lab = posData.segm_data[posData.frame_i]
            else:
                posData.lab = np.zeros_like(posData.segm_data[0])
        else:
            posData.lab = posData.allData_li[posData.frame_i]['labels']

        self.setImageImg1()
        if self.overlayButton.isChecked():
            self.setOverlayImages()

        if self.navigateScrollBarStartedMoving:
            self.clearAllItems()

        self.navSpinBox.setValueNoEmit(posData.frame_i+1)
        if self.labelsGrad.showLabelsImgAction.isChecked():
            self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False)
        self.updateLookuptable()
        self.updateFramePosLabel()
        self.updateViewerWindow()
        self.navigateScrollBarStartedMoving = False

    def framesScrollBarReleased(self):
        self.navigateScrollBarStartedMoving = True
        posData = self.data[self.pos_i]
        posData.frame_i = self.navigateScrollBar.sliderPosition()-1
        self.updateFramePosLabel()
        proceed_cca, never_visited = self.get_data()
        self.updateAllImages()

    def unstore_data(self):
        posData = self.data[self.pos_i]
        posData.allData_li[posData.frame_i] = myutils.get_empty_stored_data_dict()
    
    def getStoredSegmData(self):
        posData = self.data[self.pos_i]
        segm_data = []
        for data_frame_i in posData.allData_li:
            lab = data_frame_i['labels']
            if lab is None:
                break
            segm_data.append(lab)
        return np.array(segm_data)
    
    def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask):
        posData = self.data[self.pos_i]
        try:
            nextLab = posData.allData_li[posData.frame_i+1]['labels']
        except IndexError:
            # This is last frame --> there are no future frames
            return
        
        if nextLab is None:
            return
        
        newID_lab = np.zeros_like(posData.lab)
        newID_lab[newIDmask] = newID
        newLab_rp = [posData.rp[posData.IDs_idxs[newID]]]
        newLab_IDs = [newID]        
        nextRp = posData.allData_li[posData.frame_i+1]['regionprops']
        
        tracked_lab = self.trackFrame(
            nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs,
            assign_unique_new_IDs=False
        )
        trackedID = tracked_lab[newID_lab>0][0]
        if trackedID == newID:
            # Object does not exist in future frame --> do not track
            return
        
        if posData.IDs_idxs.get(trackedID) is not None:
            # Tracked ID already exists --> do not track to avoid merging
            return
                
        return trackedID

    @exception_handler
    def store_data(
            self, pos_i=None, enforce=True, debug=False, mainThread=True,
            autosave=True, store_cca_df_copy=False
        ):
        pos_i = self.pos_i if pos_i is None else pos_i
        posData = self.data[pos_i]
        if posData.frame_i < 0:
            # In some cases we set frame_i = -1 and then call next_frame
            # to visualize frame 0. In that case we don't store data
            # for frame_i = -1
            return

        mode = str(self.modeComboBox.currentText())

        if mode == 'Viewer' and not enforce:
            return

        # if not mainThread:
        #     self.lin_tree_ask_changes()
        
        allData_li = posData.allData_li[posData.frame_i]
        allData_li['regionprops'] = posData.rp.copy()
        allData_li['labels'] = posData.lab.copy()
        allData_li['IDs'] = posData.IDs.copy()
        allData_li['manualBackgroundLab'] = (
            posData.manualBackgroundLab
        )
        allData_li['IDs_idxs'] = (
            posData.IDs_idxs.copy()
        )
        self.store_zslices_rp()

        # Store dynamic metadata
        is_cell_dead_li = [False]*len(posData.rp)
        is_cell_excluded_li = [False]*len(posData.rp)
        IDs = [0]*len(posData.rp)
        xx_centroid = [0]*len(posData.rp)
        yy_centroid = [0]*len(posData.rp)
        if self.isSegm3D:
            zz_centroid = [0]*len(posData.rp)
        areManuallyEdited = [0]*len(posData.rp)
        editedNewIDs = [vals[2] for vals in posData.editID_info]
        for i, obj in enumerate(posData.rp):
            is_cell_dead_li[i] = obj.dead
            is_cell_excluded_li[i] = obj.excluded
            IDs[i] = obj.label
            try:
                xx_centroid[i] = int(self.getObjCentroid(obj.centroid)[1])
                yy_centroid[i] = int(self.getObjCentroid(obj.centroid)[0])
            except Exception as err:
                printl(obj, obj.centroid, obj.label, posData.frame_i)
            if self.isSegm3D:
                zz_centroid[i] = int(obj.centroid[0])
            if obj.label in editedNewIDs:
                areManuallyEdited[i] = 1

        posData.STOREDmaxID = max(IDs, default=0)

        acdc_df = allData_li['acdc_df']
        if acdc_df is None:
            allData_li['acdc_df'] = pd.DataFrame(
                {
                    'Cell_ID': IDs,
                    'is_cell_dead': is_cell_dead_li,
                    'is_cell_excluded': is_cell_excluded_li,
                    'x_centroid': xx_centroid,
                    'y_centroid': yy_centroid,
                    'was_manually_edited': areManuallyEdited
                }
            ).set_index('Cell_ID')
           
            if self.isSegm3D:
               allData_li['acdc_df']['z_centroid'] = (
                    zz_centroid
                )
        else:
            # Filter or add IDs that were not stored yet
            acdc_df = acdc_df.drop(columns=['time_seconds'], errors='ignore')
            acdc_df = acdc_df.reindex(IDs, fill_value=0)
            acdc_df['is_cell_dead'] = is_cell_dead_li
            acdc_df['is_cell_excluded'] = is_cell_excluded_li
            acdc_df['x_centroid'] = xx_centroid
            acdc_df['y_centroid'] = yy_centroid
            if self.isSegm3D:
                acdc_df['z_centroid'] = zz_centroid
            acdc_df['was_manually_edited'] = areManuallyEdited
            allData_li['acdc_df'] = acdc_df

        if not mainThread:
            self.pointsLayerDataToDf(posData)
        self.store_cca_df(
            pos_i=pos_i, mainThread=mainThread, autosave=autosave, 
            store_cca_df_copy=store_cca_df_copy
        )

    def nearest_point_2Dyx(self, points, all_others):
        """
        Given 2D array of [y, x] coordinates points and all_others return the
        [y, x] coordinates of the two points (one from points and one from all_others)
        that have the absolute minimum distance
        """
        # Compute 3D array where each ith row of each kth page is the element-wise
        # difference between kth row of points and ith row in all_others array.
        # (i.e. diff[k,i] = points[k] - all_others[i])
        diff = points[:, np.newaxis] - all_others
        # Compute 2D array of distances where
        # dist[i, j] = euclidean dist (points[i],all_others[j])
        dist = np.linalg.norm(diff, axis=2)
        # Compute i, j indexes of the absolute minimum distance
        i, j = np.unravel_index(dist.argmin(), dist.shape)
        nearest_point = all_others[j]
        point = points[i]
        min_dist = np.min(dist)
        return min_dist, nearest_point

    def isCurrentFrameCcaVisited(self):
        posData = self.data[self.pos_i]
        curr_df = posData.allData_li[posData.frame_i]['acdc_df']
        return curr_df is not None and 'cell_cycle_stage' in curr_df.columns

    def warnScellsGone(self, ScellsIDsGone, frame_i):
        msg = widgets.myMessageBox()
        text = html_utils.paragraph(f"""
            In the next frame the followning cells' IDs in S/G2/M
            (highlighted with a yellow contour) <b>will disappear</b>:<br><br>
            {ScellsIDsGone}<br><br>
            If the cell <b>does not exist</b> you might have deleted it at some point. 
            If that's the case, then try to go to some previous frames and reset 
            the cell cycle annotations there (button on the top toolbar).<br><br>
            These cells are either buds or mother whose <b>related IDs will not
            disappear</b>. This is likely due to cell division happening in
            previous frame and the divided bud or mother will be
            washed away.<br><br>
            If you decide to continue these cells will be <b>automatically
            annotated as divided at frame number {frame_i}</b>.<br><br>
            Do you want to continue?
        """)
        _, yesButton, noButton = msg.warning(
           self, 'Cells in "S/G2/M" disappeared!', text,
           buttonsTexts=('Cancel', 'Yes', 'No')
        )
        return msg.clickedButton == yesButton

    def checkScellsGone(self):
        """Check if there are cells in S phase whose relative disappear in
        current frame. Allow user to choose between automatically assign
        division to these cells or cancel and not visit the frame.

        Returns
        -------
        bool
            False if there are no cells disappeared or the user decided
            to accept automatic division.
        """
        automaticallyDividedIDs = []

        mode = str(self.modeComboBox.currentText())
        if mode.find('Cell cycle') == -1:
            # No cell cycle analysis mode --> do nothing
            return False, automaticallyDividedIDs

        posData = self.data[self.pos_i]

        if posData.allData_li[posData.frame_i]['labels'] is None:
            # Frame never visited/checked in segm mode --> autoCca_df will raise
            # a critical message
            return False, automaticallyDividedIDs

        # Check if there are S cells that either only mother or only
        # bud disappeared and automatically assign division to it
        # or abort visiting this frame
        prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df']
        prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
        prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy()

        ScellsIDsGone = []
        for ccSeries in prev_cca_df.itertuples():
            ID = ccSeries.Index
            ccs = ccSeries.cell_cycle_stage
            if ccs != 'S':
                continue

            relID = ccSeries.relative_ID
            if relID == -1:
                continue
            
            # Check is relID is gone while ID stays
            if relID not in posData.IDs and ID in posData.IDs:
                ScellsIDsGone.append(relID)

        if not ScellsIDsGone:
            # No cells in S that disappears --> do nothing
            return False, automaticallyDividedIDs

        self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp)
        proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i)
        self.clearLostObjContoursItems()
        
        if not proceed:
            return True, automaticallyDividedIDs

        for IDgone in ScellsIDsGone:
            relID = prev_cca_df.at[IDgone, 'relative_ID']
            self.annotateDisappearedBeforeDivision(relID, IDgone, prev_cca_df)
            self.annotateDivision(
                prev_cca_df, IDgone, relID, frame_i=posData.frame_i-1
            )
            self.annotateDivisionCurrentFrameRelativeIDgone(relID)
            automaticallyDividedIDs.append(relID)
            
        self.store_cca_df(frame_i=posData.frame_i-1, cca_df=prev_cca_df)

        return False, automaticallyDividedIDs

    def annotateDivisionCurrentFrameRelativeIDgone(self, IDwhoseRelativeIsGone):
        posData = self.data[self.pos_i]
        if posData.cca_df is None:
            return
        ID = IDwhoseRelativeIsGone
        posData.cca_df.at[ID, 'generation_num'] += 1
        posData.cca_df.at[ID, 'division_frame_i'] = posData.frame_i-1
        posData.cca_df.at[ID, 'relationship'] = 'mother'

    def annotateDisappearedBeforeDivision(
            self, relID, IDgone, cca_df, frame_i=None
        ):
        posData = self.data[self.pos_i]        
        gen_num = cca_df.at[relID, 'generation_num']
        if frame_i is None:
            frame_i = posData.frame_i
        
        for past_frame_i in range(frame_i-1, -1, -1):
            past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True)
            if past_cca_df is None:
                return
            
            try:
                if past_cca_df.at[relID, 'generation_num'] != gen_num:
                    # ID is a mother and the cell cycle is finished here
                    return
            except Exception as err:
                # Bud stops existing --> stop process
                return
            
            past_cca_df.at[IDgone, 'disappears_before_division'] = 1
            past_cca_df.at[relID, 'daughter_disappears_before_division'] = 1
            
            self.store_cca_df(
                cca_df=past_cca_df, frame_i=past_frame_i, autosave=False
            )
    
    @exception_handler
    def attempt_auto_cca(self, enforceAll=False):
        mode = str(self.modeComboBox.currentText())
        posData = self.data[self.pos_i]

        if mode == 'Cell cycle analysis':
            notEnoughG1Cells, proceed = self.autoCca_df(
                enforceAll=enforceAll
            )
            if not proceed:
                return notEnoughG1Cells, proceed
            
            # mode = str(self.modeComboBox.currentText())
            if posData.cca_df is None: # ???
                notEnoughG1Cells = False
                proceed = True
                return notEnoughG1Cells, proceed
            if posData.cca_df.isna().any(axis=None):
                raise ValueError('Cell cycle analysis table contains NaNs')
            # self.checkMultiBudMoth()
            proceed = self.checkMothersExcludedOrDead()
            return notEnoughG1Cells, proceed

        elif mode == 'Normal division: Lineage tree':
            self.autoLinTree_df()
            notEnoughG1Cells = False
            proceed = True
            return notEnoughG1Cells, proceed
            
        else:
            notEnoughG1Cells = False
            proceed = True
            return notEnoughG1Cells, proceed
        


    def highlightIDs(self, IDs, pen):
        pass

    def warnFrameNeverVisitedSegmMode(self):
        msg = widgets.myMessageBox()
        warn_cca = msg.critical(
            self, 'Next frame NEVER visited',
            'Next frame was never visited in "Segmentation and Tracking"'
            'mode.\n You cannot perform cell cycle analysis on frames'
            'where segmentation and/or tracking errors were not'
            'checked/corrected.\n\n'
            'Switch to "Segmentation and Tracking" mode '
            'and check/correct next frame,\n'
            'before attempting cell cycle analysis again',
        )
        return False

    def checkCcaPastFramesNewIDs(self):
        posData = self.data[self.pos_i]
        if not posData.new_IDs:
            return
        
        found_cca_df_IDs = []
        for frame_i in range(posData.frame_i-2, -1, -1):
            acdc_df = posData.allData_li[frame_i]['acdc_df']
            cca_df_i = acdc_df[self.cca_df_colnames]
            intersect_idx = cca_df_i.index.intersection(posData.new_IDs)
            cca_df_i = cca_df_i.loc[intersect_idx]
            if cca_df_i.empty:
                continue
            found_cca_df_IDs.append(cca_df_i)
            
            # Remove IDs found in past frames from new_IDs list
            newIDs = np.array(posData.new_IDs, dtype=np.uint32)
            mask_index = np.in1d(newIDs, cca_df_i.index)
            posData.new_IDs = list(newIDs[~mask_index])
            if not posData.new_IDs:
                return found_cca_df_IDs
        return found_cca_df_IDs
    
    def initMissingFramesCca(self, last_cca_frame_i, current_frame_i):
        self.logger.info(
            'Initialising cell cycle annotations of missing past frames...'
        )
        posData = self.data[self.pos_i]
        current_frame_i = posData.frame_i
        
        annotated_cca_dfs = []
        for frame_i in range(last_cca_frame_i+1):
            acdc_df = posData.allData_li[frame_i]['acdc_df']
            if 'cell_cycle_stage' in acdc_df.columns:
                continue
            
            acdc_df[self.cca_df_colnames] = ''
        
        annotated_cca_dfs = [
            posData.allData_li[i]['acdc_df'][self.cca_df_colnames]
            for i in range(last_cca_frame_i+1)
        ]
        keys = range(last_cca_frame_i+1)
        names = ['frame_i', 'Cell_ID']
        annotated_cca_df = (
            pd.concat(annotated_cca_dfs, keys=keys, names=names)
            .reset_index()
            .set_index(['Cell_ID', 'frame_i'])
            .sort_index()
        )
        
        last_annotated_cca_df = annotated_cca_df.groupby(level=0).last()
        cca_df_colnames = self.cca_df_colnames
        pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100)
        for frame_i in range(last_cca_frame_i, current_frame_i+1):
            posData.frame_i = frame_i
            self.get_data()
            cca_df = self.getBaseCca_df()

            idx = last_annotated_cca_df.index.intersection(cca_df.index)
            cca_df.loc[idx, cca_df_colnames] = last_annotated_cca_df.loc[idx]

            self.store_cca_df(cca_df=cca_df, frame_i=frame_i, autosave=False)
            pbar.update()
        pbar.close()

        posData.frame_i = current_frame_i
        self.get_data()

    def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading
        """
        When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before.

        Parameters
        ----------
        current_frame_i : int
            The index of the current frame.

        Returns
        -------
        None

        Notes
        -----
        This method initializes the lineage tree annotations of missing past frames. If the lineage tree has not been initialized before, it creates a new lineage tree based on the labels of the first frame. It then iterates over the missing frames and updates the lineage tree with the labels and region properties of each frame.
        """

        self.logger.info(
            'Initialising lineage tree annotations of missing past frames...'
        )

        self.store_data(autosave=False)
        self.get_data()

        posData = self.data[self.pos_i]
        current_frame_i = posData.frame_i

        if not self.lineage_tree: # init lin tree if not done already
            self.lineage_tree = normal_division_lineage_tree(lab = posData.allData_li[0]['labels']) # here frame_i!=0
            df_li = [posData.allData_li[i]['acdc_df'] for i in range(len(posData.allData_li))]
            self.lineage_tree.load_lineage_df_list(df_li)

        missing_frames = list(range(current_frame_i+1))
        present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else []
        present_frames = [] if not present_frames else present_frames # deal with None
        missing_frames = [frame_i for frame_i in missing_frames if frame_i not in present_frames]
        missing_frames.sort()

        for frame_i in missing_frames:
            lab = posData.allData_li[frame_i]['labels']
            prev_lab = posData.allData_li[frame_i-1]['labels']
            rp = posData.allData_li[frame_i]['regionprops']
            prev_rp = posData.allData_li[frame_i-1]['regionprops']
            # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though
            self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp)

        self.lin_tree_to_acdc_df(force_all=True) # store lineage tree in acdc_df
        posData.frame_i = current_frame_i
        self.store_data()

    def _getCcaCostMatrix(
            self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours
        ):
        posData = self.data[self.pos_i]
        dataDict = posData.allData_li[posData.frame_i]
        dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df')
        if dist_matrix_df is None:
            cost = np.full((numCellsG1, numNewCells), np.inf)
            for obj in posData.rp:
                ID = obj.label
                try:
                    i = IDsCellsG1.index(ID)
                except ValueError:
                    continue

                cont = self.getObjContours(obj)
                i = IDsCellsG1.index(ID)
                
                # Get distance from cell in G1 and all other new cells
                for j, newID_cont in enumerate(newIDs_contours):
                    min_dist, nearest_xy = self.nearest_point_2Dyx(
                        cont, newID_cont
                    )
                    cost[i, j] = min_dist
            
            return cost

        cost = dist_matrix_df.loc[IDsCellsG1, posData.new_IDs].values
        
        return cost

    def autoCca_df(self, enforceAll=False):
        """
        Assign each bud to a mother with scipy linear sum assignment
        (Hungarian or Munkres algorithm). First we build a cost matrix where
        each (i, j) element is the minimum distance between bud i and mother j.
        Then we minimize the cost of assigning each bud to a mother, and finally
        we write the assignment info into cca_df
        """
        proceed = True
        notEnoughG1Cells = False
        ScellsGone = False

        posData = self.data[self.pos_i]

        # Skip cca if not the right mode
        mode = str(self.modeComboBox.currentText())
        if mode.find('Cell cycle') == -1:
            return notEnoughG1Cells, proceed


        # Make sure that this is a visited frame in segmentation tracking mode
        if posData.allData_li[posData.frame_i]['labels'] is None:
            proceed = self.warnFrameNeverVisitedSegmMode()
            return notEnoughG1Cells, proceed
        
        # Determine if this is the last visited frame for repeating
        # bud assignment on non manually correct (corrected_on_frame_i>0) buds.
        # The idea is that the user could have assigned division on a cell
        # by going previous and we want to check if this cell could be a
        # "better" mother for those non manually corrected buds
        curr_df = posData.allData_li[posData.frame_i]['acdc_df']
        isLastVisitedAgain = self.isLastVisitedAgainCca(
            curr_df, enforceAll=enforceAll
        )
        
        frameAlreadyAnnotated = (
            posData.cca_df is not None
            and not enforceAll
            and not isLastVisitedAgain
        )
        # Use stored cca_df and do not modify it with automatic stuff
        if frameAlreadyAnnotated:
            return notEnoughG1Cells, proceed
        
        # Keep only correctedAssignIDs if requested
        # For the last visited frame we perform assignment again only on
        # IDs where we didn't manually correct assignment
        correctedAssignIDs = set()
        if isLastVisitedAgain and not enforceAll:
            try:
                correctedAssignIDs = curr_df[
                    curr_df['corrected_on_frame_i']>0
                ].index
            except Exception as e:
                correctedAssignIDs = []
            posData.new_IDs = [
                ID for ID in posData.new_IDs
                if ID not in correctedAssignIDs
            ]
        
        # Check if new IDs exist some time in the past
        found_cca_df_IDs = self.checkCcaPastFramesNewIDs()

        # Check if there are some S cells that disappeared
        abort, automaticallyDividedIDs = self.checkScellsGone()
        if abort:
            notEnoughG1Cells = False
            proceed = False
            return notEnoughG1Cells, proceed

        # Get previous dataframe
        acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df']
        prev_cca_df = acdc_df[self.cca_df_colnames].copy()

        if posData.cca_df is None:
            posData.cca_df = prev_cca_df.copy()
        else:
            posData.cca_df = curr_df[self.cca_df_colnames].copy()
        
        # concatenate new IDs found in past frames (before frame_i-1)
        if found_cca_df_IDs is not None:
            cca_df = pd.concat([posData.cca_df, *found_cca_df_IDs])
            unique_idx = ~cca_df.index.duplicated(keep='first')
            posData.cca_df = cca_df[unique_idx]

        # If there are no new IDs we are done
        if not posData.new_IDs:
            proceed = True
            self.store_cca_df()
            return notEnoughG1Cells, proceed

        # Get cells in G1 (exclude dead) and check if there are enough cells in G1
        try:
            prev_df_G1 = prev_cca_df[prev_cca_df['cell_cycle_stage']=='G1']
            prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]['is_cell_dead']]
            IDsCellsG1 = set(prev_df_G1.index)
        except Exception as err:
            IDsCellsG1 = set()
        
        if isLastVisitedAgain or enforceAll:
            # If we are repeating auto cca for last visited frame
            # then we also add the cells in G1 that appears in current frame
            # and we remove the ones that are already in S in current frame 
            # if they were manually corrected (i.e., they cannot be mother).
            # Note that potential mother cells must be either appearing in 
            # current frame or in G1 also at previous frame. 
            # If we would consider cells that are in G1 at current frame 
            # but not in previous frame, assigning a bud to it would 
            # result in no G1 at all for the mother cell.
            df_G1 = posData.cca_df[posData.cca_df['cell_cycle_stage']=='G1']
            current_G1_IDs = df_G1.index
            new_cell_G1 = [
                ID for ID in current_G1_IDs if ID not in prev_cca_df.index
            ]
            IDsCellsG1.update(new_cell_G1)
            cells_S_current = posData.cca_df[
                (posData.cca_df['cell_cycle_stage']=='S')
                & (posData.cca_df['corrected_on_frame_i']==posData.frame_i)
            ].index
            IDsCellsG1 = IDsCellsG1 - set(cells_S_current)

        # Remove cells that disappeared
        IDsCellsG1 = [ID for ID in IDsCellsG1 if ID in posData.IDs]
        
        numCellsG1 = len(IDsCellsG1)
        numNewCells = len(posData.new_IDs)
        if numCellsG1 < numNewCells:
            notEnoughG1Cells, proceed = self.handleNoCellsInG1(
                numCellsG1, numNewCells
            )
            return notEnoughG1Cells, proceed

        # Compute new IDs contours
        newIDs_contours = []
        for obj in posData.rp:
            ID = obj.label
            if ID in posData.new_IDs:
                cont = self.getObjContours(obj)
                newIDs_contours.append(cont)

        # Compute cost matrix
        cost = self._getCcaCostMatrix(
            numCellsG1, numNewCells, IDsCellsG1, newIDs_contours
        )

        # Run hungarian (munkres) assignment algorithm
        row_idx, col_idx = scipy.optimize.linear_sum_assignment(cost)
        
        # New mother cells
        newMothIDs = {IDsCellsG1[i] for i in row_idx}
        
        # Assign buds to mothers
        for i, j in zip(row_idx, col_idx):
            mothID = IDsCellsG1[i]
            budID = posData.new_IDs[j]
            
            relID = None
            # If we are repeating assignment for the bud then we also have to
            # correct the possibily wrong mother --> it goes back to 
            # G1 if it's not a mother that we assign now
            if budID in posData.cca_df.index:
                relID = posData.cca_df.at[budID, 'relative_ID']
                if relID in prev_cca_df.index and relID not in newMothIDs:
                    posData.cca_df.loc[relID] = prev_cca_df.loc[relID]
            
            posData.cca_df.at[mothID, 'relative_ID'] = budID
            posData.cca_df.at[mothID, 'cell_cycle_stage'] = 'S'

            bud_cca_dict = base_cca_dict.copy()
            bud_cca_dict['cell_cycle_stage'] = 'S'
            bud_cca_dict['generation_num'] = 0
            bud_cca_dict['relative_ID'] = mothID
            bud_cca_dict['relationship'] = 'bud'
            bud_cca_dict['emerg_frame_i'] = posData.frame_i
            bud_cca_dict['is_history_known'] = True
            bud_cca_dict['corrected_on_frame_i'] = -1
            posData.cca_df.loc[budID] = pd.Series(bud_cca_dict)
        
        # Keep only existing IDs
        posData.cca_df = posData.cca_df.loc[posData.IDs]

        self.store_cca_df()
        proceed = True
        return notEnoughG1Cells, proceed

    def autoLinTree_df(self, enforceAll=False):
        """Automatically generates a lineage tree dataframe.

        This method generates a lineage tree dataframe based on the current mode and data.
        It checks if the mode is set to 'Normal division: Lineage tree' and if the current frame
        is not already processed. If the conditions are met, it retrieves the necessary data
        from the current position data and previous position data, and passes it to the
        `real_time` method of the `lineage_tree` object. Finally, it converts the lineage tree
        to an ACDC dataframe and adds the current frame to the set of frames that have been
        processed.

        Parameters
        ----------
        enforceAll : bool, optional
            If True, enforces processing of all frames, even if they have been processed before.
            If False, only processes frames that have not been processed before. Default is False.

        Returns
        -------
        bool
            True if there are not enough G1 cells for lineage tree generation, False otherwise.
        bool
            True if the lineage tree generation should proceed, False otherwise.
        """
        proceed = True
        notEnoughG1Cells = False
        mode = str(self.modeComboBox.currentText())

        # Skip if not the right mode
        if mode != 'Normal division: Lineage tree':
            return notEnoughG1Cells, proceed
        
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i

        if frame_i in self.lineage_tree.frames_for_dfs:
            return notEnoughG1Cells, proceed

        # Make sure that this is a visited frame in segmentation tracking mode
        if posData.allData_li[frame_i]['labels'] is None: # may need to change this
            proceed = self.warnFrameNeverVisitedSegmMode()
            return notEnoughG1Cells, proceed
        
        self.store_data(autosave=False)
        self.get_data()
        lab = posData.lab
        prev_lab = posData.allData_li[frame_i-1]['labels']
        rp = posData.rp
        prev_rp = posData.allData_li[frame_i-1]['regionprops']

        self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp)
        self.lin_tree_to_acdc_df()
        self.store_data()

    def getObjBbox(self, obj_bbox):
        if self.isSegm3D and len(obj_bbox)==6:
            obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5])
            return obj_bbox
        else:
            return obj_bbox

    def z_lab(self, checkIfProj=False):
        if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice':
            return
        
        if not self.isSegm3D:
            return 
        
        idx = self.zSliceScrollBar.sliderPosition()
        if not self.switchPlaneCombobox.isEnabled():
            return idx
        
        depthAxes = self.switchPlaneCombobox.depthAxes()
        if depthAxes == 'z':
            return idx
        elif depthAxes == 'y':
            return (slice(None), idx)
        else:
            return (slice(None), slice(None), idx)
                
    def get_2Dlab(self, lab, force_z=True):
        if self.isSegm3D:
            if force_z:
                return lab[self.z_lab()]
            zProjHow = self.zProjComboBox.currentText()
            isZslice = zProjHow == 'single z-slice'
            if isZslice:
                return lab[self.z_lab()]
            else:
                return lab.max(axis=0)
        else:
            return lab

    # @exec_time
    def applyEraserMask(self, mask):
        posData = self.data[self.pos_i]
        if self.isSegm3D:
            zProjHow = self.zProjComboBox.currentText()
            isZslice = zProjHow == 'single z-slice'
            if isZslice:
                posData.lab[self.z_lab(), mask] = 0
            else:
                posData.lab[:, mask] = 0
        else:
            posData.lab[mask] = 0
    
    def changeBrushID(self):
        """Function called when pressing or releasing shift
        """        
        if not self.isSegm3D:
            # Changing brush ID with shift is only for 3D segm
            return

        if not self.brushButton.isChecked():
            # Brush if not active
            return
        
        if not self.isMouseDragImg2 and not self.isMouseDragImg1:
            # Mouse is not brushing at the moment
            return

        posData = self.data[self.pos_i]
        forceNewObj = not self.isNewID
        
        if forceNewObj:
            # Shift is down --> force new object with brush
            # e.g., 24 --> 28: 
            # 24 is hovering ID that we store as self.prevBrushID
            # 24 object becomes 28 that is the new posData.brushID
            self.isNewID = True
            self.changedID = posData.brushID
            self.restoreBrushID = posData.brushID
            # Set a new ID
            self.setBrushID()
            self.ax2BrushID = posData.brushID
        else:
            # Shift released or hovering on ID in z+-1 
            # --> restore brush ID from before shift was pressed or from 
            # when we started brushing from outside an object 
            # but we hovered on ID in z+-1 while dragging.
            # We change the entire 28 object to 24 so before changing the 
            # brush ID back to 24 we builg the mask with 28 to change it to 24
            self.isNewID = False
            self.changedID = posData.brushID
            # Restore ID   
            posData.brushID = self.restoreBrushID
            self.ax2BrushID = self.restoreBrushID
               
        brushID = posData.brushID
        brushIDmask = self.get_2Dlab(posData.lab) == self.changedID
        self.applyBrushMask(brushIDmask, brushID)
        if self.isMouseDragImg1:
            self.brushColor = self.lut[posData.brushID]/255
            self.setTempImg1Brush(True, brushIDmask, posData.brushID)

    def applyBrushMask(self, mask, ID, toLocalSlice=None):
        posData = self.data[self.pos_i]
        if self.isSegm3D:
            zProjHow = self.zProjComboBox.currentText()
            isZslice = zProjHow == 'single z-slice'
            if isZslice:
                if toLocalSlice is not None:
                    toLocalSlice = (self.z_lab(), *toLocalSlice)
                    posData.lab[toLocalSlice][mask] = ID
                else:
                    posData.lab[self.z_lab()][mask] = ID
            else:
                if toLocalSlice is not None:
                    for z in range(len(posData.lab)):
                        _slice = (z, *toLocalSlice)
                        posData.lab[_slice][mask] = ID
                else:
                    posData.lab[:, mask] = ID
        else:
            if toLocalSlice is not None:
                posData.lab[toLocalSlice][mask] = ID
            else:
                posData.lab[mask] = ID

    def assignNewIDfromClickedID(
            self, clickedID: int, event: QGraphicsSceneMouseEvent
        ):
        posData = self.data[self.pos_i]
        x, y = event.pos().x(), event.pos().y()
        newID = self.setBrushID(return_val=True)
        mapper = [(clickedID, newID)]
        self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y)
            
    def get_2Drp(self, lab=None):  
        if self.isSegm3D:
            if lab is None:
                # self.currentLab2D is defined at self.setImageImg2()
                lab = self.currentLab2D
            lab = self.get_2Dlab(lab)
            rp = skimage.measure.regionprops(lab)
            return rp
        else:
            return self.data[self.pos_i].rp

    def set_2Dlab(self, lab2D):
        posData = self.data[self.pos_i]
        if self.isSegm3D:
            zProjHow = self.zProjComboBox.currentText()
            isZslice = zProjHow == 'single z-slice'
            if isZslice:
                posData.lab[self.z_lab()] = lab2D
            else:
                posData.lab[:] = lab2D
        else:
            posData.lab = lab2D

    def get_labels(
            self, 
            from_store=False, 
            frame_i=None, 
            return_existing=False,
            return_copy=True
        ):
        """Get the labels array.
        
        Parameters
        ----------
        from_store : bool, optional
            If True load the labels array from the stored posData.allData_li, 
            i.e., from RAM. Default is False
        frame_i : int, optional
            If None, use the current frame index. Default is  None
        return_existing : bool, optional
            If True, the second return element will be a boolean that 
            is True if the labels array was found stored in `posData.allData_li`. 
            Default is  False
        return_copy : bool, optional
            If True returns a copy of the labels array

        Returns
        -------
        numpy.ndarray or tuple of (numpy.ndarray, bool)
            The first element is the labels array requested. If `return_existing` 
            is True then this method also returns a second boolean element that 
            is True if the labels array was found in in `posData.allData_li`.
        
        Note
        ----
        
        If `from_store` is True then this method will try to get the stored 
        labels array. If any error occurs then the returned labels are the 
        saved ones in the segmentation file (i.e., from hard drive).
        
        """   
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i
        
        existing = True
        if from_store:
            try:
                labels = posData.allData_li[frame_i]['labels']
                if labels is None:
                    from_store = False
            except Exception as err:
                from_store = False
        
        if not from_store:
            try:
                labels = posData.segm_data[frame_i]
            except IndexError:
                existing = False
                # Visting a frame that was not segmented --> empty masks
                if self.isSegm3D:
                    shape = (posData.SizeZ, posData.SizeY, posData.SizeX)
                else:
                    shape = (posData.SizeY, posData.SizeX)
                labels = np.zeros(shape, dtype=np.uint32)
                return_copy = False
        
        if return_copy:
            labels = labels.copy()
            
        if return_existing:
            return labels, existing
        else:
            return labels

    def _get_editID_info(self, df):
        if 'was_manually_edited' not in df.columns:
            return []
        manually_edited_df = df[df['was_manually_edited'] > 0]
        editID_info = [
            (row.y_centroid, row.x_centroid, row.Index)
            for row in manually_edited_df.itertuples()
        ]
        return editID_info

    def _get_data_unvisited(self, posData, debug=False,lin_tree_init=True,):
        posData.editID_info = []
        proceed_cca = True
        never_visited = True
        if str(self.modeComboBox.currentText()) == 'Cell cycle analysis':
            # Warn that we are visiting a frame that was never segm-checked
            # on cell cycle analysis mode
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(
                'Segmentation and Tracking was <b>never checked from '
                f'frame {posData.frame_i+1} onwards</b>.<br><br>'
                'To ensure correct cell cell cycle analysis you have to '
                'first visit the frames after '
                f'{posData.frame_i+1} with "Segmentation and Tracking" mode.'
            )
            warn_cca = msg.critical(
                self, 'Never checked segmentation on requested frame', txt                    
            )
            proceed_cca = False
            return proceed_cca, never_visited

        elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree':
            # Warn that we are visiting a frame that was never segm-checked
            # on cell cycle analysis mode
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(
                'Segmentation and Tracking was <b>never checked from '
                f'frame {posData.frame_i+1} onwards</b>.<br><br>'
                'To ensure correct lineage tree analysis you have to '
                'first visit the frames after '
                f'{posData.frame_i+1} with "Segmentation and Tracking" mode.'
            )
            warn_cca = msg.critical(#???
                self, 'Never checked segmentation on requested frame', txt                    
            )
            proceed_cca = False
            return proceed_cca, never_visited
        
        # Requested frame was never visited before. Load from HDD
        posData.lab = self.get_labels()
        posData.rp = skimage.measure.regionprops(posData.lab)
        self.setManualBackgroundLab()
        if posData.acdc_df is not None:
            frames = posData.acdc_df.index.get_level_values(0)
            if posData.frame_i in frames:
                # Since there was already segmentation metadata from
                # previous closed session add it to current metadata
                df = posData.acdc_df.loc[posData.frame_i].copy()
                binnedIDs_df = df[df['is_cell_excluded']>0]
                binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs)
                posData.binnedIDs = binnedIDs
                ripIDs_df = df[df['is_cell_dead']>0]
                ripIDs = set(ripIDs_df.index).union(posData.ripIDs)
                posData.ripIDs = ripIDs
                posData.editID_info.extend(self._get_editID_info(df))
                # Load cca df into current metadata
                if 'cell_cycle_stage' in df.columns:
                    if any(df['cell_cycle_stage'].isna()):
                        if 'is_history_known' not in df.columns:
                            df['is_history_known'] = True
                        if 'corrected_on_frame_i' not in df.columns:
                            df['corrected_on_frame_i'] = -1
                        df = df.drop(columns=self.cca_df_colnames)
                    else:
                        # Convert to ints since there were NaN
                        cols = self.cca_df_int_cols
                        df[cols] = df[cols].astype(int)

                i = posData.frame_i
                posData.allData_li[i]['acdc_df'] = df.copy()
        
        if self.lineage_tree is None and lin_tree_init:
            self.initLinTree()
    
        self.get_cca_df()
        
        return proceed_cca, never_visited
    
    def _get_data_visited(self, posData, debug=False,lin_tree_init=True,):        
        # Requested frame was already visited. Load from RAM.
        never_visited = False
        posData.lab = self.get_labels(from_store=True)
        posData.rp = skimage.measure.regionprops(posData.lab)
        df = posData.allData_li[posData.frame_i]['acdc_df']
        try:
            binnedIDs_df = df[df['is_cell_excluded']>0]
        except Exception as err:
            df = myutils.fix_acdc_df_dtypes(df)
            binnedIDs_df = df[df['is_cell_excluded']>0]
        posData.binnedIDs = set(binnedIDs_df.index)
        ripIDs_df = df[df['is_cell_dead']>0]
        posData.ripIDs = set(ripIDs_df.index)
        posData.editID_info = self._get_editID_info(df)
        self.setManualBackgroundLab(load_from_store=True, debug=debug)
        if self.lineage_tree is None and lin_tree_init:
            self.initLinTree()
        
        self.get_cca_df()

        return True, never_visited
    
    @get_data_exception_handler
    def get_data(self, debug=False, lin_tree_init=True):
        posData = self.data[self.pos_i]
        proceed_cca = True
        never_visited = False
        if posData.frame_i > 2:
            # Remove undo states from 4 frames back to avoid memory issues
            posData.UndoRedoStates[posData.frame_i-4] = []
            # Check if current frame contains undo states (not empty list)
            if posData.UndoRedoStates[posData.frame_i]:
                self.undoAction.setDisabled(False)
            elif posData.UndoRedoCcaStates[posData.frame_i]:
                self.undoAction.setDisabled(False)
            else:
                self.undoAction.setDisabled(True)
        self.UndoCount = 0
        # If stored labels is None then it is the first time we visit this frame
        if posData.allData_li[posData.frame_i]['labels'] is None:
            proceed_cca, never_visited =  self._get_data_unvisited(
                posData,lin_tree_init=lin_tree_init,
            )
            if not proceed_cca:
                return proceed_cca, never_visited
        else:
            proceed_cca, never_visited = self._get_data_visited(
                posData,lin_tree_init=lin_tree_init,
            )
        
        self.update_rp_metadata(draw=False)
        posData.IDs = [obj.label for obj in posData.rp]
        posData.IDs_idxs = {
            ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs)))
        }
        self.get_zslices_rp()
        self.pointsLayerDfsToData(posData)
        return proceed_cca, never_visited

    def addIDBaseCca_df(self, posData, ID):
        if ID <= 0:
            # When calling update_cca_df_deletedIDs we add relative IDs
            # but they could be -1 for cells in G1
            return

        _zip = zip(
            self.cca_df_colnames,
            self.cca_df_default_values,
        )
        if posData.cca_df.empty:
            posData.cca_df = pd.DataFrame(
                {col: val for col, val in _zip},
                index=[ID]
            )
        else:
            for col, val in _zip:
                posData.cca_df.at[ID, col] = val
        self.store_cca_df()

    def getBaseCca_df(self, with_tree_cols=False): 
        posData = self.data[self.pos_i]
        IDs = [obj.label for obj in posData.rp]
        cca_df = core.getBaseCca_df(IDs, with_tree_cols=with_tree_cols)
        return cca_df
    
    def get_last_tracked_i(self):
        posData = self.data[self.pos_i]
        last_tracked_i = 0
        for frame_i, data_dict in enumerate(posData.allData_li):
            lab = data_dict['labels']
            if lab is None and frame_i == 0:
                last_tracked_i = 0
                break
            elif lab is None:
                last_tracked_i = frame_i-1
                break
            else:
                last_tracked_i = posData.segmSizeT-1
        return last_tracked_i

    def initSegmTrackMode(self):
        posData = self.data[self.pos_i]
        last_tracked_i = self.get_last_tracked_i()
        
        if posData.frame_i > last_tracked_i:
            # Prompt user to go to last tracked frame
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(
                f'The last visited frame in "Segmentation and Tracking mode" '
                f'is frame {last_tracked_i+1}.\n\n'
                f'We recommend to resume from that frame.<br><br>'
                'How do you want to proceed?'
            )
            goToButton, stayButton = msg.warning(
                self, 'Go to last visited frame?', txt,
                buttonsTexts=(
                    f'Resume from frame {last_tracked_i+1} (RECOMMENDED)',
                    f'Stay on current frame {posData.frame_i+1}'
                )
            )
            if msg.clickedButton == goToButton:
                posData.frame_i = last_tracked_i
                self.get_data()
                self.updateAllImages()
                self.updateScrollbars()
            else:
                last_tracked_i = posData.frame_i
                current_frame_i = posData.frame_i
                self.logger.info(
                    f'Storing data up until frame n. {current_frame_i+1}...'
                )
                pbar = tqdm(total=current_frame_i+1, ncols=100)
                for i in range(current_frame_i):
                    posData.frame_i = i
                    self.get_data()
                    self.store_data(autosave=i==current_frame_i-1)
                    pbar.update()
                pbar.close()

                posData.frame_i = current_frame_i
                self.get_data()
        
        self.highlightLostNew()
        self.updateLastCheckedFrameWidgets(last_tracked_i)

        self.checkTrackingEnabled()
        self.initRealTimeTracker()
    
    def updateLastCheckedFrameWidgets(self, last_tracked_i):
        self.navigateScrollBar.setMaximum(last_tracked_i+1)
        self.navSpinBox.setMaximum(last_tracked_i+1)
        self.lastTrackedFrameLabel.setText(
            f'Last checked frame n. = {last_tracked_i+1}'
        )
    
    @exception_handler
    def initCca(self):
        posData = self.data[self.pos_i]
        last_tracked_i = self.get_last_tracked_i()
        defaultMode = 'Viewer'
        if last_tracked_i == 0:
            txt = html_utils.paragraph(
                'On this dataset either you <b>never checked</b> that the segmentation '
                'and tracking are <b>correct</b> or you did not save yet.<br><br>'
                'If you already visited some frames with "Segmentation and tracking" '
                'mode save data before switching to "Cell cycle analysis mode".<br><br>'
                'Otherwise you first have to check (and eventually correct) some frames '
                'in "Segmentation and Tracking" mode before proceeding '
                'with cell cycle analysis.')
            msg = widgets.myMessageBox()
            msg.critical(
                self, 'Tracking was never checked', txt
            )
            self.modeComboBox.setCurrentText(defaultMode)
            return

        proceed = True
        i = 0
        # Determine last annotated frame index
        for i, dict_frame_i in enumerate(posData.allData_li):
            df = dict_frame_i['acdc_df']
            if df is None:
                break
            elif 'cell_cycle_stage' not in df.columns:
                break
        
        last_cca_frame_i = i if i==0 or i+1==len(posData.allData_li) else i-1

        if last_cca_frame_i == 0:
            # Remove undoable actions from segmentation mode
            posData.UndoRedoStates[0] = []
            self.undoAction.setEnabled(False)
            self.redoAction.setEnabled(False)
        
        if posData.frame_i > last_cca_frame_i:
            # Prompt user to go to last annotated frame
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(f"""
                The <b>last annotated frame</b> is frame {last_cca_frame_i+1}.<br><br>
                Do you want to restart cell cycle analysis from frame 
                {last_cca_frame_i+1}?<br>
            """)
            _, goToFrameButton, stayButton = msg.warning(
                self, 'Go to last annotated frame?', txt, 
                buttonsTexts=(
                    'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', 
                    'No, stay on current frame')
            )
            if goToFrameButton == msg.clickedButton:
                self.addMissingIDs_cca_df(posData)
                self.store_cca_df()
                msg = 'Looking good!'
                self.last_cca_frame_i = last_cca_frame_i
                posData.frame_i = last_cca_frame_i
                self.titleLabel.setText(msg, color=self.titleColor)
                self.get_data()
                self.addMissingIDs_cca_df(posData)
                self.store_cca_df()
                self.updateAllImages()
                self.updateScrollbars()
            elif stayButton == msg.clickedButton:
                self.addMissingIDs_cca_df(posData)
                self.store_cca_df()
                self.initMissingFramesCca(last_cca_frame_i, posData.frame_i)
                last_cca_frame_i = posData.frame_i
                msg = 'Cell cycle analysis initialised!'
                self.titleLabel.setText(msg, color='g')
            elif msg.cancel:
                msg = 'Cell cycle analysis aborted.'
                self.logger.info(msg)
                self.titleLabel.setText(msg, color=self.titleColor)
                self.modeComboBox.setCurrentText(defaultMode)
                proceed = False
                return
        elif posData.frame_i < last_cca_frame_i:
            # Prompt user to go to last annotated frame
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(f"""
                The <b>last annotated frame</b> is frame {last_cca_frame_i+1}.<br><br>
                Do you want to restart cell cycle analysis from frame
                {last_cca_frame_i+1}?<br>
            """)
            yesButton, noButton, _ = msg.question(
                self, 'Go to last annotated frame?', txt, 
                buttonsTexts=('Yes', 'No', 'Cancel')
            )
            if msg.cancel:
                msg = 'Cell cycle analysis aborted.'
                self.logger.info(msg)
                self.titleLabel.setText(msg, color=self.titleColor)
                self.modeComboBox.setCurrentText(defaultMode)
                proceed = False
                return
            
            self.addMissingIDs_cca_df(posData)
            if msg.clickedButton == yesButton:
                self.addMissingIDs_cca_df(posData)
                msg = 'Looking good!'
                self.titleLabel.setText(msg, color=self.titleColor)
                self.last_cca_frame_i = last_cca_frame_i
                posData.frame_i = last_cca_frame_i
                self.get_data()
                self.addMissingIDs_cca_df(posData)
                self.store_cca_df()
                self.updateAllImages()
                self.updateScrollbars()
        else:
            self.get_data()
            self.addMissingIDs_cca_df(posData)
            self.store_cca_df()

        self.last_cca_frame_i = last_cca_frame_i

        self.navigateScrollBar.setMaximum(last_cca_frame_i+1)
        self.navSpinBox.setMaximum(last_cca_frame_i+1)
        self.lastTrackedFrameLabel.setText(
            f'Last cc annot. frame n. = {last_cca_frame_i+1}'
        )
        
        if posData.cca_df is None:
            posData.cca_df = self.getBaseCca_df()
            self.store_cca_df()
            msg = 'Cell cycle analysis initialized!'
            self.logger.info(msg)
            self.titleLabel.setText(msg, color=self.titleColor)
        else:
            self.get_cca_df()
        
        self.enqCcaIntegrityChecker()
        
        return proceed
    @exception_handler
    def initLinTree(self, force=False):
        """
        Initializes the lineage tree analysis.

        This method checks if the tracking has been previously checked and saved. If not, it displays a message to the user.
        It also prompts the user to go to the last annotated frame and restart the lineage tree analysis if necessary.
        Finally, it initializes the necessary data structures and updates the GUI.

        Returns
        -------
        proceed : bool
            True if the initialization is successful, nothing otherwise.
        """

        if not force and self.lineage_tree is not None:
            return
        
        mode = str(self.modeComboBox.currentText())
        if mode != 'Normal division: Lineage tree' and not force:
            return

        posData = self.data[self.pos_i]
        last_tracked_i = self.get_last_tracked_i()
        defaultMode = 'Viewer'
        if last_tracked_i == 0:
            # Display message to the user
            txt = html_utils.paragraph(
                'On this dataset either you <b>never checked</b> that the segmentation '
                'and tracking are <b>correct</b> or you did not save yet.<br><br>'
                'If you already visited some frames with "Segmentation and tracking" '
                'mode save data before switching to "Normal division: Lineage Tree".<br><br>'
                'Otherwise you first have to check (and eventually correct) some frames '
                'in "Segmentation and Tracking" mode before proceeding '
                'with lineage tree analysis.')
            msg = widgets.myMessageBox()
            msg.critical(
                self, 'Tracking was never checked', txt
            )
            self.modeComboBox.setCurrentText(defaultMode)
            return

        proceed = True
        last_lin_tree_frame_i = 0
        # Determine last annotated frame index
        for i, dict_frame_i in enumerate(posData.allData_li):
            df = dict_frame_i['acdc_df']
            if (df is None or
                'generation_num_tree' not in df.columns 
                  or df['generation_num_tree'].isin([np.nan, 0]).all()
                  ):
                break
            else:
                last_lin_tree_frame_i = i

        if last_lin_tree_frame_i == 0:
            # Remove undoable actions from segmentation mode
            posData.UndoRedoStates[0] = []
            self.undoAction.setEnabled(False)
            self.redoAction.setEnabled(False)

        if posData.frame_i > last_lin_tree_frame_i:
            # Prompt user to go to last annotated frame
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(f"""
                The <b>last annotated frame</b> is frame {last_lin_tree_frame_i+1}.<br><br>
                Do you want to restart lineage tree analysis from frame 
                {last_lin_tree_frame_i+1}?<br>
            """)
            _, yesButton, stayButton = msg.warning(
                self, 'Go to last annotated frame?', txt, 
                buttonsTexts=(
                    'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', 
                    'No, stay on current frame')
            )
            if yesButton == msg.clickedButton:
                msg = 'Looking good!'
                self.last_lin_tree_frame_i = last_lin_tree_frame_i
                posData.frame_i = last_lin_tree_frame_i
                self.titleLabel.setText(msg, color=self.titleColor)
                self.get_data(lin_tree_init=False)
                self.updateAllImages() # i dont think I need to change this
                self.updateScrollbars() # i dont think I need to change this
            elif stayButton == msg.clickedButton:
                self.initMissingFramesLinTree(posData.frame_i) #!!!
                last_lin_tree_frame_i = posData.frame_i
                msg = 'Lineage tree analysis initialised!'
                self.titleLabel.setText(msg, color='g')
            elif msg.cancel:
                msg = 'Lineage tree analysis aborted.'
                self.logger.info(msg)
                self.titleLabel.setText(msg, color=self.titleColor)
                self.modeComboBox.setCurrentText(defaultMode)
                proceed = False
                return
            
        elif posData.frame_i < last_lin_tree_frame_i:
            # Prompt user to go to last annotated frame
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(f"""
                The <b>last annotated frame</b> is frame {last_lin_tree_frame_i+1}.<br><br>
                Do you want to restart lineage tree analysis from frame
                {last_lin_tree_frame_i+1}?<br>
            """)
            goTo_last_annotated_frame_i = msg.question(
                self, 'Go to last annotated frame?', txt, 
                buttonsTexts=('Yes', 'No', 'Cancel')
            )[0]
            if goTo_last_annotated_frame_i == msg.clickedButton:
                msg = 'Looking good!'
                self.titleLabel.setText(msg, color=self.titleColor)
                self.last_lin_tree_frame_i = last_lin_tree_frame_i
                posData.frame_i = last_lin_tree_frame_i
                self.get_data(lin_tree_init=False)
                self.updateAllImages() # i dont think I need to change this
                self.updateScrollbars() # i dont think I need to change this
            elif msg.cancel:
                msg = 'Lineage tree analysis aborted.'
                self.logger.info(msg)
                self.titleLabel.setText(msg, color=self.titleColor)
                self.modeComboBox.setCurrentText(defaultMode)
                proceed = False
                return
        else:
            self.get_data(lin_tree_init=False)

        self.last_lin_tree_frame_i = last_lin_tree_frame_i

        self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1)
        self.navSpinBox.setMaximum(last_lin_tree_frame_i+1)

        if self.lineage_tree is None or force:
            self.store_data(autosave=False)
            self.get_data(lin_tree_init=False)
            if posData.frame_i == 0:
                lab = posData.lab
            else:
                lab = posData.allData_li[0]['labels']
            self.lineage_tree = normal_division_lineage_tree(lab = lab)
            df_li = [posData.allData_li[i]['acdc_df'] for i in range(len(posData.allData_li))] 
            self.lineage_tree.load_lineage_df_list(df_li)

            msg = 'Lineage tree analysis initialized!'
            self.logger.info(msg)
            self.titleLabel.setText(msg, color=self.titleColor)

        return proceed
     
    def isCcaCheckerChecking(self):
        if not self.ccaCheckerRunning:
            return False
        
        return self.ccaIntegrityCheckerWorker.isChecking
     
    def getConcatCcaDf(self):
        posData = self.data[self.pos_i]
        cca_dfs = []
        keys = []
        for frame_i in range(0, posData.SizeT):
            cca_df = self.get_cca_df(frame_i=frame_i, return_df=True)
            if cca_df is None:
                break
            
            cca_dfs.append(cca_df)
            keys.append(frame_i)
        
        if not cca_dfs:
            return
        
        global_cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i'])
        return global_cca_df
    
    def storeFromConcatCcaDf(self, global_cca_df):
        posData = self.data[self.pos_i]
        for frame_i in range(0, posData.SizeT):
            try:
                cca_df = global_cca_df.loc[frame_i]
            except KeyError as err:
                break
            
            self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False)
        
        self.get_cca_df()
    
    def resetWillDivideInfo(self):
        global_cca_df = self.getConcatCcaDf()
        if global_cca_df is None:
            return
        
        global_cca_df = load._fix_will_divide(global_cca_df)
        self.storeFromConcatCcaDf(global_cca_df)
    
    def ccaCheckerStopChecking(self):
        if not self.ccaCheckerRunning:
            return
        
        self.ccaIntegrityCheckerWorker.clearQueue()
        
        if self.ccaIntegrityCheckerWorker.isChecking:
            self.ccaIntegrityCheckerWorker.abortChecking = True
    
    def updateLastVisitedFrame(self, last_visited_frame_i=None):
        if last_visited_frame_i is None:
            posData = self.data[self.pos_i]
            last_visited_frame_i = posData.frame_i
        
        mode = str(self.modeComboBox.currentText())
        if mode == 'Viewer':
            return
        elif mode == 'Segmentation and Tracking':
            posData = self.data[self.pos_i]
            if posData.last_tracked_i >= last_visited_frame_i:
                return
            posData.last_tracked_i = last_visited_frame_i
        elif mode == 'Cell cycle analysis':
            if self.last_cca_frame_i >= last_visited_frame_i:
                return
            self.last_cca_frame_i = last_visited_frame_i
    
    def resetCcaFuture(self, from_frame_i):
        posData = self.data[self.pos_i]
        self.last_cca_frame_i = from_frame_i-1
        self.ccaCheckerStopChecking()
        
        self.setNavigateScrollBarMaximum() 
        for i in range(from_frame_i, posData.SizeT):
            posData.allData_li[i].pop('cca_df', None)
            posData.allData_li[i].pop('cca_df_checker', None)
            
            df = posData.allData_li[i]['acdc_df']
            if df is None:
                # No more saved info to delete
                break

            if 'cell_cycle_stage' not in df.columns:
                # No cell cycle info present
                continue

            df = df.drop(columns=self.cca_df_colnames)
            posData.allData_li[i]['acdc_df'] = df
        
        if posData.acdc_df is not None:
            frames = posData.acdc_df.index.get_level_values(0)
            if from_frame_i in frames:
                posData.acdc_df = posData.acdc_df.loc[:from_frame_i]
        
        self.resetWillDivideInfo()
    
    def resetFutureCcaColCurrentFrame(self):
        posData = self.data[self.pos_i]
        
        cca_df_S_mask = posData.cca_df.cell_cycle_stage == 'S'
        posData.cca_df.loc[cca_df_S_mask, 'will_divide'] = 0
        
        mothers_mask = (
            (posData.cca_df.relationship == 'mother')
            & cca_df_S_mask
        )
        bud_mask = posData.cca_df.relationship == 'bud'
        
        posData.cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0
        posData.cca_df.loc[bud_mask, 'disappears_before_division'] = 0
        
        cca_df = self.get_cca_df(frame_i=posData.frame_i, return_df=True)
        if cca_df is not None:
            cca_df_S_mask = cca_df.cell_cycle_stage == 'S'
            cca_df.loc[cca_df_S_mask, 'will_divide'] = 0
            
            mothers_mask = (
                (cca_df.relationship == 'mother')
                & cca_df_S_mask
            )
            bud_mask = cca_df.relationship == 'bud'
            
            cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0
            cca_df.loc[bud_mask, 'disappears_before_division'] = 0
        
        self.store_data()
    
    def get_cca_df(self, frame_i=None, return_df=False):
        # cca_df is None unless the metadata contains cell cycle annotations
        # NOTE: cell cycle annotations are either from the current session
        # or loaded from HDD in "initPosAttr" with a .question to the user
        posData = self.data[self.pos_i]
        cca_df = None
        i = posData.frame_i if frame_i is None else frame_i
        df = posData.allData_li[i]['acdc_df']
        if df is not None:
            if 'cell_cycle_stage' in df.columns:
                cca_df = df[self.cca_df_colnames].copy()
        if cca_df is None and self.isSnapshot:
            cca_df = self.getBaseCca_df()
            posData.cca_df = cca_df
        if return_df:
            return cca_df
        else:
            posData.cca_df = cca_df

    def changeIDfutureFrames(
            self, endFrame_i, oldIDnewIDMapper, includeUnvisited
        ):
        posData = self.data[self.pos_i]
        self.current_frame_i = posData.frame_i
        
        # Store data for current frame
        self.store_data()
        if endFrame_i is None:
            self.app.restoreOverrideCursor()
            return
        
        segmSizeT = len(posData.segm_data)
        for i in range(posData.frame_i+1, segmSizeT):
            lab = posData.allData_li[i]['labels']
            if lab is None and not includeUnvisited:
                self.enqAutosave()
                break

            if lab is not None:
                # Visited frame
                posData.frame_i = i
                self.get_data()
                if self.onlyTracking:
                    self.tracking(enforce=True)
                elif not posData.IDs:
                    continue
                else:
                    maxID = max(posData.IDs, default=0) + 1
                    for old_ID, new_ID in oldIDnewIDMapper:
                        if new_ID in posData.lab:
                            tempID = maxID + 1 # posData.lab.max() + 1
                            posData.lab[posData.lab == old_ID] = tempID
                            posData.lab[posData.lab == new_ID] = old_ID
                            posData.lab[posData.lab == tempID] = new_ID
                            maxID += 1
                        else:
                            posData.lab[posData.lab == old_ID] = new_ID
                    self.update_rp(draw=False)
                self.store_data(autosave=i==endFrame_i)
            elif includeUnvisited:
                # Unvisited frame (includeUnvisited = True)
                lab = posData.segm_data[i]
                for old_ID, new_ID in oldIDnewIDMapper:
                    if new_ID in lab:
                        tempID = lab.max() + 1
                        lab[lab == old_ID] = tempID
                        lab[lab == new_ID] = old_ID
                        lab[lab == tempID] = new_ID
                    else:
                        lab[lab == old_ID] = new_ID
        
        # Back to current frame
        posData.frame_i = self.current_frame_i
        self.get_data()
        self.app.restoreOverrideCursor()

    def unstore_cca_df(self):
        posData = self.data[self.pos_i]
        acdc_df = posData.allData_li[posData.frame_i]['acdc_df']
        for col in self.cca_df_colnames:
            if col not in acdc_df.columns:
                continue
            acdc_df.drop(col, axis=1, inplace=True)

    def store_cca_df_checker(self, posData, frame_i, cca_df):
        if not self.ccaCheckerRunning:
            return
        
        if cca_df is None:
            return
        
        posData.allData_li[frame_i]['cca_df_checker'] = cca_df.copy()
    
    def store_cca_df(
            self, pos_i=None, frame_i=None, cca_df=None, mainThread=True,
            autosave=True, store_cca_df_copy=False
        ):
        pos_i = self.pos_i if pos_i is None else pos_i
        posData = self.data[pos_i]
        i = posData.frame_i if frame_i is None else frame_i
        if cca_df is None:
            cca_df = posData.cca_df
            if self.ccaTableWin is not None and mainThread:
                zoomIDs = self.getZoomIDs()
                self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs)
        
        acdc_df = posData.allData_li[i]['acdc_df']
        if acdc_df is None:
            current_frame_i = None
            if frame_i is not None and frame_i != posData.frame_i:
                current_frame_i = posData.frame_i
                posData.frame_i = frame_i
                self.get_data()
            self.store_data()
            acdc_df = posData.allData_li[i]['acdc_df']
            if current_frame_i is not None:
                # Back to current frame
                posData.frame_i = current_frame_i
                self.get_data(debug=False)
        
        if 'cell_cycle_stage' in acdc_df.columns:
            # Cell cycle info already present --> overwrite with new
            acdc_df[self.cca_df_colnames] = cca_df[self.cca_df_colnames]
            posData.allData_li[i]['acdc_df'] = acdc_df            
        elif cca_df is not None:
            df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore')
            df = df.join(cca_df, how='left')
            posData.allData_li[i]['acdc_df'] = df
        
        # Store copy for cca integrity worker
        self.store_cca_df_checker(posData, i, cca_df)
        
        if store_cca_df_copy and cca_df is not None:
            posData.allData_li[i]['cca_df'] = cca_df.copy()
        
        if autosave:
            self.enqAutosave()
            self.enqCcaIntegrityChecker()

    def lin_tree_to_acdc_df(self, force_all=False, ignore=set(), force=set(), specific=set()):
        """
        Syncs the lineage tree DataFrame with the acdc_df DataFrame. By default, it will only try to sync frames which have not been synced before. 
        This can be changed using the optional arguments.

        Parameters
        ----------
        force_all : bool, optional 
            If True, forces synchronization for all frames. Defaults to False.
        ignore : set, optional
            Set of frames to ignore during synchronization. Defaults to set().
        force : set, optional
            Set of frames to force synchronization. Defaults to set().
        specific : set, optional
            Set of frames to specifically synchronize. In this case it will ignore all other inputs and sync those no matter what. Defaults to set().
        """

        if self.lineage_tree is None:
            return
        
        # df_for_sync = []
        # lineage_copy = self.lineage_tree.lineage_list.copy()
        lin_tree_set = self.lineage_tree.frames_for_dfs.copy()

        if not force_all and not specific:
            dont_sync = self.already_synced_lin_tree
            dont_sync = {frame for frame in dont_sync if not frame in force}
            dont_sync.update(ignore)

            lin_tree_set = lin_tree_set.difference(dont_sync)

        if specific:
            lin_tree_set = lin_tree_set.intersection(specific)


        if lin_tree_set == []:
            return

        posData = self.data[self.pos_i]


        lin_tree_colnames = None
        for frame_i in lin_tree_set:
            acdc_df = posData.allData_li[frame_i]['acdc_df']

            lin_tree_df = self.lineage_tree.export_df(frame_i)
            if lin_tree_colnames is None:
                lin_tree_colnames = lin_tree_df.columns

            acdc_df.loc[lin_tree_df.index, lin_tree_colnames] = lin_tree_df[lin_tree_colnames]
            
            try:
                if np.all(acdc_df['generation_num']==2) and not (acdc_df['generation_num_tree'].isna().all()): # check if generation_num is all just the default value and if yes, replace it with the tree values
                    acdc_df['generation_num'] = acdc_df['generation_num_tree']
            except KeyError:
                acdc_df['generation_num'] = acdc_df['generation_num_tree']

            posData.allData_li[frame_i]['acdc_df'] = acdc_df
            self.already_synced_lin_tree.add(frame_i)

    def turnOffAutoSaveWorker(self):
        self.autoSaveToggle.setChecked(False)
    
    def enqAutosave(self):
        posData = self.data[self.pos_i]  
        # if self.autoSaveToggle.isChecked():
        if not self.autoSaveActiveWorkers:
            self.gui_createAutoSaveWorker()
        
        worker, thread = self.autoSaveActiveWorkers[-1]
        self.statusBarLabel.setText(
            f'{self.statusBarLabel.text()} | Autosaving...'
        )
        worker.enqueue(posData)
    
    def enqCcaIntegrityChecker(self):
        if not self.ccaCheckerRunning:
            return
        posData = self.data[self.pos_i]  
        self.ccaIntegrityCheckerWorker.enqueue(posData)
    
    def drawAllMothBudLines(self):
        posData = self.data[self.pos_i]
        for obj in posData.rp:
            self.drawObjMothBudLines(obj, posData, ax=0)
            self.drawObjMothBudLines(obj, posData, ax=1)

    def drawObjMothBudLines(self, obj, posData, ax=0):
        if not self.areMothBudLinesRequested(ax):
            return
        
        if posData.cca_df is None:
            return 

        mode = str(self.modeComboBox.currentText())
        if mode == 'Normal division: Lineage Tree':
            return

        ID = obj.label
        try:
            cca_df_ID = posData.cca_df.loc[ID]
        except KeyError:
            return        
        
        isObjVisible = self.isObjVisible(obj.bbox)
        if not isObjVisible:
            return
        
        ccs_ID = cca_df_ID['cell_cycle_stage']
        if ccs_ID == 'G1':
            return

        relationship = cca_df_ID['relationship']
        if relationship != 'bud':
            return

        emerg_frame_i = cca_df_ID['emerg_frame_i']
        isNew = emerg_frame_i == posData.frame_i
        scatterItem = self.getMothBudLineScatterItem(ax, isNew)
        relative_ID = cca_df_ID['relative_ID']

        try:
            relative_rp_idx = posData.IDs_idxs[relative_ID]
        except KeyError:
            return

        relative_ID_obj = posData.rp[relative_rp_idx]
        y1, x1 = self.getObjCentroid(obj.centroid)
        y2, x2 = self.getObjCentroid(relative_ID_obj.centroid)
        xx, yy = core.get_line(y1, x1, y2, x2, dashed=True)
        scatterItem.addPoints(xx, yy)
    
    def clearAllCellToCellLines(self):
        self.ax1_newMothBudLinesItem.setData([], [])
        self.ax1_oldMothBudLinesItem.setData([], [])
        self.ax2_newMothBudLinesItem.setData([], [])
        self.ax2_oldMothBudLinesItem.setData([], [])

    def drawAllLineageTreeLines(self):
        """
        Draw all lineage tree lines on the GUI.

        This method retrieves the lineage tree data and draws the lineage tree lines
        connecting cells and their respective mothers when the mother has split.
        """
        if self.lineage_tree is None:
            return
        
        if len(self.lineage_tree.lineage_list) < 2:
            return

        self.clearAllCellToCellLines()
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i
        lin_tree_df = self.lineage_tree.export_df(frame_i)
        lin_tree_df_prev = self.lineage_tree.export_df(frame_i-1)
        rp = posData.rp
        prev_rp = posData.allData_li[frame_i-1]['regionprops']

        self.setTitleText()

        if lin_tree_df.shape[0] > lin_tree_df_prev.shape[0]: # check if new cells have arrived
            new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes
            if new_cells.shape[0] == 0:
                self.logger.info('No new cells in the lineage tree for this frame.')
                return
            
            for ax in (0, 1):
                if not self.areMothBudLinesRequested(ax):
                    continue

                for ID in new_cells:
                    curr_obj = get_obj_by_label(rp, ID)
                    lin_tree_df_ID = lin_tree_df.loc[ID]

                    # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]]
                    if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped
                        continue
                    mother_obj = get_obj_by_label(prev_rp, lin_tree_df_ID["parent_ID_tree"])

                    emerg_frame_i = lin_tree_df_ID["emerg_frame_i"]
                    isNew = emerg_frame_i == frame_i

                    self.drawObjLin_TreeMothBudLines(ax, curr_obj, mother_obj, isNew, ID=ID)

    def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None):
        """
        Draw moth-bud lines between an object and its mother object.

        Parameters
        ----------
        ax : ???
            The matplotlib axes object to draw on.
        obj : Object
            The object for which to draw the moth-bud lines.
        mother_obj : Object
            The mother object to connect with.
        isNew : bool
            Indicates whether the object is new or not.
        ID : int, optional
            The ID of the object, by default None.
        """
        if not self.areMothBudLinesRequested(ax):
            return

        if not ID:
            ID = obj.label
        
        isObjVisible = self.isObjVisible(obj.bbox)
        
        if not isObjVisible:
            return

        scatterItem = self.getMothBudLineScatterItem(ax, isNew)

        y1, x1 = self.getObjCentroid(obj.centroid)
        y2, x2 = self.getObjCentroid(mother_obj.centroid)
        xx, yy = core.get_line(y1, x1, y2, x2, dashed=True)
        scatterItem.addPoints(xx, yy)

    def getObjCentroid(self, obj_centroid):
        if self.isSegm3D:
            depthAxes = self.switchPlaneCombobox.depthAxes()
            zc, yc, xc = obj_centroid
            if depthAxes == 'z':
                return yc, xc 
            elif depthAxes == 'y':
                return zc, xc 
            else:
                return zc, yc 
        else:
            return obj_centroid
    
    def getAnnotateHowRightImage(self):
        if not self.labelsGrad.showRightImgAction.isChecked():
            return 'nothing'
        
        if self.rightBottomGroupbox.isChecked():
            how = self.annotateRightHowCombobox.currentText()
        else:
            how = self.drawIDsContComboBox.currentText()
        return how

    def getObjOptsSegmLabels(self, obj):
        if not self.labelsGrad.showLabelsImgAction.isChecked():
            return

        objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1)
        return objOpts

    def store_zslices_rp(self, force_update=False):
        if not self.isSegm3D:
            return
        
        posData = self.data[self.pos_i]        
        are_zslices_rp_stored = (
            posData.allData_li[posData.frame_i].get('z_slices_rp') is not None
        )
        if force_update or not are_zslices_rp_stored:
            self._update_zslices_rp()
        
        posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp
    
    def removeObjectFromRp(self, delID):
        posData = self.data[self.pos_i]
        rp = []
        IDs = []
        IDs_idxs = {}
        idx = 0
        for obj in posData.rp:
            if obj.label == delID:
                continue
            rp.append(obj)
            IDs.append(obj.label)
            IDs_idxs[obj.label] = idx
            idx += 1
        
        posData.rp = rp
        posData.IDs = IDs
        posData.IDs_idxs = IDs_idxs
        
        if not self.isSegm3D:
            return
        
        zSlicesRp = {}
        for z, zSliceRp in posData.zSlicesRp.items():
            if delID in zSliceRp:
                continue
            
            zSlicesRp[z] = zSlicesRp
        
        posData.zSlicesRp = zSlicesRp
        self.store_zslices_rp(force_update=True)
    
    def get_zslices_rp(self):
        if not self.isSegm3D:
            return
        
        posData = self.data[self.pos_i]
        self.store_zslices_rp()
        posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp']
    
    # @exec_time
    def _update_zslices_rp(self):
        if not self.isSegm3D:
            return
        
        posData = self.data[self.pos_i]
        posData.zSlicesRp = {}
        for z, lab2d in enumerate(posData.lab):
            lab2d_rp = skimage.measure.regionprops(lab2d)
            posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp}
    
    @exception_handler
    def update_rp(
            self, draw=True, debug=False, update_IDs=True, 
            wl_update=True, wl_track_og_curr=False,wl_update_lab=False
        ):

        posData = self.data[self.pos_i]
        # Update rp for current posData.lab (e.g. after any change)

        if wl_update:
            if self.whitelistOriginalIDs is None:
                old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff
            else:
                old_IDs = self.whitelistOriginalIDs.copy()
                self.whitelistOriginalIDs = None
        elif self.whitelistOriginalIDs is None:
            self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy()

        posData.rp = skimage.measure.regionprops(posData.lab)
        if update_IDs:
            IDs = []
            IDs_idxs = {}
            for idx, obj in enumerate(posData.rp):
                IDs.append(obj.label)
                IDs_idxs[obj.label] = idx
            posData.IDs = IDs
            posData.IDs_idxs = IDs_idxs
        self.update_rp_metadata(draw=draw)        
        self.store_zslices_rp(force_update=True)

        if not wl_update:
            return

        # Update tracking whitelist
        accepted_lost_centroids = self.getTrackedLostIDs()
        new_IDs = posData.IDs
        added_IDs = set(new_IDs) - set(old_IDs)
        removed_IDs = (
            set(old_IDs) 
            - set(new_IDs) 
            - set(accepted_lost_centroids)
        )
        if debug:
            printl(added_IDs, removed_IDs)

        self.whitelistPropagateIDs(
            IDs_to_add=added_IDs, IDs_to_remove=removed_IDs,
            curr_frame_only=True, IDs_curr=new_IDs,
            track_og_curr=wl_track_og_curr,
            curr_lab=posData.lab, curr_rp=posData.rp,
            update_lab=wl_update_lab
        )

    def extendLabelsLUT(self, lenNewLut):
        posData = self.data[self.pos_i]
        # Build a new lut to include IDs > than original len of lut
        if lenNewLut > len(self.lut):
            numNewColors = lenNewLut-len(self.lut)
            # Index original lut
            _lut = np.zeros((lenNewLut, 3), np.uint8)
            _lut[:len(self.lut)] = self.lut
            # Pick random colors and append them at the end to recycle them
            randomIdx = np.random.randint(0,len(self.lut),size=numNewColors)
            for i, idx in enumerate(randomIdx):
                rgb = self.lut[idx]
                _lut[len(self.lut)+i] = rgb
            self.lut = _lut
            self.initLabelsImageItems()
            return True
        return False

    def initLookupTableLab(self):
        self.img2.setLookupTable(self.lut)
        self.img2.setLevels([0, len(self.lut)])
        self.initLabelsImageItems()
    
    def getLabelsImageLut(self):
        lut = np.zeros((len(self.lut), 4), dtype=np.uint8)
        lut[:,-1] = 255
        lut[:,:-1] = self.lut
        lut[0] = [0,0,0,0]
        return lut
    
    def initLabelsImageItems(self):
        lut = self.getLabelsImageLut()
        self.labelsLayerImg1.setLevels([0, len(lut)])
        self.labelsLayerRightImg.setLevels([0, len(lut)])
        self.labelsLayerImg1.setLookupTable(lut)
        self.labelsLayerRightImg.setLookupTable(lut)
        alpha = self.imgGrad.labelsAlphaSlider.value()
        self.labelsLayerImg1.setOpacity(alpha)
        self.labelsLayerRightImg.setOpacity(alpha)
    
    def initKeepObjLabelsLayers(self):
        lut = np.zeros((len(self.lut), 4), dtype=np.uint8)
        lut[:,:-1] = self.lut
        lut[:,-1:] = 255
        lut[0] = [0,0,0,0]
        self.keepIDsTempLayerLeft.setLevels([0, len(lut)])
        self.keepIDsTempLayerLeft.setLookupTable(lut)

        # # Gray out objects
        # alpha = self.imgGrad.labelsAlphaSlider.value()
        # self.labelsLayerImg1.setOpacity(alpha/3)
        # self.labelsLayerRightImg.setOpacity(alpha/3)

        # # Gray out contours
        # imageItem = self.getContoursImageItem(0)
        # if imageItem is not None:
        #     imageItem.setOpacity(0.3)
        
        # imageItem = self.getContoursImageItem(1)
        # if imageItem is not None:
        #     imageItem.setOpacity(0.3)
        
    
    def updateTempLayerKeepIDs(self):
        if not self.keepIDsButton.isChecked():
            return
        
        keptLab = np.zeros_like(self.currentLab2D)

        posData = self.data[self.pos_i]
        for obj in posData.rp:
            if obj.label not in self.keptObjectsIDs:
                continue

            if not self.isObjVisible(obj.bbox):
                continue

            _slice = self.getObjSlice(obj.slice)
            _objMask = self.getObjImage(obj.image, obj.bbox)

            keptLab[_slice][_objMask] = obj.label

        self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False)

    def highlightLabelID(self, ID, ax=0):        
        posData = self.data[self.pos_i]
        try:
            obj = posData.rp[posData.IDs_idxs[ID]]
        except KeyError:
            return
        
        self.textAnnot[ax].highlightObject(obj)
    
    def _keepObjects(self, keepIDs=None, lab=None, rp=None):
        posData = self.data[self.pos_i]
        if lab is None:
            lab = posData.lab
        
        if rp is None:
            rp = posData.rp
        
        if keepIDs is None:
            keepIDs = self.keptObjectsIDs

        for obj in rp:
            if obj.label in keepIDs:
                continue

            lab[obj.slice][obj.image] = 0
        
        return lab
    
    def clearHighlightedText(self):
        pass

    def removeHighlightLabelID(self, IDs=None, ax=0):
        posData = self.data[self.pos_i]
        if IDs is None:
            IDs = posData.IDs
        
        for ID in IDs:
            obj = posData.rp[posData.IDs_idxs[ID]]
            self.textAnnot[ax].removeHighlightObject(obj)
    
    def updateKeepIDs(self, IDs):
        posData = self.data[self.pos_i]

        self.clearHighlightedText()

        isAnyIDnotExisting = False
        # Check if IDs from line edit are present in current keptObjectIDs list
        for ID in IDs:
            if ID not in posData.allIDs:
                isAnyIDnotExisting = True
                continue
            if ID not in self.keptObjectsIDs:
                self.keptObjectsIDs.append(ID, editText=False)
                self.highlightLabelID(ID)
        
        # Check if IDs in current keptObjectsIDs are present in IDs from line edit
        for ID in self.keptObjectsIDs:
            if ID not in posData.allIDs:
                isAnyIDnotExisting = True
                continue
            if ID not in IDs:
                self.keptObjectsIDs.remove(ID, editText=False)
        
        self.updateTempLayerKeepIDs()
        if isAnyIDnotExisting:
            self.keptIDsLineEdit.warnNotExistingID()
        else:
            self.keptIDsLineEdit.setInstructionsText()
    
    @exception_handler
    def applyKeepObjects(self):
        # Store undo state before modifying stuff
        self.storeUndoRedoStates(False)

        self._keepObjects()
        self.highlightHoverIDsKeptObj(0, 0, hoverID=0)
        
        posData = self.data[self.pos_i]

        self.update_rp()
        # Repeat tracking
        self.tracking(enforce=True, assign_unique_new_IDs=False)

        if self.isSnapshot:
            self.fixCcaDfAfterEdit('Deleted non-selected objects')
            self.updateAllImages()
            self.keptObjectsIDs = widgets.KeptObjectIDsList(
                self.keptIDsLineEdit, self.keepIDsConfirmAction
            )
            return
        else:
            removeAnnot = self.warnEditingWithCca_df(
                'Deleted non-selected objects', get_answer=True
            )
            if not removeAnnot:
                # We can propagate changes only if the user agrees on 
                # removing annotations
                return
        
        self.current_frame_i = posData.frame_i
        if posData.frame_i > 0:
            txt = html_utils.paragraph("""
                Do you want to <b>remove un-kept objects in the past</b> frames too?
            """)
            msg = widgets.myMessageBox(wrapText=False, showCentered=False)
            _, _, applyToPastButton = msg.question(
                self, 'Propagate to past frames?', txt,
                buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames')
            )
            if msg.cancel:
                return
            if msg.clickedButton == applyToPastButton:
                self.store_data()
                self.logger.info('Applying keep objects to past frames...')
                if not removeAnnot and posData.cca_df is not None:
                    delIDs = [
                        ID for ID in posData.cca_df.index 
                        if ID not in posData.IDs
                    ]
                    self.update_cca_df_deletedIDs(posData, delIDs)
                
                for i in tqdm(range(posData.frame_i), ncols=100):
                    lab = posData.allData_li[i]['labels']
                    rp = posData.allData_li[i]['regionprops']
                    keepLab = self._keepObjects(lab=lab, rp=rp)
                    # Store change
                    posData.allData_li[i]['labels'] = keepLab.copy()
                    # Get the rest of the stored metadata based on the new lab
                    posData.frame_i = i
                    self.get_data()
                    self.store_data(autosave=False)
                
                posData.frame_i = self.current_frame_i
                self.get_data()

        # Ask to propagate change to all future visited frames
        key = 'Keep ID'
        askAction = self.askHowFutureFramesActions[key]
        doNotShow = not askAction.isChecked()
        (UndoFutFrames, applyFutFrames, endFrame_i,
        doNotShowAgain) = self.propagateChange(
            self.keptObjectsIDs, key, doNotShow,
            posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID,
            force=True, applyTrackingB=True
        )

        if UndoFutFrames is None:
            # Empty keep object list
            self.keptObjectsIDs = widgets.KeptObjectIDsList(
                self.keptIDsLineEdit, self.keepIDsConfirmAction
            )
            return

        posData.doNotShowAgain_keepID = doNotShowAgain
        posData.UndoFutFrames_keepID = UndoFutFrames
        posData.applyFutFrames_keepID = applyFutFrames
        includeUnvisited = posData.includeUnvisitedInfo['Keep ID']

        if applyFutFrames:
            self.store_data()

            self.logger.info('Applying to future frames...')
            pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100)
            segmSizeT = len(posData.segm_data)
            if not removeAnnot and posData.cca_df is not None:
                delIDs = [
                    ID for ID in posData.cca_df.index 
                    if ID not in posData.IDs
                ]
                self.update_cca_df_deletedIDs(posData, delIDs)
                
            for i in range(posData.frame_i+1, segmSizeT):
                lab = posData.allData_li[i]['labels']
                if lab is None and not includeUnvisited:
                    self.enqAutosave()
                    pbar.update(posData.SizeT-i)
                    break
                
                rp = posData.allData_li[i]['regionprops']

                if lab is not None:
                    keepLab = self._keepObjects(lab=lab, rp=rp)
                    # Store change
                    posData.allData_li[i]['labels'] = keepLab.copy()
                    # Get the rest of the stored metadata based on the new lab
                    posData.frame_i = i
                    self.get_data()
                    self.store_data(autosave=False)
                elif includeUnvisited:
                    # Unvisited frame (includeUnvisited = True)
                    lab = posData.segm_data[i]
                    rp = skimage.measure.regionprops(lab)
                    keepLab = self._keepObjects(lab=lab, rp=rp)
                    posData.segm_data[i] = keepLab
                
                pbar.update()
            pbar.close()
        
        # Back to current frame
        if applyFutFrames:
            posData.frame_i = self.current_frame_i
            self.get_data()

        self.keptObjectsIDs = widgets.KeptObjectIDsList(
            self.keptIDsLineEdit, self.keepIDsConfirmAction
        )

    def updateLookuptable(self, lenNewLut=None, delIDs=None):
        posData = self.data[self.pos_i]
        if lenNewLut is None:
            try:
                if delIDs is None:
                    IDs = posData.IDs
                else:
                    # Remove IDs removed with ROI from LUT
                    IDs = [ID for ID in posData.IDs if ID not in delIDs]
                lenNewLut = max(IDs, default=0) + 1
            except ValueError:
                # Empty segmentation mask
                lenNewLut = 1
        # Build a new lut to include IDs > than original len of lut
        updateLevels = self.extendLabelsLUT(lenNewLut)
        lut = self.lut.copy()

        try:
            # lut = self.lut[:lenNewLut].copy()
            for ID in posData.binnedIDs:
                lut[ID] = lut[ID]*0.2

            for ID in posData.ripIDs:
                lut[ID] = lut[ID]*0.2
        except Exception as e:
            err_str = traceback.format_exc()
            print('='*30)
            self.logger.info(err_str)
            print('='*30)

        if updateLevels:
            self.img2.setLevels([0, len(lut)])
        
        if self.keepIDsButton.isChecked():
            lut = np.round(lut*0.3).astype(np.uint8)
            keptLut = np.round(lut[self.keptObjectsIDs]/0.3).astype(np.uint8)
            lut[self.keptObjectsIDs] = keptLut

        self.img2.setLookupTable(lut)

    # @exec_time
    def update_rp_metadata(self, draw=True):
        posData = self.data[self.pos_i]
        # Add to rp dynamic metadata (e.g. cells annotated as dead)
        for i, obj in enumerate(posData.rp):
            ID = obj.label
            obj.excluded = ID in posData.binnedIDs
            obj.dead = ID in posData.ripIDs
    
    def annotate_rip_and_bin_IDs(self, updateLabel=False):
        depthAxes = self.switchPlaneCombobox.depthAxes()
        if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z':
            return 
        
        posData = self.data[self.pos_i]
        binnedIDs_xx = []
        binnedIDs_yy = []
        ripIDs_xx = []
        ripIDs_yy = []
        for obj in posData.rp:
            obj.excluded = obj.label in posData.binnedIDs
            obj.dead = obj.label in posData.ripIDs
            if not self.isObjVisible(obj.bbox):
                continue
            
            if obj.excluded:
                y, x = self.getObjCentroid(obj.centroid)
                binnedIDs_xx.append(x)
                binnedIDs_yy.append(y)
                if updateLabel:
                    self.getObjOptsSegmLabels(obj)
                    how = self.drawIDsContComboBox.currentText()
            
            if obj.dead:
                y, x = self.getObjCentroid(obj.centroid)
                ripIDs_xx.append(x)
                ripIDs_yy.append(y)
                if updateLabel:
                    self.getObjOptsSegmLabels(obj)
                    how = self.drawIDsContComboBox.currentText()
        
        self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy)
        self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy)
        self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy)
        self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy)

    def loadNonAlignedFluoChannel(self, fluo_path):
        posData = self.data[self.pos_i]
        if posData.filename.find('aligned') != -1:
            filename, _ = os.path.splitext(os.path.basename(fluo_path))
            path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz'
            msg = widgets.myMessageBox()
            msg.critical(
                self, 'Aligned fluo channel not found!',
                'Aligned data for fluorescence channel not found!\n\n'
                f'You loaded aligned data for the cells channel, therefore '
                'loading NON-aligned fluorescence data is not allowed.\n\n'
                'Run the script "dataPrep.py" to create the following file:\n\n'
                f'{path}'
            )
            return None
        fluo_data = np.squeeze(skimage.io.imread(fluo_path))
        return fluo_data

    def load_fluo_data(self, fluo_path, isGuiThread=True):
        self.logger.info(f'Loading fluorescence image data from "{fluo_path}"...')
        bkgrData = None
        posData = self.data[self.pos_i]
        # Load overlay frames and align if needed
        filename = os.path.basename(fluo_path)
        filename_noEXT, ext = os.path.splitext(filename)
        if ext == '.npy' or ext == '.npz':
            fluo_data = np.load(fluo_path)
            try:
                fluo_data = np.squeeze(fluo_data['arr_0'])
            except Exception as e:
                fluo_data = np.squeeze(fluo_data)

            # Load background data
            bkgrData_path = os.path.join(
                posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz'
            )
            if os.path.exists(bkgrData_path):
                bkgrData = np.load(bkgrData_path)
        elif ext == '.tif' or ext == '.tiff':
            aligned_filename = f'{filename_noEXT}_aligned.npz'
            aligned_path = os.path.join(posData.images_path, aligned_filename)
            if os.path.exists(aligned_path):
                fluo_data = np.load(aligned_path)['arr_0']

                # Load background data
                bkgrData_path = os.path.join(
                    posData.images_path, f'{aligned_filename}_bkgrRoiData.npz'
                )
                if os.path.exists(bkgrData_path):
                    bkgrData = np.load(bkgrData_path)
            else:
                fluo_data = self.loadNonAlignedFluoChannel(fluo_path)
                if fluo_data is None:
                    return None, None

                # Load background data
                bkgrData_path = os.path.join(
                    posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz'
                )
                if os.path.exists(bkgrData_path):
                    bkgrData = np.load(bkgrData_path)
        elif isGuiThread:
            txt = html_utils.paragraph(
                f'File format {ext} is not supported!\n'
                'Choose either .tif or .npz files.'
            )
            msg = widgets.myMessageBox()
            msg.critical(self, 'File not supported', txt)
            return None, None

        return fluo_data, bkgrData

    def setOverlayColors(self):
        self.overlayRGBs = [
            (255, 255, 0),
            (252, 72, 254),
            (49, 222, 134),
            (22, 108, 27)
        ]
        self.overlayCmap = matplotlib.colormaps['hsv']
        self.overlayRGBs.extend(
            [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) 
            for i in np.linspace(0,1,8)]
        )

    def getFileExtensions(self, images_path):
        alignedFound = any([f.find('_aligned.np')!=-1
                            for f in myutils.listdir(images_path)])
        if alignedFound:
            extensions = (
                'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)'
                ';;All Files (*)'
            )
        else:
            extensions = (
                'Tif channels(*tiff *tif);; All Files (*)'
            )
        return extensions

    def loadOverlayData(self, ol_channels, addToExisting=False):
        posData = self.data[self.pos_i]
        for ol_ch in ol_channels:
            if ol_ch not in list(posData.loadedFluoChannels):
                # Requested channel was never loaded --> load it at first
                # iter i == 0
                success = self.loadFluo_cb(fluo_channels=[ol_ch])
                if not success:
                    return False

        lastChannelName = ol_channels[-1]
        for action in self.fluoDataChNameActions:
            if action.text() == lastChannelName:
                action.setChecked(True)

        for p, posData in enumerate(self.data):
            if addToExisting:
                ol_data = posData.ol_data
            else:
                ol_data = {}
            for i, ol_ch in enumerate(ol_channels):
                _, filename = self.getPathFromChName(ol_ch, posData)
                ol_data[filename] = posData.ol_data_dict[filename].copy()                                  
                self.addFluoChNameContextMenuAction(ol_ch)
            posData.ol_data = ol_data

        return True

    def askSelectOverlayChannel(self):
        ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name]
        selectFluo = widgets.QDialogListbox(
            'Select channel',
            'Select channel names to overlay:\n',
            ch_names, multiSelection=True, parent=self
        )
        selectFluo.exec_()
        if selectFluo.cancel:
            return

        return selectFluo.selectedItemsText
    
    def overlayLabels_cb(self, checked):
        if checked:
            if not self.drawModeOverlayLabelsChannels:
                selectedLabelsEndnames = self.askLabelsToOverlay()
                if selectedLabelsEndnames is None:
                    self.logger.info('Overlay labels cancelled.')
                    self.overlayLabelsButton.setChecked(False)
                    return
                for selectedEndname in selectedLabelsEndnames:
                    self.loadOverlayLabelsData(selectedEndname)
                    for action in self.overlayLabelsContextMenu.actions():
                        if not action.isCheckable():
                            continue
                        if action.text() == selectedEndname:
                            action.setChecked(True)
                lastSelectedName = selectedLabelsEndnames[-1]
                for action in self.selectOverlayLabelsActionGroup.actions():
                    if action.text() == lastSelectedName:
                        action.setChecked(True)
        self.updateAllImages()
    
    def askLabelsToOverlay(self):
        selectOverlayLabels = widgets.QDialogListbox(
            'Select segmentation to overlay',
            'Select segmentation file to overlay:\n',
            list(self.existingSegmEndNames), 
            multiSelection=True, 
            parent=self
        )
        selectOverlayLabels.exec_()
        if selectOverlayLabels.cancel:
            return

        return selectOverlayLabels.selectedItemsText

    def closeToolbars(self):
        for toolbar in self.sender().toolbars:
            toolbar.setVisible(False)
            for action in toolbar.actions():
                try:
                    action.button.setChecked(False)
                except Exception as e:
                    pass
    
    def askSaveAddedPoints(self):
        msg = widgets.myMessageBox(wrapText=False)
        txt = html_utils.paragraph(
            'Do you want to <b>save the annotated points</b>?'
        )
        _, noButton, yesButton = msg.question(
            self, 'Save?', txt,
            buttonsTexts=('Cancel', 'No', 'Yes')
        )
        if msg.clickedButton != yesButton:
            return
        
        for toolbar in self.pointsLayersToolbars:
            for action in self.pointsLayersToolbar.actions():
                try:
                    if 'Save annotated' in action.text():
                        action.trigger()
                except Exception as err:
                    pass
    
    def pointsLayerToggled(self, checked):
        if not checked:
            for action in self.pointsLayersToolbar.actions():
                try:
                    if 'Save annotated' in action.text():
                        self.askSaveAddedPoints()
                        break
                except Exception as err:
                    pass
        self.pointsLayersToolbar.setVisible(checked)
        self.autoPilotZoomToObjToolbar.setVisible(checked)
        if self.pointsLayersNeverToggled:
            self.pointsLayersToolbar.sigAddPointsLayer.emit()
        self.pointsLayersNeverToggled = False
        QTimer.singleShot(200, self.autoRange)
    
    def addPointsLayerTriggered(self, checked=False, toolbar=None):
        if toolbar is None:
            toolbar = self.pointsLayersToolbar
        
        if self.addPointsWin is not None:
            self.logger.info(
                'Add points layer window is already open. Cannot add now.'
            )
            return
        
        onlyMouseClicks = toolbar==self.promptSegmentPointsLayerToolbar
        posData = self.data[self.pos_i]
        self.addPointsWin = apps.AddPointsLayerDialog(
            channelNames=posData.chNames, 
            imagesPath=posData.images_path, 
            hideCentroidsSection=onlyMouseClicks,
            hideWeightedCentroidsSection=onlyMouseClicks,
            hideFromTableSection=onlyMouseClicks,
            hideManualEntrySection=onlyMouseClicks,
            hideWithMouseClicksSection=False,
            parent=self,
        )
        cmap = matplotlib.colormaps['gist_rainbow']
        i = np.random.default_rng(seed=123).uniform()
        for action in toolbar.actions()[1:]:
            if not hasattr(action, 'layerTypeIdx'):
                continue
            rgb = [round(c*255) for c in cmap(i)][:3]
            self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb)
            break
        
        self.addPointsWin.sigCriticalReadTable.connect(self.logger.info)
        self.addPointsWin.sigLoadedTable.connect(self.logLoadedTablePointsLayer)
        self.addPointsWin.sigClosed.connect(
            partial(self.addPointsLayer, toolbar=toolbar)
        )
        self.addPointsWin.sigCheckClickEntryTableEndnameExists.connect(
            self.checkClickEntryTableEndnameExists
        )
        self.addPointsWin.show()
        if self.addPointsWin.clickEntryRadiobutton.isChecked():
            QTimer.singleShot(
                200, 
                partial(
                    self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit,
                    self.addPointsWin.clickEntryTableEndname.text(), 
                    False
                )
            )
    
    def logLoadedTablePointsLayer(self, df):
        separator = f'-'*100
        text = f'{separator}\nFirst 10 rows of loaded table:\n\n{df.head(10)}\n{separator}'
        self.logger.info(text)
    
    def buttonAddPointsByClickingActive(self):
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                if not hasattr(action, 'layerTypeIdx'):
                    continue
                if action.layerTypeIdx == 4 and action.button.isChecked():
                    return action.button
    
    def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar):
        self.LeftClickButtons.append(toolButton)
        posData = self.data[self.pos_i]
        tableEndName = self.addPointsWin.clickEntryTableEndnameText
        if isLoadedDf is not None:
            posData = self.data[self.pos_i]
            tableEndName = tableEndName[len(posData.basename):]
            self.loadClickEntryDfs(tableEndName)
        
        toolButton.toolbar = toolbar
        toolButton.clickEntryTableEndName = tableEndName
        self.checkableQButtonsGroup.addButton(toolButton)
        toolButton.toggled.connect(self.addPointsByClickingButtonToggled)

        self.addPointsByClickingButtonToggled(sender=toolButton)
        
        toolButton.setToolTip(tableEndName)
        
        pointIdSpinbox = widgets.SpinBox()
        pointIdSpinbox.setMinimum(0)
        pointIdSpinbox.setValue(1)
        pointIdSpinbox.label = QLabel(' Left-click ID: ')
        pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label)
        if toolbar == self.promptSegmentPointsLayerToolbar:
            newID = self.setBrushID(return_val=True)
            pointIdSpinbox.setValue(newID)
        toolButton.actions.append(pointIdSpinbox.labelAction)
        pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox)
        toolButton.actions.append(pointIdSpinbox.action)
        pointIdSpinbox.toolButton = toolButton
        toolButton.pointIdSpinbox = pointIdSpinbox
        
        rightClickIDSpinbox = widgets.SpinBox()
        pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox)
        rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width())
        rightClickIDSpinbox.setValue(pointIdSpinbox.value())
        rightClickIDSpinbox.setMinimum(0)
        rightClickIDSpinbox.label = QLabel(' | Right-click ID: ')
        rightClickIDSpinbox.labelAction = toolbar.addWidget(
            rightClickIDSpinbox.label
        )
        toolButton.actions.append(rightClickIDSpinbox.labelAction)
        rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox)
        toolButton.actions.append(rightClickIDSpinbox.action)
        rightClickIDSpinbox.toolButton = toolButton
        toolButton.rightClickIDSpinbox = rightClickIDSpinbox
        
        saveToolbutton = widgets.SavePointsLayerButton(
            tableEndName, parent=self
        )
        saveToolbutton.sigRenameTableAction.connect(
            self.updatePointsLayerClickEntryTableEndname
        )
        saveToolbutton.sigLeftClick.connect(self.savePointsAddedByClicking)
        saveAction = toolbar.addWidget(saveToolbutton)
        saveToolbutton.action = saveAction
        saveAction.toolButton = toolButton
        toolButton.saveAction = saveAction
        toolButton.saveToolbutton = saveToolbutton
        
        toolButton.actions.append(saveAction)
        
        vlineAction = toolbar.addWidget(widgets.QVLine())
        spacerAction = toolbar.addWidget(
            widgets.QHWidgetSpacer(width=5)
        )
        
        toolButton.actions.append(vlineAction)
        toolButton.actions.append(spacerAction)
        
        action = toolButton.action
        scatterItem = action.scatterItem
        scatterItem.sigHoverEntered.connect(
            self.addPointsByClickingScatterItemHoverEntered
        )
        
        self.pointsLayerClicksDfsToData(posData, toolbar=toolbar)
    
    def storeUndoAddPoint(self, action):
        if not hasattr(self, 'undoAddPointQueueMapper'):
            self.undoAddPointQueueMapper = defaultdict(list)

        state = deepcopy(action.pointsData)
        self.undoAddPointQueueMapper[action].append(state)
        self.undoAction.setEnabled(True)
        
    def undoAddPoint(self, action):
        undoAddPointQueue = self.undoAddPointQueueMapper.get(action)
        if undoAddPointQueue is None:
            return False
        
        if len(undoAddPointQueue) == 0:
            return False
        
        state = undoAddPointQueue.pop(-1)
        action.pointsData = state
        
        self.drawPointsLayers(computePointsLayers=False)
        
        if len(self.undoAddPointQueueMapper[action]) == 0:
           self.undoAction.setEnabled(True)
        
        return True
    
    def getAddedPointId(
            self, isMagicPrompts, addPointsByClickingButton, 
            right_click, left_click, middle_click
        ):
        action = addPointsByClickingButton.action
        if right_click:
            id = addPointsByClickingButton.rightClickIDSpinbox.value()
        elif left_click:
            id = addPointsByClickingButton.pointIdSpinbox.value()                        
            id = self.getClickedPointNewId(
                action, id, addPointsByClickingButton.pointIdSpinbox,
                isMagicPrompts=isMagicPrompts
            )
            if isMagicPrompts:
                proceed = self.warnAddingPointWithExistingId(id)
                if not proceed:
                    return
            
            addPointsByClickingButton.pointIdSpinbox.setValue(id)
        elif middle_click:
            id = 0
        
        return id
    
    def addPointsByClickingScatterItemHoverEntered(self, item, points, event):
        point = points[0]
        point_id = point.data()
        toolButton = item.action.button
        toolButton.rightClickIDSpinbox.prevId = (
            toolButton.rightClickIDSpinbox.value()
        )
        toolButton.rightClickIDSpinbox.setValue(point_id)
    
    def autoPilotZoomToObjToggled(self, checked):
        if not checked:
            self.zoomOut()
            return
        
        posData = self.data[self.pos_i]
        if not posData.IDs:
            self.logger.info('There are no objects in current segmentation mask')
            return
        self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0])
        self.zoomToObj(posData.rp[0])
    
    def savePointsAddedByClickingFromEndname(self, tableEndName):
        self.pointsLayerDataToDf(self.data[self.pos_i])
        for posData in self.data:
            if not posData.basename.endswith('_'):
                basename = f'{posData.basename}_'
            else:
                basename = posData.basename
            tableFilename = f'{basename}{tableEndName}.csv'
            tableFilepath = os.path.join(posData.images_path, tableFilename)
            df = posData.clickEntryPointsDfs.get(tableEndName)
            if df is None:
                continue
            df = df.sort_values(['frame_i', 'Cell_ID'])
            df.to_csv(tableFilepath, index=False)
    
    @exception_handler
    def savePointsAddedByClicking(self, button, event):
        sender = button.action
        
        toolButton = sender.toolButton
        tableEndName = toolButton.clickEntryTableEndName
        
        self.logger.info(f'Saving _{tableEndName}.csv table...')
        
        self.savePointsAddedByClickingFromEndname(tableEndName)
        
        self.logger.info(f'{tableEndName}.csv saved!')
        self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g')
    
    def updatePointsLayerClickEntryTableEndname(
            self, saveToolbutton, table_endname
        ):
        saveAction = saveToolbutton.action
        toolButton = saveAction.toolButton
        toolButton.clickEntryTableEndName = table_endname
        
        self.logger.info(
            f'Done. Click entry table endname updated to "{table_endname}"'
        )
    
    def pointsLayerDfsToData(self, posData):
        self.pointsLayerClicksDfsToData(posData)
    
    def pointsLayerLoadedDfsToData(self):
        posData = self.data[self.pos_i]
        
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                if not hasattr(action, 'loadedDfInfo'):
                    continue
                
                if action.loadedDfInfo is None:
                    continue
                
                endname = action.loadedDfInfo.get('endname')
                if endname is None:
                    continue
                
                filename = f'{posData.basename}{endname}'
                filepath = os.path.join(posData.images_path, filename)
                if not os.path.exists(filepath):
                    action.pointsData = {}
                
                df = load.load_df_points_layer(filepath)
                action.pointsData = load.loaded_df_to_points_data(
                    df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], 
                    action.loadedDfInfo['y'], action.loadedDfInfo['x']
                )
                self.logLoadedTablePointsLayer(df)
            
    def setPointsLayerLoadedDfEndanme(self, action):
        if action.loadedDfInfo is None:
            return
        
        posData = self.data[self.pos_i]
        images_path = posData.images_path.replace('\\', '/')
        
        df_folderpath = os.path.dirname(
            action.loadedDfInfo['filepath'].replace('\\', '/')
        )
        
        if images_path != df_folderpath:
            return
        
        df_filename = os.path.basename(action.loadedDfInfo['filepath'])
        
        if not df_filename.startswith(posData.basename):
            return
        
        endname = df_filename[len(posData.basename):]
        action.loadedDfInfo['endname'] = endname    
        
        action.button.setToolTip(endname)
    
    def pointsLayerClicksDfsToData(self, posData, toolbar=None):
        if toolbar is None:
            toolbar = self.pointsLayersToolbar
        
        for action in toolbar.actions()[1:]:
            if not hasattr(action, 'button'):
                continue
            
            if not hasattr(action.button, 'clickEntryTableEndName'):
                continue
            tableEndName = action.button.clickEntryTableEndName
            action.pointsData = {}
            if posData.clickEntryPointsDfs.get(tableEndName) is None:
                continue
            
            df = posData.clickEntryPointsDfs[tableEndName]
            
            if posData.SizeZ > 1 and df['z'].isna().any():
                self.warnLoadedPointsTableIsNot3D(tableEndName)
                return
            
            for frame_i, df_frame in df.groupby('frame_i'):
                action.pointsData[frame_i] = {}
                if posData.SizeZ > 1:
                    for z, df_zlice in df_frame.groupby('z'):
                        xx = df_zlice['x'].to_list()
                        yy = df_zlice['y'].to_list()
                        ids = df_zlice['id'].to_list()
                        action.pointsData[frame_i][z] = {
                            'x': xx, 'y': yy, 'id': ids
                        }
                else:
                    xx = df_frame['x'].to_list()
                    yy = df_frame['y'].to_list()
                    ids = df_frame['id'].to_list()
                    action.pointsData[frame_i] = {
                        'x': xx, 'y': yy, 'id': ids
                    }
            
    def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None):
        df = None
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                if not hasattr(action, 'button'):
                    continue
                if not hasattr(action.button, 'clickEntryTableEndName'):
                    continue
                
                tableEndName = action.button.clickEntryTableEndName
                if getOnlyActive and not action.button.isChecked():
                    continue
                
                df = toolbar.fromActionToDataFrame(
                    action, posData, isSegm3D=self.isSegm3D
                )
                posData.clickEntryPointsDfs[tableEndName] = df
        return df
    
    def restartZoomAutoPilot(self):
        if not self.autoPilotZoomToObjToggle.isChecked():
            return
        
        posData = self.data[self.pos_i]
        if not posData.IDs:
            return
        
        self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0])
        self.zoomToObj(posData.rp[0])
    
    def resizeRangeWelcomeText(self):
        xRange, yRange = self.ax1.viewRange()
        deltaX = xRange[1] - xRange[0]
        deltaY = yRange[1] - yRange[0]
        self.ax1.setXRange(0, deltaX)
        self.ax1.setYRange(0, deltaY)
        self.ax1.setLimits(
            xMin=0, xMax=deltaX, yMin=0, yMax=deltaY
        )
        # self.ax1.setXRange(0, 0)
        # self.ax1.setYRange(0, 0)
    
    def zoomToObj(self, obj=None):
        if not hasattr(self, 'data'):
            return
        posData = self.data[self.pos_i]
        if obj is None:
            ID = self.sender().value()
            try:
                ID_idx = posData.IDs_idxs[ID]
                obj = obj = posData.rp[ID_idx]
            except Exception as e:
                self.logger.warning(
                    f'ID {ID} does not exist (add points by clicking)'
                )
        
        if obj is None:
            return
        
        self.goToZsliceSearchedID(obj)  
        min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox)
        xRange = min_col-5, max_col+5
        yRange = max_row+5, min_row-5

        self.ax1.setRange(xRange=xRange, yRange=yRange)
        
    def addPointsByClickingButtonToggled(self, checked=True, sender=None):
        if sender is None:
            sender = self.sender()
        if not sender.isChecked():
            action = sender.action
            action.scatterItem.setVisible(False)
            return
        
        self.disconnectLeftClickButtons()
        self.uncheckLeftClickButtons(sender)
        self.connectLeftClickButtons()
        action = sender.action
        action.scatterItem.setVisible(True)
        self.ax1_BrushCircle.setBrush(action.brushColor)
        self.ax1_BrushCircle.setPen(action.penColor)
    
    def autoZoomNextObj(self):
        self.sender().setValue(self.sender().value() - 1)
        self.pointsLayerAutoPilot('next')
        self.setFocusMain()
        self.setFocusGraphics()
    
    def autoZoomPrevObj(self):
        self.sender().setValue(self.sender().value() + 1)
        self.pointsLayerAutoPilot('prev')
        self.setFocusMain()
        self.setFocusGraphics()
    
    def pointsLayerAutoPilot(self, direction):
        if not self.autoPilotZoomToObjToggle.isChecked():
            return
        ID = self.autoPilotZoomToObjSpinBox.value()
        posData = self.data[self.pos_i]
        if not posData.IDs:
            return
        
        try:
            ID_idx = posData.IDs_idxs[ID]
            if direction == 'next':
                nextID_idx = ID_idx + 1
            else:
                nextID_idx = ID_idx - 1
            obj = posData.rp[nextID_idx]
        except Exception as e:
            self.logger.info(
                f'Auto-pilot restarted from first ID'
            )
            obj = posData.rp[0]
        
        self.autoPilotZoomToObjSpinBox.setValue(obj.label)
        self.zoomToObj(obj)        
        
    def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading):
        doesTableExists = False
        for posData in self.data:
            files = myutils.listdir(posData.images_path)
            for file in files:
                if file.endswith(f'{tableEndName}.csv'):
                    doesTableExists = True
                    break
        
        if not doesTableExists:
            return
        
        if not forceLoading:
            msg = widgets.myMessageBox(wrapText=False)
            txt = html_utils.paragraph(
                f'The table <code>{tableEndName}.csv</code> already exists!<br><br>'
                'Do you want to load it?'
            )
            _, yesButton, _ = msg.warning(
                self.addPointsWin, 'Table exists!', txt,
                buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name')
            )
            if msg.clickedButton != yesButton:
                return

        self.loadClickEntryDfs(tableEndName)

    def checkLoadedTableIds(self, toolbar):
        if toolbar != self.promptSegmentPointsLayerToolbar:
            return True
        
        for posData in self.data:
            for tableEndName, df in posData.clickEntryPointsDfs.items():
                for point_id in df['id'].values:
                    if point_id in posData.IDs_idxs:
                        proceed = self.warnAddingPointWithExistingId(
                            point_id, table_endname=tableEndName
                        )
                        return proceed
        
        return True
    
    @exception_handler
    def addPointsLayer(self, toolbar=None):
        proceed = self.checkLoadedTableIds(toolbar)
        
        if self.addPointsWin.cancel or not proceed:
            self.addPointsWin = None
            self.logger.info('Adding points layer cancelled.')
            return
        
        if toolbar is None:
            toolbar = self.pointsLayersToolbar
        
        symbol = self.addPointsWin.symbol
        color = self.addPointsWin.color
        pointSize = self.addPointsWin.pointSize
        zRadius = int((self.addPointsWin.zHeight-1)/2)
        r,g,b,a = color.getRgb()

        scatterItem = widgets.PointsScatterPlotItem(
            [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize,
            brush=pg.mkBrush(color=(r,g,b,100)),
            pen=pg.mkPen(width=2, color=(r,g,b)),
            hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), 
            tip=None
        )
        self.ax1.addItem(scatterItem)

        toolButton = widgets.PointsLayerToolButton(symbol, color, parent=self)
        toolButton.actions = []
        toolButton.setCheckable(True)
        toolButton.setChecked(True)
        if self.addPointsWin.keySequence is not None:
            toolButton.setShortcut(self.addPointsWin.keySequence)
        toolButton.toggled.connect(self.pointLayerToolbuttonToggled)
        toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance)
        toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled)
        toolButton.sigRemove.connect(
            partial(self.removePointsLayer, toolbar=toolbar)
        )
        
        action = toolbar.addWidget(toolButton)
        action.state = self.addPointsWin.state()

        toolButton.action = action
        action.brushColor = (r,g,b,100)
        action.brushColorId0 = (
            *colors.hex_to_rgb(
                colors.lighten_color(
                    np.array(action.brushColor)/255, 0.3
                )
            ), 100
        )
        action.penColor = (r,g,b)
        action.penColorId0 = colors.lighten_color(
            np.array(action.penColor)/255, 0.3
        )
        action.pointSize = pointSize
        action.zRadius = zRadius
        action.button = toolButton
        action.scatterItem = scatterItem
        scatterItem.action = action
        action.layerType = self.addPointsWin.layerType
        action.layerTypeIdx = self.addPointsWin.layerTypeIdx
        action.pointsData = self.addPointsWin.pointsData
        action.snapToMax = False
        action.loadedDfInfo = self.addPointsWin.loadedDfInfo
        self.setPointsLayerLoadedDfEndanme(action)
        
        if self.addPointsWin.layerType.startswith('Click to annotate point'):
            action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked()
            isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf
            self.setupAddPointsByClicking(
                toolButton, isLoadedDf, toolbar=toolbar
            )
            if self.addPointsWin.autoPilotToggle.isChecked():
                self.autoPilotZoomToObjToggle.setChecked(True)
        
        weighingChannel = self.addPointsWin.weighingChannel
        self.loadPointsLayerWeighingData(action, weighingChannel)

        self.drawPointsLayers()
        
        if toolbar == self.promptSegmentPointsLayerToolbar:
            self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True
            self.magicPromptsToolbar.clearPointsAction.setDisabled(False)
            QTimer.singleShot(
                200, self.magicPromptsToolbar.selectModelAction.trigger
            )
        
        self.addPointsWin = None
    
    def loadClickEntryDfs(self, tableEndName):
        for posData in self.data:
            if posData.basename.endswith('_'):
                basename = posData.basename
            else:
                basename = f'{posData.basename}_'
            csv_filename = f'{basename}{tableEndName}'
            if not csv_filename.endswith('.csv'):
                csv_filename = f'{csv_filename}.csv'
            filepath = os.path.join(posData.images_path, csv_filename)
            if not os.path.exists(filepath):
                continue
            
            self.logger.info(f'Loading points from "{filepath}.csv"...')
            df = pd.read_csv(filepath)
            if 'id' not in df.columns:
                df['id'] = range(1, len(df)+1)
            posData.clickEntryPointsDfs[tableEndName] = df
        
        try:
            self.addPointsWin.loadButton.confirmAction()
        except Exception as err:
            pass
    
    def removeClickedPoints(self, action, points):
        posData = self.data[self.pos_i]
        framePointsData = action.pointsData[posData.frame_i]
        if posData.SizeZ > 1:
            zProjHow = self.zProjComboBox.currentText()
            if zProjHow != 'single z-slice':
                _warnings.warnCannotAddRemovePointsProjection()
                return
            zSlice = self.zSliceScrollBar.sliderPosition()
        else:
            zSlice = None
        
        id = None
        for point in points:
            pos = point.pos()
            x, y = pos.x(), pos.y()
            if zSlice is not None:
                framePointsData[zSlice]['x'].remove(x)
                framePointsData[zSlice]['y'].remove(y)
                framePointsData[zSlice]['id'].remove(point.data())
            else:
                framePointsData['x'].remove(x)
                framePointsData['y'].remove(y)
                framePointsData['id'].remove(point.data())
            id = point.data()
        
        return id
    
    def restorePrevPointIdRightClick(self, addPointsByClickingButton):
        # Try to restore the id that was there before hovering 
        # because the hovering was required only to delete the 
        # point
        try:
            prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId
            addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId)
        except Exception as err:
            addPointsByClickingButton.rightClickIDSpinbox.prevId = None
    
    def getClickedPointNewId(
            self, action, current_id, pointIdSpinbox, isMagicPrompts=False
        ):
        removed_id = getattr(pointIdSpinbox, 'removedId', None)
        if removed_id is not None:
            pointIdSpinbox.removedId = None
            return removed_id
        
        posData = self.data[self.pos_i]
        if isMagicPrompts:
            if current_id not in posData.IDs_idxs:
                return current_id
            
            new_id = self.setBrushID(return_val=True)
            return new_id
        else:
            framePointsData = action.pointsData.get(posData.frame_i)
            if framePointsData is None:
                return 1
            if posData.SizeZ > 1:
                new_id = 1
                for z_data in action.pointsData[posData.frame_i].values():
                    max_id = max(z_data.get('id', 0), default=0) + 1
                    if max_id > new_id:
                        new_id = max_id
            else:
                new_id = max(framePointsData.get('id', 0), default=0) + 1
            if current_id >= new_id:
                return current_id
            return new_id
    
    def setHoverCircleAddPoint(self, x, y):
        addPointsByClickingButton = self.buttonAddPointsByClickingActive()
        if addPointsByClickingButton is None:
            return
        action = addPointsByClickingButton.action
        self.setHoverToolSymbolData(
            [x], [y], (self.ax1_BrushCircle,),
            size=action.pointSize
        )
    
    def addClickedPoint(self, action, x, y, id):
        x, y = round(x, 2), round(y, 2)
        posData = self.data[self.pos_i]
        framePointsData = action.pointsData.get(posData.frame_i)
        if action.snapToMax:
            radius = round(action.pointSize/2)
            rr, cc = skimage.draw.disk((round(y), round(x)), radius)
            idx_max = (self.img1.image[rr, cc]).argmax()
            y, x = rr[idx_max], cc[idx_max]

        if framePointsData is None:
            if posData.SizeZ > 1:
                zSlice = self.zSliceScrollBar.sliderPosition()
                action.pointsData[posData.frame_i] = {
                    zSlice: {'x': [x], 'y': [y], 'id': [id]}
                }
            else:
                action.pointsData[posData.frame_i] = {
                    'x': [x], 'y': [y], 'id': [id]
                }
        else:
            if posData.SizeZ > 1:
                zSlice = self.zSliceScrollBar.sliderPosition()
                z_data = action.pointsData[posData.frame_i].get(zSlice)
                if z_data is None:
                    framePointsData[zSlice] = {'x': [x], 'y': [y], 'id': [id]}
                else:
                    framePointsData[zSlice]['x'].append(x)
                    framePointsData[zSlice]['y'].append(y)
                    framePointsData[zSlice]['id'].append(id)
                action.pointsData[posData.frame_i] = framePointsData
            else:
                action.pointsData[posData.frame_i]['x'].append(x)
                action.pointsData[posData.frame_i]['y'].append(y)
                action.pointsData[posData.frame_i]['id'].append(id)
        
    def showPointsLayerIdsToggled(self, button, checked):
        button.action.scatterItem.drawIds = checked
        self.drawPointsLayers()
    
    def removePointsLayer(self, button, toolbar=None):
        button.setChecked(False)
        button.action.scatterItem.setData([], [])
        button.action.loadedDfInfo = None
        self.ax1.removeItem(button.action.scatterItem)
        toolbar.removeAction(button.action)
        for action in button.actions:
            toolbar.removeAction(action)
        
        if toolbar == self.promptSegmentPointsLayerToolbar:
            self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False
    
    def editPointsLayerAppearance(self, button):
        win = apps.EditPointsLayerAppearanceDialog(parent=self)
        win.restoreState(button.action.state)
        win.exec_()
        if win.cancel:
            return
        
        symbol = win.symbol
        color = win.color
        pointSize = win.pointSize
        zRadius = int((win.zHeight-1)/2)
        r,g,b,a = color.getRgb()

        scatterItem = button.action.scatterItem
        scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200))
        scatterItem.setSymbol(symbol, update=False)
        scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False)
        scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False)
        scatterItem.setSize(pointSize, update=True)
        
        button.action.brushColor = (r,g,b,100)
        button.action.penColor = (r,g,b)
        button.action.pointSize = pointSize
        button.action.zRadius = zRadius

        button.action.state = win.state()
    
    def loadPointsLayerWeighingData(self, action, weighingChannel):
        if not weighingChannel:
            return
        
        self.logger.info(f'Loading "{weighingChannel}" weighing data...')
        action.weighingData = []
        for p, posData in enumerate(self.data):
            if weighingChannel == posData.user_ch_name:
                wData = posData.img_data
                action.weighingData.append(wData)
                continue

            path, filename = self.getPathFromChName(weighingChannel, posData)
            if path is None:
                self.criticalFluoChannelNotFound(weighingChannel, posData) 
                action.weighingData = []
                return
            
            if filename in posData.fluo_data_dict:
                # Weighing data already loaded as additional fluo channel
                wData = posData.fluo_data_dict[filename]
            else:
                # Weighing data never loaded --> load now
                wData, _ = self.load_fluo_data(path)
                if posData.SizeT == 1:
                    wData = wData[np.newaxis]
            action.weighingData.append(wData)

    def pointLayerToolbuttonToggled(self, checked):
        action = self.sender().action
        action.scatterItem.setVisible(checked)
    
    def getCentroidsPointsData(self, action):
        # Centroids (either weighted or not)
        # NOTE: if user requested to draw from table we load that in 
        # apps.AddPointsLayerDialog.ok_cb()
        posData = self.data[self.pos_i]
        action.pointsData[posData.frame_i] = {}
        if hasattr(action, 'weighingData'):
            lab = posData.lab
            img = action.weighingData[self.pos_i][posData.frame_i]
            rp = skimage.measure.regionprops(lab, intensity_image=img)
            attr = 'weighted_centroid'
        else:
            rp = posData.rp
            attr = 'centroid'
        for i, obj in enumerate(rp):
            centroid = getattr(obj, attr)
            if len(centroid) == 3:
                zc, yc, xc = centroid
                z_int = round(zc)
                if z_int not in action.pointsData[posData.frame_i]:
                    action.pointsData[posData.frame_i][z_int] = {
                        'x': [xc], 'y': [yc]
                    }
                else:
                    z_data = action.pointsData[posData.frame_i][z_int]
                    z_data['x'].append(xc)
                    z_data['y'].append(yc)
            else:
                yc, xc = centroid
                if 'y' not in action.pointsData[posData.frame_i]:
                    action.pointsData[posData.frame_i]['y'] = [yc]
                    action.pointsData[posData.frame_i]['x'] = [xc]
                else:
                    action.pointsData[posData.frame_i]['y'].append(yc)
                    action.pointsData[posData.frame_i]['x'].append(xc)
    
    def drawPointsLayers(self, computePointsLayers=True):
        posData = self.data[self.pos_i]
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                if not hasattr(action, 'layerTypeIdx'):
                    continue

                if action.layerTypeIdx < 2 and computePointsLayers:
                    self.getCentroidsPointsData(action)

                if not action.button.isChecked():
                    continue
                
                if posData.frame_i not in action.pointsData:
                    if action.layerTypeIdx != 4:
                        self.logger.info(
                            f'Frame number {posData.frame_i+1} does not have any '
                            f'"{action.layerType}" point to display.'
                        )
                    continue
                
                framePointsData = action.pointsData[posData.frame_i]
    
                if 'x' not in framePointsData:
                    # 3D points
                    zProjHow = self.zProjComboBox.currentText()
                    isZslice = (zProjHow == 'single z-slice' and posData.SizeZ > 1)
                    if isZslice:
                        xx, yy, ids = [], [], []
                        zSlice = self.zSliceScrollBar.sliderPosition()
                        zRadius = action.zRadius
                        zRange = range(zSlice-zRadius, zSlice+zRadius+1)
                        for z in zRange:
                            z_data = framePointsData.get(z)
                            if z_data is None:
                                continue
                            xx.extend(z_data['x'])
                            yy.extend(z_data['y'])
                            ids.extend(z_data['id'])
                    else:
                        xx, yy, ids = [], [], []
                        # z-projection --> draw all points
                        for z, z_data in framePointsData.items():
                            xx.extend(z_data['x'])
                            yy.extend(z_data['y'])
                            ids.extend(z_data['id'])
                else:
                    # 2D segmentation
                    xx = framePointsData['x']
                    yy = framePointsData['y']
                    ids = framePointsData['id']

                brushColors = [
                    action.brushColor if id != 0 else action.brushColorId0
                    for id in ids
                ]
                brushes = [pg.mkBrush(color) for color in brushColors]
                
                pensColor = [
                    action.penColor if id != 0 else action.penColorId0
                    for id in ids
                ]
                pens = [pg.mkPen(color) for color in pensColor]
                
                xx = np.array(xx) # + 0.5
                yy = np.array(yy) # + 0.5
                action.scatterItem.setData(
                    xx, yy, data=ids, brush=brushes, pen=pens
                )

    def overlay_cb(self, checked):
        self.UserNormAction, _, _ = self.getCheckNormAction()
        posData = self.data[self.pos_i]
        if checked:
            self.setRetainSizePolicyLutItems()
            if posData.ol_data is None:
                selectedChannels = self.askSelectOverlayChannel()
                if selectedChannels is None:
                    self.overlayButton.toggled.disconnect()
                    self.overlayButton.setChecked(False)
                    self.overlayButton.toggled.connect(self.overlay_cb)
                    return
                
                success = self.loadOverlayData(selectedChannels)         
                if not success:
                    return False
                lastChannel = selectedChannels[-1]
                self.setCheckedOverlayContextMenusActions(selectedChannels)
                imageItem = self.overlayLayersItems[lastChannel][0]
                self.setOpacityOverlayLayersItems(0.5, imageItem=imageItem)
                self.img1.setOpacity(0.5)

            self.normalizeRescale0to1Action.setChecked(True)

            self.updateAllImages()
            self.updateImageValueFormatter()
            self.enableOverlayWidgets(True)
        else:
            self.img1.setOpacity(1.0)
            self.updateAllImages()
            self.updateImageValueFormatter()
            self.enableOverlayWidgets(False)
            
            for items in self.overlayLayersItems.values():
                imageItem = items[0]
                imageItem.clear()
        
        self.setOverlayItemsVisible()
    
    def countObjectsCb(self, checked):
        if self.countObjsWindow is None:
            categoryCountMapper = self.countObjects()
            self.countObjsWindow = apps.ObjectCountDialog(
                categoryCountMapper=categoryCountMapper, 
                parent=self
            )
            self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts)
            self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts)
        
        if checked:
            self.countObjsWindow.show()
        else:
            self.countObjsWindow.hide()
    
    def showLabelRoiContextMenu(self, event):
        menu = QMenu(self.labelRoiButton)
        action = QAction('Re-initialize magic labeller model...')
        action.triggered.connect(self.initLabelRoiModel)
        menu.addAction(action)
        menu.exec_(QCursor.pos())
    
    def initLabelRoiModel(self):
        self.app.restoreOverrideCursor()
        # Ask which model
        win = apps.QDialogSelectModel(parent=self)
        win.exec_()
        if win.cancel:
            self.logger.info('Magic labeller aborted.')
            return True
        self.app.setOverrideCursor(Qt.WaitCursor)
        model_name = win.selectedModel
        self.labelRoiModel = self.repeatSegm(
            model_name=model_name, askSegmParams=True,
            is_label_roi=True
        )
        if self.labelRoiModel is None:
            return True
        self.labelRoiViewCurrentModelAction.setDisabled(False)
        return False

    def showOverlayContextMenu(self, event):
        if not self.overlayButton.isChecked():
            return

        self.overlayContextMenu.exec_(QCursor.pos())
    
    def showOverlayLabelsContextMenu(self, event):
        if not self.overlayLabelsButton.isChecked():
            return

        self.overlayLabelsContextMenu.exec_(QCursor.pos())

    def showInstructionsCustomModel(self):
        modelFilePath = apps.addCustomModelMessages(self)
        if modelFilePath is None:
            self.logger.info('Adding custom model process stopped.')
            return
        
        myutils.store_custom_model_path(modelFilePath)
        modelName = os.path.basename(os.path.dirname(modelFilePath))
        customModelAction = QAction(modelName)
        self.segmSingleFrameMenu.addAction(customModelAction)
        self.segmActions.append(customModelAction)
        self.segmActionsVideo.append(customModelAction)
        self.modelNames.append(modelName)
        self.models.append(None)
        self.sender().callback(customModelAction)
    
    def showInstructionsCustomPromptModel(self):
        modelFilePath = apps.addCustomPromptModelMessages(QParent=self)
        if modelFilePath is None:
            self.logger.info('Adding custom promptable model process stopped.')
            return
        
        myutils.store_custom_promptable_model_path(modelFilePath)
        
        msg = widgets.myMessageBox(wrapText=False)
        info_txt = html_utils.paragraph(f"""
            Done!<br><br>
            The custom promptable model has been added to the list of models.<br><br>
            Use the <b>Magic prompts</b> button (top toolbar) to use it.<br><br>
            Have fun!
        """)
        msg.information(self, 'Custom promptable model added', info_txt)
    
    def segmWithPromptableModelActionTriggered(self):
        self.blinker = qutils.QControlBlink(
            self.magicPromptsToolButton, qparent=self
        )
        self.blinker.start()
    
    def setCheckedOverlayContextMenusActions(self, channelNames):
        for action in self.overlayContextMenu.actions():
            if action.text() in channelNames:
                action.setChecked(True)
                self.checkedOverlayChannels.add(action.text())

    def enableOverlayWidgets(self, enabled):
        posData = self.data[self.pos_i]   
        if enabled:
            self.overlayColorButton.setDisabled(False)
            self.editOverlayColorAction.setDisabled(False)

            if posData.SizeZ == 1:
                return

            self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1)
            if self.zProjOverlay_CB.currentText().find('max') != -1:
                self.overlay_z_label.setDisabled(True)
                self.zSliceOverlay_SB.setDisabled(True)
            else:
                z = self.zSliceOverlay_SB.sliderPosition()
                self.overlay_z_label.setText(f'Overlay z-slice  {z+1:02}/{posData.SizeZ}')
                self.zSliceOverlay_SB.setDisabled(False)
                self.overlay_z_label.setDisabled(False)
            self.zSliceOverlay_SB.show()
            self.overlay_z_label.show()
            self.zProjOverlay_CB.show()
            self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice)
            self.zProjOverlay_CB.currentTextChanged.connect(self.updateOverlayZproj)
            self.zProjOverlay_CB.setCurrentIndex(4)
            self.zProjOverlay_CB.activated.connect(self.clearComboBoxFocus)
        else:
            self.zSliceOverlay_SB.setDisabled(True)
            self.zSliceOverlay_SB.hide()
            self.overlay_z_label.hide()
            self.zProjOverlay_CB.hide()
            self.overlayColorButton.setDisabled(True)
            self.editOverlayColorAction.setDisabled(True)

            if posData.SizeZ == 1:
                return

            self.zSliceOverlay_SB.valueChanged.disconnect()
            self.zProjOverlay_CB.currentTextChanged.disconnect()
            self.zProjOverlay_CB.activated.disconnect()


    def criticalFluoChannelNotFound(self, fluo_ch, posData):
        msg = widgets.myMessageBox(showCentered=False)
        ls = "\n".join(myutils.listdir(posData.images_path))
        msg.setDetailedText(
            f'Files present in the {posData.relPath} folder:\n'
            f'{ls}'
        )
        title = 'Requested channel data not found!'
        txt = html_utils.paragraph(
            f'The folder <code>{posData.pos_path}</code> '
            '<b>does not contain</b> '
            'either one of the following files:<br><br>'
            f'{posData.basename}{fluo_ch}.tif<br>'
            f'{posData.basename}{fluo_ch}_aligned.npz<br><br>'
            'Data loading aborted.'
        )
        msg.addShowInFileManagerButton(posData.images_path)
        okButton = msg.warning(
            self, title, txt, buttonsTexts=('Ok')
        )

    def imgGradLUT_cb(self, LUTitem):
        pass

    def imgGradLUTfinished_cb(self):
        posData = self.data[self.pos_i]
        ticks = self.imgGrad.gradient.listTicks()

        self.img1ChannelGradients[self.user_ch_name] = {
            'ticks': [(x, t.color.getRgb()) for t,x in ticks],
            'mode': 'rgb'
        }
        
        self.df_settings = self.imgGrad.saveState(self.df_settings)
        self.df_settings.to_csv(self.settings_csv_path)

    def updateContColour(self, colorButton):
        color = colorButton.color().getRgb()
        self.df_settings.at['contLineColor', 'value'] = str(color)
        self._updateContColour(color)
        self.updateAllImages()
    
    def _updateContColour(self, color):
        self.gui_createContourPens()
        for items in self.overlayLayersItems.values():
            lutItem = items[1]
            lutItem.contoursColorButton.setColor(color)
        
    def saveContColour(self, colorButton):
        self.df_settings.to_csv(self.settings_csv_path)
    
    def updateMothBudLineColour(self, colorButton):
        color = colorButton.color().getRgb()
        self.df_settings.at['mothBudLineColor', 'value'] = str(color)
        self._updateMothBudLineColour(color)
        self.updateAllImages()
    
    def _updateMothBudLineColour(self, color):
        self.gui_createMothBudLinePens()
        self.ax1_newMothBudLinesItem.setBrush(self.newMothBudLineBrush)
        self.ax1_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush)
        self.ax2_newMothBudLinesItem.setBrush(self.newMothBudLineBrush)
        self.ax2_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush)
        for items in self.overlayLayersItems.values():
            lutItem = items[1]
            lutItem.mothBudLineColorButton.setColor(color)

    def saveMothBudLineColour(self, colorButton):
        self.df_settings.to_csv(self.settings_csv_path)

    def contLineWeightToggled(self, checked=True):
        if not checked:
            return
        self.imgGrad.uncheckContLineWeightActions()
        w = self.sender().lineWeight
        self.df_settings.at['contLineWeight', 'value'] = w
        self.df_settings.to_csv(self.settings_csv_path)
        self._updateContLineThickness()
        self.updateAllImages()
    
    def _updateContLineThickness(self):
        self.gui_createContourPens()
        for act in self.imgGrad.contLineWightActionGroup.actions():
            if act == self.sender():
                act.setChecked(True)
            act.toggled.connect(self.contLineWeightToggled)
    
    def mothBudLineWeightToggled(self, checked=True):
        if not checked:
            return
        self.imgGrad.uncheckContLineWeightActions()
        w = self.sender().lineWeight
        self.df_settings.at['mothBudLineSize', 'value'] = w
        self.df_settings.to_csv(self.settings_csv_path)
        self._updateMothBudLineSize(w)
        self.updateAllImages()
    
    def _updateMothBudLineSize(self, size):
        self.gui_createMothBudLinePens()
        
        for act in self.imgGrad.mothBudLineWightActionGroup.actions():
            if act == self.sender():
                act.setChecked(True)
            act.toggled.connect(self.mothBudLineWeightToggled)
        
        self.ax1_oldMothBudLinesItem.setSize(size)
        self.ax1_newMothBudLinesItem.setSize(size)
        self.ax2_oldMothBudLinesItem.setSize(size)
        self.ax2_newMothBudLinesItem.setSize(size)

    def getOlImg(self, key, frame_i=None):
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i

        img = posData.ol_data[key][frame_i]
        if posData.SizeZ > 1:
            zProjHow = self.zProjOverlay_CB.currentText()
            z = self.zSliceOverlay_SB.sliderPosition()
            if zProjHow == 'same as above':
                zProjHow = self.zProjComboBox.currentText()
                z = self.zSliceScrollBar.sliderPosition()
                reconnect = False
                try:
                    self.zSliceOverlay_SB.valueChanged.disconnect()
                    reconnect = True
                except TypeError:
                    pass
                self.zSliceOverlay_SB.setSliderPosition(z)
                if reconnect:
                    self.zSliceOverlay_SB.valueChanged.connect(
                        self.updateOverlayZslice
                    )
            if zProjHow == 'single z-slice':
                self.overlay_z_label.setText(f'Overlay z-slice  {z+1:02}/{posData.SizeZ}')
                ol_img = img[z].copy()
            elif zProjHow == 'max z-projection':
                ol_img = img.max(axis=0).copy()
            elif zProjHow == 'mean z-projection':
                ol_img = img.mean(axis=0).copy()
            elif zProjHow == 'median z-proj.':
                ol_img = np.median(img, axis=0).copy()
        else:
            ol_img = img.copy()

        return ol_img
    
    def setTextAnnotZsliceScrolling(self):
        pass
    
    def setGraphicalAnnotZsliceScrolling(self):
        posData = self.data[self.pos_i]
        if self.isSegm3D:
            self.currentLab2D = posData.lab[self.z_lab()]
            self.setOverlaySegmMasks()
            self.doCustomAnnotation(0)
            self.update_rp_metadata()
        else:
            self.currentLab2D = posData.lab
            self.setOverlaySegmMasks()
        self.updateContoursImage(0)
        self.updateContoursImage(1)

    def initContoursImage(self):
        posData = self.data[self.pos_i]
        z_slice = self.z_lab()
        img = posData.img_data[posData.frame_i]
        Y, X = img[z_slice].shape[-2:]
            
        self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8)
    
    def initDelRoiLab(self):
        posData = self.data[self.pos_i]
        z_slice = self.z_lab()
        img = posData.img_data[posData.frame_i]
        Y, X = img[z_slice].shape[-2:]
        
        self.delRoiLab = np.zeros((Y, X), dtype=np.uint32)
    
    def initLostObjContoursImage(self):
        posData = self.data[self.pos_i]
        z_slice = self.z_lab()
        img = posData.img_data[posData.frame_i]
        Y, X = img[z_slice].shape[-2:]
            
        self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8)
    
    def initLostTrackedObjContoursImage(self):
        posData = self.data[self.pos_i]
        z_slice = self.z_lab()
        img = posData.img_data[posData.frame_i]
        Y, X = img[z_slice].shape[-2:]
            
        self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8)
    
    def initManualBackgroundImage(self):
        posData = self.data[self.pos_i]
        if hasattr(posData, 'lab'):
            Y, X = posData.lab.shape[-2:]
        else:
            Y, X = posData.img_data.shape[-2:]
        if not hasattr(self, 'manualBackgroundTextItems'):
            self.manualBackgroundTextItems = {}
        posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8)
        if posData.manualBackgroundLab is None:
            posData.manualBackgroundLab = np.zeros((Y, X), dtype=np.uint32)
    
    def initTextAnnot(self, force=False):
        posData = self.data[self.pos_i]
        if hasattr(posData, 'lab'):
            Y, X = posData.lab.shape[-2:]
        else:
            Y, X = posData.img_data.shape[-2:]
        self.textAnnot[0].initItem((Y, X))
        self.textAnnot[1].initItem((Y, X))  
    
    def getObjContours(
            self, obj, all_external=False, local=False, force_calc=True
        ):
        posData = self.data[self.pos_i]
        dataDict = posData.allData_li[posData.frame_i]
        allContours = dataDict.get('contours')    
        if allContours is not None and not force_calc:
            z = self.z_lab()
            key = (obj.label, str(z), all_external, local)
            contours = allContours.get(key)
            if contours is not None:
                return contours
        
        obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8)
        obj_bbox = self.getObjBbox(obj.bbox)
        try:
            contours = core.get_obj_contours(
                obj_image=obj_image, 
                obj_bbox=obj_bbox, 
                local=local,
                all_external=all_external
            )
        except Exception as e:
            if all_external:
                contours = []
            else:
                contours = None
            self.logger.warning(
                f'Object ID {obj.label} contours drawing failed. '
                f'(bounding box = {obj.bbox})'
            )
        return contours
    
    def clearComputedContours(self):
        for posData in self.data:
            for frame_i, dataDict in enumerate(posData.allData_li):
                dataDict['contours'] = {}

    def _computeAllContours2D(self, dataDict, obj, z, obj_bbox):
        obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z)
        if obj_image is None:
            return
            
        all_external = False
        local = False
        contours = core.get_obj_contours(
            obj_image=obj_image, 
            obj_bbox=obj_bbox, 
            local=local,
            all_external=all_external
        )
        key = (obj.label, str(z), all_external, local)
        dataDict['contours'][key] = contours
        
        all_external = True
        local = False
        contours = core.get_obj_contours(
            obj_image=obj_image, 
            obj_bbox=obj_bbox, 
            local=local,
            all_external=all_external
        )
        key = (obj.label, str(z), all_external, local)
        dataDict['contours'][key] = contours

        return dataDict
    
    def computeAllContours(self):
        self.logger.info('Computing all contours...')
        posData = self.data[self.pos_i]
        zz = [None]
        if self.isSegm3D:
            zz.extend(range(posData.SizeZ))
        for frame_i, dataDict in enumerate(posData.allData_li):
            lab = dataDict['labels']
            if lab is None:
                break
            
            rp = dataDict['regionprops']
            if rp is None:
                rp = skimage.measure.regionprops(lab)
                
            dataDict['contours'] = {}
            for obj in rp:
                obj_bbox = self.getObjBbox(obj.bbox)
                for z in zz:
                    if not self.isObjVisible(obj.bbox, z_slice=z):
                        continue
                    
                    try:
                        self._computeAllContours2D(
                            dataDict, obj, z, obj_bbox
                        )
                    except Exception as err:
                        # Contours computation fails on weird objects
                        pass
    
    def computeAllObjToObjCostPairs(self):
        desc = (
            'Computing all object-to-object cost matrices...'
        )
        self.logger.info(desc)
        posData = self.data[self.pos_i]
        
        
        self.progressWin = apps.QDialogWorkerProgress(
            title=desc, parent=self, pbarDesc=desc
        )
        self.progressWin.mainPbar.setMaximum(0)
        self.progressWin.show(self.app)
        
        self.computeAllObjCostPairsThread = QThread()
        self.computeAllObjCostPairsWorker = workers.SimpleWorker(
            posData, self._computeAllObjToObjCostPairs
        )
        
        self.computeAllObjCostPairsWorker.moveToThread(
            self.computeAllObjCostPairsThread
        )
        
        self.computeAllObjCostPairsWorker.signals.finished.connect(
            self.computeAllObjCostPairsThread.quit
        )
        self.computeAllObjCostPairsWorker.signals.finished.connect(
            self.computeAllObjCostPairsWorker.deleteLater
        )
        self.computeAllObjCostPairsThread.finished.connect(
            self.computeAllObjCostPairsThread.deleteLater
        )
        
        self.computeAllObjCostPairsWorker.signals.critical.connect(
            self.computeAllObjCostPairsWorkerCritical
        )
        self.computeAllObjCostPairsWorker.signals.initProgressBar.connect(
            self.workerInitProgressbar
        )
        self.computeAllObjCostPairsWorker.signals.progressBar.connect(
            self.workerUpdateProgressbar
        )
        self.computeAllObjCostPairsWorker.signals.progress.connect(
            self.workerProgress
        )
        self.computeAllObjCostPairsWorker.signals.finished.connect(
            self.computeAllObjCostPairsWorkerFinished
        )
        
        self.computeAllObjCostPairsThread.started.connect(
            self.computeAllObjCostPairsWorker.run
        )
        self.computeAllObjCostPairsThread.start()
        
        self.computeAllObjCostPairsWorkerLoop = QEventLoop()
        self.computeAllObjCostPairsWorkerLoop.exec_()
    
    def _computeAllObjToObjCostPairs(self, posData):
        self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(
            len(posData.allData_li)
        )
        for frame_i, dataDict in enumerate(posData.allData_li):
            if frame_i == 0:
                continue
            
            rp = dataDict['regionprops']
            if rp is None:
                break
            
            prev_rp = posData.allData_li[frame_i-1]['regionprops']
            dist_matrix = core._compute_all_obj_to_obj_contour_dist_pairs(
                dataDict['contours'], rp, 
                prev_rp=prev_rp, 
                restrict_search=True
            )
            dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix
            self.computeAllObjCostPairsWorker.signals.progressBar.emit(1)
        self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0)
    
    def computeAllObjCostPairsWorkerCritical(self, error):
        self.computeAllObjCostPairsWorkerLoop.exit()
        self.workerCritical(error)
    
    def computeAllObjCostPairsWorkerFinished(self, output):
        if self.progressWin is not None:
            self.progressWin.workerFinished = True
            self.progressWin.close()
            self.progressWin = None
        self.computeAllObjCostPairsWorkerLoop.exit()
     
    def setOverlaySegmMasks(self, force=False, forceIfNotActive=False):
        if not hasattr(self, 'currentLab2D'):
            return

        how = self.drawIDsContComboBox.currentText()
        isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1

        how_ax2 = self.getAnnotateHowRightImage()
        isOverlaySegmRightActive = (
            how_ax2.find('overlay segm. masks') != -1
            and self.labelsGrad.showRightImgAction.isChecked()
        )

        isOverlaySegmActive = (
            isOverlaySegmLeftActive or isOverlaySegmRightActive
            or force
        )
        if not isOverlaySegmActive and not forceIfNotActive:
            return 

        alpha = self.imgGrad.labelsAlphaSlider.value()
        if alpha == 0:
            return

        posData = self.data[self.pos_i]
        maxID = max(posData.IDs, default=0)

        if maxID >= len(self.lut):
            self.extendLabelsLUT(maxID+10)

        currentLab2D = self.currentLab2D
        if isOverlaySegmLeftActive:
            self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False)

        if isOverlaySegmRightActive: 
            self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False)
    
    def getObject2DimageFromZ(self, z, obj):
        posData = self.data[self.pos_i]
        z_min = obj.bbox[0]
        local_z = z - z_min
        if local_z >= posData.SizeZ or local_z < 0:
            return
        return obj.image[local_z]
    
    def getObject2DsliceFromZ(self, z, obj):
        posData = self.data[self.pos_i]
        z_min = obj.bbox[0]
        local_z = z - z_min
        if local_z >= posData.SizeZ or local_z < 0:
            return
        return obj.image[local_z]

    def isObjVisible(self, obj_bbox, debug=False, z_slice=None):
        if z_slice is None:
            z_slice = self.z_lab()
            
        if self.isSegm3D:
            zProjHow = self.zProjComboBox.currentText()
            isZslice = zProjHow == 'single z-slice'
            if not isZslice:
                # required a projection --> all obj are visible
                return True
            
            depthAxes = self.switchPlaneCombobox.depthAxes()
            
            min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox
            if depthAxes == 'z':
                min_val, max_val = min_z, max_z
                val = z_slice
            elif depthAxes == 'y':
                min_val, max_val = min_y, max_y
                val = z_slice[-1]
            else:
                min_val, max_val = min_x, max_x
                val = z_slice[-1]
            
            if val >= min_val and val < max_val:
                return True
            else:
                return False
        else:
            return True

    def getObjImage(self, obj_image, obj_bbox, z_slice=None):
        if self.isSegm3D and len(obj_bbox)==6:
            zProjHow = self.zProjComboBox.currentText()
            isZslice = zProjHow == 'single z-slice'
            if not isZslice:
                # required a projection
                return obj_image.max(axis=0)

            min_z = obj_bbox[0]
            if z_slice is None:
                z_slice = self.z_lab()
            if isinstance(z_slice, tuple):
                z_slice = z_slice[-1]
                
            local_z = z_slice - min_z
            try:
                obi_image_2d = obj_image[local_z]
            except Exception as err:
                obi_image_2d = None
            return obi_image_2d
        else:
            return obj_image

    def getObjSlice(self, obj_slice):
        if self.isSegm3D:
            return obj_slice[1:3]
        else:
            return obj_slice
    
    def setOverlayImages(self, frame_i=None):
        posData = self.data[self.pos_i]
        if posData.ol_data is None:
            return
        for filename in posData.ol_data:
            chName = myutils.get_chname_from_basename(
                filename, posData.basename, remove_ext=False
            )
            if chName not in self.checkedOverlayChannels:
                continue
            imageItem = self.overlayLayersItems[chName][0]

            ol_img = self.getOlImg(filename, frame_i=frame_i)

            self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem)
            imageItem.setImage(ol_img)
        
    def initShortcuts(self):
        from . import config
        cp = config.ConfigParser()
        if os.path.exists(shortcut_filepath):
            cp.read(shortcut_filepath)
        
        if 'keyboard.shortcuts' not in cp:
            cp['keyboard.shortcuts'] = {}
        
        if cp.has_option('keyboard.shortcuts', 'Zoom out'):
            zoomOutKeyValueStr = cp['keyboard.shortcuts']['Zoom out']
            try:
                self.zoomOutKeyValue = int(zoomOutKeyValueStr)
            except Exception as err:
                self.logger.warning(
                    f'{zoomOutKeyValueStr} is not a valid key '
                    'zooming out action. Restoring default key "H".'
                )
        
        if 'delete_object.action' not in cp:
            self.delObjAction = None
        else:
            delObjKeySequenceText = cp['delete_object.action']['Key sequence']
            delObjButtonText = cp['delete_object.action']['Mouse button']
            delObjQtButton = (
                Qt.MouseButton.LeftButton if delObjButtonText == 'Left click'
                else Qt.MouseButton.MiddleButton
            )
            if not delObjKeySequenceText:
                delObjKeySequence = None
            else:
                delObjKeySequence = widgets.KeySequenceFromText(
                    delObjKeySequenceText
                )
            self.delObjToolAction.setChecked(True)
            self.delObjAction = delObjKeySequence, delObjQtButton
              
        shortcuts = {}
        for name, widget in self.widgetsWithShortcut.items():
            if name not in cp.options('keyboard.shortcuts'):
                if hasattr(widget, 'keyPressShortcut'):
                    key = widget.keyPressShortcut
                    shortcut = widgets.KeySequenceFromText(key)
                else:
                    shortcut = widget.shortcut()
                shortcut_text = shortcut.toString()
                cp['keyboard.shortcuts'][name] = shortcut_text
            else:
                shortcut_text = cp['keyboard.shortcuts'][name]
                shortcut = widgets.KeySequenceFromText(shortcut_text)
            
            shortcuts[name] = (shortcut_text, shortcut)
        self.setShortcuts(shortcuts, save=False)
        with open(shortcut_filepath, 'w') as ini:
            cp.write(ini)
    
    def setShortcuts(self, shortcuts: dict, save=True):
        for name, (text, shortcut) in shortcuts.items():
            widget = self.widgetsWithShortcut[name]
            if shortcut is None:
                shortcut = QKeySequence()
            if hasattr(widget, 'keyPressShortcut'):
                widget.keyPressShortcut = shortcut
            else:
                widget.setShortcut(shortcut)
            s = widget.toolTip()
            toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s)
            widget.setToolTip(toolTip)
        
        if not save: 
            return
        
        from . import config
        cp = config.ConfigParser()
        if os.path.exists(shortcut_filepath):
            cp.read(shortcut_filepath)
        
        if 'keyboard.shortcuts' not in cp:
            cp['keyboard.shortcuts'] = {}

        for name, (text, shortcut) in shortcuts.items():
            cp['keyboard.shortcuts'][name] = text
        
        cp['keyboard.shortcuts']['Zoom out'] = str(self.zoomOutKeyValue)
        
        if self.delObjAction is None:
            with open(shortcut_filepath, 'w') as ini:
                cp.write(ini)
            return
    
        delObjKeySequence, delObjQtButton = self.delObjAction
        try:
            if delObjKeySequence is None:
                delObjKeySequenceText = ''
            else:
                delObjKeySequenceText = delObjKeySequence.toString()
                
            delObjKeySequenceText = (
                delObjKeySequenceText
                .encode('ascii', 'ignore')
                .decode('utf-8')
            )
            delObjButtonText = (
                'Left click' if delObjQtButton == Qt.MouseButton.LeftButton
                else 'Middle click'
            )
            cp['delete_object.action'] = {
                'Key sequence': delObjKeySequenceText, 
                'Mouse button': delObjButtonText
            }
        except Exception as err:
            self.logger.warning(
                f'{delObjKeySequence} is not a valid keys sequence for '
                'deleting objects. Setting default action'
            )
            self.delObjAction = None
            cp.remove_section('delete_object.action')
            
        with open(shortcut_filepath, 'w') as ini:
            cp.write(ini)
    
    def editShortcuts_cb(self):
        if sys.platform == 'darwin':
            delObjKeySequenceText = 'Ctrl'
            delObjButtonText = 'Left click'
        else:
            delObjKeySequenceText = ''
            delObjButtonText = 'Middle click'
            
        if self.delObjAction is not None:
            delObjKeySequence, delObjQtButton = self.delObjAction
            if delObjKeySequence is None:
                delObjKeySequenceText = ''
            else:
                delObjKeySequenceText = delObjKeySequence.toString()
            delObjKeySequenceText = (
                delObjKeySequenceText.encode('ascii', 'ignore').decode('utf-8')
            )
            delObjButtonText = (
                'Left click' if delObjQtButton == Qt.MouseButton.LeftButton
                else 'Middle click'
            )
            
        win = apps.ShortcutEditorDialog(
            self.widgetsWithShortcut, 
            delObjectKey=delObjKeySequenceText,
            delObjectButton=delObjButtonText,
            zoomOutKeyValue=self.zoomOutKeyValue,
            parent=self
        )
        win.exec_()
        if win.cancel:
            return

        self.delObjAction = win.delObjAction
        self.zoomOutKeyValue = win.zoomOutKeyValue
        self.setShortcuts(win.customShortcuts)
            
    def toggleOverlayColorButton(self, checked=True):
        self.mousePressColorButton(None)

    def toggleTextIDsColorButton(self, checked=True):
        self.textIDsColorButton.selectColor()

    def updateTextAnnotColor(self, button):
        r, g, b = np.array(self.textIDsColorButton.color().getRgb()[:3])
        self.imgGrad.textColorButton.setColor((r, g, b))
        for items in self.overlayLayersItems.values():
            lutItem = items[1]
            lutItem.textColorButton.setColor((r, g, b))
        self.gui_createTextAnnotColors(r,g,b, custom=True)
        self.gui_setTextAnnotColors()
        self.updateAllImages()

    def saveTextIDsColors(self, button):
        self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb
        self.df_settings.to_csv(self.settings_csv_path)

    def setLut(self, shuffle=True):
        self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255)     
        if shuffle:
            np.random.shuffle(self.lut)
        
        # Insert background color
        if 'labels_bkgrColor' in self.df_settings.index:
            rgbString = self.df_settings.at['labels_bkgrColor', 'value']
            try:
                r, g, b = rgbString
            except Exception as e:
                r, g, b = colors.rgb_str_to_values(rgbString)
        else:
            r, g, b = 25, 25, 25
            self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b)

        self.lut = np.insert(self.lut, 0, [r, g, b], axis=0)

    def useCenterBrushCursorHoverIDtoggled(self, checked):
        if checked:
            self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes'
        else:
            self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No'
        self.df_settings.to_csv(self.settings_csv_path)

    def shuffle_cmap(self):
        np.random.shuffle(self.lut[1:])
        self.initLabelsImageItems()
        self.updateAllImages()
    
    def greedyShuffleCmap(self):
        lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255)
        greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut)
        self.lut = greedy_lut
        self.initLabelsImageItems()
        self.updateAllImages()
    
    def highlightZneighLabels_cb(self, checked):
        if checked:
            pass
        else:
            pass
    
    def setTwoImagesLayout(self, isTwoImages):
        self.isTwoImageLayout = isTwoImages
        if isTwoImages:
            self.graphLayout.removeItem(self.titleLabel)
            self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2)
            # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignLeft)
            self.ax2.show()
            self.ax2.vb.setYLink(self.ax1.vb)
            self.ax2.vb.setXLink(self.ax1.vb)
        else:
            self.graphLayout.removeItem(self.titleLabel)
            self.graphLayout.addItem(self.titleLabel, row=0, col=1)
            # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter)  
            self.ax2.hide()
            oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis)
            try:
                oldLink.sigYRangeChanged.disconnect()
                oldLink.sigXRangeChanged.disconnect()
            except TypeError:
                pass
    
    def showNextFrameImageItem(self, checked):
        self.rightImageFramesScrollbar.setVisible(checked)
        self.rightImageFramesScrollbar.setDisabled(not checked)
        self.setTwoImagesLayout(checked)
        if checked:
            self.df_settings.at['isNextFrameVisible', 'value'] = 'Yes'
            self.df_settings.at['isRightImageVisible', 'value'] = 'No'
            self.df_settings.at['isLabelsVisible', 'value'] = 'No'
            self.graphLayout.addItem(
                self.imgGradRight, row=1, col=self.plotsCol+2
            )
            self.rightBottomGroupbox.show()
            self.rightBottomGroupbox.setChecked(True)
            self.drawNothingCheckboxRight.click()            
            if not self.isDataLoading:
                self.updateAllImages()
        else:
            self.clearAx2Items()
            self.rightBottomGroupbox.hide()
            self.df_settings.at['isNextFrameVisible', 'value'] = 'No'
            try:
                self.graphLayout.removeItem(self.imgGradRight)
            except Exception:
                return
            self.rightImageItem.clear()
        
        self.df_settings.to_csv(self.settings_csv_path)
            
        QTimer.singleShot(300, self.resizeGui)

        self.setBottomLayoutStretch()    
        
    
    def showRightImageItem(self, checked):
        self.rightImageFramesScrollbar.setVisible(not checked)
        self.rightImageFramesScrollbar.setDisabled(checked)
        self.setTwoImagesLayout(checked)
        if checked:
            self.df_settings.at['isRightImageVisible', 'value'] = 'Yes'
            self.df_settings.at['isNextFrameVisible', 'value'] = 'No'
            self.df_settings.at['isLabelsVisible', 'value'] = 'No'
            self.graphLayout.addItem(
                self.imgGradRight, row=1, col=self.plotsCol+2
            )
            self.rightBottomGroupbox.show()
            if not self.isDataLoading:
                self.updateAllImages()
        else:
            self.clearAx2Items()
            self.rightBottomGroupbox.hide()
            self.df_settings.at['isRightImageVisible', 'value'] = 'No'
            try:
                self.graphLayout.removeItem(self.imgGradRight)
            except Exception:
                return
            self.rightImageItem.clear()
        
        self.df_settings.to_csv(self.settings_csv_path)
            
        QTimer.singleShot(300, self.resizeGui)

        self.setBottomLayoutStretch()    
        
    def showLabelImageItem(self, checked):
        self.rightImageFramesScrollbar.setVisible(not checked)
        self.rightImageFramesScrollbar.setDisabled(checked)
        self.setTwoImagesLayout(checked)
        self.setAnnotOptionsRightImageLabelsDisabled(checked)
        if checked:
            self.df_settings.at['isLabelsVisible', 'value'] = 'Yes'
            self.df_settings.at['isNextFrameVisible', 'value'] = 'No'
            self.df_settings.at['isRightImageVisible', 'value'] = 'No'
            self.rightBottomGroupbox.show()
            self.rightBottomGroupbox.setChecked(True)
            if not self.isDataLoading:
                self.updateAllImages()
        else:
            self.clearAx2Items()
            self.img2.clear()
            self.df_settings.at['isLabelsVisible', 'value'] = 'No'
            self.rightBottomGroupbox.hide()
            self.moveDelRoisToLeft()
        
        self.df_settings.to_csv(self.settings_csv_path)
        QTimer.singleShot(200, self.resizeGui)

        self.setBottomLayoutStretch()

    def setAnnotOptionsRightImageLabelsDisabled(self, disabled):
        self.annotContourCheckboxRight.setDisabled(disabled)
        self.annotSegmMasksCheckboxRight.setDisabled(disabled)
        if disabled:
            self.annotSegmMasksCheckboxRight.setChecked(False)
            self.annotSegmMasksCheckboxRight.setChecked(False)
            self.annotIDsCheckboxRight.setChecked(True)
    
    def moveDelRoisToLeft(self):
        # Move del ROIs to the left image
        for posData in self.data:
            delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
            for roi in delROIs_info['rois']:
                if not self.ax2.isDelRoiItemPresent(roi):
                    continue

                self.ax1.addDelRoiItem(roi, roi.key)
                self.ax2.removeDelRoiItem(roi)
    
    def setBottomLayoutStretch(self):
        if (
            self.labelsGrad.showRightImgAction.isChecked()
            or self.labelsGrad.showNextFrameAction.isChecked()
        ):
            # Equally share space between the two control groupboxes
            self.bottomLayout.setStretch(1, 1)
            self.bottomLayout.setStretch(2, 5)
            self.bottomLayout.setStretch(3, 1)
            self.bottomLayout.setStretch(4, 5)
            self.bottomLayout.setStretch(5, 1)
        elif self.labelsGrad.showLabelsImgAction.isChecked():
            # Left control takes only left space
            self.bottomLayout.setStretch(1, 1)
            self.bottomLayout.setStretch(2, 5)
            self.bottomLayout.setStretch(3, 5)
            self.bottomLayout.setStretch(4, 1)
            self.bottomLayout.setStretch(5, 1)
        else:
            # Left control takes all the space
            self.bottomLayout.setStretch(1, 3)
            self.bottomLayout.setStretch(2, 10)
            self.bottomLayout.setStretch(3, 1)
            self.bottomLayout.setStretch(4, 1)
            self.bottomLayout.setStretch(5, 1)

    def setCheckedInvertBW(self, checked):
        self.invertBwAction.setChecked(checked)

    def ticksCmapMoved(self, gradient):
        pass
        # posData = self.data[self.pos_i]
        # self.setLut(posData, shuffle=False)
        # self.updateLookuptable()

    def updateLabelsCmap(self, gradient):
        self.setLut()
        self.updateLookuptable()
        self.initLabelsImageItems()

        self.df_settings = self.labelsGrad.saveState(self.df_settings)
        self.df_settings.to_csv(self.settings_csv_path)

        self.updateAllImages()

    def updateBkgrColor(self, button):
        color = button.color().getRgb()[:3]
        self.lut[0] = color
        self.updateLookuptable()

    def updateTextLabelsColor(self, button):
        self.ax2_textColor = button.color().getRgb()[:3]
        posData = self.data[self.pos_i]
        if posData.rp is None:
            return

        for obj in posData.rp:
            self.getObjOptsSegmLabels(obj)

    def saveTextLabelsColor(self, button):
        color = button.color().getRgb()[:3]
        self.df_settings.at['labels_text_color', 'value'] = color
        self.df_settings.to_csv(self.settings_csv_path)

    def saveBkgrColor(self, button):
        color = button.color().getRgb()[:3]
        self.df_settings.at['labels_bkgrColor', 'value'] = color
        self.df_settings.to_csv(self.settings_csv_path)
        self.updateAllImages()

    def changeOverlayColor(self, button):
        rgb = button.color().getRgb()[:3]
        lutItem = self.overlayLayersItems[button.channel][1]
        self.initColormapOverlayLayerItem(rgb, lutItem)
        lutItem.overlayColorButton.setColor(rgb)
    
    def saveOverlayColor(self, button):
        rgb = button.color().getRgb()[:3]
        rgb_text = '_'.join([str(val) for val in rgb])
        self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text
        self.df_settings.to_csv(self.settings_csv_path)

    def getImageDataFromFilename(self, filename):
        posData = self.data[self.pos_i]
        if filename == posData.filename:
            return posData.img_data[posData.frame_i]
        else:
            return posData.ol_data_dict.get(filename)

    def z_slice_index(self):
        posData = self.data[self.pos_i]
        if posData.SizeZ == 1:
            return None
        zProjHow = self.zProjComboBox.currentText()
        if zProjHow != 'single z-slice':
            return zProjHow
        
        axis_slice = self.zSliceScrollBar.sliderPosition()
        if self.switchPlaneCombobox.depthAxes() == 'x':
            z_slice = (
                slice(None, None, None), slice(None, None, None), axis_slice
            )
        elif self.switchPlaneCombobox.depthAxes() == 'y':
            z_slice = (
                slice(None, None, None), axis_slice
            )
        else:
            z_slice = axis_slice
            
        return z_slice
    
    def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None):
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i
        if frame_i < 0:
            frame_i = 0
            frame_i = posData.frame_i = 0
        
        axis_slice = self.zSliceScrollBar.sliderPosition()
        if self.switchPlaneCombobox.depthAxes() == 'x':
            return imgData[:, :, axis_slice].copy()
        elif self.switchPlaneCombobox.depthAxes() == 'y':
            return imgData[:, axis_slice].copy()
        
        idx = (posData.filename, frame_i)
        zProjHow_L0 = self.zProjComboBox.currentText()
        if isLayer0:
            try:
                z = posData.segmInfo_df.at[idx, 'z_slice_used_gui']
            except ValueError as e:
                z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] 
            zProjHow = zProjHow_L0
        else:
            z = self.zSliceOverlay_SB.sliderPosition()
            zProjHow_L1 = self.zProjOverlay_CB.currentText()
            if zProjHow_L1 == 'same as above': 
                zProjHow = zProjHow_L0
            else:
                zProjHow = zProjHow_L1
        
        if zProjHow == 'single z-slice':
            img = imgData[z] #.copy()
        elif zProjHow == 'max z-projection':
            img = imgData.max(axis=0)
        elif zProjHow == 'mean z-projection':
            img = imgData.mean(axis=0)
        elif zProjHow == 'median z-proj.':
            img = np.median(imgData, axis=0)
        return img

    def updateZsliceScrollbar(self, frame_i):
        posData = self.data[self.pos_i]
        if self.switchPlaneCombobox.depthAxes() != 'z':
            return
            
        idx = (posData.filename, frame_i)
        try:
            z = posData.segmInfo_df.at[idx, 'z_slice_used_gui']
        except ValueError as e:
            z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] 
        try:
            zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui']
        except ValueError as e:
            zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] 
        
        self.zProjComboBox.setCurrentText(zProjHow)
        # if zProjHow != 'single z-slice':
        #     return
        
        reconnect = False
        try:
            self.zSliceScrollBar.actionTriggered.disconnect()
            self.zSliceScrollBar.sliderReleased.disconnect()
            reconnect = True
        except TypeError:
            pass
        self.zSliceScrollBar.setSliderPosition(z)
        if reconnect:
            self.zSliceScrollBar.actionTriggered.connect(
                self.zSliceScrollBarActionTriggered
            )
            self.zSliceScrollBar.sliderReleased.connect(
                self.zSliceScrollBarReleased
            )
        self.zSliceSpinbox.setValueNoEmit(z+1)
    
    def getRawImage(self, frame_i=None, filename=None):
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i
        if filename is None:
            rawImgData = posData.img_data[frame_i]
            isLayer0 = True
        else: 
            rawImgData = posData.ol_data[filename][frame_i]
            isLayer0 = False
        if posData.SizeZ > 1:
            rawImg = self.get_2Dimg_from_3D(rawImgData, isLayer0=isLayer0)
        else:
            rawImg = rawImgData
        return rawImg
    
    def getRawImageLayer0(self, frame_i):
        posData = self.data[self.pos_i]

        if posData.SizeZ > 1:
            img = posData.img_data[frame_i]
            self.updateZsliceScrollbar(frame_i)
            img = self.get_2Dimg_from_3D(img)
        else:
            img = posData.img_data[frame_i].copy()
        return img
    
    def getImage(self, frame_i=None, raw=False):
        posData = self.data[self.pos_i]
        if frame_i is None:
            frame_i = posData.frame_i
        
        if raw:
            return self.getRawImageLayer0(frame_i)
        elif self.viewPreprocDataToggle.isChecked():
            try:
                img = posData.preproc_img_data[frame_i]
                if posData.SizeZ == 1:
                    return np.array(img)
                
                self.updateZsliceScrollbar(frame_i)
                z_slice = self.z_slice_index()
                img = img[z_slice]
                return img
            except Exception as err:
                # self.logger.warning(
                #     'Pre-processed image not existing --> returning raw image'
                # )
                return self.getRawImageLayer0(frame_i)
        elif self.viewCombineChannelDataToggle.isChecked():
            try:
                img = posData.combine_img_data[frame_i]
                if posData.SizeZ == 1:
                    return np.array(img)
                
                self.updateZsliceScrollbar(frame_i)
                z_slice = self.z_slice_index()
                img = img[z_slice]
                return img
            except Exception as err:
                # self.logger.warning(
                #     'Pre-processed image not existing --> returning raw image'
                # )
                return self.getRawImageLayer0(frame_i)
        else:
            return self.getRawImageLayer0(frame_i)

    def setImageImg2(self, updateLookuptable=True, set_image=True):
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        if mode == 'Segmentation and Tracking' or self.isSnapshot:
            # self.addExistingDelROIs()
            allDelIDs, lab2D = self.getDelROIlab()
        else:
            lab2D = self.get_2Dlab(posData.lab, force_z=False)
            allDelIDs = set()
        if self.labelsGrad.showLabelsImgAction.isChecked() and set_image:
            self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False)
        self.currentLab2D = lab2D
        if updateLookuptable:
            self.updateLookuptable(delIDs=allDelIDs)

    def applyDelROIimg1(self, roi, init=False, ax=0):
        if ax == 0:
            how = self.drawIDsContComboBox.currentText()
        else:
            how = self.getAnnotateHowRightImage()
        
        if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked():
            return
        
        if init and how.find('contours') == -1:
            self.setOverlaySegmMasks(force=True)
            return

        posData = self.data[self.pos_i]
        delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
        idx = delROIs_info['rois'].index(roi)
        delIDs = delROIs_info['delIDsROI'][idx]
        delMask = delROIs_info['delMasks'][idx]
        if how.find('nothing') != -1:
            return
        elif how.find('contours') != -1:
            self.updateContoursImage(ax=ax)
        
        if not delIDs:
            return
        
        if how.find('overlay segm. masks') != -1:
            lab = self.currentLab2D.copy()
            lab[delMask > 0] = 0
            if ax == 0:
                self.labelsLayerImg1.setImage(lab, autoLevels=False)
            else:
                self.labelsLayerRightImg.setImage(lab, autoLevels=False)

        self.setAllTextAnnotations(labelsToSkip={ID:True for ID in delIDs})
    
    def applyDelROIs(self):
        self.logger.info('Applying deletion ROIs (if present)...')
        
        for posData in self.data:
            self.current_frame_i = posData.frame_i
            for frame_i in range(posData.SizeT):
                lab = posData.allData_li[frame_i]['labels']
                if lab is None:
                    break
                delROIs_info = posData.allData_li[frame_i]['delROIs_info']
                delIDs_rois = delROIs_info['delIDsROI']
                if not delIDs_rois:
                    continue
                for delIDs in delIDs_rois:
                    for delID in delIDs:
                        lab[lab==delID] = 0
                posData.allData_li[frame_i]['labels'] = lab
                # Get the rest of the metadata and store data based on the new lab
                posData.frame_i = frame_i
                self.get_data()
                self.store_data(autosave=False)
            
            # Back to current frame
            posData.frame_i = self.current_frame_i
            self.get_data()
                
    def initTempLayerBrush(self, ID, ax=0):
        if ax == 0:
            how = self.drawIDsContComboBox.currentText()
        else:
            how = self.getAnnotateHowRightImage()
        
        self.hideItemsHoverBrush(ID=ID, force=True)
        Y, X = self.img1.image.shape[:2]
        tempImage = np.zeros((Y, X), dtype=np.uint32)
        if how.find('contours') != -1:
            tempImage[self.currentLab2D==ID] = ID
            self.brushImage = tempImage.copy()
            self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8)
            color = self.imgGrad.contoursColorButton.color()
            self.brushContoursRgba = color.getRgb()
            opacity = 1.0
        else:
            opacity = self.imgGrad.labelsAlphaSlider.value()
            color = self.lut[ID]
            lut = np.zeros((2, 4), dtype=np.uint8)
            lut[1,-1] = 255
            lut[1,:-1] = color
            self.tempLayerImg1.setLookupTable(lut)
        self.tempLayerImg1.setOpacity(opacity)
        self.tempLayerImg1.setImage(tempImage)
    
    def _setTempImageBrushContour(self):
        pass
    
    # @exec_time
    def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0):
        if init:
            self.initTempLayerBrush(ID, ax=ax)
        
        if self.annotContourCheckbox.isChecked():
            brushImage = self.brushImage
        else:
            brushImage = self.tempLayerImg1.image
            
        if toLocalSlice is None:
            brushImage[mask] = ID
        else:
            brushImage[toLocalSlice][mask] = ID
        
        if self.annotContourCheckbox.isChecked():
            try:
                obj = skimage.measure.regionprops(brushImage)[0]
            except IndexError:
                return
            objContour = [self.getObjContours(obj)]
            # objContour = core.get_obj_contours(
            #     obj_image=(brushImage>0).astype(np.uint8), local=True
            # )
            self.brushContourImage[:] = 0
            img = self.brushContourImage
            color = self.brushContoursRgba
            cv2.drawContours(img, objContour, -1, color, 1)
            self.tempLayerImg1.setImage(img)
        else:
            self.tempLayerImg1.setImage(brushImage)
    
    def getLabelsLayerImage(self, ax=0):
        if ax == 0:
            return self.labelsLayerImg1.image
        else:
            return self.labelsLayerRightImg.image
    
    def clearObjFromMask(self, image, mask, toLocalSlice=None):
        if mask is None:
            return image

        if toLocalSlice is None:
            image[mask] = 0
        else:
            image[toLocalSlice][mask] = 0
        
        return image
    
    # @exec_time
    def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0):
        if init:
            self.erasedLab = np.zeros_like(self.currentLab2D)

        if ax == 0:
            how = self.drawIDsContComboBox.currentText()
        else:
            how = self.getAnnotateHowRightImage()
        
        if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked():
            return
        
        if how.find('contours') != -1:
            self.clearObjFromMask(
                self.contoursImage, mask, toLocalSlice=toLocalSlice
            )
            erasedRp = skimage.measure.regionprops(self.erasedLab)
            for obj in erasedRp:
                self.addObjContourToContoursImage(obj=obj, ax=ax)
        elif how.find('overlay segm. masks') != -1:
            labelsImage = self.getLabelsLayerImage(ax=ax)
            self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice)           
            if ax == 0:
                self.labelsLayerImg1.setImage(
                    self.labelsLayerImg1.image, autoLevels=False
                )
            else:
                self.labelsLayerRightImg.setImage(
                    self.labelsLayerRightImg.image, autoLevels=False
                )

    def setTempImg1ExpandLabel(self, prevCoords, expandedObjCoords, ax=0):
        if ax == 0:
            how = self.drawIDsContComboBox.currentText()
        else:
            how = self.getAnnotateHowRightImage()
        
        if how.find('overlay segm. masks') != -1:
            # Remove previous overlaid mask
            labelsImage = self.getLabelsLayerImage(ax=ax)
            labelsImage[prevCoords] = 0
            
            # Overlay new moved mask
            labelsImage[prevCoords] = self.expandingID

            if ax == 0:
                self.labelsLayerImg1.setImage(
                    self.labelsLayerImg1.image, autoLevels=False)
            else:
                self.labelsLayerRightImg.setImage(
                    self.labelsLayerRightImg.image, autoLevels=False)
        else:
            currentLab2Drp = skimage.measure.regionprops(self.currentLab2D)
            for obj in currentLab2Drp:
                if obj.label == self.expandingID:
                    self.clearObjContour(obj=obj, ax=ax)
                    self.addObjContourToContoursImage(obj=obj, ax=ax)
                    break

    def setTempImg1MoveLabel(self, ax=0):
        if ax == 0:
            how = self.drawIDsContComboBox.currentText()
        else:
            how = self.getAnnotateHowRightImage()
        
        if how.find('contours') != -1:
            currentLab2Drp = skimage.measure.regionprops(self.currentLab2D)
            for obj in currentLab2Drp:
                if obj.label == self.movingID:
                    self.addObjContourToContoursImage(obj=obj, ax=ax)
                    break
        elif how.find('overlay segm. masks') != -1:
            if ax == 0:
                self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False)
                self.highLightIDLayerImg1.image[:] = 0
                mask = self.currentLab2D==self.movingID
                self.highLightIDLayerImg1.image[mask] = self.movingID
                highlightedImage = self.highLightIDLayerImg1.image
                self.highLightIDLayerImg1.setImage(highlightedImage)
            else:
                self.labelsLayerRightImg.setImage(
                    self.currentLab2D, autoLevels=False
                )
                self.highLightIDLayerRightImage.image[:] = 0
                mask = self.currentLab2D==self.movingID
                self.highLightIDLayerRightImage.image[mask] = self.movingID
                highlightedImage = self.highLightIDLayerRightImage.image
                self.highLightIDLayerRightImage.setImage(highlightedImage)

    def addMissingIDs_cca_df(self, posData):
        base_cca_df = self.getBaseCca_df()
        if posData.cca_df is None:
            posData.cca_df = base_cca_df
            return
        
        posData.cca_df = posData.cca_df.combine_first(base_cca_df)

    def update_cca_df_relabelling(self, posData, oldIDs, newIDs):
        relIDs = posData.cca_df['relative_ID']
        posData.cca_df['relative_ID'] = relIDs.replace(oldIDs, newIDs)
        mapper = dict(zip(oldIDs, newIDs))
        posData.cca_df = posData.cca_df.rename(index=mapper)

    def update_cca_df_deletedIDs(
            self, posData, deletedIDs, dropInPast=True, dropInFuture=True
        ):
        if posData.cca_df is None:
            return
        
        # Store cca_df for undo action
        undoId = uuid.uuid4()
        self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId)
        
        relIDs = posData.cca_df.reindex(deletedIDs, fill_value=-1)['relative_ID']
        posData.cca_df = posData.cca_df.drop(deletedIDs, errors='ignore')
        if self.isSnapshot:
            self.update_cca_df_newIDs(posData, relIDs)
        else:
            self.updateCcaDfDeletedIDsTimelapse(
                posData, relIDs, deletedIDs, undoId, dropInPast, dropInFuture
            )

    @disableWindow
    def updateCcaDfDeletedIDsTimelapse(
            self, posData, relIDsOfDelIDs, deletedIDs, undoId, 
            dropInPast, dropInFuture
        ):
        # Get status of the relIDs (of deleted IDs) to restore
        relIDsCcaStatus = {}
        for relID in relIDsOfDelIDs:
            try:
                ccs = posData.cca_df.at[relID, 'cell_cycle_stage']
                relationship = posData.cca_df.at[relID, 'relationship']
            except Exception as err:
                continue
            
            ccaStatus = core.getBaseCca_df([relID]).loc[relID]
            if relationship == 'mother' and ccs == 'S':
                for past_frame_i in range(posData.frame_i-1, -1, -1):
                    cca_df_i = self.get_cca_df(
                        frame_i=past_frame_i, return_df=True
                    )
                    ccs_past = cca_df_i.at[relID, 'cell_cycle_stage']      
                    if ccs_past == 'G1':
                        ccaStatus = cca_df_i.loc[relID]
                        break
            
            posData.cca_df.loc[relID] = ccaStatus
            self.store_data(autosave=False)
            relIDsCcaStatus[relID] = ccaStatus
            
        for fut_frame_i in range(posData.frame_i+1, posData.SizeT):
            cca_df_i = self.get_cca_df(frame_i=fut_frame_i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break
            
            self.storeUndoRedoCca(fut_frame_i, cca_df_i, undoId)

            if dropInFuture:
                cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore')
            else:
                for delID in deletedIDs:
                    dataDict = posData.allData_li[fut_frame_i]
                    delIDexists = dataDict['IDs_idxs'].get(delID, False)
                    if not delIDexists:
                        continue
                    
                    cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID]
            
            areRelIDsPresent = False
            for relID in relIDsOfDelIDs:
                try:
                    ccs = cca_df_i.at[relID, 'cell_cycle_stage']
                    relationship = cca_df_i.at[relID, 'relationship']
                    ccaStatus = relIDsCcaStatus[relID]
                    cca_df_i.loc[relID] = ccaStatus
                    areRelIDsPresent = True
                except Exception as err:
                    continue
            
            if not areRelIDsPresent:
                break
            
            self.store_cca_df(
                frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False
            )
            
        # Correct past frames
        for past_frame_i in range(posData.frame_i-1, -1, -1):
            cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True)
            if cca_df_i is None:
                # ith frame was not visited yet
                break
            
            self.storeUndoRedoCca(past_frame_i, cca_df_i, undoId)
            if dropInPast:
                cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore')
            else:
                for delID in deletedIDs:
                    dataDict = posData.allData_li[past_frame_i]
                    delIDexists = dataDict['IDs_idxs'].get(delID, False)
                    if not delIDexists:
                        continue
                    
                    cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID]
            
            areRelIDsPresent = False
            for relID in relIDsOfDelIDs:
                try:
                    ccs = cca_df_i.at[relID, 'cell_cycle_stage']
                    relationship = cca_df_i.at[relID, 'relationship']
                    ccaStatus = relIDsCcaStatus[relID]
                    cca_df_i.loc[relID] = ccaStatus
                    areRelIDsPresent = True
                except Exception as err:
                    continue
            
            if not areRelIDsPresent:
                break
            
            self.store_cca_df(
                frame_i=past_frame_i, cca_df=cca_df_i, autosave=False
            )
    
    def update_cca_df_newIDs(self, posData, new_IDs):
        for newID in new_IDs:
            self.addIDBaseCca_df(posData, newID)

    def update_cca_df_snapshots(self, editTxt, posData):
        cca_df = posData.cca_df
        cca_df_IDs = cca_df.index
        if editTxt == 'Delete ID':
            deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs]
            self.update_cca_df_deletedIDs(posData, deleted_IDs)

        elif editTxt == 'Separate IDs':
            new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs]
            self.update_cca_df_newIDs(posData, new_IDs)
            deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs]
            self.update_cca_df_deletedIDs(posData, deleted_IDs)

        elif editTxt == 'Edit ID':
            new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs]
            self.update_cca_df_newIDs(posData, new_IDs)
            old_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs]
            self.update_cca_df_deletedIDs(posData, old_IDs)

        elif editTxt == 'Annotate ID as dead':
            return
        
        elif editTxt == 'Deleted non-selected objects':
            deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs]
            self.update_cca_df_deletedIDs(posData, deleted_IDs)

        elif editTxt == 'Delete ID with eraser':
            deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs]
            self.update_cca_df_deletedIDs(posData, deleted_IDs)

        elif editTxt == 'Add new ID with brush tool':
            new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs]
            self.update_cca_df_newIDs(posData, new_IDs)

        elif editTxt == 'Merge IDs':
            deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs]
            self.update_cca_df_deletedIDs(posData, deleted_IDs)

        elif editTxt == 'Add new ID with curvature tool':
            new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs]
            self.update_cca_df_newIDs(posData, new_IDs)

        elif editTxt == 'Delete IDs using ROI':
            deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs]
            self.update_cca_df_deletedIDs(posData, deleted_IDs)

        elif editTxt == 'Repeat segmentation':
            posData.cca_df = self.getBaseCca_df()
    
    def fixCcaDfAfterEdit(self, editTxt):
        posData = self.data[self.pos_i]
        if posData.cca_df is not None:
            # For snapshot mode we fix or reinit cca_df depending on the edit
            self.update_cca_df_snapshots(editTxt, posData)
            self.store_data()

    def isFrameCcaAnnotated(self):
        posData = self.data[self.pos_i]
        acdc_df = posData.allData_li[posData.frame_i]['acdc_df']
        if acdc_df is None:
            return False

        return 'cell_cycle_stage' in acdc_df.columns

    def warnEditingWithCca_df(
            self, editTxt, return_answer=False, get_answer=False, 
            get_cancelled=False, update_images=True
        ):
        # Function used to warn that the user is editing in "Segmentation and
        # Tracking" mode a frame that contains cca annotations.
        # Ask whether to remove annotations from all future frames 
        if self.isSnapshot:
            return True

        posData = self.data[self.pos_i]
        acdc_df = posData.allData_li[posData.frame_i]['acdc_df']
        if acdc_df is None:
            if update_images:
                self.updateAllImages()
            return True
        else:
            if 'cell_cycle_stage' not in acdc_df.columns:
                if update_images:
                    self.updateAllImages()
                return True
            
        action = self.warnEditingWithAnnotActions.get(editTxt, None)
        if action is not None:
            if not action.isChecked():
                if update_images:
                    self.updateAllImages()
                return True

        msg = widgets.myMessageBox()
        txt = html_utils.paragraph(
            'You modified a frame that <b>has cell cycle annotations</b>.<br><br>'
            f'The change <b>"{editTxt}"</b> most likely makes the '
            '<b>annotations wrong</b>.<br><br>'
            'If you really want to apply this change we reccommend to remove'
            'ALL cell cycle annotations<br>'
            'from current frame to the end.<br><br>'
            'What do you want to do?'
        )
        if action is not None:
            checkBox = QCheckBox('Remember my choice and do not ask again')
        else:
            checkBox = None
        
        dropDelIDsNoteText = (
            '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)'
        )
        _, removeAnnotButton, _ = msg.warning(
            self, 'Edited segmentation with annotations!', txt,
            buttonsTexts=(
                'Cancel',
                'Remove annotations from future frames (RECOMMENDED)',
                f'Do not remove annotations{dropDelIDsNoteText}'
            ), widgets=checkBox
            )
        if msg.cancel:
            if get_cancelled:
                return 'cancelled'
            removeAnnotations = False
            return removeAnnotations
        
        if action is not None:
            action.setChecked(not checkBox.isChecked())
            action.removeAnnot = msg.clickedButton == removeAnnotButton
        
        if return_answer:
            return msg.clickedButton == removeAnnotButton
        
        if msg.clickedButton == removeAnnotButton:
            self.resetFutureCcaColCurrentFrame()
            self.resetCcaFuture(posData.frame_i+1)
            self.updateAllImages()
        else:
            if dropDelIDsNoteText and posData.cca_df is not None:
                delIDs = [
                    ID for ID in posData.cca_df.index if ID not in posData.IDs
                ]
                self.update_cca_df_deletedIDs(
                    posData, delIDs, dropInPast=False
                )
            self.addMissingIDs_cca_df(posData)
            self.updateAllImages()
            self.store_data()
        if action is not None:
            if action.removeAnnot:
                self.store_data()
                posData.frame_i -= 1
                self.get_data()
                self.resetCcaFuture(posData.frame_i)
                self.next_frame()
        
        if get_answer:
            return msg.clickedButton == removeAnnotButton
        else:
            return True
    
    def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n):
        msg = widgets.myMessageBox()
        txt = html_utils.paragraph(
            'You are repeating tracking on frames that <b>have cell cycle '
            'annotations</b>.<br><br>'
            'This will very likely make the <b>annotations wrong</b>.<br><br>'
            'If you really want to repeat tracking on the frames before '
            f'{last_tracked_i+1} the <b>annotations from frame '
            f'{start_n} to frame {last_tracked_i+1} '
            'will be removed</b>.<br><br>'
            'Do you want to continue?'
        )
        noButton, yesButton = msg.warning(
            self, 'Repating tracking with annotations!', txt,
            buttonsTexts=(
                '  No, stop tracking and keep annotations.',
                '  Yes, repeat tracking and DELETE annotations.' 
            )
        )
        if msg.cancel:
            return False

        if msg.clickedButton == noButton:
            return False
        else:
            return True

    def setDelRoiState(self, roi: pg.ROI, state):
        roi.sigRegionChanged.disconnect()
        roi.sigRegionChangeFinished.disconnect()
        roi.setState(state)
        roi.sigRegionChanged.connect(self.delROImoving)
        roi.sigRegionChangeFinished.connect(self.delROImovingFinished)
    
    def addExistingDelROIs(self):
        posData = self.data[self.pos_i]
        delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info']
        isAx2hidden = not self.labelsGrad.showLabelsImgAction.isChecked()

        for r, roi in enumerate(delROIs_info['rois']):
            if isinstance(roi, pg.PolyLineROI) or isAx2hidden:
                # PolyLine ROIs are only on ax1
                self.ax1.addDelRoiItem(roi, roi.key)
            else:
                # Rect ROI is on ax2 because ax2 is visible
                self.ax2.addDelRoiItem(roi, roi.key)    

            self.setDelRoiState(roi, delROIs_info['state'][r])
            
    def updateFramePosLabel(self):
        if self.isSnapshot:
            posData = self.data[self.pos_i]
            self.navSpinBox.setValueNoEmit(self.pos_i+1)
        else:
            posData = self.data[0]
            self.navSpinBox.setValueNoEmit(posData.frame_i+1)
    
    def highlightHoverID(self, x, y, hoverID=None):
        if hoverID is None:
            try:
                hoverID = self.currentLab2D[int(y), int(x)]
            except IndexError:
                return

        if hoverID == 0:
            return
        
        posData = self.data[self.pos_i]
        objIdx = posData.IDs_idxs[hoverID]
        obj = posData.rp[objIdx]
        self.goToZsliceSearchedID(obj)
        self.highlightSearchedID(hoverID)
    
    def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None):
        if nonGrayedIDs is None:
            nonGrayedIDs = set()
            
        posData = self.data[self.pos_i]
        if alpha is None:
            alpha = self.imgGrad.labelsAlphaSlider.value()
        
        if not hasattr(self, 'highlightedLab'):
            self.highlightedLab = np.zeros_like(self.currentLab2D)
        else:
            self.highlightedLab[:] = 0
            
        lut = np.zeros((2, 4), dtype=np.uint8)
        for _obj in posData.rp:
            if not self.isObjVisible(_obj.bbox):
                continue
            if _obj.label not in nonGrayedIDs:
                continue
            _slice = self.getObjSlice(_obj.slice)
            _objMask = self.getObjImage(_obj.image, _obj.bbox)
            self.highlightedLab[_slice][_objMask] = _obj.label
            rgb = self.lut[_obj.label].copy()    
            lut[1, :-1] = rgb
            # Set alpha to 0.7
            lut[1, -1] = 178  
        
        return lut
    
    def grayOutOverlaySegm(self, ax=0):
        if ax == 0:
            how = self.drawIDsContComboBox.currentText()
        else:
            how = self.getAnnotateHowRightImage()
        
        isOverlaySegmActive = how.find('segm. masks') != -1
        if not isOverlaySegmActive:
            return
        
        grayedLut = self.grayOutHighlightedLabels()
    
    def highlightHoverIDsKeptObj(self, x, y, hoverID=None):
        if hoverID is None:
            try:
                hoverID = self.currentLab2D[int(y), int(x)]
            except IndexError:
                return

        self.highlightSearchedID(hoverID, greyOthers=False)
        
        if hoverID == 0 and self.highlightedID == 0:
            return
        
        if hoverID == 0 and self.highlightedID != 0:
            self.clearHighlightedKeepIDs()
            for ID in self.keptObjectsIDs:
                self.highlightLabelID(ID)
            return
        
        posData = self.data[self.pos_i]
        try:
            objIdx = posData.IDs_idxs[hoverID]
        except KeyError as err:
            return 
        
        obj = posData.rp[objIdx]
        self.goToZsliceSearchedID(obj)
        
        for ID in self.keptObjectsIDs:
            self.highlightLabelID(ID)

    def getHighlightedID(self):
        if self.highlightedID > 0:
            return self.highlightedID
        
        doHighlight = (
            self.propsDockWidget.isVisible() 
            and (
                self.guiTabControl.highlightCheckbox.isChecked()
                or self.guiTabControl.highlightSearchedCheckbox.isChecked()
            )
        )
        if not doHighlight:
            return 0
        
        return self.guiTabControl.propsQGBox.idSB.value()
    
    def clearHighlightedKeepIDs(self):
        self.setAllTextAnnotations()
        self.highlightedID = 0
        self.searchedIDitemRight.setData([], [])
        self.searchedIDitemLeft.setData([], [])
        self.highLightIDLayerImg1.clear()
        self.highLightIDLayerRightImage.clear()
    
    def highlightSearchedID(self, ID, force=False, greyOthers=True):
        if ID == 0:
            return

        if ID == self.highlightedID and not force:
            return

        doHighlight = (
            self.propsDockWidget.isVisible() 
            and (
                self.guiTabControl.highlightCheckbox.isChecked()
                or self.guiTabControl.highlightSearchedCheckbox.isChecked()
            )
        )
        if doHighlight:
            self.highlightedID = self.guiTabControl.propsQGBox.idSB.value()
            ID = self.highlightedID
        
        if self.highlightedID > 0:
            self.clearHighlightedText()
        
        self.searchedIDitemRight.setData([], [])
        self.searchedIDitemLeft.setData([], [])

        posData = self.data[self.pos_i]

        self.highlightedID = ID

        objIdx = posData.IDs_idxs.get(ID)
        if objIdx is None:
            return
        
        obj = posData.rp[objIdx]
        isObjVisible = self.isObjVisible(obj.bbox)
        if not isObjVisible:
            return
        
        if greyOthers:
            self.textAnnot[0].grayOutAnnotations()
            self.textAnnot[1].grayOutAnnotations()

        how_ax1 = self.drawIDsContComboBox.currentText()
        how_ax2 = self.getAnnotateHowRightImage()
        isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 
        isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1
        alpha = self.imgGrad.labelsAlphaSlider.value()
        
        if isOverlaySegm_ax1 or isOverlaySegm_ax2:
            grayedLut = self.grayOutHighlightedLabels(
                nonGrayedIDs={obj.label}, 
                alpha=alpha
            )
        
        cont = None
        contours = None
        if isOverlaySegm_ax1:
            self.highLightIDLayerImg1.setLookupTable(grayedLut)
            self.highLightIDLayerImg1.setImage(self.highlightedLab)          
            self.labelsLayerImg1.setOpacity(alpha/3)
        else:
            contours = self.getObjContours(obj, all_external=True)
            for cont in contours:
                self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5)
        
        if isOverlaySegm_ax2:
            self.highLightIDLayerRightImage.setLookupTable(grayedLut)
            self.highLightIDLayerRightImage.setImage(self.highlightedLab)
            self.labelsLayerRightImg.setOpacity(alpha/3)
        else:
            if contours is None:
                contours = self.getObjContours(obj, all_external=True)
            for cont in contours:
                self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5)       

        # Gray out all IDs excpet searched one
        lut = self.lut.copy() # [:max(posData.IDs)+1]
        lut[:ID] = lut[:ID]*0.2
        lut[ID+1:] = lut[ID+1:]*0.2
        self.img2.setLookupTable(lut)

        # Highlight text
        self.highlightLabelID(ID, ax=0)
        self.highlightLabelID(ID, ax=1)
    
    def _drawGhostContour(self, x, y):
        if self.ghostObject is None:
            return
        
        ID = self.ghostObject.label
        yc, xc = self.ghostObject.local_centroid
        Dx = x-xc
        Dy = y-yc
        xx = self.ghostObject.xx_contour + Dx
        yy = self.ghostObject.yy_contour + Dy
        self.ghostContourItemLeft.setData(
            xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x
        )
        self.ghostContourItemRight.setData(
            xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x
        )
    
    def _drawManualBackgroundObjContour(self, x, y):
        if self.manualBackgroundObj is None:
            return
        
        ID = self.manualBackgroundObj.label
        yc, xc = self.manualBackgroundObj.local_centroid
        Dx = x-xc
        Dy = y-yc
        xx = self.manualBackgroundObj.xx_contour + Dx
        yy = self.manualBackgroundObj.yy_contour + Dy
        self.manualBackgroundObjItem.setData(
            xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x
        )
    
    def _drawGhostMask(self, x, y):
        if self.ghostObject is None:
            return
        
        self.clearGhostMask()
        ID = self.ghostObject.label
        h, w = self.ghostObject.image.shape[-2:]
        yc, xc = self.ghostObject.local_centroid
        Dx = int(x-xc)
        Dy = int(y-yc)
        bbox = ((Dy, Dy+h), (Dx, Dx+w))

        Y, X = self.currentLab2D.shape
        slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X))
        slice_global_to_local, slice_crop_local = slices

        obj_image = self.ghostObject.image[slice_crop_local]

        self.ghostMaskItemLeft.image[slice_global_to_local][obj_image] = ID
        self.ghostMaskItemLeft.updateGhostImage(
            fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x
        )

        self.ghostMaskItemRight.image[slice_global_to_local][obj_image] = ID
        self.ghostMaskItemRight.updateGhostImage(
            fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x
        )

    def drawManualBackgroundObj(self, x, y):
        if x is None or y is None:
            self.clearGhost()
            return
        
        self._drawManualBackgroundObjContour(x, y)

    def drawManualTrackingGhost(self, x, y):
        if not self.manualTrackingToolbar.showGhostCheckbox.isChecked():
            return
        
        if x is None or y is None:
            self.clearGhost()
            return
        
        if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked():
            self._drawGhostContour(x, y)
        else:
            self._drawGhostMask(x, y)

    def restoreDefaultSettings(self):
        df = self.df_settings
        df.at['contLineWeight', 'value'] = 1
        df.at['mothBudLineSize', 'value'] = 1
        df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255)
        df.at['contLineColor', 'value'] = (205, 0, 0, 220)

        self._updateContColour((205, 0, 0, 220))
        self._updateMothBudLineColour((255, 165, 0, 255))
        self._updateMothBudLineSize(1)
        self._updateContLineThickness()

        df.at['overlaySegmMasksAlpha', 'value'] = 0.3
        df.at['img_cmap', 'value'] = 'grey'
        self.imgCmap = self.imgGrad.cmaps['grey']
        self.imgCmapName = 'grey'
        self.labelsGrad.item.loadPreset('viridis')
        df.at['labels_bkgrColor', 'value'] = (25, 25, 25)
        
        if df.at['is_bw_inverted', 'value'] == 'Yes':
            self.invertBw(update=False)
        
        df = df[~df.index.str.contains('lab_cmap')]
        df.to_csv(self.settings_csv_path)
        self.imgGrad.restoreState(df)
        for items in self.overlayLayersItems.values():
            lutItem = items[1]
            lutItem.restoreState(df)

        self.labelsGrad.saveState(df)
        self.labelsGrad.restoreState(df, loadCmap=False)

        self.df_settings.to_csv(self.settings_csv_path)
        self.updateAllImages()

    def updateLabelsAlpha(self, value):
        self.df_settings.at['overlaySegmMasksAlpha', 'value'] = value
        self.df_settings.to_csv(self.settings_csv_path)
        if self.keepIDsButton.isChecked():
            value = value/3
        self.labelsLayerImg1.setOpacity(value)
        self.labelsLayerRightImg.setOpacity(value)

    
    def _getImageupdateAllImages(self, image=None):
        if image is not None:
            return image
        
        img = self.getImage()
        return img
    
    def setImageImg1(self, image=None):
        img = self._getImageupdateAllImages(image=image)
        if self.equalizeHistPushButton.isChecked():
            img = skimage.exposure.equalize_adapthist(img)
        posData = self.data[self.pos_i]
        self.img1.setCurrentPosIndex(self.pos_i)
        self.img1.setCurrentFrameIndex(posData.frame_i)
        if posData.SizeZ > 1:
            z = self.zSliceScrollBar.sliderPosition()
            self.img1.setCurrentZsliceIndex(z)

        self.img1.setImage(
            img, next_frame_image=self.nextFrameImage(),
            scrollbar_value=posData.frame_i+2
        )
    
    def getContoursImageItem(self, ax):
        if not self.areContoursRequested(ax):
            return
        
        if ax == 0:
            return self.ax1_contoursImageItem
        else:
            return self.ax2_contoursImageItem
    
    def getLostObjImageItem(self, ax):
        if ax == 0:
            return self.ax1_lostObjImageItem
        else:
            return self.ax1_lostTrackedObjImageItem
    
    def getLostTrackedObjImageItem(self, ax):
        if ax == 0:
            return self.ax1_lostTrackedObjImageItem
        else:
            return self.ax2_lostTrackedObjImageItem
    
    def setManualBackgroundImage(self):
        if not self.manualBackgroundButton.isChecked():
            return
        
        posData = self.data[self.pos_i]
        if not hasattr(posData, 'manualBackgroundImage'):
            self.initManualBackgroundImage()
        
        contours = []
        for obj in skimage.measure.regionprops(posData.manualBackgroundLab):    
            obj_contours = self.getObjContours(obj, all_external=True)  
            contours.extend(obj_contours)
            textItem = self.manualBackgroundTextItems[obj.label]
            textItem.setText(f'{obj.label}')
            self.ax1.addItem(textItem)
            yc, xc = obj.centroid
            textItem.setPos(xc, yc)
        
        cv2.drawContours(
            posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1
        )
        self.manualBackgroundImageItem.setImage(posData.manualBackgroundImage)
    
    def setManualBackgrounNextID(self):
        posData = self.data[self.pos_i]
        currentID = self.manualBackgroundObj.label
        idx = posData.IDs_idxs[currentID]
        next_idx = idx + 1
        if next_idx >= len(posData.IDs):
            return
        next_ID = posData.IDs[next_idx]
        self.manualBackgroundToolbar.spinboxID.setValue(next_ID)
    
    def clearManualBackgroundObject(self, ID):
        posData = self.data[self.pos_i]
        mask = posData.manualBackgroundLab==ID
        posData.manualBackgroundImage[mask, :] = 0
        posData.manualBackgroundLab[mask] = 0
    
    def addManualBackgroundObject(self, x, y):
        posData = self.data[self.pos_i]
        
        if not hasattr(self, 'manualBackgroundObj'):
            self.initManualBackgroundObject()
        
        Y, X = self.currentLab2D.shape
        ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox
        width, height = xmax-xmin, ymax-ymin
        yc, xc = self.manualBackgroundObj.local_centroid
        xstart, ystart = round(x-xc), round(y-yc)
        xstart = xstart if xstart >= 0 else 0
        ystart = ystart if ystart >= 0 else 0
        
        xend = xstart+width
        yend = ystart+height
        xend = xend if xend <= X else X
        yend = yend if yend <= Y else Y
        
        width = xend-xstart
        height = yend-ystart
        
        obj_image = self.manualBackgroundObj.image[:height, :width]
        obj_slice = (slice(ystart, yend), slice(xstart, xend))
        ID = self.manualBackgroundObj.label
        self.clearManualBackgroundObject(ID)
        posData.manualBackgroundLab[obj_slice][obj_image] = ID
        
        if ID in self.manualBackgroundTextItems:
            self.manualBackgroundTextItems[ID].setPos(x, y)
            return
        
        textItem = pg.TextItem(
            text=str(ID), color='r', anchor=(0.5, 0.5)
        )
        textItem.setFont(font_13px)
        textItem.setPos(x, y)
        self.manualBackgroundTextItems[ID] = textItem
        
        self.ax1.addItem(textItem)
    
    def setManualBackgroundLab(self, load_from_store=False, debug=True):
        posData = self.data[self.pos_i]
        if posData.manualBackgroundLab is None:
            self.initManualBackgroundImage()
        
        for obj in skimage.measure.regionprops(posData.manualBackgroundLab):
            textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5))
            if obj.label in self.manualBackgroundTextItems:
                continue
            self.manualBackgroundTextItems[obj.label] = textItem
    
    def updateContoursImage(self, ax, delROIsIDs=None, compute=True):
        imageItem = self.getContoursImageItem(ax)
        if imageItem is None:
            return
        
        if not hasattr(self, 'contoursImage'):
            self.initContoursImage()
        else:
            self.contoursImage[:] = 0
        
        contours = []
        for obj in skimage.measure.regionprops(self.currentLab2D):    
            obj_contours = self.getObjContours(
                obj, all_external=True, force_calc=compute
            )  
            contours.extend(obj_contours)

        thickness = self.contLineWeight
        color = self.contLineColor
        self.setContoursImage(imageItem, contours, thickness, color)
    
    def setContoursImage(self, imageItem, contours, thickness, color):
        cv2.drawContours(self.contoursImage, contours, -1, color, thickness)
        imageItem.setImage(self.contoursImage)
    
    def getObjFromID(self, ID):
        posData = self.data[self.pos_i]
        try:
            idx = posData.IDs_idxs[ID]
        except KeyError as e:
            # Object already cleared
            return
        
        obj = posData.rp[idx]
        return obj
    
    def setLostObjectContour(self, obj):
        allContours = self.getObjContours(obj, all_external=True)  
        for objContours in allContours:
            xx = objContours[:,0] + 0.5
            yy = objContours[:,1] + 0.5
            data = [obj.label]*len(xx)
            self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data)
            self.ax2_lostObjScatterItem.addPoints(xx, yy)

    def setTrackedLostObjectContour(self, obj):
        if self.isExportingVideo:
            return
        
        allContours = self.getObjContours(obj, all_external=True)  
        for objContours in allContours:
            xx = objContours[:,0] + 0.5
            yy = objContours[:,1] + 0.5
            data = [obj.label]*len(xx)
            self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data)
            self.ax2_lostTrackedScatterItem.addPoints(xx, yy)
    
    def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None):
        if draw:
            imageItem = self.getLostObjImageItem(ax)
            if imageItem is None:
                return
        
        if not hasattr(self, 'lostObjContoursImage'):
            self.initLostObjContoursImage()
        else:
            self.lostObjContoursImage[:] = 0

        if delROIsIDs is None:
            delROIsIDs = set()
        
        posData = self.data[self.pos_i]
        prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
        prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs']
        contours = []
        for lostID in posData.lost_IDs:
            if lostID in delROIsIDs:
                continue
            
            obj = prev_rp[prev_IDs_idxs[lostID]]
            if not self.isObjVisible(obj.bbox):
                continue
        
            obj_contours = self.getObjContours(obj, all_external=True)
            
            if ax == 0:
                self.addLostObjsToImage(obj, lostID)
                
            contours.extend(obj_contours)

        if not draw:
            return

        self.drawLostObjContoursImage(imageItem, contours)
    
    def drawLostObjContoursImage(
            self, imageItem, contours, 
            thickness=1, 
            color=(255, 165, 0, 255) # orange
        ):
        img = self.lostObjContoursImage
        cv2.drawContours(img, contours, -1, color, thickness)
        imageItem.setImage(img)
    
    def updateLostTrackedContoursImage(
            self, ax, delROIsIDs=None, tracked_lost_IDs=None
        ):
        imageItem = self.getLostTrackedObjImageItem(ax)
        if imageItem is None:
            return
        
        if not hasattr(self, 'lostTrackedObjContoursImage'):
            self.initLostTrackedObjContoursImage()
        else:
            self.lostTrackedObjContoursImage[:] = 0
        
        if delROIsIDs is None:
            delROIsIDs = set()
        
        posData = self.data[self.pos_i]
        if tracked_lost_IDs is None:
            tracked_lost_IDs = self.getTrackedLostIDs()
            
        prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
        prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs']
        contours = []
        for tracked_lost_ID in tracked_lost_IDs:
            if tracked_lost_ID in delROIsIDs:
                continue
            
            obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]]
            if not self.isObjVisible(obj.bbox):
                continue
        
            obj_contours = self.getObjContours(obj, all_external=True)
            contours.extend(obj_contours)

        self.drawLostTrackedObjContoursImage(imageItem, contours)
    
    def drawLostTrackedObjContoursImage(self, imageItem, contours):
        thickness = 1
        color = (0, 255, 0, 255) # green
        img = self.lostTrackedObjContoursImage
        cv2.drawContours(img, contours, -1, color, thickness)
        imageItem.setImage(img)
    
    def getNearestLostObjID(self, y, x):
        if not self.annotLostObjsToggle.isChecked():
            return
        
        posData = self.data[self.pos_i]
        if not posData.lost_IDs:
            return
        
        prev_lab = posData.allData_li[posData.frame_i-1]['labels']
        if prev_lab is None:
            return
        
        # if not hasattr(self, 'lostObjContoursImage'):
        #     self.store_data()
        #     posData.frame_i -= 1
        #     self.get_data()
        #     self.store_data()
        #     posData.frame_i += 1
        #     self.get_data()
        #     self.updateLostNewCurrentIDs()
        #     self.updateLostContoursImage(ax=0)
        #     self.updateLostContoursImage(ax=1)
        #     self.updateLostNewCurrentIDs()
            
        yy, xx, _ = np.nonzero(self.lostObjContoursImage)
        lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool)
        lostObjsContourMask[yy.astype(int), xx.astype(int)] = True
        
        # Add accepted lost IDs
        try:
            yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage)
            lostObjsContourMask[yy.astype(int), xx.astype(int)] = True
        except Exception as err:
            pass
            
        _, y_nearest, x_nearest = core.nearest_nonzero_2D(
            lostObjsContourMask, y, x, return_coords=True
        )
        nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest]
        
        if nearest_ID == 0:
            return
        
        return nearest_ID
    
    def setCcaIssueContour(self, obj):
        objContours = self.getObjContours(obj, all_external=True)  
        for cont in objContours:
            xx = cont[:,0] + 0.5
            yy = cont[:,1] + 0.5
            self.ax1_lostObjScatterItem.addPoints(xx, yy)
        self.textAnnot[0].addObjAnnotation(
            obj, 'lost_object', f'{obj.label}?', False
        )
    
    def isLastVisitedAgainCca(self, curr_df, enforceAll=False):
        # Determine if this is the last visited frame for repeating
        # bud assignment on non manually corrected_on_frame_i buds.
        # The idea is that the user could have assigned division on a cell
        # by going previous and we want to check if this cell could be a
        # "better" mother for those non manually corrected buds
        posData = self.data[self.pos_i]
        if curr_df is None:
            return False
        
        if 'cell_cycle_stage' not in curr_df.columns:
            return False
        
        if enforceAll:
            return False
        
        lastVisited = False
        posData.new_IDs = [
            ID for ID in posData.new_IDs
            if curr_df.at[ID, 'is_history_known']
            and curr_df.at[ID, 'cell_cycle_stage'] == 'S'
        ]
        if posData.frame_i+1 < posData.SizeT:
            next_df = posData.allData_li[posData.frame_i+1]['acdc_df']
            if next_df is None:
                lastVisited = True
            else:
                if 'cell_cycle_stage' not in next_df.columns:
                    lastVisited = True
        else:
            lastVisited = True
        
        return lastVisited
    
    def highlightNewCellNotEnoughG1cells(self, IDsCellsG1):
        posData = self.data[self.pos_i]
        for obj in posData.rp:
            if obj.label not in IDsCellsG1:
                continue
            objContours = self.getObjContours(obj)
            if objContours is not None:
                xx = objContours[:,0] + 0.5
                yy = objContours[:,1] + 0.5
                self.ccaFailedScatterItem.addPoints(xx, yy)
            self.textAnnot[0].addObjAnnotation(
                obj, 'green', f'{obj.label}?', False
            )

    def handleNoCellsInG1(self, numCellsG1, numNewCells):
        posData = self.data[self.pos_i]
        self.highlightNewCellNotEnoughG1cells(posData.new_IDs)
        continueAnyway = _warnings.warnNotEnoughG1Cells(
            numCellsG1, posData.frame_i, numNewCells, qparent=self
        )
        if continueAnyway:
            notEnoughG1Cells = False
            proceed = True
            # Annotate the new IDs with unknown history
            for ID in posData.new_IDs:
                posData.cca_df.loc[ID] = pd.Series(base_cca_dict)
                cca_df_ID = self.getStatusKnownHistoryBud(ID)
                posData.ccaStatus_whenEmerged[ID] = cca_df_ID
        else:
            notEnoughG1Cells = True
            proceed = False
        
        # Clear new cells annotations
        self.ccaFailedScatterItem.setData([], [])
        return notEnoughG1Cells, proceed
    
    def addObjContourToContoursImage(
            self, ID=0, obj=None, ax=0, thickness=None, color=None
        ):        
        imageItem = self.getContoursImageItem(ax)
        if imageItem is None:
            return
        
        if obj is None:
            obj = self.getObjFromID(ID)
            if obj is None:
                return

        # if not self.isObjVisible(obj.bbox):
        #     self.clearObjContour(obj=obj, ax=ax)
        #     return

        contours = self.getObjContours(obj, all_external=True)
        if thickness is None:
            thickness = self.contLineWeight
        if color is None:
            color = self.contLineColor
        
        self.setContoursImage(imageItem, contours, thickness, color)
    
    def clearObjContour(self, ID=0, obj=None, ax=0, debug=False):
        imageItem = self.getContoursImageItem(ax)
        if imageItem is None:
            return

        if ID > 0:
            self.contoursImage[self.currentLab2D==ID] = [0,0,0,0]
        else:
            obj_slice = self.getObjSlice(obj.slice)
            obj_image = self.getObjImage(obj.image, obj.bbox)
            self.contoursImage[obj_slice][obj_image] = [0,0,0,0]
        
        imageItem.setImage(self.contoursImage)        
    
    def clearAnnotItems(self):
        self.textAnnot[0].clear()
        self.textAnnot[1].clear()

    # @exec_time
    def setAllTextAnnotations(self, labelsToSkip=None):
        delROIsIDs = self.setLostNewOldPrevIDs()
        posData = self.data[self.pos_i]
        self.textAnnot[0].setAnnotations(
            posData=posData, 
            labelsToSkip=labelsToSkip, 
            isVisibleCheckFunc=self.isObjVisible,
            highlightedID=self.highlightedID, 
            delROIsIDs=delROIsIDs,
            annotateLost=self.annotLostObjsToggle.isChecked(), 
            getCurrentZfunc=self.z_lab, 
            getObjCentroidFunc=self.getObjCentroid
        )
        self.textAnnot[1].setAnnotations(
            posData=posData, labelsToSkip=labelsToSkip, 
            isVisibleCheckFunc=self.isObjVisible,
            highlightedID=self.highlightedID, 
            delROIsIDs=delROIsIDs,
            annotateLost=self.annotLostObjsToggle.isChecked(), 
            getCurrentZfunc=self.z_lab, 
            getObjCentroidFunc=self.getObjCentroid
        )
        self.textAnnot[0].update()
        self.textAnnot[1].update()
        return delROIsIDs
    
    def setAllContoursImages(self, delROIsIDs=None, compute=True):
        if compute:
            self.computeAllContours()
        self.updateContoursImage(ax=0, delROIsIDs=delROIsIDs, compute=compute)
        self.updateContoursImage(ax=1, delROIsIDs=delROIsIDs, compute=compute)

    def setAllLostObjContoursImage(self, delROIsIDs=None):
        self.updateLostContoursImage(ax=0, delROIsIDs=None)
        self.updateLostContoursImage(ax=1, delROIsIDs=None)
    
    def setAllLostTrackedObjContoursImage(self, delROIsIDs=None):
        self.updateLostTrackedContoursImage(ax=0, delROIsIDs=None)
        self.updateLostTrackedContoursImage(ax=1, delROIsIDs=None)
    
    def nextFrameImage(self, current_frame_i=None):
        if not self.labelsGrad.showNextFrameAction.isEnabled():
            return
        
        if not self.labelsGrad.showNextFrameAction.isChecked():
            return
        
        posData = self.data[self.pos_i]
        if current_frame_i is None:
            current_frame_i = posData.frame_i
        
        next_frame_i = current_frame_i + 1
        if next_frame_i >= len(posData.img_data):
            img = posData.img_data[-1]
        else:
            img = posData.img_data[next_frame_i]
        
        if posData.SizeZ > 1:
            img = self.get_2Dimg_from_3D(img, isLayer0=True)
        
        # img = self.normalizeIntensities(img)
        
        return img
    
    def onKeyHome(self):
        self.zSliceScrollBar.triggerAction(
            QAbstractSlider.SliderAction.SliderSingleStepAdd
        )
    
    def onKeyEnd(self):
        self.zSliceScrollBar.triggerAction(
            QAbstractSlider.SliderAction.SliderSingleStepSub
        )
    
    def onKeyPageUp(self):
        isAutoPilotActive = (
            self.autoPilotZoomToObjToggle.isChecked()
            and self.autoPilotZoomToObjToolbar.isVisible()
        )
        if isAutoPilotActive:
            self.pointsLayerAutoPilot('next')
        elif self.zSliceScrollBar.isVisible():
            self.zSliceScrollBar.triggerAction(
                QAbstractSlider.SliderAction.SliderSingleStepAdd
            )
    
    def onKeyPageDown(self):
        isAutoPilotActive = (
            self.autoPilotZoomToObjToggle.isChecked()
            and self.autoPilotZoomToObjToolbar.isVisible()
        )
        if isAutoPilotActive:
            self.pointsLayerAutoPilot('prev')
        elif self.zSliceScrollBar.isVisible():
            self.zSliceScrollBar.triggerAction(
                QAbstractSlider.SliderAction.SliderSingleStepAdd
            )
    
    def keyUpCallback(
            self, isBrushActive, isWandActive, isExpandLabelActive, 
            isLabelRoiCircActive
        ):
        isAutoPilotActive = (
            self.autoPilotZoomToObjToggle.isChecked()
            and self.autoPilotZoomToObjToolbar.isVisible()
        )
        if isBrushActive:
            brushSize = self.brushSizeSpinbox.value()
            self.brushSizeSpinbox.setValue(brushSize+1)
        elif isWandActive:
            wandTolerance = self.wandToleranceSlider.value()
            self.wandToleranceSlider.setValue(wandTolerance+1)
        elif isExpandLabelActive:
            self.expandLabel(dilation=True)
            self.expandFootprintSize += 1
        elif isLabelRoiCircActive:
            val = self.labelRoiCircularRadiusSpinbox.value()
            self.labelRoiCircularRadiusSpinbox.setValue(val+1)
        elif isAutoPilotActive:
            self.pointsLayerAutoPilot('next')
        else:
            self.zSliceScrollBar.triggerAction(
                QAbstractSlider.SliderAction.SliderSingleStepAdd
            )
    
    def keyDownCallback(
            self, isBrushActive, isWandActive, isExpandLabelActive, 
            isLabelRoiCircActive
        ):
        isAutoPilotActive = (
            self.autoPilotZoomToObjToggle.isChecked()
            and self.autoPilotZoomToObjToolbar.isVisible()
        )
        if isBrushActive:
            brushSize = self.brushSizeSpinbox.value()
            self.brushSizeSpinbox.setValue(brushSize-1)
        elif isWandActive:
            wandTolerance = self.wandToleranceSlider.value()
            self.wandToleranceSlider.setValue(wandTolerance-1)
        elif isExpandLabelActive:
            self.expandLabel(dilation=False)
            self.expandFootprintSize += 1
        elif isLabelRoiCircActive:
            val = self.labelRoiCircularRadiusSpinbox.value()
            self.labelRoiCircularRadiusSpinbox.setValue(val-1)
        elif isAutoPilotActive:
            self.pointsLayerAutoPilot('prev')
        elif self.isNavigateActionOnNextFrame():
            posData = self.data[self.pos_i]
            self.rightImageFramesScrollbar.setValue(posData.frame_i+2)
        else:
            self.zSliceScrollBar.triggerAction(
                QAbstractSlider.SliderAction.SliderSingleStepSub
            )
    
    # @exec_time
    @exception_handler
    def updateAllImages(
            self, image=None, computePointsLayers=True, computeContours=True
        ):
        self.clearAllItems()

        posData = self.data[self.pos_i]

        self.last_pos_i = self.pos_i
        self.last_frame_i = posData.frame_i
        
        self.rescaleIntensitiesLut(setImage=False)

        self.setImageImg1(image=image)       
        self.setImageImg2()
        
        if self.overlayButton.isChecked():
            self.setOverlayImages()

        self.setOverlayLabelsItems()
        self.setOverlaySegmMasks()
              
        if self.slideshowWin is not None:
            self.slideshowWin.frame_i = posData.frame_i
            self.slideshowWin.update_img()

        # self.update_rp()

        # Annotate ID and draw contours
        delROIsIDs = self.setAllTextAnnotations()    
        self.setAllContoursImages(
            delROIsIDs=delROIsIDs, compute=False
        )

        mode = self.modeComboBox.currentText()
        self.drawAllMothBudLines()
        if mode == 'Normal division: Lineage tree':
            self.drawAllLineageTreeLines()

        self.highlightLostNew()      

        if self.ccaTableWin is not None: # need to add for lin tree, later
            zoomIDs = self.getZoomIDs()
            self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs)

        self.doCustomAnnotation(0)

        self.annotate_rip_and_bin_IDs()
        self.updateTempLayerKeepIDs()
        self.whitelistUpdateTempLayer()
        self.drawPointsLayers(computePointsLayers=computePointsLayers)
        self.setManualBackgroundImage()
        self.annotateAssignedObjsAcdcTrackerSecondStep()
        
        self.highlightSearchedID(self.highlightedID, force=True) 
        self.updateTimestampFrame()   

    def updateTimestampFrame(self):
        if not hasattr(self, 'timestamp'):
            return
        
        if not self.addTimestampAction.isChecked():
            return
        
        posData = self.data[self.pos_i]
        self.timestamp.setText(posData.frame_i)
    
    def deleteIDFromLab(self, lab, delID, frame_i=None, delMask=None):
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i if frame_i is None else frame_i


        if frame_i==posData.frame_i:
            rp = posData.rp
            IDs_idxs = posData.IDs_idxs
        else:
            rp = posData.allData_li[frame_i]['regionprops']
            IDs_idxs = posData.allData_li[frame_i]['IDs_idxs']

        if isinstance(delID, int):
            delID = [delID]
            
        is_any_id_present = False
        for _delID in delID:
            if _delID in IDs_idxs:
                is_any_id_present = True
                break
        
        if not is_any_id_present:
            return lab, delMask

        if delMask is None: 
            delMask = np.zeros(lab.shape, dtype=bool)
        else:
            delMask[:] = False

        for _delID in delID:
            idx = IDs_idxs.get(_delID, None)
            if idx is None:
                continue
            obj = rp[idx]
            delMask[obj.slice][obj.image] = True
        lab[delMask] = 0
        return lab, delMask
    
    def removeStoredContours(self, delID, frame_i=None):
        posData = self.data[self.pos_i]
        
        if frame_i is None:
            frame_i = posData.frame_i
            
        dataDict = posData.allData_li[posData.frame_i]
        try:
            newContours = {}
            for key, contours in dataDict['contours'].items():
                ID = key[0]
                if ID == delID:
                    continue
                
                newContours[key] = contours
            
            dataDict['contours'] = newContours
        except KeyError as err:
            pass
    
    @disableWindow
    def deleteIDmiddleClick(
            self, delIDs: Iterable, applyFutFrames, includeUnvisited
        ):
        self.clearHighlightedID()

        posData = self.data[self.pos_i]
        current_frame_i = posData.frame_i

        # Apply Delete ID to future frames if requested
        if applyFutFrames:
            delMask = np.zeros(posData.lab.shape, dtype=bool)
            # Store current data before going to future frames
            self.store_data()
            segmSizeT = len(posData.segm_data)
            for i in range(posData.frame_i+1, segmSizeT):
                lab = posData.allData_li[i]['labels']
                if lab is None and not includeUnvisited:
                    self.enqAutosave()
                    break

                if lab is not None:
                    # Visited frame
                    lab, _ = self.deleteIDFromLab(lab, delIDs, frame_i=i, delMask=delMask)

                    # Store change
                    posData.allData_li[i]['labels'] = lab
                    # Get the rest of the stored metadata based on the new lab
                    posData.frame_i = i
                    self.get_data()
                    self.store_data(autosave=False)
                elif includeUnvisited:
                    # Unvisited frame (includeUnvisited = True)
                    lab = posData.segm_data[i]
                    lab, _ = self.deleteIDFromLab(lab, delIDs, frame_i=i, delMask=delMask)

        # Back to current frame
        if applyFutFrames:
            posData.frame_i = current_frame_i
            self.get_data()   

        posData.lab, delID_mask = self.deleteIDFromLab(posData.lab, delIDs, )
        for _delID in delIDs:
            self.clearObjContour(ID=_delID, ax=0)     
            self.clearObjContour(ID=_delID, ax=1)  
            self.removeObjectFromRp(_delID)    
            self.removeStoredContours(_delID) 

        self.store_data(autosave=False)
        self.whitelistPropagateIDs(IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames))
        return delID_mask
    
    def setOverlayLabelsItems(self):
        if not self.overlayLabelsButton.isChecked():
            return 

        for segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items():  
            ol_lab = self.getOverlayLabelsData(segmEndname)
            items = self.overlayLabelsItems[segmEndname]
            imageItem, contoursItem, gradItem = items
            contoursItem.clear()
            if drawMode == 'Draw contours':
                for obj in skimage.measure.regionprops(ol_lab):
                    contours = self.getObjContours(
                        obj, all_external=True
                    )
                    for cont in contours:
                        contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5)
            elif drawMode == 'Overlay labels':
                imageItem.setImage(ol_lab, autoLevels=False)
    
    def getOverlayLabelsData(self, segmEndname):
        posData = self.data[self.pos_i]
        
        if posData.ol_labels_data is None:
            self.loadOverlayLabelsData(segmEndname)            
        elif segmEndname not in posData.ol_labels_data:
            self.loadOverlayLabelsData(segmEndname)
        
        if self.isSegm3D:
            zProjHow = self.zProjComboBox.currentText()
            isZslice = zProjHow == 'single z-slice'
            if isZslice:
                z = self.zSliceScrollBar.sliderPosition()
                ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z]
                return ol_lab
            else:
                ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max(axis=0)
                return ol_lab
        else:
            return posData.ol_labels_data[segmEndname][posData.frame_i]
    
    def loadOverlayLabelsData(self, segmEndname):
        posData = self.data[self.pos_i]
        filePath, filename = load.get_path_from_endname(
            segmEndname, posData.images_path
        )
        self.logger.info(f'Loading "{segmEndname}.npz" to overlay...')
        labelsData = np.load(filePath)['arr_0']
        if posData.SizeT == 1:
            labelsData = labelsData[np.newaxis]
        if self.isSegm3D and labelsData.ndim == 3:
            # 2D segm --> stack to 3D
            T, Y, X = labelsData.shape
            repeat = [labelsData]*posData.SizeZ
            labelsData = np.stack(repeat, axis=1)
        
        if posData.ol_labels_data is None:
            posData.ol_labels_data = {}
        posData.ol_labels_data[segmEndname] = labelsData

    def startBlinkingModeCB(self):
        try:
            self.timer.stop()
            self.stopBlinkTimer.stop()
        except Exception as e:
            pass
        if self.rulerButton.isChecked():
            return
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.blinkModeComboBox)
        self.timer.start(200)
        self.stopBlinkTimer = QTimer(self)
        self.stopBlinkTimer.timeout.connect(self.stopBlinkingCB)
        self.stopBlinkTimer.start(2000)

    def blinkModeComboBox(self):
        if self.flag:
            self.modeComboBox.setStyleSheet('background-color: orange')
        else:
            self.modeComboBox.setStyleSheet('background-color: none')
        self.flag = not self.flag

    def stopBlinkingCB(self):
        self.timer.stop()
        self.modeComboBox.setStyleSheet('background-color: none')

    def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None):
        if rp is None:
            posData = self.data[self.pos_i]
            rp = posData.rp
        for obj in rp:
            if obj.label not in IDsWithIssue:
                continue
            self.setCcaIssueContour(obj)

    # @exec_time
    def highlightLostNew(self):
        if self.modeComboBox.currentText() == 'Viewer':
            return
        
        posData = self.data[self.pos_i]
        delROIsIDs = self.getDelRoisIDs()
        
        # self.setAllContoursImages(delROIsIDs=delROIsIDs)
        if posData.frame_i == 0:
            return 

        if not self.annotLostObjsToggle.isChecked():
            return
        
        prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
        
        if prev_rp is None:
            return

        self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs)        
        self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs)

    def addLostObjsToImage(self, lostObj, lostID, force=False):
        if not force:
            if not self.copyLostObjButton.isChecked():
                return
        
        obj_slice = self.getObjSlice(lostObj.slice)
        obj_image = self.getObjImage(lostObj.image, lostObj.bbox)
        self.lostObjImage[obj_slice][obj_image] = lostID

    def highlightHoverLostObj(self, modifiers, event):
        noModifier = modifiers == Qt.NoModifier
        if not noModifier:
            return
        
        if not self.copyLostObjButton.isChecked():
            return
        
        if event.isExit():
            return
        
        posData = self.data[self.pos_i]
        x, y = event.pos()
        xdata, ydata = int(x), int(y)
        try:
            hoverLostID = self.lostObjImage[ydata, xdata]
        except IndexError:
            return
        
        self.ax1_lostObjScatterItem.hoverLostID = hoverLostID        
        if hoverLostID == 0:
            self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1)
            self.ax1_lostObjScatterItem.setData([], [])
        else:
            prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
            prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs']
            lostObj = prev_rp[prev_IDs_idxs[hoverLostID]]
            obj_contours = self.getObjContours(lostObj, all_external=True)
            for cont in obj_contours:
                xx = cont[:,0]
                yy = cont[:,1]
                self.ax1_lostObjScatterItem.addPoints(xx, yy)
            self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2)
    
    def annotLostObjsToggled(self, checked):
        if not self.isDataLoaded:
            return
        self.updateAllImages()

    def getPrevFrameIDs(self, current_frame_i=None):
        posData = self.data[self.pos_i]
        if current_frame_i is None:
            current_frame_i = posData.frame_i
        
        if current_frame_i is None:
            return []
        
        prev_frame_i = current_frame_i - 1
        prevIDs = posData.allData_li[prev_frame_i]['IDs']
        
        if prevIDs:
            return prevIDs
        
        # IDs in previous frame were not stored --> load prev lab from HDD
        prev_lab = self.get_labels(
            from_store=False, 
            frame_i=prev_frame_i,
            return_copy=False
        )
        rp = skimage.measure.regionprops(prev_lab)
        prevIDs = [obj.label for obj in rp]
        return prevIDs
            
    # @exec_time
    def setLostNewOldPrevIDs(self):
        posData = self.data[self.pos_i]
        if posData.frame_i == 0:
            posData.lost_IDs = []
            posData.new_IDs = []
            posData.old_IDs = []
            # posData.multiContIDs = set()
            self.titleLabel.setText('Looking good!', color=self.titleColor)
            return []
        
        # elif self.modeComboBox.currentText() == 'Viewer':
        #     pass
        
        out = self.updateLostNewCurrentIDs()
        lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = (
            out
        )
        self.setTitleText(
            lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs
        )
        return curr_delRoiIDs
    

    def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs):
        if not IDs:
            return htmlTxt_li, htmlTxtFull_li
        
        if isinstance(IDs, set):
            IDs = list(IDs)

        trim_IDs = myutils.get_trimmed_list(IDs)
        txt = f'{pretxt}: {trim_IDs}'
        txt_full = f'{pretxt}:<br>{IDs}'

        txt = f'<font color="{color}">{txt}</font>'
        txt_full = f'<font color="{color}">{txt_full}</font>'

        htmlTxt_li.append(txt)
        htmlTxtFull_li.append(txt_full)

        return htmlTxt_li, htmlTxtFull_li

    def setTitleText(   
            self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, 
            tracked_lost_IDs=None
        ):
        mode = self.modeComboBox.currentText()
        try:
            posData = self.data[self.pos_i]
            posData.segm_data[posData.frame_i]
            prev_segmented = True
        except IndexError:
            prev_segmented = False
            
        if prev_segmented:
            htmlTxt_li = []
            htmlTxtFull_li = []
        else:
            htmlTxt = f'<font color="white">Never segmented frame. </font>'
            self.titleLabel.setText(htmlTxt)
            self.titleLabel.setToolTip(htmlTxt)
            return
        
        if mode != 'Normal division: Lineage tree':
            htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter(
                htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs
            )
            htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter(
                htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs
            )
            htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter(
                htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', 
                tracked_lost_IDs
            )

            for i, htmlTxtFull in enumerate(htmlTxtFull_li):
                htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted')

            htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter(
                htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', 
                IDs_with_holes
            )
        else:
            try:
                cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i)
            except IndexError:
                title = 'Processing lineage tree...'
                htmlTxt = f'<font color="{self.titleColor}">{title}</font>'
                self.titleLabel.setText(htmlTxt)
                self.titleLabel.setToolTip(htmlTxt)
                return
            except AttributeError:
                title = 'Lineage tree still initializing...'
                htmlTxt = f'<font color="{self.titleColor}">{title}</font>'
                self.titleLabel.setText(htmlTxt)
                self.titleLabel.setToolTip(htmlTxt)
                return
            
            parent_cell_txt_raw = []
            if cells_with_parent:
                for cell, parent in cells_with_parent:
                    parent_cell_txt_raw.append(f'{parent} --> {cell}')

            htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter(
                htmlTxt_li, htmlTxtFull_li, 'New cells w/out mother', 'red', 
                orphan_cells
            )
            htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter(
                htmlTxt_li, htmlTxtFull_li, 'Lost cells', 'red', lost_cells
            )
            htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter(
                htmlTxt_li, htmlTxtFull_li, 'Parent --> Cell', 'green', 
                parent_cell_txt_raw
            )

        if not htmlTxt_li:
            title = 'Looking good'
            htmlTxt = f'<font color="{self.titleColor}">{title}</font>'
            self.titleLabel.setText(htmlTxt)
            self.titleLabel.setToolTip(htmlTxt)
            return

        htmlTxt = ', '.join(htmlTxt_li)
        htmlTxtFull = '<br>'.join(htmlTxtFull_li)

        self.titleLabel.setText(htmlTxt)
        self.titleLabel.setToolTip(htmlTxtFull)

    def separateByLabelling(self, lab, rp, maxID=None):
        """
        Label each single object in posData.lab and if the result is more than
        one object then we insert the separated object into posData.lab
        """
        setRp = False
        posData = self.data[self.pos_i]
        if maxID is None:
            maxID = max(posData.IDs, default=1)
        for obj in rp:
            lab_obj = skimage.measure.label(obj.image)
            rp_lab_obj = skimage.measure.regionprops(lab_obj)
            if len(rp_lab_obj)<=1:
                continue
            lab_obj += maxID
            _slice = obj.slice # self.getObjSlice(obj.slice)
            _objMask = obj.image # self.getObjImage(obj.image)
            lab[_slice][_objMask] = lab_obj[_objMask]
            setRp = True
            maxID += 1
        return setRp

    def checkTrackingEnabled(self):
        posData = self.data[self.pos_i]
        posData.last_tracked_i = self.navigateScrollBar.maximum()-1
        if posData.frame_i <= posData.last_tracked_i:
            return True
        else:
            return False

    def trackManuallyAddedObject(
            self, added_IDs: List[int] | int | Set[int], isNewID: bool,
            wl_update:bool=True, wl_track_og_curr:bool=False
        ):
        """Track object added manually on frame that was already visited.

        Parameters
        ----------
        added_IDs : int | list of int | set
            ID or IDs of the object added manually
        isNewID : bool
            If True, the added object is new
        
        Notes
        -----
        This method tracks the new added object against the previous frame 
        labels. If the ID determined by tracking is different from `added_ID` 
        (meaning that tracking thinks the new ID should be changed to the 
        tracked ID) and the tracked ID is not already existing (which would 
        otherwise causing merging) we assign the tracked ID to the object with 
        `added_ID`. 
        
        If instead the tracked ID is the same as `added_ID` we are dealing 
        with a truly new object. In this case we want to try tracking it against 
        the next frame (since the next frame was already validated). 
        As before, we assign the tracked ID (against the next frame) only if 
        not already existing in current frame (to avoid merging).    
        """        
        if self.isSnapshot:
            return 
        
        if not isNewID:
            return

        if isinstance(added_IDs, int):
            added_IDs = [added_IDs]
        
        posData = self.data[self.pos_i]
        tracked_lab = self.tracking(
            enforce=True, assign_unique_new_IDs=False, return_lab=True,
            IDs=added_IDs
        )
        self.clearAssignedObjsSecondStep()
        if tracked_lab is None:
            return
        
        # Track only new object
        prevIDs = posData.allData_li[posData.frame_i-1]['IDs']

        # mask = np.zeros(posData.lab.shape, dtype=bool)
        update_rp = False

        for added_ID in added_IDs:
            # try:
            #     obj = posData.rp[added_ID] # ID not present
            #     mask[obj.slice][obj.image] = True

            # except IndexError as err:
            mask = posData.lab == added_ID
            try:
                trackedID = tracked_lab[mask][0]
            except IndexError as err:
                # added_ID is not present
                continue 
            
            isTrackedIDalreadyPresentAndNotNew = (
                posData.IDs_idxs.get(trackedID) is not None
                and added_ID != trackedID
            )
            if isTrackedIDalreadyPresentAndNotNew:
                continue
            
            isTrackedIDinPrevIDs = trackedID in prevIDs
            if isTrackedIDinPrevIDs:
                posData.lab[mask] = trackedID
            else:
                # New object where we can try to track against next frame
                trackedID = self.trackNewIDtoNewIDsFutureFrame(added_ID, mask)
                if trackedID is None:
                    self.clearAssignedObjsSecondStep()
                    continue
                posData.lab[mask] = trackedID
        
            self.keepOnlyNewIDAssignedObjsSecondStep(trackedID)
            update_rp = True
        
        if update_rp:
            self.update_rp(wl_update=wl_update)
    
    def trackFrameCustomTracker(self, prev_lab, currentLab, IDs=None):
        try:
            tracked_result = self.realTimeTracker.track_frame(
                prev_lab, currentLab,
                unique_ID=self.setBrushID(),
                **self.track_frame_params,
                IDs=IDs
            )
        except TypeError as err:
            if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1:
                try:
                    tracked_result = self.realTimeTracker.track_frame(
                        prev_lab, currentLab, IDs=IDs,
                        **self.track_frame_params
                    )
                except TypeError as err:
                    if str(err).find('an unexpected keyword argument \'IDs\'') != -1:
                        tracked_result = self.realTimeTracker.track_frame(
                            prev_lab, currentLab,
                            **self.track_frame_params)
                    else:
                        raise err
            elif str(err).find('an unexpected keyword argument \'IDs\'') != -1:
                try:
                    tracked_result = self.realTimeTracker.track_frame(
                        prev_lab, currentLab,
                        unique_ID=self.setBrushID(),
                        **self.track_frame_params
                    )
                except TypeError as err:
                    if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1:
                        tracked_result = self.realTimeTracker.track_frame(
                            prev_lab, currentLab,
                            **self.track_frame_params
                        )
                    else:
                        raise err
            else:
                raise err
        return tracked_result
    
    def trackFrame(
            self, prev_lab, prev_rp, currentLab, currentRp, currentIDs,
            assign_unique_new_IDs=True, IDs=None,
        ):
        # printl(f'Tracking frame {self.data[self.pos_i].frame_i}...')
        if self.trackWithAcdcAction.isChecked():
            tracked_result = CellACDC_tracker.track_frame(
                prev_lab, prev_rp, currentLab, currentRp,
                IDs_curr_untracked=currentIDs,
                setBrushID_func=self.setBrushID,
                posData=self.data[self.pos_i],
                assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs
            )
        elif self.trackWithYeazAction.isChecked():
            tracked_result = self.tracking_yeaz.correspondence(
                prev_lab, currentLab, use_modified_yeaz=True,
                use_scipy=True
            )
        else:
            tracked_result = self.trackFrameCustomTracker(prev_lab, currentLab, IDs=IDs)

        # Check if tracker also returns additional info
        if isinstance(tracked_result, tuple):
            tracked_lab, tracked_lost_IDs = tracked_result
            self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs)
        else:
            tracked_lab = tracked_result
        
        return tracked_lab
    
    def clearAssignedObjsSecondStep(self):
        posData = self.data[self.pos_i]
        posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None
    
    def trackSubsetIDs(self, subsetIDs: Iterable[int]):
        posData = self.data[self.pos_i]
        if posData.frame_i == 0:
            return

        subsetLab = np.zeros_like(posData.lab)
        for subsetID in subsetIDs:
            subsetLab[posData.lab == subsetID] = subsetID
        
        prev_lab = posData.allData_li[posData.frame_i-1]['labels']
        prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
        tracked_lab = self.trackFrame(
            prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs,
            assign_unique_new_IDs=True
        )
        doUpdateRp = False
        for subsetID in subsetIDs:
            subsetIDmask = posData.lab == subsetID
            trackedID = tracked_lab[subsetIDmask][0]
            if trackedID == subsetID:
                continue
            
            is_manually_edited = False
            for y, x, new_ID in posData.editID_info:
                if new_ID == subsetID:
                    # Do not track because it was manually edited
                    break
                
            posData.lab[subsetIDmask] = tracked_lab[subsetIDmask]
            doUpdateRp = True
        
        if not doUpdateRp:
            return
        
        self.update_rp()
    
    # @exec_time
    @exception_handler
    def tracking(
            self, enforce=False, DoManualEdit=True,
            storeUndo=False, prev_lab=None, prev_rp=None,
            return_lab=False, assign_unique_new_IDs=True,
            separateByLabel=True,wl_update=True,
            IDs=None
        ):
        posData = self.data[self.pos_i]
        mode = str(self.modeComboBox.currentText())
        skipTracking = (
            posData.frame_i == 0 or mode.find('Tracking') == -1
            or self.isSnapshot
        )
        if skipTracking:
            self.setLostNewOldPrevIDs()
            return

        # Disable tracking for already visited frames
        trackingDisabled = self.checkTrackingEnabled()

        if enforce or self.UserEnforced_Tracking:
            # Tracking enforced by the user
            do_tracking = True
        elif self.UserEnforced_DisabledTracking:
            # Tracking specifically DISABLED by the user
            do_tracking = False
        elif trackingDisabled:
            # User did not choose what to do --> tracking disabled for
            # visited frames and enabled for never visited frames
            do_tracking = False
        else:
            do_tracking = True

        if not do_tracking:
            self.setLostNewOldPrevIDs()
            return
        
        """Tracking starts here"""
        staturBarLabelText = self.statusBarLabel.text()
        self.statusBarLabel.setText('Tracking...')

        if storeUndo:
            # Store undo state before modifying stuff
            self.storeUndoRedoStates(False)

        # First separate by labelling
        if separateByLabel:
            maxID = max(posData.IDs, default=1)
            setRp = core.split_connected_components(
                posData.lab, rp=posData.rp, max_ID=maxID
            )
            if setRp:
                self.update_rp(wl_update=wl_update, )

        if prev_lab is None:
            prev_lab = posData.allData_li[posData.frame_i-1]['labels']
        if prev_rp is None:
            prev_rp = posData.allData_li[posData.frame_i-1]['regionprops']
        
        tracked_lab = self.trackFrame(
            prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs,
            assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs
        )
        
        if DoManualEdit:
            # Correct tracking with manually changed IDs
            rp = skimage.measure.regionprops(tracked_lab)
            IDs = [obj.label for obj in rp]
            self.manuallyEditTracking(tracked_lab, IDs)

        if return_lab:
            QTimer.singleShot(50, partial(
                self.statusBarLabel.setText, staturBarLabelText
            ))
            return tracked_lab
        
        # Update labels, regionprops and determine new and lost IDs
        posData.lab = tracked_lab
        self.update_rp(wl_update=wl_update, )
        self.setAllTextAnnotations()
        QTimer.singleShot(50, partial(
            self.statusBarLabel.setText, staturBarLabelText
        ))

    def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args):
        if self._rtTrackerName == 'CellACDC_normal_division':
            tracked_lost_IDs = args[0]
            self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs)
        elif self._rtTrackerName == 'CellACDC_2steps':
            if args[0] is None:
                return
            posData = self.data[self.pos_i]
            posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = args[0]
    
    def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID):
        posData = self.data[self.pos_i]
        annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i)
        
        if annotInfo is None:
            return 
        
        new_objs_1st_step, lost_objs_1st_step = annotInfo
        correct_new_objs, correct_lost_objs = [], []
        for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step):
            newObj_ID = posData.lab[newObj.slice][newObj.image][0]
            if newObj_ID != trackedID:
                continue
            
            correct_new_objs.append(newObj)
            correct_lost_objs.append(lostObj)
        
        if not correct_new_objs:
            posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None
        else:
            posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = (
                correct_new_objs, correct_lost_objs
            )
        # self.annotateAssignedObjsAcdcTrackerSecondStep()
    
    def updateAssignedObjsAcdcTrackerSecondStep(self, newID):
        posData = self.data[self.pos_i]
        annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i)
        if annotInfo is None:
            return
        
        new_objs_1st_step, lost_objs_1st_step = annotInfo
        correct_new_objs, correct_lost_objs = [], []
        for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step):
            newObj_ID = posData.lab[newObj.slice][newObj.image][0]
            if newObj_ID == newID:
                # The ID of the new object tracked with 2nd step was 
                # manually edit --> do not annotate its linking to lost obj anymore
                continue
            correct_new_objs.append(newObj)
            correct_lost_objs.append(lostObj)
        
        if not correct_new_objs:
            posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None
        else:
            posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = (
                correct_new_objs, correct_lost_objs
            )
        self.annotateAssignedObjsAcdcTrackerSecondStep()
        
     
    def annotateAssignedObjsAcdcTrackerSecondStep(self):
        posData = self.data[self.pos_i]
        annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i)
        if annotInfo is None:
            return
        
        new_objs_1st_step, lost_objs_1st_step = annotInfo
        for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step):
            allContours = self.getObjContours(lostObj, all_external=True) 
            for objContours in allContours:
                isObjVisible = self.isObjVisible(newObj.bbox)
                if not isObjVisible:
                    continue
                xx = objContours[:,0] + 0.5
                yy = objContours[:,1] + 0.5
                self.yellowContourScatterItem.addPoints(xx, yy)
                
            y1, x1 = self.getObjCentroid(lostObj.centroid)
            y2, x2 = self.getObjCentroid(newObj.centroid)
            xx, yy = core.get_line(y1, x1, y2, x2, dashed=False)
            self.ax1_oldMothBudLinesItem.addPoints(xx, yy)
        
        posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None
                        
    def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs):
        """Store centroids of those IDs the tracker decided is fine to lose 
        (e.g., upon standard cell division the ID of the mother is fine)

        Parameters
        ----------
        prev_rp : skimage.measure.RegionProperties
            List of region properties of the object in previous frame
        tracked_lost_IDs : iterable
            List-like container of the IDs that is fine to lose from previous
            frame to current frame
        
        Note
        ----
        This function stores the centroids because the user could change IDs 
        in multiple ways. Storing centroids is more robust.
        """        
        posData = self.data[self.pos_i]
        frame_i = posData.frame_i
        
        for obj in prev_rp:
            if obj.label not in tracked_lost_IDs:
                continue
            
            int_centroid = tuple([int(val) for val in obj.centroid])
            try:
                posData.tracked_lost_centroids[frame_i].add(int_centroid)
            except KeyError:
                posData.tracked_lost_centroids[frame_i] = {int_centroid}
                printl('Need to fix probably? Why is this not properly init?')    

    def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None):
        trackedLostIDs = set()
        posData = self.data[self.pos_i]
        if self.isExportingVideo:
            posData.trackedLostIDs = trackedLostIDs
            return trackedLostIDs
        
        retrackedLostcent = set()
        if frame_i is None:
            frame_i = posData.frame_i
            
        if prev_lab is None:
            prev_lab = self.get_labels(
                from_store=True, 
                frame_i=posData.frame_i-1, 
                return_existing=False,
                return_copy=False
            )

        if IDs_in_frames is None:
            IDs_in_frames = posData.IDs

        try:
            tracked_lost_centroids = posData.tracked_lost_centroids[frame_i]
        except KeyError:
            tracked_lost_centroids = set()

        for centroid in tracked_lost_centroids:
            ID = prev_lab[centroid]
            if ID == 0:
                continue

            if ID in IDs_in_frames:
                retrackedLostcent.add(centroid)
                continue

            trackedLostIDs.add(ID)

        posData.tracked_lost_centroids[frame_i] = (
            tracked_lost_centroids - retrackedLostcent
        )
        posData.trackedLostIDs = trackedLostIDs

        return trackedLostIDs
    
    def manuallyEditTracking(self, tracked_lab, allIDs):
        posData = self.data[self.pos_i]
        infoToRemove = []
        # Correct tracking with manually changed IDs
        maxID = max(allIDs, default=1)
        for y, x, new_ID in posData.editID_info:
            old_ID = tracked_lab[y, x]
            if old_ID == 0 or old_ID == new_ID:
                infoToRemove.append((y, x, new_ID))
                continue
            if new_ID in allIDs:
                tempID = maxID+1
                tracked_lab[tracked_lab == old_ID] = tempID
                tracked_lab[tracked_lab == new_ID] = old_ID
                tracked_lab[tracked_lab == tempID] = new_ID
            else:
                tracked_lab[tracked_lab == old_ID] = new_ID
                if new_ID > maxID:
                    maxID = new_ID
        for info in infoToRemove:
            posData.editID_info.remove(info)
    
    def warnReinitLastSegmFrame(self):
        current_frame_n = self.navigateScrollBar.value()
        msg = widgets.myMessageBox()
        txt = html_utils.paragraph(f"""
            Are you sure you want to <b>re-initialize the last visited and 
            validated</b> frame to number {current_frame_n}?<br><br>
            WARNING: If you save, <b>all annotations after frame number 
            {current_frame_n} will be lost!</b> 
        """)
        msg.warning(
            self, 'WARNING: Potential loss of data', txt,
            buttonsTexts=('Cancel', 'Yes, I am sure')
        )
        return msg.cancel

    def extendSegmDataIfNeeded(self, stopFrameNum):
        posData = self.data[self.pos_i]
        segmSizeT = len(posData.segm_data)
        if stopFrameNum <= segmSizeT:
            return
        numFramesToAdd = stopFrameNum - segmSizeT
        posData.allData_li.extend(
            [myutils.get_empty_stored_data_dict() for i in range(numFramesToAdd)]
        )
        lab_shape = posData.segm_data[0].shape
        shapeToAdd = (numFramesToAdd, *lab_shape)
        additionalSegmData = np.zeros(shapeToAdd, dtype=posData.segm_data.dtype)
        extendedSegmData = np.concatenate((posData.segm_data, additionalSegmData))
        posData.segm_data = extendedSegmData
    
    def reInitLastSegmFrame(
            self, checked=True, from_frame_i=None, updateImages=True,
            force=False
        ):
        if not force:
            cancel = self.warnReinitLastSegmFrame()
            if cancel:
                self.logger.info(
                    'Re-initialization of last validated frame cancelled.'
                )
                return

        posData = self.data[self.pos_i]
        if from_frame_i is None:
            from_frame_i = posData.frame_i
        
        self.updateLastCheckedFrameWidgets(from_frame_i)
        posData.last_tracked_i = from_frame_i
        self.navigateScrollBar.setMaximum(from_frame_i+1)
        self.navSpinBox.setMaximum(from_frame_i+1)
        # self.navigateScrollBar.setMinimum(1)
        
        # posData.tracked_lost_centroids[from_frame_i-1] = set()
        for i in range(from_frame_i, posData.SizeT):
            if posData.allData_li[i]['labels'] is None:
                break
            
            posData.segm_data[i] = posData.allData_li[i]['labels']
            posData.allData_li[i] = myutils.get_empty_stored_data_dict()
            
            posData.tracked_lost_centroids[i] = set()
            posData.acdcTracker2stepsAnnotInfo.pop(i, None)            
        
        if posData.acdc_df is not None:
            frames = posData.acdc_df.index.get_level_values(0)
            if from_frame_i in frames:
                posData.acdc_df = posData.acdc_df.loc[:from_frame_i]
        
        self.removeAlldelROIsCurrentFrame()
        
        if not updateImages:
            return
        
        self.updateAllImages()

    def resetAcceptedLostIDs(self, from_frame_i=None):
        posData = self.data[self.pos_i]
        if from_frame_i is None:
            from_frame_i = posData.frame_i
        
        posData.tracked_lost_centroids[from_frame_i-1] = set()
        for i in range(from_frame_i, posData.SizeT):
            posData.tracked_lost_centroids[i] = set()
    
    def removeAllItems(self):
        self.ax1.clear()
        self.ax2.clear()
        try:
            self.chNamesQActionGroup.removeAction(self.userChNameAction)
        except Exception as e:
            pass
        try:
            posData = self.data[self.pos_i]
            for action in self.fluoDataChNameActions:
                self.chNamesQActionGroup.removeAction(action)
        except Exception as e:
            pass
        try:
            self.overlayButton.setChecked(False)
        except Exception as e:
            pass

        if hasattr(self, 'contoursImage'):
            self.initContoursImage()
    
    def createUserChannelNameAction(self):
        self.userChNameAction = QAction(self)
        self.userChNameAction.setCheckable(True)
        self.userChNameAction.setText(self.user_ch_name)

    def createChannelNamesActions(self):
        # LUT histogram channel name context menu actions
        self.chNamesQActionGroup = QActionGroup(self) 
        self.chNamesQActionGroup.addAction(self.userChNameAction)
        posData = self.data[self.pos_i]
        for action in self.fluoDataChNameActions:
            self.chNamesQActionGroup.addAction(action)       
            action.setChecked(False)
        
        self.userChNameAction.setChecked(True)

        for action in self.overlayContextMenu.actions():
            action.setChecked(False)

    def restoreDefaultColors(self):
        try:
            color = self.defaultToolBarButtonColor
            self.overlayButton.setStyleSheet(f'background-color: {color}')
        except AttributeError:
            # traceback.print_exc()
            pass

    @exception_handler
    def _createEmptyData(self):
        self.MostRecentPath = self.getMostRecentPath()
        exp_path = QFileDialog.getExistingDirectory(
            self,
            'Select experiment folder where to create empty data',
            self.MostRecentPath
        )
        if not exp_path:
            return
        
        pos_path = os.path.join(exp_path, 'Position_1')
        images_path = os.path.join(pos_path, 'Images')
        if os.path.exists(images_path):
            raise FileExistsError(f'The following path already exists "{images_path}"')

        os.makedirs(images_path, exist_ok=True)
        
        basename = 'test_empty_'
        tif_filename = f'{basename}channel_1.tif'
        tif_filepath = os.path.join(images_path, tif_filename)
        empty_img = np.zeros((256,256), dtype=np.uint8)
        empty_img[0,0] = 255
        skimage.io.imsave(tif_filepath, empty_img)
        
        metadata_filename = f'{basename}metadata.csv'
        metadata_filepath = os.path.join(images_path, metadata_filename)
        df_metadata = pd.DataFrame({
            'Description': ['basename'],
            'values': [basename]
        })
        df_metadata.to_csv(metadata_filepath, index=False)
        
        self.isNewFile = True
        self._openFolder(exp_path=images_path)

    
    def segmNdimIndicatorClicked(self):
        ndimText = self.segmNdimIndicator.text()
        if ndimText == '2D':
            alternativeNdimText = '3D'
            toggleText = 'activate'
        else:
            alternativeNdimText = '2D'
            toggleText = 'de-activate'
        msg = widgets.myMessageBox(wrapText=False)
        important_txt = ("""
            The toggle to activate 3D segmentation is visible only when 
            the <code>Number of z-slices</code> is greater than 1.
        """)
        txt = html_utils.paragraph(f"""
            This indicator shows that you are working with {ndimText} 
            segmentation masks.<br><br>
            
            If instead, you want to work with {alternativeNdimText} segmentation, 
            you need to initialize a new segmentation file.<br><br>
            
            To do so, go the menu on the top menubar <code>File --> 
            New Segmentation File...</code> and,<br>
            at the dialog where you insert the metadata (Number of z-slices, 
            pixel size, etc.),<br> 
            <b>{toggleText}</b> the parameter called <code>Work with 3D 
            segmentation masks (z-stack)</code><br> 
            as indicated in the screenshot below<br>.
            {html_utils.to_admonition(important_txt, admonition_type='note')}
            <br>
        """)
        msg.information(
            self, 'Segmentation nmber of dimensions info', txt,
            image_paths=':toggle_3D_screenshot.png'
        )
        self.segmNdimIndicator.setChecked(True)
    
    def newFile(self):
        self.newSegmEndName = ''
        self.isNewFile = True
        msg = widgets.myMessageBox(parent=self, showCentered=False)
        msg.setWindowTitle('File or folder?')
        msg.addText(html_utils.paragraph(f"""
            Do you want to load an <b>image file</b> or <b>Position 
            folder(s)</b>?
        """))
        loadPosButton = QPushButton('Load Position folder', msg)
        loadPosButton.setIcon(QIcon(":folder-open.svg"))
        loadFileButton = QPushButton('Load image file', msg)
        loadFileButton.setIcon(QIcon(":image.svg"))
        helpButton = widgets.helpPushButton('Help...')
        msg.addButton(helpButton)
        helpButton.disconnect()
        helpButton.clicked.connect(self.helpNewFile)
        msg.addCancelButton(connect=True)
        msg.addButton(loadFileButton)
        msg.addButton(loadPosButton)
        loadPosButton.setDefault(True)
        msg.exec_()
        if msg.cancel:
            return
        
        if msg.clickedButton == loadPosButton:
            self._openFolder()
        else:
            self._openFile()
    
    def openNewWindow(self):
        self.logger.info('Opening a new window...')
        if self.launcherSlot is not None:
            self.launcherSlot()
            return

        winClass = self.__class__
        win = winClass(
            self.app, parent=self, mainWin=self.mainWin, version=self._version
        )
        win.run()
        self.newWindows.append(win)
    
    def helpNewFile(self):
        msg = widgets.myMessageBox(showCentered=False)
        href = f'<a href="{user_manual_url}">user manual</a>'
        txt = html_utils.paragraph(f"""
            Cell-ACDC can open both a single image file or files structured 
            into Position folders.<br><br>
            If you are just testing out you can load a single image file, but 
            in general <b>we reccommend structuring your data into Position 
            folders.</b><br><br>
            More info about Position folders in the {href} at the section 
            called "Create required data structure from microscopy file(s)".
        """)
        msg.information(
            self, 'Help on Position folders', txt
        )

    def openFile(self, checked=False, file_path=None):
        self.logger.info(f'Opening FILE "{file_path}"')

        self.isNewFile = False
        self._openFile(file_path=file_path)
    
    def manageVersions(self):
        posData = self.data[self.pos_i]
        selectVersion = apps.SelectAcdcDfVersionToRestore(posData, parent=self)
        selectVersion.exec_()

        if selectVersion.cancel:
            return

        undoId = uuid.uuid4()
        if posData.cca_df is not None:
            self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId)
        
        selectedTime = selectVersion.selectedTimestamp

        self.modeComboBox.setCurrentText('Viewer')
        self.logger.info(f'Loading file from {selectedTime}...')

        acdc_df = load.read_acdc_df_from_archive(
            selectVersion.archiveFilePath, selectVersion.selectedKey
        )
        posData.acdc_df = acdc_df
        frames = acdc_df.index.get_level_values(0)
        last_visited_frame_i = frames.max()
        current_frame_i = posData.frame_i
        pbar = tqdm(total=last_visited_frame_i+1, ncols=100)
        for frame_i in range(last_visited_frame_i+1):
            posData.frame_i = frame_i
            self.get_data()
            if posData.cca_df is not None:
                self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId)
            if posData.allData_li[frame_i]['labels'] is None:
                pbar.update()
                continue
        
            if frame_i not in frames:
                acdc_df_i = pd.DataFrame(columns=acdc_df.columns)
                acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore')
                acdc_df_i.index.name = 'Cell_ID'
            else:
                acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all')
            
            posData.allData_li[frame_i]['acdc_df'] = acdc_df_i
            pbar.update()
        pbar.close()
        
        # Back to current frame
        posData.frame_i = current_frame_i
        self.get_data(debug=False)
        self.updateAllImages()
        self.logger.info('Annotations correctly recovered.')

    def warnUserCreationImagesFolder(self, images_path):
        msg = widgets.myMessageBox(wrapText=False)
        txt = html_utils.paragraph(f"""
            To load the data, Cell-ACDC requires the <b>image(s) to be located in a
            folder called <code>Images</code></b>.<br><br>
            The <b>file format</b> of the images must be <b>TIFF</b> (.tif extension).<br><br>
            You can choose to let Cell-ACDC deal with that, or you can stop the 
            process and manually place the image(s) into a folder called 
            <code>Images</code>.<br><br>
            If you choose to proceed, Cell-ACDC will create the following 
            folder:<br><br>
            <code>{images_path}</code>
            <br><br>
            How do you want to proceed?
        """)
        copyButton = widgets.copyPushButton('Copy the image into the new folder')
        moveButton = widgets.movePushButton('Move the image into the new folder')
        _, copyButton, moveButton = msg.warning(
            self, 'Creating Images folder', txt, 
            buttonsTexts=('Cancel', copyButton, moveButton)
        )
        if msg.cancel:
            return False, None

        if msg.clickedButton == copyButton:
            return True, True
        elif msg.clickedButton == moveButton:
            return True, False

    @exception_handler
    def _openFile(self, file_path=None):
        """
        Function used for loading an image file directly.
        """
        if file_path is None:
            self.MostRecentPath = self.getMostRecentPath()
            file_path = QFileDialog.getOpenFileName(
                self, 'Select image file', self.MostRecentPath,
                "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)"
                ";;All Files (*)")[0]
            if file_path == '':
                return
        dirpath = os.path.dirname(file_path)
        dirname = os.path.basename(dirpath)
        do_copy = True
        if dirname != 'Images':
            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
            acdc_folder = f'{timestamp}_acdc'
            exp_path = os.path.join(dirpath, acdc_folder, 'Images')
            proceed, do_copy = self.warnUserCreationImagesFolder(exp_path)
            if not proceed:
                self.logger.info('Loading image file aborted.')
                return
            os.makedirs(exp_path, exist_ok=True)
        else:
            exp_path = dirpath

        filename, ext = os.path.splitext(os.path.basename(file_path))
        if ext == '.tif' or ext == '.npz':
            filename_ext = os.path.basename(file_path)
            new_filepath = os.path.join(exp_path, filename_ext)
            if not os.path.exists(new_filepath):
                self.logger.info('Copying file to Image folder...')
                if do_copy:
                    shutil.copy2(file_path, new_filepath)
                else:
                    shutil.move(file_path, new_filepath)
            self._openFolder(exp_path=exp_path, imageFilePath=new_filepath)
        else:
            self.logger.info('Copying file to .tif format...')
            data = load.loadData(file_path, '', log_func=self.logger.info)
            data.loadImgData()
            img = data.img_data
            if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4):
                self.logger.info('Converting RGB image to grayscale...')
                if img.shape[-1] == 3:
                    data.img_data = skimage.color.rgb2gray(data.img_data)
                else:
                    data.img_data = cv2.cvtColor(
                        data.img_data, cv2.COLOR_RGBA2GRAY
                    )
                data.img_data = skimage.img_as_ubyte(data.img_data)
            tif_path = os.path.join(exp_path, f'{filename}.tif')
            if data.img_data.ndim == 3:
                SizeT = data.img_data.shape[0]
                SizeZ = 1
            elif data.img_data.ndim == 4:
                SizeT = data.img_data.shape[0]
                SizeZ = data.img_data.shape[1]
            else:
                SizeT = 1
                SizeZ = 1
            is_imageJ_dtype = (
                data.img_data.dtype == np.uint8
                or data.img_data.dtype == np.uint32
                or data.img_data.dtype == np.uint32
                or data.img_data.dtype == np.float32
            )
            if not is_imageJ_dtype:
                data.img_data = skimage.img_as_ubyte(data.img_data)

            myutils.to_tiff(tif_path, data.img_data)
            self._openFolder(exp_path=exp_path, imageFilePath=tif_path)

    def criticalNoTifFound(self, images_path):
        err_title = 'No .tif files found in folder.'
        err_msg = html_utils.paragraph(
            'The following folder<br><br>'
            f'<code>{images_path}</code><br><br>'
            '<b>does not contain .tif or .h5 files</b>.<br><br>'
            'Only .tif or .h5 files can be loaded with "Open Folder" button.<br><br>'
            'Try with <code>File --> Open image/video file...</code> '
            'and directly select the file you want to load.'
        )
        msg = widgets.myMessageBox()
        msg.addShowInFileManagerButton(images_path)
        msg.critical(self, err_title, err_msg)

    def reinitStoredSegmModels(self):
        self.models = [None]*len(self.models)
    
    def checkAskSavePointsLayers(self):
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                if not hasattr(action, 'layerTypeIdx'):
                    continue
                if action.layerTypeIdx != 4:
                    continue
                
                scatterItem = action.scatterItem
                xx, yy = scatterItem.getData()
                
                if xx is None:
                    continue
                
                if len(xx) == 0:
                    continue
                
                cancel = self.askSavePointsLayer(action)
                if cancel:
                    return cancel
        
        return False
    
    def askSavePointsLayer(self, action):
        toolButton = action.button
        tableEndName = toolButton.clickEntryTableEndName
        saveAction = toolButton.saveAction
        
        txt = html_utils.paragraph(f"""
            Do you want to <b>save</b> the points you added 
            (table called <code>{tableEndName}.csv</code>)?
        """
        )
        msg = widgets.myMessageBox(wrapText=False)
        _, _, saveButton = msg.question(
            self, 'Save points layer?', txt,
            buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points')
        )
        if msg.clickedButton == saveButton:
            self.savePointsAddedByClicking(saveAction)
        
        return msg.cancel
    
    def reInitGui(self):
        cancel = self.checkAskSavePointsLayers()
        if cancel:
            return False
        
        self.secondLevelToolbar.setVisible(False)
        
        self.gui_createLazyLoader()

        try:
            self.navSpinBox.valueChanged.disconnect()
        except Exception as e:
            pass
        
        try: 
            self.scaleBar.removeFromAxis(self.ax1)
        except Exception as e:
            pass

        self.lineage_tree = None
        self.getDistanceListMissingIDsCachedFrame = None
        self.isZmodifier = False
        self.zKeptDown = False
        self.askRepeatSegment3D = True
        self.askZrangeSegm3D = True
        self.isDataLoaded = False
        self.retainSizeLutItems = False
        self.setMeasWinState = None
        self.addPointsWin = None
        self.showPropsDockButton.setDisabled(True)
        self.lutItemsLayout.clear()
        self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0)
        
        self.reinitWidgetsPos()
        self.removeAllItems()
        self.reinitCustomAnnot()
        self.reinitPointsLayers()
        self.gui_createPlotItems()
        self.setUncheckedAllButtons()
        self.setUncheckedPointsLayers()
        self.restoreDefaultColors()
        self.reinitStoredSegmModels()
        self.removeAxLimits()
        self.curvToolButton.setChecked(False)

        self.segmNdimIndicatorAction.setVisible(False)
        
        self.navigateToolBar.hide()
        self.ccaToolBar.hide()
        self.editToolBar.hide()
        self.brushEraserToolBar.hide()
        self.modeToolBar.hide()

        self.modeComboBox.setCurrentText('Viewer')
        
        alpha = self.imgGrad.labelsAlphaSlider.value()
        self.labelsLayerImg1.setOpacity(alpha)
        self.labelsLayerRightImg.setOpacity(alpha)
        self.lastTrackedFrameLabel.setText('')
        
        for action in self.askHowFutureFramesActions.values():
            action.setChecked(True)
            action.setDisabled(True)
        
        return True
    
    def reinitPointsLayers(self):
        for toolbar in self.pointsLayersToolbars:
            for action in toolbar.actions()[1:]:
                toolbar.removeAction(action)
            toolbar.setVisible(False)
            self.autoPilotZoomToObjToolbar.setVisible(False)
    
    def reinitWidgetsPos(self):
        pass
        # try:
        #     # self.highlightZneighObjCheckbox will be connected in 
        #     # self.showHighlightZneighCheckbox()
        #     self.highlightZneighObjCheckbox.toggled.disconnect()
        # except Exception as e:
        #     pass
        # layout = self.bottomLeftLayout
        # self.highlightZneighObjCheckbox.hide()
        # try:
        #     layout.removeWidget(self.highlightZneighObjCheckbox)
        # except Exception as e:
        #     pass
        # self.highlightZneighObjCheckbox.hide()
        # # layout.addWidget(
        # #     self.drawIDsContComboBox, 0, 1, 1, 2,
        # #     alignment=Qt.AlignCenter
        # # )

    def reinitCustomAnnot(self):
        buttons = list(self.customAnnotDict.keys())
        for button in buttons:
            self.removeCustomAnnotButton(button, save=False, askHow=False)

    def loadingDataAborted(self):
        self.openFolderAction.setEnabled(True)
        self.titleLabel.setText('Loading data aborted.')
    
    def cleanUpOnError(self):
        self.onEscape()
        caller = 'Cell-ACDC'
        if self.module.startswith('spotmax'):
            caller = 'spotMAX'
        txt = f'WARNING: {caller} is in error state. Please, restart.'
        _hl = '*'*100
        self.titleLabel.setText(txt, color='r')
        self.logger.info(f'{_hl}\n{txt}\n{_hl}')

    def openFolder(
            self, checked=False, exp_path=None, imageFilePath=''
        ):
        if exp_path is None:
            self.logger.info('Asking to select a folder path...')
        else:
            self.logger.info(f'Opening FOLDER "{exp_path}"...')

        self.isNewFile = False
        if hasattr(self, 'data') and self.titleLabel.text != 'Saved!':
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(
                'Do you want to <b>save</b> before loading another dataset?'
            )
            _, no, yes = msg.question(
                self, 'Save?', txt,
                buttonsTexts=('Cancel', 'No', 'Yes')
            )
            if msg.clickedButton == yes:
                func = partial(self._openFolder, exp_path, imageFilePath)
                cancel = self.saveData(finishedCallback=func)
                return
            elif msg.cancel:
                self.store_data()
                return
            else:
                self.store_data(autosave=False)

        self._openFolder(
            exp_path=exp_path, imageFilePath=imageFilePath
        )

    def addToRecentPaths(self, path, logger=None):
        myutils.addToRecentPaths(path, logger=self.logger)
    
    def getMostRecentPath(self):
        return myutils.getMostRecentPath()

    @exception_handler
    def _openFolder(
            self, checked=False, exp_path=None, imageFilePath=''
        ):
        """Main function to load data.

        Parameters
        ----------
        checked : bool
            kwarg needed because openFolder can be called by openFolderAction.
        exp_path : string or None
            Path selected by the user either directly, through openFile,
            or drag and drop image file.
        imageFilePath : string
            Path of the image file that was either drag and dropped or opened
            from File --> Open image/video file (openFileAction).

        Returns
        -------
            None
        """

        if exp_path is None:
            self.MostRecentPath = self.getMostRecentPath()
            exp_path = QFileDialog.getExistingDirectory(
                self,
                'Select experiment folder containing Position_n folders '
                'or specific Position_n folder',
                self.MostRecentPath
            )

        if not exp_path:
            self.openFolderAction.setEnabled(True)
            return

        proceed = self.reInitGui()
        if not proceed:
            self.openFolderAction.setEnabled(True)
            return

        self.openFolderAction.setEnabled(False)

        if self.slideshowWin is not None:
            self.slideshowWin.close()

        if self.ccaTableWin is not None:
            self.ccaTableWin.close()

        self.exp_path = exp_path
        self.logger.info(f'Loading from {self.exp_path}')
        self.addToRecentPaths(exp_path, logger=self.logger)
        self.addPathToOpenRecentMenu(exp_path)

        folder_type = myutils.determine_folder_type(exp_path)
        is_pos_folder, is_images_folder, exp_path = folder_type

        self.titleLabel.setText('Loading data...', color=self.titleColor)

        skip_channels = []
        ch_name_selector = prompts.select_channel_name(
            which_channel='segm', allow_abort=False
        )
        user_ch_name = None
        if not is_pos_folder and not is_images_folder and not imageFilePath:
            images_paths = self._loadFromExperimentFolder(exp_path)
            if not images_paths:
                self.loadingDataAborted()
                return

        elif is_pos_folder and not imageFilePath:
            pos_foldername = os.path.basename(exp_path)
            exp_path = os.path.dirname(exp_path)
            images_paths = [os.path.join(exp_path, pos_foldername, 'Images')]

        elif is_images_folder and not imageFilePath:
            images_paths = [exp_path]
            pos_path = os.path.dirname(exp_path)
            exp_path = os.path.dirname(pos_path)
            
        elif imageFilePath:
            # images_path = exp_path because called by openFile func
            filenames = myutils.listdir(exp_path)
            ch_names, basenameNotFound = (
                ch_name_selector.get_available_channels(filenames, exp_path)
            )
            filename = os.path.basename(imageFilePath)
            self.ch_names = ch_names
            user_ch_name = [
                chName for chName in ch_names if filename.find(chName)!=-1
            ][0]
            images_paths = [exp_path]
            pos_path = os.path.dirname(exp_path)
            exp_path = os.path.dirname(pos_path)

        self.images_paths = images_paths

        # Get info from first position selected
        images_path = self.images_paths[0]
        filenames = myutils.listdir(images_path)
        if ch_name_selector.is_first_call and user_ch_name is None:
            ch_names, _ = ch_name_selector.get_available_channels(
                filenames, images_path
            )
            self.ch_names = ch_names
            if not ch_names:
                self.openFolderAction.setEnabled(True)
                self.criticalNoTifFound(images_path)
                return
            if len(ch_names) > 1:
                CbLabel='Select channel name to load: '
                ch_name_selector.QtPrompt(
                    self, ch_names, CbLabel=CbLabel
                )
                if ch_name_selector.was_aborted:
                    self.openFolderAction.setEnabled(True)
                    return
                skip_channels.extend([
                    ch for ch in ch_names if ch!=ch_name_selector.channel_name
                ])
            else:
                ch_name_selector.channel_name = ch_names[0]
            ch_name_selector.setUserChannelName()
            user_ch_name = ch_name_selector.user_ch_name
        else:
            # File opened directly with self.openFile
            ch_name_selector.channel_name = user_ch_name

        user_ch_file_paths = []
        not_allowed_ends = ['btrack_tracks.h5']
        for images_path in self.images_paths:
            channel_file_path = load.get_filename_from_channel(
                images_path, user_ch_name, skip_channels=skip_channels,
                not_allowed_ends=not_allowed_ends, logger=self.logger.info
            )
            if not channel_file_path:
                self.criticalImgPathNotFound(images_path)
                return
            user_ch_file_paths.append(channel_file_path)

        ch_name_selector.setUserChannelName()
        self.user_ch_name = user_ch_name

        self.AutoPilotProfile.storeSelectedChannel(self.user_ch_name)

        self.initGlobalAttr()
        self.createOverlayContextMenu()
        self.createUserChannelNameAction()
        self.gui_createOverlayColors()
        self.gui_createOverlayItems()
        lastRow = self.bottomLeftLayout.rowCount()
        self.bottomLeftLayout.setRowStretch(lastRow+1, 1)

        self.num_pos = len(user_ch_file_paths)
        proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name)
        if not proceed:
            self.openFolderAction.setEnabled(True)
            return
    
    def _loadFromExperimentFolder(self, exp_path):
        select_folder = load.select_exp_folder()
        values = select_folder.get_values_segmGUI(exp_path)
        if not values:
            self.criticalInvalidPosFolder(exp_path)
            self.openFolderAction.setEnabled(True)
            return []

        if len(values) > 1:
            select_folder.QtPrompt(self, values, allow_abort=False)
            if select_folder.was_aborted:
                return []
        else:
            select_folder.was_aborted = False
            select_folder.selected_pos = select_folder.pos_foldernames

        images_paths = []
        for pos in select_folder.selected_pos:
            images_paths.append(os.path.join(exp_path, pos, 'Images'))
        return images_paths
    
    def criticalInvalidPosFolder(self, exp_path):
        href = html_utils.href_tag('here', data_structure_docs_url)
        txt = html_utils.paragraph(f"""
            The selected folder:<br><br>
            
            <code>{exp_path}</code><br><br>
            
            is <b>not a valid folder</b>.<br><br>
            
            Select a folder that contains the Position_n folders, 
            or a specific Position.<br><br>
            
            If you are trying to load a single image file go to 
            <code>File --> Open image/video file...</code>.<br><br>
            
            To load a folder containing multiple .tif files the folder must 
            be called either <code>Position_n</code><br>
            (with <code>n</code> being an integer) or <code>Images</code>.<br><br>
            
            For more information about the correct folder structure see {href}.
        """)
        msg = widgets.myMessageBox(wrapText=False)
        helpButton = widgets.helpPushButton('Help...')
        msg.addButton(helpButton)
        helpButton.clicked.disconnect()
        helpButton.clicked.connect(
            partial(myutils.browse_url, data_structure_docs_url)
        )
        msg.addShowInFileManagerButton(exp_path)
        msg.critical(
            self, 'Incompatible folder', txt
        )

    def createOverlayContextMenu(self):
        ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name]
        self.overlayContextMenu = QMenu()
        self.overlayContextMenu.addSeparator()
        self.checkedOverlayChannels = set()
        for chName in ch_names:
            action = QAction(chName, self.overlayContextMenu)
            action.setCheckable(True)
            action.toggled.connect(self.overlayChannelToggled)
            self.overlayContextMenu.addAction(action)
    
    def createOverlayLabelsContextMenu(self, segmEndnames):
        self.overlayLabelsContextMenu = QMenu()
        self.overlayLabelsContextMenu.addSeparator()
        self.drawModeOverlayLabelsChannels = {}
        for segmEndname in segmEndnames:
            action = QAction(segmEndname, self.overlayLabelsContextMenu)
            action.setCheckable(True)
            action.toggled.connect(self.addOverlayLabelsToggled)
            self.overlayLabelsContextMenu.addAction(action)
    
    def createOverlayLabelsItems(self, segmEndnames):
        selectActionGroup = QActionGroup(self)
        for segmEndname in segmEndnames:
            action = QAction(segmEndname)
            action.setCheckable(True)
            action.toggled.connect(self.setOverlayLabelsItemsVisible)
            selectActionGroup.addAction(action)
        self.selectOverlayLabelsActionGroup = selectActionGroup

        self.overlayLabelsItems = {}
        for segmEndname in segmEndnames:
            imageItem = pg.ImageItem()

            gradItem = widgets.overlayLabelsGradientWidget(
                imageItem, selectActionGroup, segmEndname
            )
            gradItem.hide()
            gradItem.drawModeActionGroup.triggered.connect(
                self.overlayLabelsDrawModeToggled
            )
            self.mainLayout.addWidget(gradItem, 0, 0)

            contoursItem = pg.ScatterPlotItem()
            contoursItem.setData(
                [], [], symbol='s', pxMode=False, size=1,
                brush=pg.mkBrush(color=(255,0,0,150)),
                pen=pg.mkPen(width=1, color='r'), tip=None
            )

            items = (imageItem, contoursItem, gradItem)
            self.overlayLabelsItems[segmEndname] = items
    
    def addOverlayLabelsToggled(self, checked):
        name = self.sender().text()
        if checked:
            gradItem = self.overlayLabelsItems[name][-1]
            drawMode = gradItem.drawModeActionGroup.checkedAction().text()
            self.drawModeOverlayLabelsChannels[name] = drawMode
        else:
            self.drawModeOverlayLabelsChannels.pop(name)
        self.setOverlayLabelsItems()
    
    def overlayLabelsDrawModeToggled(self, action):
        segmEndname = action.segmEndname
        drawMode = action.text()
        if segmEndname in self.drawModeOverlayLabelsChannels:
            self.drawModeOverlayLabelsChannels[segmEndname] = drawMode
            self.setOverlayLabelsItems()
    
    def overlayChannelToggled(self, checked):
        # Action toggled from overlayButton context menu
        channelName = self.sender().text()
        if checked:
            posData = self.data[self.pos_i]
            if channelName not in posData.loadedFluoChannels:
                self.loadOverlayData([channelName], addToExisting=True)
            
            self.checkedOverlayChannels.add(channelName)    
        else:
            self.checkedOverlayChannels.remove(channelName)
            imageItem = self.overlayLayersItems[channelName][0]
            imageItem.clear()
        self.setOverlayItemsVisible()
        self.updateAllImages()

    @exception_handler
    def loadDataWorkerDataIntegrityWarning(self, pos_foldername):
        err_msg = (
            'WARNING: Segmentation mask file ("..._segm.npz") not found. '
            'You could run segmentation module first.'
        )
        self.workerProgress(err_msg, 'INFO')
        self.titleLabel.setText(err_msg, color='r')
        abort = False
        msg = widgets.myMessageBox(parent=self)
        warn_msg = html_utils.paragraph(f"""
            The folder {pos_foldername} <b>does not contain a
            pre-computed segmentation mask</b>.<br><br>
            You can continue with a blank mask or cancel and
            pre-compute the mask with the segmentation module.<br><br>
            Do you want to continue?
        """)
        msg.setIcon(iconName='SP_MessageBoxWarning')
        msg.setWindowTitle('Segmentation file not found')
        msg.addText(warn_msg)
        msg.addButton('Ok')
        continueWithBlankSegm = msg.addButton(' Cancel ')
        msg.show(block=True)
        if continueWithBlankSegm == msg.clickedButton:
            abort = True
        self.loadDataWorker.abort = abort
        self.loadDataWaitCond.wakeAll()

    def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram):
        total_ram = myutils._bytes_to_GB(total_ram)
        available_ram = myutils._bytes_to_GB(available_ram)
        required_ram = myutils._bytes_to_GB(required_ram)
        required_perc = round(100*required_ram/available_ram)
        msg = widgets.myMessageBox()
        txt = html_utils.paragraph(f"""
            The total amount of data that you requested to load is about
            <b>{required_ram:.2f} GB</b> ({required_perc}% of the available memory)
            but there are only <b>{available_ram:.2f} GB</b> available.<br><br>
            For <b>optimal operation</b>, we recommend loading <b>maximum 30%</b>
            of the available memory. To do so, try to close open apps to
            free up some memory. Another option is to crop the images
            using the data prep module.<br><br>
            If you choose to continue, the <b>system might freeze</b>
            or your OS could simply kill the process.<br><br>
            What do you want to do?
        """)
        cancelButton, continueButton = msg.warning(
            self, 'Memory not sufficient', txt,
            buttonsTexts=('Cancel', 'Continue anyway')
        )
        if msg.clickedButton == continueButton:
            # Disable autosaving since it would keep a copy of the data and 
            # we cannot afford it with low memory
            self.autoSaveToggle.setChecked(False)
            return True
        else:
            return False

    def checkMemoryRequirements(self, required_ram):
        memory = psutil.virtual_memory()
        total_ram = memory.total
        available_ram = memory.available
        if required_ram/available_ram > 0.3:
            proceed = self.warnMemoryNotSufficient(
                total_ram, available_ram, required_ram
            )
            return proceed
        else:
            return True

    def criticalImgPathNotFound(self, images_path):
        self.logger.info(
            'The following folder does not contain valid image files: '
            f'"{images_path}"\n\n'
            'Check that all the positions loaded contain the same channel name. '
            'Make sure to double check for spelling mistakes or types in the '
            'channel names.'
        )
        msg = widgets.myMessageBox()
        msg.addShowInFileManagerButton(images_path)
        err_msg = html_utils.paragraph(f"""
            The folder<br><br>
            <code>{images_path}</code><br><br>
            <b>does not contain any valid image file!</b><br><br>
            Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz.
        """)
        okButton = msg.critical(
            self, 'No valid files found!', err_msg, buttonsTexts=('Ok',)
        )
    
    def initRealTimeTracker(self, force=False):
        for rtTrackerAction in self.trackingAlgosGroup.actions():
            if rtTrackerAction.isChecked():
                break
        
        aliases = myutils.aliases_real_time_trackers(reverse=True)
        
        rtTracker = rtTrackerAction.text()
        rtTracker_txt = rtTracker

        if rtTracker in aliases:
            rtTracker = aliases[rtTracker]
        
        if rtTracker == 'Cell-ACDC':
            return
        if rtTracker == 'YeaZ':
            return
        
        if self.isRealTimeTrackerInitialized and not force:
            return
        
        self.logger.info(f'Initializing {rtTracker_txt} tracker...')
        self._rtTrackerName = rtTracker
        posData = self.data[self.pos_i]
        realTimeTracker, track_frame_params = myutils.init_tracker(
            posData, rtTracker, qparent=self, realTime=True
        )
        if realTimeTracker is None:
            self.logger.info(f'{rtTracker} tracker initialization cancelled.')
            return
        
        self.realTimeTracker = realTimeTracker
        self.track_frame_params = track_frame_params
        self.logger.info(f'{rtTracker} tracker successfully initialized.')
        if 'image_channel_name' in self.track_frame_params:
            # Remove the channel name since it was already loaded in init_tracker
            del self.track_frame_params['image_channel_name']

    def initFluoData(self):
        if len(self.ch_names) <= 1:
            return
        
        if 'ask_load_fluo_at_init' in self.df_settings.index:
            if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No':
                return   
        msg = widgets.myMessageBox(allowClose=False)
        txt = (
            'Do you also want to <b>load fluorescence images?</b><br>'
            'You can load <b>as many channels as you want</b>.<br><br>'
            'If you load fluorescence images then the software will '
            '<b>calculate metrics</b> for each loaded fluorescence channel '
            'such as min, max, mean, quantiles, etc. '
            'of each segmented object.<br><br>'
            'NOTE: You can always load them later from the menu '
            '<code>File --> Load fluorescence images...</code> or when you set '
            'measurements from the menu '
            '<code>Measurements --> Set measurements...</code>'
        )
        msg.addDoNotShowAgainCheckbox(text="Don't ask again")
        no, yes = msg.question(
            self, 'Load fluorescence images?', html_utils.paragraph(txt),
            buttonsTexts=('No', 'Yes')
        )
        if msg.doNotShowAgainCheckbox.isChecked():
            self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No'
            self.df_settings.to_csv(self.settings_csv_path)
        if msg.clickedButton == yes:
            self.loadFluo_cb(None)
        self.AutoPilotProfile.storeClickMessageBox(
            'Load fluorescence images?', msg.clickedButton.text()
        )

    def getPathFromChName(self, chName, posData):
        ls = myutils.listdir(posData.images_path)
        endnames = {f[len(posData.basename):]:f for f in ls}
        validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz']
        for end in validEnds:
            files = [
                filename for endname, filename in endnames.items()
                if endname == f'{chName}{end}'
            ]
            if files:
                filename = files[0]
                break
        else:
            self.criticalFluoChannelNotFound(chName, posData)
            self.app.restoreOverrideCursor()
            return None, None

        fluo_path = os.path.join(posData.images_path, filename)
        filename, _ = os.path.splitext(filename)
        return fluo_path, filename
    
    def loadPosTriggered(self):
        if not self.isDataLoaded:
            return
        
        self.startAutomaticLoadingPos()
    
    def startAutomaticLoadingPos(self):
        self.AutoPilot = autopilot.AutoPilot(self)
        self.AutoPilot.execLoadPos()
    
    def stopAutomaticLoadingPos(self):
        if self.AutoPilot is None:
            return
        
        if self.AutoPilot.timer.isActive():
            self.AutoPilot.timer.stop()
        self.AutoPilot = None

    def startCcaIntegrityCheckerWorker(self):
        if not hasattr(self, 'data'):
            return
        
        if not self.isDataLoaded:
            return
        
        if not self.ccaIntegrCheckerToggle.isChecked():
            return
        
        ccaCheckerThread = QThread()
        self.ccaCheckerMutex = QMutex()
        self.ccaCheckerWaitCond = QWaitCondition()
        
        worker = workers.CcaIntegrityCheckerWorker(
            self.ccaCheckerMutex, self.ccaCheckerWaitCond
        )
        self.ccaIntegrityCheckerWorker = worker
        self.ccaCheckerThread = ccaCheckerThread
        
        worker.moveToThread(ccaCheckerThread)
        worker.finished.connect(ccaCheckerThread.quit)
        worker.finished.connect(worker.deleteLater)
        ccaCheckerThread.finished.connect(ccaCheckerThread.deleteLater)

        worker.sigDone.connect(self.ccaCheckerWorkerDone)
        worker.progress.connect(self.workerProgress)
        worker.critical.connect(self.ccaIntegrityWorkerCritical)
        worker.finished.connect(self.ccaCheckerWorkerClosed)
        worker.sigWarning.connect(self.warnCcaIntegrity)
        worker.sigFixWillDivide.connect(self.fixWillDivide)
        
        ccaCheckerThread.started.connect(worker.run)
        ccaCheckerThread.start()
        
        self.ccaCheckerRunning = True
        
        self.initCcaIntegrityChecker()
        
        self.logger.info('Cell cycle annotations integrity checker started.')

    # def startLinTreeIntegrityCheckerWorker(self): # not done
    #     if not hasattr(self, 'data'):
    #         return
        
    #     if not self.dataIsLoaded:
    #         return
        
    #     if self._isLinTreeIntegrityCheckedDisabled:
    #         return
        
    #     LinTreeCheckerThread = QThread()
    #     self.LinTreeCheckerMutex = QMutex()
    #     self.LinTreeCheckerWaitCond = QWaitCondition()
        
    #     worker = workers.LinTreeIntegrityCheckerWorker( # need to add workers.LinTreeIntegrityCheckerWorker
    #         self.LinTreeCheckerMutex, self.LinTreeCheckerWaitCond
    #     )
    #     self.LinTreeIntegrityCheckerWorker = worker
    #     self.LinTreeCheckerThread = LinTreeCheckerThread
        
    #     worker.moveToThread(LinTreeCheckerThread)
    #     worker.finished.connect(LinTreeCheckerThread.quit)
    #     worker.finished.connect(worker.deleteLater) # double check if this works after adding workers.LinTreeIntegrityCheckerWorker
    #     LinTreeCheckerThread.finished.connect(LinTreeCheckerThread.deleteLater)

    #     worker.destroyed.connect(self.LinTreeIntegrityCheckerWorkerDestroyed) # need to add
    #     worker.sigDone.connect(self.LinTreeCheckerWorkerDone) # need to add
    #     worker.progress.connect(self.workerProgress)
    #     worker.critical.connect(self.LinTreeIntegrityWorkerCritical) # need to add
    #     worker.finished.connect(self.LinTreeCheckerWorkerClosed) # need to add
    #     worker.sigWarning.connect(self.warnLinTreeIntegrity) # need to add
        
    #     LinTreeCheckerThread.started.connect(worker.run)
    #     LinTreeCheckerThread.start()
        
    #     self.logger.info('Lin Tree annotations integrity checker started.')
    
    def initCcaIntegrityChecker(self):
        posData = self.data[self.pos_i]
        for frame_i, data_frame_i in enumerate(posData.allData_li):
            lab = data_frame_i['labels']
            if lab is None:
                break
            
            cca_df = self.get_cca_df(frame_i, return_df=True)
            self.store_cca_df_checker(posData, frame_i, cca_df)
        
        self.enqCcaIntegrityChecker()
    
    def initCcaIntegrityChecker(self):
        posData = self.data[self.pos_i]
        for frame_i, data_frame_i in enumerate(posData.allData_li):
            lab = data_frame_i['labels']
            if lab is None:
                break
            
            cca_df = self.get_cca_df(frame_i, return_df=True)
            self.store_cca_df_checker(posData, frame_i, cca_df)
        
        self.enqCcaIntegrityChecker()
    
    def disableCcaIntegrityChecker(self):
        self.stopCcaIntegrityCheckerWorker()
    
    def stopCcaIntegrityCheckerWorker(self):
        try:
            self.ccaIntegrityCheckerWorker._stop()
        except Exception as err:
            pass
    
    def loadFluo_cb(self, checked=True, fluo_channels=None):
        if fluo_channels is None:
            posData = self.data[self.pos_i]
            ch_names = [
                ch for ch in self.ch_names if ch != self.user_ch_name
                and ch not in posData.loadedFluoChannels
            ]
            if not ch_names:
                msg = widgets.myMessageBox()
                txt = html_utils.paragraph(
                    'You already <b>loaded ALL channels</b>.<br><br>'
                    'To <b>change the overlaid channel</b> '
                    '<b>right-click</b> on the overlay button.'
                )
                msg.information(self, 'All channels are loaded', txt)
                return False
            selectFluo = widgets.QDialogListbox(
                'Select channel to load',
                'Select channel names to load:\n',
                ch_names, multiSelection=True, parent=self
            )
            selectFluo.exec_()

            if selectFluo.cancel:
                return False

            fluo_channels = selectFluo.selectedItemsText
            self.AutoPilotProfile.storeLoadedFluoChannels(fluo_channels)

        for p, posData in enumerate(self.data):
            # posData.ol_data = None
            for fluo_ch in fluo_channels:
                fluo_path, filename = self.getPathFromChName(fluo_ch, posData)
                if fluo_path is None:
                    self.criticalFluoChannelNotFound(fluo_ch, posData)
                    return False
                fluo_data, bkgrData = self.load_fluo_data(fluo_path)
                if fluo_data is None:
                    return False
                posData.loadedFluoChannels.add(fluo_ch)

                if posData.SizeT == 1:
                    fluo_data = fluo_data[np.newaxis]

                posData.fluo_data_dict[filename] = fluo_data
                posData.fluo_bkgrData_dict[filename] = bkgrData
                posData.ol_data_dict[filename] = fluo_data.copy()
                
        self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}')
        self.guiTabControl.addChannels([
            posData.user_ch_name, *posData.loadedFluoChannels
        ])
        return True
    
    def labelRoiCancelled(self):
        self.labelRoiRunning = False
        self.app.restoreOverrideCursor() 
        self.labelRoiItem.setPos((0,0))
        self.labelRoiItem.setSize((0,0))
        self.freeRoiItem.clear()
        self.logger.info('Magic labeller process cancelled.')

    def labelRoiCheckStartStopFrame(self):
        if not self.labelRoiTrangeCheckbox.isChecked():
            return True
        
        start_n = self.labelRoiStartFrameNoSpinbox.value()
        stop_n = self.labelRoiStopFrameNoSpinbox.value()
        if start_n <= stop_n:
            return True
        
        self.blinker = qutils.QControlBlink(
            self.labelRoiStopFrameNoSpinbox, 
            qparent=self
        )
        self.blinker.start()
        msg = widgets.myMessageBox()
        txt = html_utils.paragraph("""
            Stop frame number is less than start frame number!<br><br>
            What do you want to do?
        """)
        msg.warning(
            self, 'Stop frame number lower than start', txt, 
            buttonsTexts=('Cancel', 'Segment only current frame')
        )
        if msg.cancel:
            return False
        
        posData = self.data[self.pos_i]
        self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1)
        self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1)
        
    def getSecondChannelData(self):
        if self.secondChannelName is None:
            return

        posData = self.data[self.pos_i]

        fluo_ch = self.secondChannelName
        fluo_path, filename = self.getPathFromChName(fluo_ch, posData)
        if filename in posData.fluo_data_dict:
            fluo_data = posData.fluo_data_dict[filename]
        else:
            fluo_data, bkgrData = self.load_fluo_data(fluo_path)
            posData.fluo_data_dict[filename] = fluo_data
            posData.fluo_bkgrData_dict[filename] = bkgrData
        
        if self.labelRoiTrangeCheckbox.isChecked():
            start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1
            stop_frame_n = self.labelRoiStopFrameNoSpinbox.value()
            tRangeLen = stop_frame_n-start_frame_i
        else:
            tRangeLen = 1
        
        if tRangeLen > 1:
            # fluo_img_data = fluo_data[start_frame_i:stop_frame_n]
            if self.isSegm3D or posData.SizeZ == 1:
                return fluo_data
            else:
                T, Z, Y, X = fluo_data.shape
                secondChannelData = np.zeros((T, Y, X), dtype=fluo_data.dtype)
                for frame_i, fluo_img in enumerate(fluo_data):
                    secondChannelData[frame_i] = self.get_2Dimg_from_3D(
                        fluo_data, frame_i=frame_i
                    )
                return secondChannelData
        else:
            if posData.SizeT > 1:
                fluo_img_data = fluo_data[posData.frame_i]
            else:
                fluo_img_data = fluo_data
                
            if self.isSegm3D or posData.SizeZ == 1:
                return fluo_img_data
            else:
                return self.get_2Dimg_from_3D(fluo_img_data)
    
    def addActionsLutItemContextMenu(self, lutItem):      
        lutItem.gradient.menu.addSection('Visible channels: ')
        for action in self.overlayContextMenu.actions():
            if action.isSeparator():
                continue
            lutItem.gradient.menu.addAction(action)
        lutItem.gradient.menu.addSeparator()

        annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings')
        ID_menu = annotationMenu.addMenu('IDs')
        self.annotSettingsIDmenu = QActionGroup(annotationMenu)
        labID_action = QAction("Show label's ID")
        labID_action.setCheckable(True)
        labID_action.setChecked(True)
        labID_action.toggled.connect(self.annotLabelIDtreeToggled)
        treeID_action = QAction("Show tree's ID")
        treeID_action.setCheckable(True)
        treeID_action.toggled.connect(self.annotLabelIDtreeToggled)
        self.annotSettingsIDmenu.addAction(labID_action)
        self.annotSettingsIDmenu.addAction(treeID_action)
        ID_menu.addAction(labID_action)
        ID_menu.addAction(treeID_action)

        ID_menu = annotationMenu.addMenu('Generation number')
        self.annotSettingsGenNumMenu = QActionGroup(annotationMenu)
        gen_num_action = QAction("Show default generation number")
        gen_num_action.setCheckable(True)
        gen_num_action.setChecked(True)
        gen_num_action.toggled.connect(self.annotGenNumTreeToggled)
        tree_gen_num_action = QAction("Show tree generation number")
        tree_gen_num_action.setCheckable(True)
        tree_gen_num_action.toggled.connect(self.annotGenNumTreeToggled)
        self.annotSettingsGenNumMenu.addAction(gen_num_action)
        self.annotSettingsGenNumMenu.addAction(tree_gen_num_action)
        ID_menu.addAction(gen_num_action)
        ID_menu.addAction(tree_gen_num_action)

    def annotGenNumTreeToggled(self, checked):
        self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked)
    
    def annotLabelIDtreeToggled(self, checked):
        self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked)

    def setAnnotInfoMode(self, checked):
        if checked:
            for action in self.annotSettingsIDmenu.actions():
                if action.text().find('tree') != -1:
                    self.textAnnot[0].setLabelTreeAnnotationsEnabled(True)
                    action.setChecked(True)
                    break
            for action in self.annotSettingsGenNumMenu.actions():
                if action.text().find('tree') != -1:
                    self.textAnnot[0].setGenNumTreeAnnotationsEnabled(True)
                    action.setChecked(True)
                    break
        else:
            for action in self.annotSettingsIDmenu.actions():
                if action.text().find('tree') == -1:
                    action.setChecked(False)
                    self.textAnnot[0].setLabelTreeAnnotationsEnabled(False)
                    break
            for action in self.annotSettingsGenNumMenu.actions():
                if action.text().find('tree') == -1:
                    action.setChecked(False)
                    self.textAnnot[0].setGenNumTreeAnnotationsEnabled(False)
                    break
        self.setAllTextAnnotations()
    
    def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True):
        if sender is None:
            sender = self.sender()
        # First manually set exclusive with uncheckable
        clickedIDs = sender == self.annotIDsCheckbox
        clickedCca = sender == self.annotCcaInfoCheckbox
        clickedMBline = sender == self.drawMothBudLinesCheckbox
        if self.annotIDsCheckbox.isChecked() and clickedIDs:
            if self.annotCcaInfoCheckbox.isChecked():
                self.annotCcaInfoCheckbox.setChecked(False)
            if self.drawMothBudLinesCheckbox.isChecked():
                self.drawMothBudLinesCheckbox.setChecked(False)
        
        if self.annotCcaInfoCheckbox.isChecked() and clickedCca:
            if self.annotIDsCheckbox.isChecked():
                self.annotIDsCheckbox.setChecked(False)
            if self.drawMothBudLinesCheckbox.isChecked():
                self.drawMothBudLinesCheckbox.setChecked(False)
        
        if self.drawMothBudLinesCheckbox.isChecked() and clickedMBline:
            if self.annotIDsCheckbox.isChecked():
                self.annotIDsCheckbox.setChecked(False)
            if self.annotCcaInfoCheckbox.isChecked():
                self.annotCcaInfoCheckbox.setChecked(False)
        
        clickedCont = sender == self.annotContourCheckbox
        clickedSegm = sender == self.annotSegmMasksCheckbox
        if self.annotContourCheckbox.isChecked() and clickedCont:
            if self.annotSegmMasksCheckbox.isChecked():
                self.annotSegmMasksCheckbox.setChecked(False)
        
        if self.annotSegmMasksCheckbox.isChecked() and clickedSegm:
            if self.annotContourCheckbox.isChecked():
                self.annotContourCheckbox.setChecked(False)
        
        clickedDoNot = sender == self.drawNothingCheckbox
        if clickedDoNot:
            self.annotIDsCheckbox.setChecked(False)
            self.annotCcaInfoCheckbox.setChecked(False)
            self.annotContourCheckbox.setChecked(False)
            self.annotSegmMasksCheckbox.setChecked(False)
            self.drawMothBudLinesCheckbox.setChecked(False)
            self.annotNumZslicesCheckbox.setChecked(False)
        else:
            self.drawNothingCheckbox.setChecked(False)
        
        if sender == self.annotNumZslicesCheckbox:
            self.annotIDsCheckbox.setChecked(True)
            self.drawNothingCheckbox.setChecked(False)
        
        self.setDrawAnnotComboboxText(saveSettings=saveSettings)

    def setDisabledAnnotCheckBoxesLeft(self, disabled):
        self.annotIDsCheckbox.setDisabled(disabled)
        self.annotCcaInfoCheckbox.setDisabled(disabled)
        self.annotContourCheckbox.setDisabled(disabled)
        self.annotSegmMasksCheckbox.setDisabled(disabled)
        self.drawMothBudLinesCheckbox.setDisabled(disabled)
        self.annotNumZslicesCheckbox.setDisabled(disabled)
        self.drawNothingCheckbox.setDisabled(disabled)
    
    def setEnabledAnnotCheckBoxesLeftZdepthAxes(self):
        self.annotIDsCheckbox.setDisabled(False)
        self.annotContourCheckbox.setDisabled(False)
        self.annotIDsCheckbox.setChecked(True)
        self.annotContourCheckbox.setChecked(True)
        self.annotOptionClicked(
            sender=self.annotIDsCheckbox, saveSettings=False)
    
    def setDisabledAnnotCheckBoxesRight(self, disabled):
        self.annotIDsCheckboxRight.setDisabled(disabled)
        self.annotCcaInfoCheckboxRight.setDisabled(disabled)
        self.annotContourCheckboxRight.setDisabled(disabled)
        self.annotSegmMasksCheckboxRight.setDisabled(disabled)
        self.drawMothBudLinesCheckboxRight.setDisabled(disabled)
        self.annotNumZslicesCheckboxRight.setDisabled(disabled)
        self.drawNothingCheckboxRight.setDisabled(disabled)
    
    def annotOptionClickedRight(
            self, clicked=True, sender=None, saveSettings=True
        ):
        if sender is None:
            sender = self.sender()
        # First manually set exclusive with uncheckable
        clickedIDs = sender == self.annotIDsCheckboxRight
        clickedCca = sender == self.annotCcaInfoCheckboxRight
        clickedMBline = sender == self.drawMothBudLinesCheckboxRight
        if self.annotIDsCheckboxRight.isChecked() and clickedIDs:
            if self.annotCcaInfoCheckboxRight.isChecked():
                self.annotCcaInfoCheckboxRight.setChecked(False)
            if self.drawMothBudLinesCheckboxRight.isChecked():
                self.drawMothBudLinesCheckboxRight.setChecked(False)
        
        if self.annotCcaInfoCheckboxRight.isChecked() and clickedCca:
            if self.annotIDsCheckboxRight.isChecked():
                self.annotIDsCheckboxRight.setChecked(False)
            if self.drawMothBudLinesCheckboxRight.isChecked():
                self.drawMothBudLinesCheckboxRight.setChecked(False)
        
        if self.drawMothBudLinesCheckboxRight.isChecked() and clickedMBline:
            if self.annotIDsCheckboxRight.isChecked():
                self.annotIDsCheckboxRight.setChecked(False)
            if self.annotCcaInfoCheckboxRight.isChecked():
                self.annotCcaInfoCheckboxRight.setChecked(False)
        
        clickedCont = sender == self.annotContourCheckboxRight
        clickedSegm = sender == self.annotSegmMasksCheckboxRight
        if self.annotContourCheckboxRight.isChecked() and clickedCont:
            if self.annotSegmMasksCheckboxRight.isChecked():
                self.annotSegmMasksCheckboxRight.setChecked(False)
        
        if self.annotSegmMasksCheckboxRight.isChecked() and clickedSegm:
            if self.annotContourCheckboxRight.isChecked():
                self.annotContourCheckboxRight.setChecked(False)
        
        clickedDoNot = sender == self.drawNothingCheckboxRight
        if clickedDoNot:
            self.annotIDsCheckboxRight.setChecked(False)
            self.annotCcaInfoCheckboxRight.setChecked(False)
            self.annotContourCheckboxRight.setChecked(False)
            self.annotSegmMasksCheckboxRight.setChecked(False)
            self.drawMothBudLinesCheckboxRight.setChecked(False)
            self.annotNumZslicesCheckboxRight.setChecked(False)
        else:
            self.drawNothingCheckboxRight.setChecked(False)
        
        if sender == self.annotNumZslicesCheckboxRight:
            self.annotIDsCheckboxRight.setChecked(True)
            self.drawNothingCheckboxRight.setChecked(False)

        self.setDrawAnnotComboboxTextRight(saveSettings=saveSettings)

    def setAnnotOptionsCcaMode(self):
        self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1(
            return_value=True
        )
        self.annotCcaInfoCheckbox.setChecked(True)
        self.annotIDsCheckbox.setChecked(False)
        self.drawMothBudLinesCheckbox.setChecked(False)
        self.setDrawAnnotComboboxText()
    
    def setAnnotOptionsLin_treeMode(self):
        # self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1(
        #     return_value=True
        # )
        self.annotCcaInfoCheckbox.setChecked(True)
        self.annotIDsCheckbox.setChecked(False)
        self.drawMothBudLinesCheckbox.setChecked(False)
        self.setDrawAnnotComboboxText()
        self.showTreeInfoCheckbox.setChecked(True)
    
    def setDrawAnnotComboboxText(self, saveSettings=True):
        if self.annotIDsCheckbox.isChecked():
            if self.annotContourCheckbox.isChecked():
                t = 'Draw IDs and contours'
            elif self.annotSegmMasksCheckbox.isChecked():
                t = 'Draw IDs and overlay segm. masks'
            else:
                t = 'Draw only IDs'
        
        elif self.annotCcaInfoCheckbox.isChecked():
            if self.annotContourCheckbox.isChecked():
                t = 'Draw cell cycle info and contours'
            elif self.annotSegmMasksCheckbox.isChecked():
                t = 'Draw cell cycle info and overlay segm. masks'
            else:
                t = 'Draw only cell cycle info'
        
        elif self.annotSegmMasksCheckbox.isChecked():
            t = 'Draw only overlay segm. masks'

        elif self.annotContourCheckbox.isChecked():
            t = 'Draw only contours'
        
        elif self.drawMothBudLinesCheckbox.isChecked():
            t = 'Draw only mother-bud lines'
        
        elif self.drawNothingCheckbox.isChecked():
            t = 'Draw nothing'
        else:
            t = 'Draw nothing'

        if t == self.drawIDsContComboBox.currentText():
            self.drawIDsContComboBox_cb(0)
        
        self.drawIDsContComboBox.saveSettings = saveSettings
        self.drawIDsContComboBox.setCurrentText(t)

    def setDrawAnnotComboboxTextRight(self, saveSettings=True):
        if self.annotIDsCheckboxRight.isChecked():
            if self.annotContourCheckboxRight.isChecked():
                t = 'Draw IDs and contours'
            elif self.annotSegmMasksCheckboxRight.isChecked():
                t = 'Draw IDs and overlay segm. masks'
            else:
                t = 'Draw only IDs'
        
        elif self.annotCcaInfoCheckboxRight.isChecked():
            if self.annotContourCheckboxRight.isChecked():
                t = 'Draw cell cycle info and contours'
            elif self.annotSegmMasksCheckboxRight.isChecked():
                t = 'Draw cell cycle info and overlay segm. masks'
            else:
                t = 'Draw only cell cycle info'
        
        elif self.annotSegmMasksCheckboxRight.isChecked():
            t = 'Draw only overlay segm. masks'

        elif self.annotContourCheckboxRight.isChecked():
            t = 'Draw only contours'
        
        elif self.drawMothBudLinesCheckboxRight.isChecked():
            t = 'Draw only mother-bud lines'
        
        elif self.drawNothingCheckboxRight.isChecked():
            t = 'Draw nothing'
        else:
            t = 'Draw nothing'

        if t == self.annotateRightHowCombobox.currentText():
            self.annotateRightHowCombobox_cb(0)
        
        self.annotateRightHowCombobox.saveSettings = saveSettings
        self.annotateRightHowCombobox.setCurrentText(t)
        
    def getOverlayItems(self, channelName):
        imageItem = widgets.OverlayImageItem()
        imageItem.setOpacity(0.5)
        imageItem.channelName = channelName

        lutItem = widgets.myHistogramLUTitem(
            parent=self, name='image', axisLabel=channelName
        )
        imageItem.lutItem = lutItem
        for action in lutItem.rescaleActionGroup.actions():
            if action.text() == self.defaultRescaleIntensHow:
                action.setChecked(True)
            break
        
        lutItem.removeAddScaleBarAction()
        lutItem.removeAddTimestampAction()
        lutItem.restoreState(self.df_settings)
        lutItem.setImageItem(imageItem)
        lutItem.vb.raiseContextMenu = lambda x: None
        initColor = self.overlayColors[channelName]
        self.initColormapOverlayLayerItem(initColor, lutItem)
        lutItem.addOverlayColorButton(initColor, channelName)
        lutItem.initColor = initColor
        lutItem.hide()

        lutItem.overlayColorButton.sigColorChanging.connect(
            self.changeOverlayColor
        )
        lutItem.overlayColorButton.sigColorChanged.connect(
            self.saveOverlayColor
        )

        lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW)

        lutItem.contoursColorButton.disconnect() 
        lutItem.contoursColorButton.clicked.connect(
            self.imgGrad.contoursColorButton.click
        )
        for act in lutItem.contLineWightActionGroup.actions():
            act.toggled.connect(self.contLineWeightToggled)
        
        lutItem.mothBudLineColorButton.disconnect() 
        lutItem.mothBudLineColorButton.clicked.connect(
            self.imgGrad.mothBudLineColorButton.click
        )
        for act in lutItem.mothBudLineWightActionGroup.actions():
            act.toggled.connect(self.mothBudLineWeightToggled)

        lutItem.textColorButton.disconnect()
        lutItem.textColorButton.clicked.connect(
            self.editTextIDsColorAction.trigger
        )

        lutItem.defaultSettingsAction.triggered.connect(
            self.restoreDefaultSettings
        )
        lutItem.labelsAlphaSlider.valueChanged.connect(
            self.setValueLabelsAlphaSlider
        )
        lutItem.sigRescaleIntes.connect(
            partial(self.rescaleIntensitiesLut, imageItem=imageItem)
        )
        
        self.rescaleIntensChannelHowMapper[channelName] = (
            'Rescale each 2D image'
        )

        self.addActionsLutItemContextMenu(lutItem)

        alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem)

        return imageItem, lutItem, alphaScrollBar
    
    def addAlphaScrollbar(self, channelName, imageItem):
        alphaScrollBar = widgets.ScrollBar(Qt.Horizontal)
        
        label = QLabel(f'Alpha {channelName}')
        label.setFont(_font)
        label.hide()
        alphaScrollBar.imageItem = imageItem
        alphaScrollBar.label = label
        alphaScrollBar.setFixedHeight(self.h)
        alphaScrollBar.hide()
        alphaScrollBar.setMinimum(0)
        alphaScrollBar.setMaximum(40)
        alphaScrollBar.setValue(20)
        alphaScrollBar.setToolTip(
            f'Control the alpha value of the overlaid channel {channelName}.\n'
            'alpha=0 results in NO overlay,\n'
            'alpha=1 results in only fluorescence data visible'
        )
        self.bottomLeftLayout.addWidget(
            alphaScrollBar.label, self.alphaScrollbarRow, 0, 
            alignment=Qt.AlignRight
        )
        self.bottomLeftLayout.addWidget(
            alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2
        )

        alphaScrollBar.valueChanged.connect(self.setOpacityOverlayLayersItems)

        self.alphaScrollbarRow += 1
        return alphaScrollBar
    
    def setValueLabelsAlphaSlider(self, value):
        self.imgGrad.labelsAlphaSlider.setValue(value)
        self.updateLabelsAlpha(value)
    
    def setOverlayLabelsItemsVisible(self, checked):  
        for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items():
            items = self.overlayLabelsItems[_segmEndname]
            gradItem = items[-1]
            gradItem.hide()
        
        if checked:
            segmEndname = self.sender().text()
            gradItem = self.overlayLabelsItems[segmEndname][-1]
            gradItem.show()
    
    def setRetainSizePolicyLutItems(self):
        if not self.retainSizeLutItems:
            return
        for channel, items in self.overlayLayersItems.items():
            _, lutItem, alphaSB = items
            myutils.setRetainSizePolicy(lutItem, retain=True)
        QTimer.singleShot(300, self.autoRange)

    def setOverlayItemsVisible(self):
        for channel, items in self.overlayLayersItems.items():
            _, lutItem, alphaSB = items
            if not self.overlayButton.isChecked():
                lutItem.hide()
                alphaSB.hide()
                alphaSB.label.hide()
            elif channel in self.checkedOverlayChannels:
                lutItem.show()
                alphaSB.show()
                alphaSB.label.show()
            else:
                lutItem.hide()
                alphaSB.hide()
                alphaSB.label.hide()

    def initColormapOverlayLayerItem(self, foregrColor, lutItem):
        if self.invertBwAction.isChecked():
            bkgrColor = (255,255,255,255)
        else:
            bkgrColor = (0,0,0,255)
        gradient = colors.get_pg_gradient((bkgrColor, foregrColor))
        lutItem.setGradient(gradient)
    
    def setOpacityOverlayLayersItems(self, value, imageItem=None):
        if imageItem is None:
            imageItem = self.sender().imageItem
            alpha = value/self.sender().maximum()
        else:
            alpha = value
        imageItem.setOpacity(alpha)
        
    def showInExplorer_cb(self):
        posData = self.data[self.pos_i]
        path = posData.images_path
        myutils.showInExplorer(path)

    def zSliceAbsent(self, filename, posData):
        self.app.restoreOverrideCursor()
        SizeZ = posData.SizeZ
        chNames = posData.chNames
        filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique()
        chNamesPresent = [
            ch for ch in chNames
            for file in filenamesPresent
            if file.endswith(ch) or file.endswith(f'{ch}_aligned')
        ]
        win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent)
        win.exec_()
        if win.cancel:
            self.worker.abort = True
            self.waitCond.wakeAll()
            return
        if win.useMiddleSlice:
            user_ch_name = filename[len(posData.basename):]
            for _posData in self.data:
                if _posData is None:
                    continue
                _, filename = self.getPathFromChName(user_ch_name, _posData)
                df = myutils.getDefault_SegmInfo_df(_posData, filename)
                _posData.segmInfo_df = pd.concat([df, _posData.segmInfo_df])
                unique_idx = ~_posData.segmInfo_df.index.duplicated()
                _posData.segmInfo_df = _posData.segmInfo_df[unique_idx]
                _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path)
        elif win.useSameAsCh:
            user_ch_name = filename[len(posData.basename):]
            for _posData in self.data:
                if _posData is None:
                    continue
                _, srcFilename = self.getPathFromChName(
                    win.selectedChannel, _posData
                )
                cellacdc_df = _posData.segmInfo_df.loc[srcFilename].copy()
                _, dstFilename = self.getPathFromChName(user_ch_name, _posData)
                if dstFilename is None:
                    self.worker.abort = True
                    self.waitCond.wakeAll()
                    return
                dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename)
                for z_info in cellacdc_df.itertuples():
                    frame_i = z_info.Index
                    zProjHow = z_info.which_z_proj
                    if zProjHow == 'single z-slice':
                        src_idx = (srcFilename, frame_i)
                        if _posData.segmInfo_df.at[src_idx, 'resegmented_in_gui']:
                            col = 'z_slice_used_gui'
                        else:
                            col = 'z_slice_used_dataPrep'
                        z_slice = _posData.segmInfo_df.at[src_idx, col]
                        dst_idx = (dstFilename, frame_i)
                        dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice
                        dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice
                _posData.segmInfo_df = pd.concat([dst_df, _posData.segmInfo_df])
                unique_idx = ~_posData.segmInfo_df.index.duplicated()
                _posData.segmInfo_df = _posData.segmInfo_df[unique_idx]
                _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path)
        elif win.runDataPrep:
            user_ch_file_paths = []
            user_ch_name = filename[len(self.data[self.pos_i].basename):]
            for _posData in self.data:
                if _posData is None:
                    continue
                user_ch_path = load.get_filename_from_channel(
                    _posData.images_path, user_ch_name
                )
                if user_ch_path is None:
                    self.worker.abort = True
                    self.waitCond.wakeAll()
                    return
                user_ch_file_paths.append(user_ch_path)
                exp_path = os.path.dirname(_posData.pos_path)

            dataPrepWin = dataPrep.dataPrepWin()
            dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint)
            dataPrepWin.titleText = (
            """
            Select z-slice (or projection) for each frame/position.<br>
            Once happy, close the window.
            """)
            dataPrepWin.show()
            dataPrepWin.initLoading()
            dataPrepWin.SizeT = self.data[0].SizeT
            dataPrepWin.SizeZ = self.data[0].SizeZ
            dataPrepWin.metadataAlreadyAsked = True
            self.logger.info(f'Loading channel {user_ch_name} data...')
            dataPrepWin.loadFiles(
                exp_path, user_ch_file_paths, user_ch_name
            )
            dataPrepWin.startAction.setDisabled(True)
            dataPrepWin.onlySelectingZslice = True

            loop = QEventLoop(self)
            dataPrepWin.loop = loop
            loop.exec_()

        self.waitCond.wakeAll()

    def showSetMeasurements(self, checked=False):
        try:
            df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path)
            favourite_funcs = df_favourite_funcs['favourite_func_name'].to_list()
        except Exception as e:
            favourite_funcs = None

        posData = self.data[self.pos_i]
        allPos_acdc_df_cols = set()
        for _posData in self.data:
            for frame_i, data_dict in enumerate(_posData.allData_li):
                acdc_df = data_dict['acdc_df']
                if acdc_df is None:
                    continue
                
                allPos_acdc_df_cols.update(acdc_df.columns)
        loadedChNames = posData.setLoadedChannelNames(returnList=True)
        loadedChNames.insert(0, self.user_ch_name)
        notLoadedChNames = [c for c in self.ch_names if c not in loadedChNames]
        self.notLoadedChNames = notLoadedChNames
        self.measurementsWin = apps.SetMeasurementsDialog(
            loadedChNames, notLoadedChNames, posData.SizeZ > 1, self.isSegm3D,
            favourite_funcs=favourite_funcs, 
            allPos_acdc_df_cols=list(allPos_acdc_df_cols),
            acdc_df_path=posData.images_path, posData=posData,
            addCombineMetricCallback=self.addCombineMetric,
            allPosData=self.data, parent=self, 
            state=self.setMeasWinState
        )
        self.measurementsWin.sigClosed.connect(self.setMeasurements)
        self.measurementsWin.show()

    def setMeasurements(self):
        posData = self.data[self.pos_i]
        if self.measurementsWin.delExistingCols:
            self.logger.info('Removing existing unchecked measurements...')
            delCols = self.measurementsWin.existingUncheckedColnames
            delRps = self.measurementsWin.existingUncheckedRps
            delCols_format = [f'  *  {colname}' for colname in delCols]
            delRps_format = [f'  *  {colname}' for colname in delRps]
            delCols_format.extend(delRps_format)
            delCols_format = '\n'.join(delCols_format)
            self.logger.info(delCols_format)
            for _posData in self.data:
                for frame_i, data_dict in enumerate(_posData.allData_li):
                    acdc_df = data_dict['acdc_df']
                    if acdc_df is None:
                        continue
                    
                    acdc_df = acdc_df.drop(columns=delCols, errors='ignore')
                    for col_rp in delRps:
                        drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1)
                        drop_cols_rp = drop_df_rp.columns
                        acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore')
                    _posData.allData_li[frame_i]['acdc_df'] = acdc_df
        self.setMeasWinState = self.measurementsWin.state()
        self.logger.info('Setting measurements...')
        self._setMetrics(self.measurementsWin)
        self.logger.info('Metrics successfully set.')
        self.measurementsWin = None

    def _setMetrics(self, measurementsWin):
        self.chNamesToSkip = []
        self.metricsToSkip = {chName:[] for chName in self.ch_names}
        self.metricsToSave = {chName:[] for chName in self.ch_names}
        self.calc_for_each_zslice_mapper = {}
        self.calc_size_for_each_zslice = False
        
        favourite_funcs = set()
        last_selected_groupboxes_measurements = load.read_last_selected_gb_meas(
            logger_func=self.logger.info
        )
        refChannel = measurementsWin.chNameGroupboxes[0].chName
        if refChannel not in last_selected_groupboxes_measurements:
            last_selected_groupboxes_measurements[refChannel] = []
        # Remove unchecked metrics and load checked not loaded channels
        for chNameGroupbox in measurementsWin.chNameGroupboxes:
            chName = chNameGroupbox.chName
            if not chNameGroupbox.isChecked():
                # Skip entire channel
                self.chNamesToSkip.append(chName)
                continue
            
            self.calc_for_each_zslice_mapper[chName] = (
                chNameGroupbox.isCalcForEachZsliceRequested()
            )
            last_selected_groupboxes_measurements[refChannel].append(
                chNameGroupbox.title()
            )
            if chName in self.notLoadedChNames:
                success = self.loadFluo_cb(fluo_channels=[chName])
                if not success:
                    continue
            for checkBox in chNameGroupbox.checkBoxes:
                colname = checkBox.text()
                if not checkBox.isChecked():
                    self.metricsToSkip[chName].append(colname)
                else:
                    self.metricsToSave[chName].append(colname)
                    func_name = colname[len(chName):]
                    favourite_funcs.add(func_name)

        self.calc_size_for_each_zslice = (
            measurementsWin.sizeMetricsQGBox.isCalcForEachZsliceRequested()
        )
        if not measurementsWin.sizeMetricsQGBox.isChecked():
            self.sizeMetricsToSave = []
        else:
            self.sizeMetricsToSave = []
            title = measurementsWin.sizeMetricsQGBox.title()
            last_selected_groupboxes_measurements[refChannel].append(title)
            for checkBox in measurementsWin.sizeMetricsQGBox.checkBoxes:
                if checkBox.isChecked():
                    self.sizeMetricsToSave.append(checkBox.text())
                    favourite_funcs.add(checkBox.text())

        if not measurementsWin.regionPropsQGBox.isChecked():
            self.regionPropsToSave = ()
        else:
            self.regionPropsToSave = []
            title = measurementsWin.regionPropsQGBox.title()
            last_selected_groupboxes_measurements[refChannel].append(title)
            for checkBox in measurementsWin.regionPropsQGBox.checkBoxes:
                if checkBox.isChecked():
                    self.regionPropsToSave.append(checkBox.text())
                    favourite_funcs.add(checkBox.text())
            self.regionPropsToSave = tuple(self.regionPropsToSave)

        if measurementsWin.chIndipendCustomeMetricsQGBox is not None:
            skipAll = (
                not measurementsWin.chIndipendCustomeMetricsQGBox.isChecked()
            )
            if not skipAll:
                title = measurementsWin.chIndipendCustomeMetricsQGBox.title()
                last_selected_groupboxes_measurements[refChannel].append(title)
            chIndipendCustomMetricsToSave = []
            win = measurementsWin
            checkBoxes = win.chIndipendCustomeMetricsQGBox.checkBoxes
            for checkBox in checkBoxes:
                if skipAll:
                    continue
    
                if checkBox.isChecked():
                    chIndipendCustomMetricsToSave.append(checkBox.text())           
                    favourite_funcs.add(checkBox.text())
            self.chIndipendCustomMetricsToSave = tuple(
                chIndipendCustomMetricsToSave
            )
        
        if measurementsWin.mixedChannelsCombineMetricsQGBox is not None:
            skipAll = (
                not measurementsWin.mixedChannelsCombineMetricsQGBox.isChecked()
            )
            if not skipAll:
                title = measurementsWin.mixedChannelsCombineMetricsQGBox.title()
                last_selected_groupboxes_measurements[refChannel].append(title)
            mixedChCombineMetricsToSkip = []
            win = measurementsWin
            checkBoxes = win.mixedChannelsCombineMetricsQGBox.checkBoxes
            for checkBox in checkBoxes:
                if skipAll:
                    mixedChCombineMetricsToSkip.append(checkBox.text())
                elif not checkBox.isChecked():
                    mixedChCombineMetricsToSkip.append(checkBox.text())
                else:             
                    favourite_funcs.add(checkBox.text())
            self.mixedChCombineMetricsToSkip = tuple(mixedChCombineMetricsToSkip)

        df_favourite_funcs = pd.DataFrame(
            {'favourite_func_name': list(favourite_funcs)}
        )
        df_favourite_funcs.to_csv(favourite_func_metrics_csv_path)

        load.save_last_selected_gb_meas(last_selected_groupboxes_measurements)

    def addCustomMetric(self, checked=False):
        txt = measurements.add_metrics_instructions()
        metrics_path = measurements.metrics_path
        msg = widgets.myMessageBox()
        msg.addShowInFileManagerButton(metrics_path, 'Show example...')
        title = 'Add custom metrics instructions'
        msg.information(self, title, txt, buttonsTexts=('Ok',))

    def addCombineMetric(self):
        posData = self.data[self.pos_i]
        isZstack = posData.SizeZ > 1
        win = apps.combineMetricsEquationDialog(
            self.ch_names, isZstack, self.isSegm3D, parent=self
        )
        win.sigOk.connect(self.saveCombineMetricsToPosData)
        win.exec_()
        win.sigOk.disconnect()

    def saveCombineMetricsToPosData(self, window):
        for posData in self.data:
            equationsDict, isMixedChannels = window.getEquationsDict()
            for newColName, equation in equationsDict.items():
                posData.addEquationCombineMetrics(
                    equation, newColName, isMixedChannels
                )
                posData.saveCombineMetrics()
        
        if self.measurementsWin is None:
            return
        
        self.measurementsWinState = self.measurementsWin.state()
        self.measurementsWin.close()
        self.showSetMeasurements()
        self.measurementsWin.restoreState(self.measurementsWinState)

    def labelRoiToEndFramesTriggered(self):
        posData = self.data[self.pos_i]
        self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT)
    
    def labelRoiFromCurrentFrameTriggered(self):
        posData = self.data[self.pos_i]
        self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1)
    
    def labelRoiViewCurrentModel(self):
        from . import config
        ini_path = os.path.join(
            settings_folderpath, 'last_params_segm_models.ini'
        )
        configPars = config.ConfigParser()
        configPars.read(ini_path)
        model_name = self.labelRoiModel.model_name
        txt = f'Model: <b>{model_name}</b>'
        SECTION = f'{model_name}.init'
        txt = f'{txt}<br><br>[Initialization parameters]<br>'
        for option in configPars.options(SECTION):
            value = configPars[SECTION][option]
            param_txt = f'<i>{option}</i> = {value}<br>'
            txt = f'{txt}{param_txt}'
        
        SECTION = f'{model_name}.segment'
        txt = f'{txt}<br>[Segmentation parameters]<br>'
        for option in configPars.options(SECTION):
            value = configPars[SECTION][option]
            param_txt = f'<i>{option}</i> = {value}<br>'
            txt = f'{txt}{param_txt}'
        
        win = apps.ViewTextDialog(txt, parent=self)
        win.exec_()

    def setMetricsFunc(self):
        posData = self.data[self.pos_i]
        (metrics_func, all_metrics_names, custom_func_dict, total_metrics,
        ch_indipend_custom_func_dict) = measurements.getMetricsFunc(posData)
        self.metrics_func = metrics_func
        self.all_metrics_names = all_metrics_names
        self.total_metrics = total_metrics
        self.custom_func_dict = custom_func_dict
        self.ch_indipend_custom_func_dict = ch_indipend_custom_func_dict

    def getLastTrackedFrame(self, posData):
        last_tracked_i = 0
        for frame_i, data_dict in enumerate(posData.allData_li):
            lab = data_dict['labels']
            if lab is None:
                frame_i -= 1
                break
        if frame_i > 0:
            return frame_i
        else:
            return last_tracked_i

    def computeVolumeRegionprop(self):
        if 'cell_vol_vox' not in self.sizeMetricsToSave:
            return

        # We compute the cell volume in the main thread because calling
        # skimage.transform.rotate in a separate thread causes crashes
        # with segmentation fault on macOS. I don't know why yet.
        self.logger.info('Computing cell volume...')
        end_i = self.save_until_frame_i
        pos_iter = tqdm(self.data, ncols=100)
        for p, posData in enumerate(pos_iter):
            if self.posToSave is not None:
                if posData.pos_foldername not in self.posToSave:
                    continue
                
            PhysicalSizeY = posData.PhysicalSizeY
            PhysicalSizeX = posData.PhysicalSizeX
            frame_iter = tqdm(
                posData.allData_li[:end_i+1], ncols=100, position=1, leave=False
            )
            for frame_i, data_dict in enumerate(frame_iter):
                lab = data_dict['labels']
                if lab is None:
                    break
                rp = data_dict['regionprops']
                obj_iter = tqdm(rp, ncols=100, position=2, leave=False)
                for i, obj in enumerate(obj_iter):
                    vol_vox, vol_fl = _calc_rot_vol(
                        obj, PhysicalSizeY, PhysicalSizeX
                    )
                    obj.vol_vox = vol_vox
                    obj.vol_fl = vol_fl
                posData.allData_li[frame_i]['regionprops'] = rp

    def askSaveOriginalSegm(self, isQuickSave=False):
        if isQuickSave:
            return "", True, True

        posData = self.data[self.pos_i]
        if not posData.whitelist:
            return "", True, True
        
        help_txt = html_utils.paragraph(f"""
            You have <b>whitelisted IDs</b> in the current position.<br>
            Do you want to save the <b>not whitelisted</b> segmentation data<br>
            This will allow you to <b>revisit the original segmentation</b>.<br>
            """)

        txt = html_utils.paragraph(f"""
            You have <b>whitelisted IDs</b> in the current position.<br>
            Do you want to save the <b>not whitelisted</b> segmentation data?<br>
            """)

        found_files = load.get_segm_files(posData.images_path)
        existingEndnames = load.get_endnames(
            posData.basename, found_files
        )

        segmFilename = os.path.basename(posData.segm_npz_path)
        segmFilename = f"{segmFilename[:-4]}_not_whitelisted"
        win = apps.filenameDialog(
            basename=posData.basename,
            hintText=txt,
            defaultEntry=segmFilename,
            existingNames=existingEndnames,
            helpText=help_txt, 
            allowEmpty=False,
            parent=self,
            title='Save not whitelisted segmentation data',
            addDoNotSaveButton=True
        )
        win.exec_()
        if win.cancel:
            return "", False, True
        if win.doNotSave:
            return "", True, True
        return win.entryText, True, False

    def askSaveLastVisitedCcaMode(self, isQuickSave=False):
         posData = self.data[self.pos_i]
         current_frame_i = posData.frame_i
         frame_i = 0
         last_tracked_i = 0
         self.save_until_frame_i = 0
         if self.isSnapshot:
             return True
         
         for frame_i, data_dict in enumerate(posData.allData_li):
             lab = data_dict['labels']
             if lab is None:
                 frame_i -= 1
                 break
         
         self.save_until_frame_i = frame_i
         self.last_tracked_i = frame_i
         
         if isQuickSave:
             return True
         
         last_cca_frame_i = self.navigateScrollBar.maximum()-1
         # Ask to save last visited frame or not
         txt = html_utils.paragraph(f"""
             You annotated the cell cycle stages up 
             until frame number {last_cca_frame_i+1}.<br><br>
             Enter <b>up to which frame number</b> you want to save the 
             cell cycle annotations:
         """)
         lastFrameDialog = apps.QLineEditDialog(
             title='Last annoated frame number to save', 
             defaultTxt=str(last_cca_frame_i+1),
             msg=txt, parent=self, allowedValues=(1, last_cca_frame_i+1),
             warnLastFrame=True, isInteger=True, stretchEntry=False,
             lastVisitedFrame=last_cca_frame_i+1
         )
         lastFrameDialog.exec_()
         if lastFrameDialog.cancel:
             return False
 
         last_save_cca_frame_i = lastFrameDialog.EntryID - 1
         if last_save_cca_frame_i < last_cca_frame_i:
             self.resetCcaFuture(last_cca_frame_i)
         
         return True
    
    def askSaveLastVisitedSegmMode(self, isQuickSave=False):
        posData = self.data[self.pos_i]
        current_frame_i = posData.frame_i
        frame_i = 0
        last_tracked_i = 0
        self.save_until_frame_i = 0
        if self.isSnapshot:
            return True

        for frame_i, data_dict in enumerate(posData.allData_li):
            lab = data_dict['labels']
            if lab is None:
                frame_i -= 1
                break

        if isQuickSave:
            self.save_until_frame_i = frame_i
            self.last_tracked_i = frame_i
            return True

        # Ask to save last visited frame or not
        txt = html_utils.paragraph(f"""
            You visualised and corrected segmentation and tracking data up 
            until frame number {frame_i+1}.<br><br>
            Enter <b>up to which frame number</b> you want to save data:
        """)
        lastFrameDialog = apps.QLineEditDialog(
            title='Last frame number to save', defaultTxt=str(frame_i+1),
            msg=txt, parent=self, allowedValues=(1, posData.SizeT),
            warnLastFrame=True, isInteger=True, stretchEntry=False,
            lastVisitedFrame=frame_i+1
        )
        lastFrameDialog.exec_()
        if lastFrameDialog.cancel:
            return False

        self.save_until_frame_i = lastFrameDialog.EntryID - 1
        if self.save_until_frame_i > frame_i:
            self.logger.info(
                f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...'
            )
            current_frame_i = posData.frame_i
            # User is requesting to save past the last visited frame -->
            # store data as if they were visited
            for i in range(frame_i+1, self.save_until_frame_i+1):
                posData.frame_i = i
                self.get_data()
                self.store_data(autosave=False)
            
            # Go back to current frame
            posData.frame_i = current_frame_i
            self.get_data()
        last_tracked_i = self.save_until_frame_i
        
        self.last_tracked_i = last_tracked_i
        return True

    def askSaveMetrics(self):
        txt = html_utils.paragraph(
        """
            Do you also want to <b>save the measurements</b> 
            (e.g., cell volume, mean, amount etc.)?<br><br>
            
            You can find <b>more information</b> by clicking on the 
            "Set measurements" button below <br>
            where you will be able to select which <b>measurements 
            you want to save</b>.<br><br>
            If you already set the measurements and you want to save them click "Yes".<br><br>
            
            NOTE: Saving metrics might be <b>slow</b>,
            we recommend doing it <b>only when you need it</b>.<br>
        """)
        msg = widgets.myMessageBox(
            parent=self, resizeButtons=False, wrapText=False
        )
        setMeasurementsButton = widgets.setPushButton('Set measurements...')
        _, yesButton, noButton, _ = msg.question(
            self, 'Save measurements?', txt, 
            buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton),
            showDialog=False
        )
        setMeasurementsButton.disconnect()
        setMeasurementsButton.clicked.connect(self.showSetMeasurements)
        msg.exec_()
        save_metrics = msg.clickedButton == yesButton
        return save_metrics, msg.cancel

    def askSelectPos(self, action='to save'):
        last_pos = 1
        for p, posData in enumerate(self.data):
            acdc_df = posData.allData_li[0]['acdc_df']
            if acdc_df is None:
                last_pos = p
                break
        else:
            last_pos = len(self.data)

        items = [posData.pos_foldername for posData in self.data]
        selectPosWin = widgets.QDialogListbox(
            f'Select Positions {action}', f'Select Positions {action}:\n',
            items, multiSelection=True, parent=self,
            preSelectedItems=items[:last_pos]
        )
        selectPosWin.exec_()
        if selectPosWin.cancel:
            return
        
        return selectPosWin.selectedItemsText
    
    def askPosToSave(self):
        return self.askSelectPos()

    def saveMetricsCritical(self, traceback_format):
        print('\n====================================')
        self.logger.exception(traceback_format)
        print('====================================\n')
        self.logger.info('Warning: calculating metrics failed see above...')
        print('------------------------------')

        msg = widgets.myMessageBox(wrapText=False)
        err_msg = html_utils.paragraph(f"""
            Error <b>while saving metrics</b>.<br><br>
            More details below or in the terminal/console.<br><br>
            Note that the error details from this session are also saved
            in the file<br>
            {self.log_path}<br><br>
            Please <b>send the log file</b> when reporting a bug, thanks!
        """)
        msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...')
        msg.setDetailedText(traceback_format, visible=True)
        msg.critical(self, 'Critical error while saving metrics', err_msg)

        self.is_error_state = True
        self.waitCond.wakeAll()

    def saveAsData(self, checked=True):
        try:
            posData = self.data[self.pos_i]
        except AttributeError:
            return

        existingFilenames = set()
        for _posData in self.data:
            segm_files = load.get_segm_files(_posData.images_path)
            _existingEndnames = load.get_endnames(
                _posData.basename, segm_files
            )
            existingFilenames.update([
                f'{_posData.basename}{endname}.npz' 
                for endname in _existingEndnames
            ])
        posData = self.data[self.pos_i]
        if posData.basename.endswith('_'):
            basename = f'{posData.basename}segm'
        else:
            basename = f'{posData.basename}_segm'
        win = apps.filenameDialog(
            basename=basename,
            hintText='Insert a <b>filename</b> for the segmentation file:<br>',
            existingNames=existingFilenames
        )
        win.exec_()
        if win.cancel:
            return

        for posData in self.data:
            posData.setFilePaths(new_endname=win.entryText)

        self.setStatusBarLabel()
        self.saveData()

    def startExportToVideoWorker(self, preferences):
        self.isExportingVideo = True
        self.setDisabled(True)
        
        self.progressWin = apps.QDialogWorkerProgress(
            title='Exporting to video', parent=self.mainWin,
            pbarDesc='Exporting to video...'
        )
        self.progressWin.show(self.app)
        self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num']
        self.numFramesExported = 0
        self.progressWin.mainPbar.setMaximum(
            preferences['stop_nav_var_num'] 
            - preferences['start_nav_var_num'] + 1
        )
        self.exportToVideoPreferences = preferences
        
        self.store_data()
        posData = self.data[self.pos_i]
        if self.exportToVideoPreferences['is_timelapse']:
            # Go to requested start frame 
            posData.frame_i = preferences['start_nav_var_num'] - 1           
            self.get_data()
            self.updateAllImages()
            self.exportToVideoNavVarIdxToRestore = posData.frame_i
        else:
            self.update_z_slice(preferences['start_nav_var_num'] - 1)
            self.exportToVideoNavVarIdxToRestore = (
                self.zSliceScrollBar.sliderPosition()
            )
        self.exportToVideoCurrentNavVarIdx = (
            preferences['start_nav_var_num'] - 1
        )
        
        self.exportToVideoImageExporter = exporters.ImageExporter(
            self.ax1, 
            save_pngs=preferences['save_pngs'], 
            dpi=preferences['dpi']
        )
        self.exportToVideoExporter = exporters.VideoExporter(
            preferences['avi_filepath'], preferences['fps']
        )
        
        QTimer.singleShot(200, self.updateAndExportFrame)
    
    def updateAndExportFrame(self):
        if self.exportToVideoCurrentNavVarIdx == self.exportToVideoStopNavVarNum:
            self.progressWin.mainPbar.setMaximum(0)
            self.progressWin.mainPbar.setValue(0)
            QTimer.singleShot(50, self.exportingFramesFinished)
            return
        
        posData = self.data[self.pos_i]
        if self.exportToVideoPreferences['is_timelapse']:
            self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1)                 
        else:
            self.update_z_slice(self.exportToVideoCurrentNavVarIdx)
            
        success = self.exportFrame()
        if success is None:
            self.exportingVideoCritical()
            return
        
        self.exportToVideoCurrentNavVarIdx += 1
        self.progressWin.mainPbar.update(1)

        QTimer.singleShot(50, self.updateAndExportFrame)
    
    @exception_handler
    def exportFrame(self):
        nd = self.exportToVideoPreferences['num_digits']
        idx = str(self.exportToVideoCurrentNavVarIdx).zfill(nd)
        filename = self.exportToVideoPreferences['filename']
        png_filename = f'{idx}_{filename}.png'
        pngs_folderpath = self.exportToVideoPreferences['pngs_folderpath']
        
        png_filepath = os.path.join(pngs_folderpath, png_filename)
        img_bgr = self.exportToVideoImageExporter.export(png_filepath)
        self.exportToVideoExporter.add_frame(img_bgr)
        return True
    
    def exportingVideoCritical(self):
        self.setDisabled(False)
        
        self.progressWin.workerFinished = True
        self.progressWin.close()
        self.progressWin = None
        
        self.logger.info('Exporting video process failed.')
    
    def exportingFramesFinished(self):
        if not self.exportToVideoPreferences['save_pngs']:
            self.logger.info('Removing PNGs...')
            try:
                shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath'])
            except Exception as err:
                pass
            
        self.logger.info('Saving video...')
        
        self.exportToVideoExporter.release()
        
        # Run ffmpeg new process
        conversion_to_mp4_successful = True
        if self.exportToVideoPreferences['filepath'].endswith('.mp4'):
            try:
                self.exportToVideoExporter.avi_to_mp4()
                try:
                    os.remove(self.exportToVideoPreferences['avi_filepath'])
                except Exception as err:
                    pass
            except Exception as err:
                self.logger.exception(traceback.format_exc())
                self.logger.info(
                    'Conversion to MP4 failed. See traceback above.'
                )
                conversion_to_mp4_successful = False
                self.exportToVideoPreferences['filepath'] = (
                    self.exportToVideoExporter._avi_filepath
                )
        
        self.exportToVideoFinished(conversion_to_mp4_successful)
        
    def exportToVideoFinished(self, conversion_to_mp4_successful):
        self.progressWin.workerFinished = True
        self.progressWin.close()
        self.progressWin = None
        
        # Back to current frame
        if self.exportToVideoPreferences['is_timelapse']:
            posData = self.data[self.pos_i]
            posData.frame_i = self.exportToVideoNavVarIdxToRestore                 
            self.get_data()
            self.store_data()
            self.updateAllImages()
            self.navigateScrollBar.setSliderPosition(posData.frame_i+1)
            self.navSpinBox.setValue(posData.frame_i+1)
        else:
            self.update_z_slice(self.exportToVideoNavVarIdxToRestore)
        
        self.setDisabled(False)
        self.isExportingVideo = False
        
        prompts.exportToVideoFinished(
            self.exportToVideoPreferences, conversion_to_mp4_successful, 
            qparent=self
        )
    
    def exportAddScaleBar(self, checked):
        self.addScaleBarAction.setChecked(checked)
    
    def exportToVideoAddTimestamp(self, checked):
        self.addTimestampAction.setChecked(checked)
    
    def askTimelapseOrZslicesVideo(self):
        txt = html_utils.paragraph("""
            Do you want to record a video of scrolling through the z-slices or 
            a Timelapse video?
        """)
        msg = widgets.myMessageBox(wrapText=False)
        _, timelapseButton = msg.question(
            self, 'Z-slices or Timelapse video?', txt,
            buttonsTexts=('Z-slices', 'Timelapse')
        )
        if msg.cancel:
            return 
        
        return msg.clickedButton == timelapseButton
    
    def exportToVideoTriggered(self):
        posData = self.data[self.pos_i]
        
        doTimelapseVideo = posData.SizeT > 1
        if posData.SizeT > 1 and posData.SizeZ > 1:
            doTimelapseVideo = self.askTimelapseOrZslicesVideo()
        
        if doTimelapseVideo is None:
            self.logger.info('Export to video process cancelled')
            return
        
        channels = [self.user_ch_name, *self.checkedOverlayChannels]
        mode = 'timelapse' if doTimelapseVideo else 'z_slices'
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        filename = f'{timestamp}_acdc_exported_{mode}_video'
        win = apps.ExportToVideoParametersDialog(
            channels,
            parent=self, 
            startFolderpath=posData.pos_path, 
            startFilename=filename, 
            startFrameNum=posData.frame_i+1, 
            SizeT=posData.SizeT, 
            SizeZ=posData.SizeZ,
            isTimelapseVideo=doTimelapseVideo,
            isScaleBarPresent=self.addScaleBarAction.isChecked(), 
            isTimestampPresent=self.addTimestampAction.isChecked(),
            rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper
        )
        win.sigAddScaleBar.connect(self.exportAddScaleBar)
        win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp)
        win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog)
        win.exec_()
        if win.cancel:
            self.logger.info('Export to video process cancelled')
            return
        
        cancel = _warnings.warnExportToVideo(qparent=self)
        if cancel:
            self.logger.info('Export to video process cancelled')
            return
        
        self.startExportToVideoWorker(win.selected_preferences)        

    def setViewRangeFromExportToImageDialog(self, viewRange, win=None):
        self.ax1.vb.sigRangeChanged.disconnect()
        xRange, yRange = viewRange
        self.ax1.vb.setRange(xRange=xRange, yRange=yRange)
        self.ax1.vb.sigRangeChanged.connect(
            win.updateViewRangeExportToImageDialog
        )
    
    def getZoomIDs(self, viewRange=None):
        if viewRange is None:
            viewRange = self.ax1.viewRange()
        
        lab = self.currentLab2D
        Y, X = lab.shape
        ((xmin, xmax), (ymin, ymax)) = viewRange
        if xmin <= 0 and ymin <= 0 and xmax >= X and ymax >= Y:
            posData = self.data[self.pos_i]
            return None
        
        xmin = xmin if xmin >= 0 else 0
        ymin = ymin if ymin >= 0 else 0
        xmax = xmax if xmax < X else X
        ymax = ymax if ymax < Y else Y
        
        zoomSlice = (
            slice(round(ymin), round(ymax)), 
            slice(round(xmin), round(xmax)), 
        )
        
        zoomLab = skimage.segmentation.clear_border(lab[zoomSlice])
        zoomRp = skimage.measure.regionprops(zoomLab)
        zoomIDs = [obj.label for obj in zoomRp]
        return zoomIDs
    
    def onSigUpdateCcaTableWindow(self, *args):
        if not self.isDataLoaded:
            return
        
        if self.ccaTableWin is None:
            return
        
        viewRange = self.ax1.viewRange()
        posData = self.data[self.pos_i]
        zoomIDs = self.getZoomIDs(viewRange=viewRange)
        
        self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs)
    
    @disableWindow
    def exportToImage(self, preferences):        
        filepath = preferences['filepath']
        self.logger.info(f'Saving image to "{filepath}"...')
        
        if filepath.endswith('.svg'):
            exporter = exporters.SVGExporter(self.ax1)
        else:
            exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi'])
        exporter.export(filepath)
        self.logger.info(f'Image saved.')
        
        self.setDisabled(False)
        prompts.exportToImageFinished(filepath, qparent=self)
    
    def exportToImageTriggered(self):
        posData = self.data[self.pos_i]
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        filename = f'{timestamp}_acdc_exported_image'
        win = apps.ExportToImageParametersDialog(
            parent=self, startFolderpath=posData.pos_path, 
            startFilename=filename, 
            startViewRange=self.ax1.viewRange(),
            isScaleBarPresent=self.addScaleBarAction.isChecked(), 
        )
        win.sigAddScaleBar.connect(self.exportAddScaleBar)
        win.sigRangeChanged.connect(
            partial(self.setViewRangeFromExportToImageDialog, win=win)
        )
        self.ax1.vb.sigRangeChanged.connect(
            win.updateViewRangeExportToImageDialog
        )
        win.exec_()
        self.ax1.vb.sigRangeChanged.disconnect()
        if win.cancel:
            self.logger.info('Export to image process cancelled')
            return
    
        self.exportToImage(win.selected_preferences)
    
    def saveDataPermissionError(self, err_msg):
        self.setDisabled(False, keepDisabled=False)
        self.activateWindow()
        msg = QMessageBox()
        msg.critical(self, 'Permission denied', err_msg, msg.Ok)
        self.waitCond.wakeAll()

    def saveDataProgress(self, text):
        self.logger.info(text)
        self.saveWin.progressLabel.setText(text)

    def saveDataCustomMetricsCritical(self, traceback_format, func_name):
        self.setDisabled(False, keepDisabled=False)
        self.activateWindow()
        self.logger.info('')
        _hl = '===================================='
        self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}')
        self.worker.customMetricsErrors[func_name] = traceback_format
    
    def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name):
        self.setDisabled(False, keepDisabled=False)
        self.activateWindow()
        self.logger.info('')
        warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.'
        _hl = '===================================='
        self.logger.info(f'{_hl}\n{warning}\n{_hl}')
        self.worker.customMetricsErrors[func_name] = warning
    
    def saveDataAddMetricsCritical(self, traceback_format, error_message):
        self.setDisabled(False, keepDisabled=False)
        self.activateWindow()
        self.logger.info('')
        _hl = '===================================='
        self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}')
        self.worker.addMetricsErrors[error_message] = traceback_format
    
    def saveDataRegionPropsCritical(self, traceback_format, error_message):
        self.setDisabled(False, keepDisabled=False)
        self.activateWindow()
        self.logger.info('')
        _hl = '===================================='
        self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}')
        self.worker.regionPropsErrors[error_message] = traceback_format

    def saveDataUpdateMetricsPbar(self, max, step):
        if max > 0:
            self.saveWin.metricsQPbar.setMaximum(max)
            self.saveWin.metricsQPbar.setValue(0)
        self.saveWin.metricsQPbar.setValue(
            self.saveWin.metricsQPbar.value()+step
        )

    def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0):
        if max >= 0:
            self.saveWin.QPbar.setMaximum(max)
        else:
            self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step)
            steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value()
            seconds = round(exec_time*steps_left)
            ETA = myutils.seconds_to_ETA(seconds)
            self.saveWin.ETA_label.setText(f'ETA: {ETA}')
    
    def quickSave(self):
        self.saveData(isQuickSave=True)
    
    def warnDifferentSegmChannel(
            self, loaded_channel, segm_channel_hyperparams, segmEndName
        ):
        txt = html_utils.paragraph(f"""
            You loaded the segmentation file ending with <code>_{segmEndName}.npz</code> 
            which corresponds to the channel 
            <code>{segm_channel_hyperparams}</code>.<br><br>
            However, <b>in this session you loaded the channel</b> 
            <code>{loaded_channel}</code>.<br><br>
            If you proceed with saving, the segmentation file ending with 
            <code>_{segmEndName}.npz</code> <b>will be OVERWRITTEN</b>.<br><br>
            Are you sure you want to proceed?
        """)
        msg = widgets.myMessageBox(showCentered=False, wrapText=False)
        msg.warning(
            self, 'WARNING: Potential for data loss', txt,
            buttonsTexts=('Cancel', 'Yes')
        )
        return msg.cancel

    def waitAutoSaveWorker(self, worker):
        if worker.isFinished or worker.isPaused or len(worker.dataQ) == 0:
            self.waitAutoSaveWorkerLoop.exit()
            self.waitAutoSaveWorkerTimer.stop()
            self.setStatusBarLabel(log=False)
        
    @exception_handler
    def saveData(self, checked=False, finishedCallback=None, isQuickSave=False):
        self.setDisabled(True, keepDisabled=True)

        self.lin_tree_ask_changes()

        self.store_data(autosave=False)
        self.applyDelROIs()
        self.store_data()
        self._isQuickSave = isQuickSave

        # Wait autosave worker to finish
        for worker, thread in self.autoSaveActiveWorkers:
            self.logger.info('Stopping autosaving process...')
            self.statusBarLabel.setText('Stopping autosaving process...')
            worker.abort()
            self.waitAutoSaveWorkerTimer = QTimer()
            self.waitAutoSaveWorkerTimer.timeout.connect(
                partial(self.waitAutoSaveWorker, worker)
            )
            self.waitAutoSaveWorkerTimer.start(100)
            self.waitAutoSaveWorkerLoop = QEventLoop()
            self.waitAutoSaveWorkerLoop.exec_()

        self.titleLabel.setText(
            'Saving data... (check progress in the terminal)', 
            color=self.titleColor
        )

        # Check channel name correspondence to warn
        posData = self.data[self.pos_i]
        lastSegmChannel, segmEndName = posData.getSegmentedChannelHyperparams()
        if lastSegmChannel != self.user_ch_name and lastSegmChannel:
            cancel = self.warnDifferentSegmChannel(
                self.user_ch_name, lastSegmChannel, segmEndName
            )
            if cancel:
                self.abortSavingInitialisation()
                self.setDisabled(False, keepDisabled=False)
                self.activateWindow()
                return True
            posData.updateSegmentedChannelHyperparams(self.user_ch_name)

        self.save_metrics = False
        if not isQuickSave:
            self.save_metrics, cancel = self.askSaveMetrics()
            if cancel:
                self.abortSavingInitialisation()
                self.setDisabled(False, keepDisabled=False)
                self.activateWindow()
                return True

        self.posToSave = None
        if self.isSnapshot and not isQuickSave and len(self.data) > 1:
            self.posToSave = self.askPosToSave()
            if self.posToSave is None:
                self.abortSavingInitialisation()
                self.setDisabled(False, keepDisabled=False)
                self.activateWindow()
                return True

        if isQuickSave:
            # Quick save only current pos
            self.posToSave = {self.data[self.pos_i].pos_foldername}
        
        if self.isSnapshot:
            self.store_data(mainThread=False)

        mode = self.modeComboBox.currentText()
        if mode == 'Cell cycle analysis':
            proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave)
            if not proceed:
                return
        else:
            proceed = self.askSaveLastVisitedSegmMode(isQuickSave=isQuickSave)
            if not proceed:
                return
        append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave)
        if not proceed:
            self.setDisabled(False, keepDisabled=False)
            self.activateWindow()
            return

        if self.save_metrics or mode == 'Cell cycle analysis':
            self.computeVolumeRegionprop()

        infoTxt = html_utils.paragraph(
            f'Saving {self.exp_path}...<br>', font_size='14px'
        )

        self.saveWin = apps.QDialogPbar(
            parent=self, title='Saving data', infoTxt=infoTxt
        )
        self.saveWin.setFont(_font)
        # if not self.save_metrics:
        self.saveWin.metricsQPbar.hide()
        self.saveWin.progressLabel.setText('Preparing data...')
        self.saveWin.show()

        # Set up separate thread for saving and show progress bar widget
        self.mutex = QMutex()
        self.waitCond = QWaitCondition()
        self.thread = QThread()
        self.worker = workers.saveDataWorker(self)
        self.worker.mode = mode
        self.worker.isQuickSave = isQuickSave
        self.worker.append_name_og_whitelist = append_name_og_whitelist
        self.worker.do_not_save_og_whitelist = do_not_save_og_whitelist

        self.worker.moveToThread(self.thread)

        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)

        # Custom signals
        self.worker.finished.connect(self.saveDataFinished)
        if finishedCallback is not None:
            self.worker.finished.connect(finishedCallback)
        self.worker.progress.connect(self.saveDataProgress)
        self.worker.sigLog.connect(self.workerLog)
        self.worker.progressBar.connect(self.saveDataUpdatePbar)
        # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar)
        self.worker.critical.connect(self.saveDataWorkerCritical)
        self.worker.customMetricsCritical.connect(
            self.saveDataCustomMetricsCritical
        )
        self.worker.sigCombinedMetricsMissingColumn.connect(
            self.saveDataCombinedMetricsMissingColumn
        )
        self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical)
        self.worker.regionPropsCritical.connect(
            self.saveDataRegionPropsCritical
        )
        self.worker.criticalPermissionError.connect(self.saveDataPermissionError)
        self.worker.askZsliceAbsent.connect(self.zSliceAbsent)
        self.worker.sigDebug.connect(self._workerDebug)

        self.thread.started.connect(self.worker.run)

        self.thread.start()
        
    def _workerDebug(self, stuff_to_debug):
        pass
        # from acdctools.plot import imshow
        # lab, frame_i, autoBkgr_masks = stuff_to_debug
        # autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks
        # imshow(lab, autoBkgr_mask)
        # self.worker.waitCond.wakeAll()
    
    def changeTextResolution(self):
        mode = 'high' if self.highLowResAction.isChecked() else 'low'
        self.logger.info(
            f'Switching to {mode} for the text annnotations...'
        )
        self.pxModeAction.setDisabled(not self.highLowResAction.isChecked())
        if not self.isDataLoaded:
            return
        
        self.setAllIDs()
        posData = self.data[self.pos_i]
        allIDs = posData.allIDs
        img_shape = self.img1.image.shape
        self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape)
        self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape)
        self.updateAllImages()
    
    def highLowResToggled(self, clicked=True):
        self.changeTextResolution()
    
    def autoSaveClose(self):
        for worker, thread in self.autoSaveActiveWorkers:
            worker._stop()
    
    def viewPreprocDataToggled(self, checked):
        self.img1.usePreprocessed = checked
        self.setImageImg1()

        if self.viewCombineChannelDataToggle.isChecked():
            self.viewCombineChannelDataToggle.toggled.disconnect()
            self.viewCombineChannelDataToggle.setChecked(False)
            self.viewCombineChannelDataToggle.toggled.connect(
                self.viewCombineChannelDataToggled
            )

    def viewCombineChannelDataToggled(self, checked):
        self.img1.useCombined = checked
        self.setImageImg1()

        if self.viewPreprocDataToggle.isChecked():
            self.viewPreprocDataToggle.toggled.disconnect()  
            self.viewPreprocDataToggle.setChecked(False)
            self.viewPreprocDataToggle.toggled.connect(
                self.viewPreprocDataToggled
            )
    
    def autoSaveToggled(self, checked):
        if not self.autoSaveActiveWorkers:
            self.gui_createAutoSaveWorker()
        
        if not self.autoSaveActiveWorkers:
            return
        
        worker, thread = self.autoSaveActiveWorkers[-1]
        worker.isAutoSaveON = checked
        # self.autoSaveClose()
        
        # if checked:
        #     self.gui_createAutoSaveWorker()
    
    def ccaIntegrCheckerToggled(self, checked):
        self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = (
            int(checked)
        )
        self.df_settings.to_csv(self.settings_csv_path)
        mode = self.modeComboBox.currentText()
        if mode != 'Cell cycle analysis':
            return
        
        if checked:
            self.startCcaIntegrityCheckerWorker()
        else:
            self.disableCcaIntegrityChecker()
    
    def warnErrorsCustomMetrics(self):
        win = apps.ComputeMetricsErrorsDialog(
            self.worker.customMetricsErrors, self.logs_path, 
            log_type='custom_metrics', parent=self
        )
        win.exec_()
    
    def warnErrorsAddMetrics(self):
        win = apps.ComputeMetricsErrorsDialog(
            self.worker.addMetricsErrors, self.logs_path, 
            log_type='standard_metrics', parent=self
        )
        win.exec_()
    
    def warnErrorsRegionProps(self):
        win = apps.ComputeMetricsErrorsDialog(
            self.worker.regionPropsErrors, self.logs_path, 
            log_type='region_props', parent=self
        )
        win.exec_()
    
    def askConcatenate(self):
        if self.mainWin is None:
            return
        
        if self._isQuickSave:
            return
        
        txt = html_utils.paragraph(f"""
            Do you want to <b>concatenate</b> the `acdc_output.csv` tables from 
            multiple Positions into <b>one single CSV file</b>?<br>
        """)
        msg = widgets.myMessageBox()
        noButton, yesButton = msg.question(
            self, 'Concatenate tables?', txt, buttonsTexts=('No', 'Yes')
        )
        if not msg.clickedButton == yesButton:
            return
        
        posData = self.data[self.pos_i]
        self.mainWin.launchConcatUtil(exp_folderpath=posData.exp_path)
        
    def updateSegmDataAutoSaveWorker(self):
        # Update savedSegmData in autosave worker
        posData = self.data[self.pos_i]
        for worker, thread in self.autoSaveActiveWorkers:
            worker.savedSegmData = posData.segm_data.copy()

    def saveDataFinished(self):
        self.setDisabled(False, keepDisabled=False)
        self.activateWindow()
        if self.saveWin.aborted or self.worker.abort:
            self.titleLabel.setText('Saving process cancelled.', color='r')
        elif self._isQuickSave:
            self.titleLabel.setText('Saved segmentation file and annotations')
        else:
            self.titleLabel.setText('Saved!')
        self.saveWin.workerFinished = True
        self.saveWin.close()

        if not self.closeGUI:
            # Update savedSegmData in autosave worker
            self.updateSegmDataAutoSaveWorker()

        if self.worker.addMetricsErrors:
           self.warnErrorsAddMetrics()    
        if self.worker.regionPropsErrors:
            self.warnErrorsRegionProps()
        if self.worker.customMetricsErrors:
            self.warnErrorsCustomMetrics()
        
        self.checkManageVersions()
        
        self.askConcatenate()
        
        if self.closeGUI:
            salute_string = myutils.get_salute_string()
            msg = widgets.myMessageBox()
            txt = html_utils.paragraph(
                'Data <b>saved!</b>. The GUI will now close.<br><br>'
                f'{salute_string}'
            )
            msg.information(self, 'Data saved', txt)
            self.close()

    def copyContent(self):
        pass

    def pasteContent(self):
        pass

    def cutContent(self):
        pass
    
    def showAbout(self):
        self.aboutWin = about.QDialogAbout(parent=self)
        self.aboutWin.show()

    def openLogFile(self):
        self.logger.info(f'Opening log file "{self.log_path}"...')
        myutils.showInExplorer(self.log_path)
    
    def showTipsAndTricks(self):
        self.welcomeWin = welcome.welcomeWin()
        self.welcomeWin.showAndSetSize()
        self.welcomeWin.showPage(self.welcomeWin.quickStartItem)

    def about(self):
        pass

    def openRecentFile(self, path):
        self.logger.info(f'Opening recent folder: {path}')
        self.addToRecentPaths(path, logger=self.logger)
        self.openFolder(exp_path=path)
    
    def _waitCloseAutoSaveWorker(self):
        didWorkersFinished = [True]
        for worker, thread in self.autoSaveActiveWorkers:
            if worker.isFinished:
                didWorkersFinished.append(True)
            else:
                didWorkersFinished.append(False)
        if all(didWorkersFinished):
            self.waitCloseAutoSaveWorkerLoop.stop()
        
    def abortSavingInitialisation(self):
        self.titleLabel.setText(
            'Saving data process cancelled.', color=self.titleColor
        )
        self.closeGUI = False
    
    @disableWindow
    def askSaveOnClosing(self, event):
        if not self.saveAction.isEnabled():
            return True
        if self.titleLabel.text == 'Saved!':
            return True
        if not self.isDataLoaded:
            return True
        
        msg = widgets.myMessageBox()
        txt = html_utils.paragraph('Do you want to <b>save before closing?</b>')
        _, noButton, yesButton = msg.question(
            self, 'Save?', txt,
            buttonsTexts=('Cancel', 'No', 'Yes')
        )
        if msg.cancel:
            event.ignore()
            return False
        
        if msg.clickedButton == yesButton:
            self.closeGUI = True
            QTimer.singleShot(100, self.saveAction.trigger)
            event.ignore()
            return False
        return True
    
    def clearMemory(self):
        if not hasattr(self, 'data'):
            return
        self.logger.info('Clearing memory...')
        for posData in self.data:
            try:
                del posData.img_data
            except Exception as e:
                pass
            try:
                del posData.segm_data
            except Exception as e:
                pass
            try:
                del posData.ol_data_dict
            except Exception as e:
                pass
            try:
                del posData.fluo_data_dict
            except Exception as e:
                pass
            try:
                del posData.ol_data
            except Exception as e:
                pass
        del self.data

    def setUncheckedPointsLayers(self):
        self.togglePointsLayerAction.setChecked(False)
        self.magicPromptsToolButton.setChecked(False)
    
    def clearHighlightedID(self):
        try:
            self.updateLostContoursImage(ax=0, delROIsIDs=None)
        except Exception as err:
            pass
        
        if self.highlightedID == 0:
            return
        
        self.highlightedID = 0
        self.guiTabControl.highlightCheckbox.setChecked(False)
        self.guiTabControl.highlightSearchedCheckbox.setChecked(False)
        self.setHighlightID(False)
    
    def onEscape(self, isTypingIDFunctionChecked=False):
        if self.keepIDsButton.isChecked() and self.keptObjectsIDs:
            self.keptObjectsIDs = widgets.KeptObjectIDsList(
                self.keptIDsLineEdit, self.keepIDsConfirmAction
            )
            self.highlightHoverIDsKeptObj(0, 0, hoverID=0)
            QTimer.singleShot(300, self.autoRange)
            return

        if self.brushButton.isChecked() and self.typingEditID:
            self.autoIDcheckbox.setChecked(True)
            self.typingEditID = False
            QTimer.singleShot(300, self.autoRange)
            return
        
        if isTypingIDFunctionChecked and self.typingEditID:
            self.typingEditID = False
            QTimer.singleShot(300, self.autoRange)
            return
        
        if self.labelRoiButton.isChecked() and self.isMouseDragImg1:
            self.isMouseDragImg1 = False
            self.labelRoiItem.setPos((0,0))
            self.labelRoiItem.setSize((0,0))
            self.freeRoiItem.clear()
            QTimer.singleShot(300, self.autoRange)
            return
        
        self.setUncheckedAllButtons()
        self.setUncheckedAllCustomAnnotButtons()
        self.setUncheckedPointsLayers()
        if hasattr(self, 'tempLayerImg1'):
            self.tempLayerImg1.setImage(self.emptyLab)
        self.isMouseDragImg1 = False
        self.typingEditID = False
        self.clearHighlightedID()
        try:
            self.polyLineRoi.clearPoints()
        except Exception as e:
            pass
        QTimer.singleShot(300, self.autoRange)
    
    def askCloseAllWindows(self):
        txt = html_utils.paragraph("""
            There are other open windows that were created from this window.
            <br><br>
            If you proceed, the <b>other windows will be closed too.<br>
        """)
        msg = widgets.myMessageBox(wrapText=False)
        msg.warning(
            self, 'Open windows', txt, 
            buttonsTexts=('Cancel', 'Ok, close now')
        )
        return msg.cancel
    
    def stopPreprocWorker(self):
        self.logger.info('Closing pre-processing worker...')
        try:
            self.preprocWorker.stop()
        except Exception as err:
            pass

    def stopCombineWorker(self):
        self.logger.info('Closing combine worker...')
        try:
            self.combineWorker.stop()
        except Exception as err:
            pass
    
    def closeEvent(self, event):
        self.setDisabled(False)
        cancel = self.checkAskSavePointsLayers()
        if cancel:
            event.ignore()
            return
    
        self.onEscape()
        self.saveWindowGeometry()
        
        if self.newWindows:
            cancel = self.askCloseAllWindows()
            if cancel:
                event.ignore()
                return

            for window in self.newWindows:
                window.close()

        if self.slideshowWin is not None:
            self.slideshowWin.close()
        if self.ccaTableWin is not None:
            self.ccaTableWin.close()
        
        proceed = self.askSaveOnClosing(event)
        if not proceed:
            event.ignore()
            return

        self.autoSaveClose()
        
        if self.autoSaveActiveWorkers:
            progressWin = apps.QDialogWorkerProgress(
                title='Closing autosaving worker', parent=self,
                pbarDesc='Closing autosaving worker...'
            )
            progressWin.show(self.app)
            progressWin.mainPbar.setMaximum(0)
            self.waitCloseAutoSaveWorkerLoop = qutils.QWhileLoop(
                self._waitCloseAutoSaveWorker, period=250
            )
            self.waitCloseAutoSaveWorkerLoop.exec_()
            progressWin.workerFinished = True
            progressWin.close()
        
        self.stopPreprocWorker()
        self.stopCombineWorker()
        self.stopCcaIntegrityCheckerWorker()
        
        # Close the inifinte loop of the thread
        if self.lazyLoader is not None:
            self.lazyLoader.exit = True
            self.lazyLoaderWaitCond.wakeAll()
            self.waitReadH5cond.wakeAll()
        
        if self.storeStateWorker is not None:
            # Close storeStateWorker
            self.storeStateWorker._stop()
            while self.storeStateWorker.isFinished:
                time.sleep(0.05)
        
        # Block main thread while separate threads closes
        time.sleep(0.1)

        self.clearMemory()

        self.logger.info('Closing GUI logger...')
        self.logger.close()
        
        if self.lazyLoader is None:
            self.sigClosed.emit(self)
        
        gc.collect()
    
    def storeManualSeparateDrawMode(self, mode):
        self.df_settings.at['manual_separate_draw_mode', 'value'] = mode
        self.df_settings.to_csv(self.settings_csv_path)

    def readSettings(self):
        settings = QSettings('schmollerlab', 'acdc_gui')
        if settings.value('geometry') is not None:
            self.restoreGeometry(settings.value("geometry"))
        # self.restoreState(settings.value("windowState"))

    def saveWindowGeometry(self):
        settings = QSettings('schmollerlab', 'acdc_gui')
        settings.setValue("geometry", self.saveGeometry())
        # settings.setValue("windowState", self.saveState())

    def storeDefaultAndCustomColors(self):
        c = self.overlayButton.palette().button().color().name()
        self.defaultToolBarButtonColor = c
        self.doublePressKeyButtonColor = '#fa693b'

    def initPixelSizePropsDockWidget(self):
        posData = self.data[self.pos_i]
        PhysicalSizeX = posData.PhysicalSizeX
        PhysicalSizeY = posData.PhysicalSizeY
        PhysicalSizeZ = posData.PhysicalSizeZ
        self.guiTabControl.initPixelSize(
            PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ
        )
    
    def showPropsDockWidget(self, checked=False):
        if self.showPropsDockButton.isExpand:
            self.propsDockWidget.setVisible(False)
            self.setHighlightID(False)
        else:
            self.highlightedID = self.guiTabControl.propsQGBox.idSB.value()
            if self.isSegm3D:
                self.guiTabControl.propsQGBox.cellVolVox3D_SB.show()
                self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.show()
                self.guiTabControl.propsQGBox.cellVolFl3D_DSB.show()
                self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.show()
            else:
                self.guiTabControl.propsQGBox.cellVolVox3D_SB.hide()
                self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.hide()
                self.guiTabControl.propsQGBox.cellVolFl3D_DSB.hide()
                self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.hide()

            self.propsDockWidget.setVisible(True)
            self.propsDockWidget.setEnabled(True)
        self.updateAllImages()
    
    def showEvent(self, event):
        if self.mainWin is not None:
            if not self.mainWin.isMinimized():
                return
            self.mainWin.showAllWindows()
        # self.setFocus()
        self.activateWindow()
    
    def super_show(self):
        super().show()

    def show(self):
        self.setFont(_font)
        QMainWindow.show(self)

        self.setWindowState(Qt.WindowNoState)
        self.setWindowState(Qt.WindowActive)
        self.raise_()

        self.readSettings()
        self.storeDefaultAndCustomColors()

        self.h = self.navSpinBox.size().height()
        fontSizeFactor = None
        heightFactor = None
        if 'bottom_sliders_zoom_perc' in self.df_settings.index:
            val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value'])
            if val != 100:
                fontSizeFactor = val/100
                heightFactor = val/100

        self.defaultWidgetHeightBottomLayout = self.h
        self.checkBoxesHeight = 14
        self.fontPixelSize = 11
        self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height()
        
        self.bottomLayout.setStretch(0, 0)
        self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width())
        self.resizeSlidersArea(
            fontSizeFactor=fontSizeFactor, heightFactor=heightFactor
        )
        self.bottomScrollArea.hide()

        self.gui_initImg1BottomWidgets()
        self.img1BottomGroupbox.hide()

        w = self.showPropsDockButton.width()
        h = self.showPropsDockButton.height()

        self.showPropsDockButton.setMaximumWidth(15)
        self.showPropsDockButton.setMaximumHeight(120)
        
        for toolbar in self.controlToolBars:
            toolbar.setMinimumHeight(
                self.secondLevelToolbar.sizeHint().height()
            )

        self.graphLayout.setFocus()
    
    def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None):
        global _font
        if heightFactor is None:
            self.newCheckBoxesHeight = self.checkBoxesHeight
            self.newHeight = self.h
        else:
            self.newHeight = round(self.h*heightFactor)
            self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor)
        
        if fontSizeFactor is None:
            newFontSize = self.fontPixelSize
        else:
            newFontSize = round(self.fontPixelSize*fontSizeFactor)
        newFont = QFont()
        newFont.setPixelSize(newFontSize)
        _font = newFont
        self.zProjComboBox.setFont(newFont)
        self.t_label.setFont(newFont)
        self.zProjOverlay_CB.setFont(newFont)
        self.annotateRightHowCombobox.setFont(newFont)
        self.drawIDsContComboBox.setFont(newFont)
        self.showTreeInfoCheckbox.setFont(newFont)
        self.highlightZneighObjCheckbox.setFont(newFont)
        self.navSpinBox.setFont(newFont)
        self.zSliceSpinbox.setFont(newFont)
        self.SizeZlabel.setFont(newFont)
        self.navSizeLabel.setFont(newFont)
        self.overlay_z_label.setFont(newFont)
        self.img1BottomGroupbox.setFont(newFont)
        self.rightBottomGroupbox.setFont(newFont)
        try:
            self.img1.alphaScrollbar.label.setFont(newFont)
        except Exception as e:
            pass
        for i in range(self.annotOptionsLayout.count()):
            widget = self.annotOptionsLayout.itemAt(i).widget()
            widget.setFont(newFont)
        for i in range(self.annotOptionsLayoutRight.count()):
            widget = self.annotOptionsLayoutRight.itemAt(i).widget()
            widget.setFont(newFont)
        try:
            for channel, items in self.overlayLayersItems.items():
                alphaScrollbar = items[2]
                alphaScrollbar.label.setFont(newFont)
        except:
            pass
        QTimer.singleShot(100, self._resizeSlidersArea)
    
    def _resizeSlidersArea(self):
        self.navigateScrollBar.setFixedHeight(self.newHeight)
        self.zSliceScrollBar.setFixedHeight(self.newHeight)
        self.zSliceOverlay_SB.setFixedHeight(self.newHeight)
        self.zProjComboBox.setFixedHeight(self.newHeight)
        self.zProjOverlay_CB.setFixedHeight(self.newHeight)
        self.navSpinBox.setFixedHeight(self.newHeight)
        self.zSliceSpinbox.setFixedHeight(self.newHeight)
        try:
            self.img1.alphaScrollbar.setFixedHeight(self.newHeight)
        except Exception as e:
            pass
        try:
            for channel, items in self.overlayLayersItems.items():
                alphaScrollbar = items[2]
                alphaScrollbar.setFixedHeight(self.newHeight)
        except:
            pass
        checkBoxStyleSheet = (
            'QCheckBox::indicator {'
                f'width: {self.newCheckBoxesHeight}px;'
                f'height: {self.newCheckBoxesHeight}px'
            '}'
        )
        for i in range(self.annotOptionsLayout.count()):
            widget = self.annotOptionsLayout.itemAt(i).widget()
            if isinstance(widget, QCheckBox):
                widget.setStyleSheet(checkBoxStyleSheet)
        for i in range(self.annotOptionsLayoutRight.count()):
            widget = self.annotOptionsLayoutRight.itemAt(i).widget()
            if isinstance(widget, QCheckBox):
                widget.setStyleSheet(checkBoxStyleSheet)
        self.zSliceCheckbox.setStyleSheet(checkBoxStyleSheet)

    def resizeEvent(self, event):
        if hasattr(self, 'ax1'):
            self.ax1.autoRange()
    
    def hoverEventDrawSpline(self, event):
        x, y = event.pos()
        xx, yy = self.curvAnchors.getData()
        hoverAnchors = self.curvAnchors.pointsAt(event.pos())
        per = False
        # If we are hovering the starting point we generate
        # a closed spline
        if len(xx) < 2:
            return 
        
        if len(hoverAnchors)>0:
            xA_hover, yA_hover = hoverAnchors[0].pos()
            if xx[0]==xA_hover and yy[0]==yA_hover:
                per=True
        if per:
            # Append start coords and close spline
            xx = np.r_[xx, xx[0]]
            yy = np.r_[yy, yy[0]]
            xi, yi = self.getSpline(xx, yy, per=per)
            # self.curvPlotItem.setData([], [])
        else:
            # Append mouse coords
            xx = np.r_[xx, x]
            yy = np.r_[yy, y]
            xi, yi = self.getSpline(xx, yy, per=per)
        self.curvHoverPlotItem.setData(xi, yi)
    
    def viewRangeChanged(self, viewBox, viewRange):
        if self.scaleBarDialog is not None:
            self.scaleBar.updateViewRange(viewRange)
            self.scaleBarDialog.onValueChanged(None)
        
        if self.timestampDialog is not None:
            self.timestamp.updateViewRange(viewRange)
            self.timestampDialog.onValueChanged(None)