import numpy as np
import matplotlib.pyplot as plt
import matplotlib.lines as lines
import ipywidgets as widgets
# To stringify variables
import inspect
# To get user ID 
import getpass
# For timestamp
import time 
from matplotlib.animation import ArtistAnimation

# This block is to hide mpl buttons (un)comment to enable or disable buttons
from matplotlib import backend_bases
 ## MPL button behaviour for images
backend_bases.NavigationToolbar2.toolitems = (
    ('Home', 'Reset original view', 'home', 'home'),
#     ('Back', 'Back to  previous view', 'back', 'back'),
#     ('Forward', 'Forward to next view', 'forward', 'forward'),
    (None, None, None, None),
    ('Pan', 'Pan axes with left mouse, zoom with right', 'move', 'pan'),
    ('Zoom', 'Zoom to rectangle', 'zoom_to_rect', 'zoom'),
#     ('Subplots', 'Configure subplots', 'subplots', 'configure_subplots'),
    (None, None, None, None),
    ('Save', 'Save the figure', 'filesave', 'save_figure'),
)

# Choose a monospace font 
# plt.rcParams["font.family"] = "monospace"

# Begin the class 
class ImageViewer():
    """Class for image visualization and manipulation, and comparison.

    The `ImageViewer` class allows you to quickly visualize an image and 
    interact with its properties. You can easily change the brightness and
    contrast of your image, display a histogram, plot the image with different
    ranges and in different colormaps and quickly access the statistics of the
    image (or a region of). Moreover, the `ImageViewer` is flexible enough to
    incorporate different transforms and filters in the form of lists of 
    additional functions and callbacks, a functionality that allows to try 
    different parameters/transforms without the need for rerunning a cell. 
    See the tutorial or the wiki for a thorough explanation.
    
    Construction:

    `IPLabViewer([image1, image2, ...], **kwargs)`, or
    `IPLabViewer([image1, image2, ...], **kwargs)`
    
    Where the images are numpy arrays. See  `.__init__` for details
    
    Parameters
    ----------
    image : NumPy array or iterable of NumPy arrays
        The image or images to visualize
        
    **kwargs : dict, optional
        Parameters for the initization of the ImageViewer. 
        See `__init__` documentation for details.

    Attributes
    ----------
    image_list : list 
        Contains all the images (`np.ndarray` objects) on the viewer inside 
        a Python list, in the order in which they were originally given.
        
    original : list
        Stores all the images as they were during instantiation.
        
    bins : list 
        Contains the bins of the images, as generated by `numpy.hist` and 
        in the same order as `image_list`. Currently, always $70$ bins.
        It is upated when a transform is applied
        
    hist : list
        Contains the histogram values of each image, for the current image 
        displayed (updated when a transform is applied)
        
    max : list
        Contains the maximum values of each image. Updated during transforms
        
    min : list
        Contains the minimum values of each image. Updated during transforms
        
    xlim : list
        Contains the current limits of the $x$-axis. Updated when zooming or
        during transforms. 
    
    ylim : list
        Contains the current limits of the $y$-axis. Updated when zooming or
        during transforms. 
    
    number_images : int
        The number of images that `ImageViewer` was instantiated with
        
    current_image : int or NoneType object
        If the mode is only one image on display keeps track of which image is
        currently on display. If all images on display, it is set to `None`
    
    axs : list 
        Contains the AxesSubplots (see Matplotlib documentation) of each image.
        If the mode of the `ImageViewer` is to visualize only one image at a
        time, there is only one AxesSubplot that gets updated when changing 
        image. Same applies for `axs_hist`, `axs_im`, and `im`.
    
    axs_hist : list 
        Contains the AxesSubplots of each histogram
        
    im : list 
        Contains the AxesImage (see Matplotlib documentation) of each image
    
    axs_hist : list 
        Contains the AxesSubplots of each histogram
        
    axs_hist : list 
        Contains the AxesSubplots of each histogram
    
    fig : Matplotlib Figure
        Main figure to display images
        
    fig_hist : Matplotlib Figure
        Main figure to display histograms
    
    init_view_rightb : ipywidgets.VBox
        Contains the initial widget menu, with the rest of the widgets for 
        color scaling, histogram control, access to other menus
        
    options_view_rightb : ipywidgets.VBox
        Options menu, with widgets to show/hide the axis of the images and 
        a colorbar, and a menu to change the colorbar.
    
    x_widget_view : ipywidgets.VBox
        Only instantiated if extra widgets are required. Contains all the 
        widgets defined by the user.
    
    view : str
        Keeps track of the view that is currently in display.
    
    button_back : ipywidgets.Button
        Button that appears on `'options'` and `'extra_widgets'` menu to go
        back to initial menu.
    
    button_hist : ipywidgets.Button
        Button to show or hide the histograms of images
    
    button_next : ipywidgets.Button
        Button to show or hide the histograms of images
    
    button_options : ipywidgets.Button
        Button on the initial menu to switcch to options menu
    
    button_prev : ipywidgets.Button
        Button to browse images. It only appears if there are several images
        in the `ImageViewer` and the mode is to display one image at a time
    
    button_next : ipywidgets.Button
        Button to browse images. Conditions of `button_prev` apply
    
    button_reset : ipywidgets.Button
        Button to reset all the parameters to the original.
    
    button_show_axis : ipywidgets.Button
        Button to show or hide the axis on the images
    
    button_show_widgets : ipywidgets.Button
        Appears only when `ImageViewer` is instantiated without widgets, and 
        is used to display the widgets initial menu
        
    dropdown_cmap : ipywidgets.DropdownMenu
        Menu to choose the colormap of the images
    
    final_view_hist : ipywidgets.HBox
        Box to hold the final view with histogram
        
    final_view_no_hist : ipywidgets.HBox 
        Box to hold the final view without histogram
    
    out_fig : ipywidgets.Output
        Output widget that holds `fig`
        
    out_hist : ipywidgets.Output
        Output widget that holds `fig_hist`
        
    slider_clim : ipywidgets.IntSlider
        Defines the min and max values (in percentages) for color scaling
        
    slider_clim : ipywidgets.TextArea
        Displays the statistics of the images    
    """
    
    ## Constructor, only mandatory argument is an image
    def __init__(self, image_list, **kwargs):
        '''Initialization funcion of ImageViewer. 

        The only required argument is an image (NumPy array) or a list of 
        images. If the images are not NumPy arrays, a ValueException will 
        be raised. All the rest of the parameters are optional         
        
        Parameters
        ----------
        image : NumPy array or iterable of NumPy arrays
            The image or images to visualize
            
        axis : boolean
            If `True`, displays the axis (units in pixels) of the image. 
            Defaults to `False`. It can be set after instantiation.
            
        callbacks :  list
            The list should have either $1$ element or the same number of
            elements as there are images. In the former case, the same
            callback will be applied to all images. In the latter, the n-th
            callback of the list will be applied to the n-th image. All
            callbacks should have as sole input and output parameters an 
            image. All other required parameters should taken from widgets
            values (see parameter `new_widgets`). 
            
        clickable : boolean
            Activates clickability functionalities for selecting points. 
            It is only useful when this points are required for a transform
            provided in the form of a callback. 
            
        clip_range : iterable of two floats
            Indicates the min and max values that will be enforced on 
            all images. Images with values outside this range will be 
            clipped to the range, as in `numpy.clip`.
            
        compare : boolean
            Whether to activate the image comparison functionality. 
            Defaults to `False`. It only has an effect when `ImageViewer`
            is initialized with two images. Regions of the two images 
            where there are differences will be shown in red.

        cmap : string {'gray', 'viridis', 'inferno', 'ocean', 'hsv',
                       'nipy_spectral', 'copper', 'spring', 'magma'}
            A string that determines the colormap of for the images. 
            If the given one is not recognized by ImageViewer but it is 
            by Matplotlib, it will be set (but after changing it it)
            cannot be accessed again. If its not recognized by 
            Matplotlib, a `ValueError` will be raised.

        colorbar : boolean 
            Whether to initialize with a colorbar or not

        hist : boolean 
            Whether to show the histogram from the beginning or not
            
        joint_zoom : boolean
            Whether to activate or not the joint zoom functionality from 
            the initialization. When it is activated, zooming to an area 
            in an image will make the rest of the images zoom to the same
            area. It only has an effect when there is an image grid. It can
            be (de)activated also through the widgets menu.
            
        line : boolean
            If clickable was set to `True`, draws a line between selected
            points. Otherwise it has no effect.
            
        new_widgets : list of ipywidgets objects
            It is meant to be used jointly with the parameter `callbacks`.
            If a callback depends on tunable parameters, this should be set
            by ipywidgets sliders/dropdown menus, which need to be passed 
            to `ImageViewer` through this parameter. The last element in the 
            list should always be an `ipywidgets.Button`, that will serve to 
            apply any transform.
            
        normalize : boolean
            Whether to scale the values of all images to the range $[0, 1]$
            
        pixel_grid : boolean
        
        scale_range : iterable of 2 floats
            If given, all images will be rescaled to the specified range.
            
        subplots :  iterable of 2 ints
            Specify a grid for the images (see Matplitlib gridspec). If there
            are more images than spaces in the grid, the rest of the images 
            will not be shown
        
        title : list of strings
            Specifies the title of every image. If not given, the name of the 
            variable will be used as title.
        
        widgets : boolean
            Display the widget menu. If not specified (or set to False),
            only the button *Show Wisgets* will be displayed.
            
        '''

        # Get timestamp
        import time
        ts = time.gmtime()

        date_str = time.strftime("%x %X", ts)
        # 09/01/20 02:21:33
        # Also possible, iso Format
        # print(time.strftime("%c", ts))
        # Tue Sep  1 02:21:33 2020
        
        # Get user ID (SCIPER in Noto), and the number of the figure to be created
        uid = getpass.getuser().split('-')[2] if len(getpass.getuser().split('-')) > 2 else getpass.getuser()
        fig_num = int(len(plt.get_fignums()) / 2) + 1        
        
        # Make sure that the image_list is a list of numpy arrays
        # If a numpy array is given, place it inside a list
        if type(image_list) == np.ndarray:
            image_list = [image_list]
        # If a list is given, check that all objects inside are numpy arrays. Raise exception otherwise.
        elif type(image_list) == list:       
            if not(all(isinstance(img, np.ndarray) for img in image_list)):
                raise ValueException('Make sure that all your images are numpy arrays. Use the method np.array(object).')
        # Raise exception otherwise. if object type not numpy array nor list.
        else:
            raise Exception('Please give the first argument as a list of numpy arrays.')
                
        # Once a list of numpy arrays is ensured, make the list an attribute of the viewer object.        
        self.image_list = image_list
        
        # Get the number of images 
        self.number_images = len(image_list)         
        
        # Default behaviour is to show only one image 
        subplots = [1 ,1]
        
        # Attribute to keep track of image in display 
        self.current_image = 0
        # Attribute to keep track on wether we have a comparison
        self.compare = False
        
        # Check if user requested a specific axis grid (subplopts = [m, n]). If so, modify the variable subplots
        if 'subplots' in kwargs:
            subplots = kwargs.get('subplots')           
            # Attribute that will be use to check if single_image was required, and If so, which image is currently on display
            self.current_image = None
            
        if 'pixel_grid' in kwargs:
            self.current_image = None
            
        # Create output widget to wrap figure and handle display of images
        self.out_fig = widgets.Output(layout = widgets.Layout(width = '80%')) #(%80 for V layouot)
        with self.out_fig:            
            # Initialize figure and subplots inside just created widget
            self.fig, self.axs = plt.subplots(subplots[0], subplots[1], num = f'Image {fig_num} - SCIPER: {uid} - Date: ' + date_str)   
        # Set an appropriate size (in inches) for the figure. These are similar to matplotlib default sizes. Modify them to change image physical size. You can also set them constant, in which case, more images --> smaller images.
        if self.current_image != None:
            self.fig.set_size_inches([subplots[1]*4.7*1.3, subplots[0]*4.5*1.3])
        else:
            self.fig.set_size_inches([subplots[1]*4.7*0.84, subplots[0]*4.5*0.75]) 
        # (subplots[1]*6.4*0.7, subplots[0]*5.5*0.7 for V layout, (0.84, 0.75) for IP1)               
        # Make sure that the axs is iterable in one foor loop (1D numpy array)
        self.axs = np.array(self.axs).reshape(-1)

        
        # This code block will get further information on the images, and store it as atttriutes of the object. 
        # First, we create lists to store the info of each image. 
        self.original = []         # Store the originals 
        self.data = []             # Store current data (only perform operations here)
        self.min = []              # Min value
        self.max = []              # Max value
        self.dx = []               # Number of pixels in x
        self.dy = []               # Number of pixels in y    
        self.channels = []         # Keep count of the channels
        # Iterate through each image and prepare for plotting, according to user specifications        
        count = 0        
        for image in image_list:
                
            # Check if any transformation is required by the user and perform. Assign min-max values accordingly   
            if kwargs.get('normalize', False):
                #Stretch contrast to range [0, 1]
                image = (image - np.amin(image)) / (np.amax(image) - np.amin(image))
                self.min.append(np.amin(image))
                self.max.append(np.amax(image))
            elif 'clip_range' in kwargs:
                # Clip image to range specified by user 
                clip_range = kwargs.get('clip_range')
                image = np.clip(image, clip_range[0], clip_range[1])
                self.min.append(np.amin(clip_range[0]))
                self.max.append(np.amax(clip_range[1]))
            elif 'scale_range' in kwargs:
                # Scale image to range specified by user
                scale_range = kwargs.get('scale_range')
                image = (image - np.amin(image)) / (np.amax(image) - np.amin(image))
                image = image * (scale_range[1] - scale_range[0]) + scale_range[0]
                self.min.append(np.amin(image))
                self.max.append(np.amax(image))
            else:
                self.min.append(np.amin(image))
                self.max.append(np.amax(image))
            
            ### Now that we have the image as the user specified it, store in attributes        
            self.original.append(np.copy(image))
            self.data.append(np.copy(image))
            
            # This will be useful in case pixel_grid was requested
            try:
                pix_dx, pix_dy = image.shape
                self.channels.append(1)
            except:
                pix_dx, pix_dy, channels = image.shape
                self.channels.append(channels)
                if not(channels in [3, 4]):
                    raise Exception(f'Image {count} does not have a valid shape for an image.')
            self.dx.append(pix_dx)
            self.dy.append(pix_dy)
            count += 1

        # Now we are going to prepare the Axes. Again, initialize arrays        
        self.im = []       # list of AxesImage objects        
        self.cb = []       # List of colorbars        
        self.titles = []   # List of titles        
        self.xlim = []     # List to store the x plotting range of each figure        
        self.ylim = []     # List to store the y plotting range of each figure
        # Check if titles were given, if not, stringified variable name (see self.retrieve_name() at the end of the class)
        title_arg = kwargs.get('title', [])
        if type(title_arg) != list:
            title_arg = [title_arg]
        # Iterate through the number of images 
        for i in range(self.number_images):
            # Check if title was given by user. If so, append to our list of titles.
            if i < len(title_arg): 
                self.titles.append(title_arg[i])
            # If not, stringify variable name and append to title list
            else:
                self.titles.append(self.retrieve_name(self.image_list[i]))
        
        # In the next for loop we will iterate through the number of images, and actually plot them (according to the use case) 
        count = 0
        for i in range(self.number_images):
            # check that we have images left to plot in our axis array (useful in case subplots option was used, and there are more spaces than images). 
            # Enter if condition if we are in the last image (in case there are more axis than images) or last axis (in case single_image)
            if i == len(self.axs) or i == self.number_images -1:
                # If we're out of axis (will happen if user requested single_image or subplots = [m, n], and m*n < number_iamges), break loop
                if len(self.axs) < len(self.data):
                    break
                else:
                    # If this is the last image (will happen if user requested subplots = [m, n], and m*n > number_iamges)...
                    for j in range(i + 1, len(self.axs)):
                        # Turn off any remaining axis and exit for loop
                        self.axs[j].axis('off')
                    
            # Check if pixel_grid = True. Assign parameter to keep track of user request (will be useful deciding wether to show axis)
            self.pixel_grid = False
            if kwargs.get('pixel_grid', False):
                self.pixel_grid = True
                # use 'minor' ticks to visualize the pixels. This ones will mark the lines
                self.axs[i].grid(which='minor')
                self.axs[i].set_yticks(np.arange(0.5, self.dx[count]-0.5, step=1), minor=True)
                self.axs[i].set_xticks(np.arange(0.5, self.dy[count]-0.5, step=1), minor=True)
                # This tickes will mark the numbers
                step = kwargs.get('num_step', 1)
                self.axs[i].set_yticks(np.arange(0, self.dx[count], step=step))
                self.axs[i].set_xticks(np.arange(0, self.dy[count], step=step))
                
            
            # Create AxesImage and plot with user-requested parameters
            self.im.append(self.axs[i].imshow(self.data[count], 
                          cmap = kwargs.get('cmap','gray'), clim = [self.min[count], self.max[count]]))

            # Set the user rquested title
            self.axs[i].set_title(self.titles[count])
            # Save the limits of the plot in each axis (this are the default by matplotlib)
            self.xlim.append(np.round(self.axs[i].get_xlim(),1))
            self.ylim.append(np.round(self.axs[i].get_ylim(),1))

            # Hide the axis by default 
#             self.axs[i].axes.yaxis.set_visible(False)
#             self.axs[i].axes.xaxis.set_visible(False)
            # Place the axis in the top of the image 
            self.axs[i].xaxis.tick_top()
            count += 1   
        
#         self.fig.tight_layout()
#         self.fig.canvas.font = 'monospace'
            
        ###############################################################################
        ### Declare widgets and link to callbacks (Names are self selfexplanatory) ####
        ###############################################################################
        
        ################### Sliders ########### Values of the slider is based in percentage of total
        # Change ranges using matplotlib clim value
        self.slider_clim = widgets.IntRangeSlider( value = [0, 100],  min = 0, max = 100, step = 1, 
                                         description = '', continuous_update = False, layout = widgets.Layout(width = '210px') )
        self.slider_clim.observe(self.clim_callback, 'value')        

        ################## Buttons ########## 
        # Enable Joint Zoom
        self.button_joint_zoom = widgets.Button(description = 'Enable Joint Zoom')
        self.button_joint_zoom.on_click(self.joint_zoom_callback)
        
        # Plot Histogram instead of data
        self.button_hist = widgets.Button(description = 'Show Histogram')
        self.button_hist.on_click(self.hist_button_click)
        
        # Button to displax axis coordinates
        self.button_show_axis = widgets.Button(description = 'Hide Axis')
        self.button_show_axis.on_click(self.axis_button_click)
        
        # Brightness and Contrast Legend
        self.b_c_text = widgets.Text(value='Brightness & Contrast (%)', disabled=True, layout = widgets.Layout(width = '170px'))
        
        # Reset view and widgets original value
        self.button_reset = widgets.Button(description = 'Reset')
        self.button_reset.on_click(self.reset)
        
        # If there are only two images with the same shape, declare compare button
        self.button_compare = widgets.Button(description = 'Compare')
        self.button_compare.on_click(self.compare_button)
        # If there are only two images with the same shape, declare compare button
        
        # Go to Options menu Button
        self.button_options = widgets.Button(description = 'Options')
        self.button_options.on_click(self.options_button)
        
        # Button to take screenshot and keep as numpy array
        self.button_save = widgets.Button(description = 'Screenshot')
        self.button_save.on_click(self.save)
        
        # Go to Main menu Button (Back)
        self.button_back = widgets.Button(description = 'Back')
        self.button_back.on_click(self.back_button_callback)
        
        # Set Colorbar button 
        self.button_colorbar = widgets.Button(description = 'Set Colorbar')
        self.button_colorbar.on_click(self.colorbar_button_click)
        
        # Show Widgets self.number_images
        self.button_showw = widgets.Button(description = 'Show Widgets')
        self.button_showw.on_click(self.showw_button_callback)
        
        # Buttons to navigate through images. Only activated if the user requested single image display
        if self.current_image != None :
            
            # Button next image ('\U02190' for right arrow, not supported by python apparently)        
            self.button_next = widgets.Button(description = 'Next', layout = widgets.Layout(width = '80px'))
            self.button_next.on_click(self.next_button_callback)
            # Button prev image ('\U02192' for left arrow)
            self.button_prev = widgets.Button(description = 'Prev', layout = widgets.Layout(width = '80px'))
            self.button_prev.on_click(self.prev_button_callback)
            # Wrap both buttons in one Horizontal widget
            self.next_prev_buttons = widgets.HBox([self.button_prev, self.button_next])
        
        ##################### Text
        # Get stats. Instead of connecting to a callback, it is updated continuously
#         mean, std, min_value, max_value, num_channels, shape, xlim, ylim, descriptive_string = self.get_statistics()
        _, _, _, _, _, _, _, descriptive_string = self.get_statistics()
        self.stats_text = widgets.Textarea(value = descriptive_string[0], continuous_update = True,
                                           layout = widgets.Layout(width = '170px', height = '220px'), disabled = True) 
        
        #################################### Dropdown menus
        # Choose a colormap. Try to get the user one, otherwise set to 'gray'
        self.cmap_orig = kwargs.get('cmap', 'gray')
        # If the usermap from the user is not supported, we use it (we have already plotted the image) but set the Dropdown menu to gray 
        if not(self.cmap_orig in ['gray', 'viridis', 'inferno', 'ocean', 'nipy_spectral', 'copper', 'spring', 'magma', 'hsv']):
            self.cmap_orig = 'gray'
        # Declare Dropdown menu
        self.dropdown_cmap = widgets.Dropdown(options = ['gray', 'viridis', 'inferno', 'ocean', 
                                                         'nipy_spectral', 'copper', 'spring', 'magma', 'hsv'], 
                                              value = self.cmap_orig, 
                                              disabled = False, 
                                              layout = widgets.Layout(width = '65%'))
        self.dropdown_cmap.observe(self.cmap_callback, 'value')
        
        # The next attribute keeps track of wether the user requested any extra widdgets
        self.extra_widgets = False                
        if 'new_widgets' in kwargs:
            self.extra_widgets = True
            # Get new widgets and callback as attributes
            self.usr_defined_widgets = kwargs.get('new_widgets')
            try:
                self.usr_defined_callbacks = kwargs.get('callbacks')
            except Error: 
                print('Please provide your widgets in a list (with the button as the last element), and the callback to your button \
                        also as a list in the parameter callbacks.')
            # Create button , and menu, to display the additional widget
            self.button_show_x_w = widgets.Button(description = 'Extra Widgets')
            self.button_show_x_w.on_click(self.x_w_button_callback)
            # Link last widget (button) to the given callback
            self.usr_defined_widgets[-1].on_click(self.x_w_callback)
        
        # store Line2d objects generated when plotting clickable points
        self.clickable = False
        self.block_clicks = False
        if 'clickable' in kwargs:
            self.clickable = True
            # Create textarea widget
            self.txt = widgets.Textarea(
                    value ='',
                    placeholder='',
                    description='',
                    disabled=False
                )
            # save mouse coordinates
            self.mouse_coords = []
            # keep track of click count
            self.click_count = 0
            # line coordinates
            self.lines = []

            self.line = False

            if 'line' in kwargs:
                self.line = True

            if 'subplots' in kwargs:
                self.subplot_click = True


            # event listener on click
            self.cid = self.fig.canvas.mpl_connect('button_press_event', self.onclick)
            
        ########################## Wrap widgets in boxes, according to the menu and use case ################################
        # Declare 'base' widget list for main menu. Depending on other parameters, it will be modified
        widget_list = [self.b_c_text, self.slider_clim, self.button_hist, self.button_options, self.button_reset, self.stats_text]
        
        # If more than one image, add next and previous buttons
        if self.current_image != None and self.number_images > 1:
            widget_list.insert(5, self.next_prev_buttons)
            
        # If extra widgets are given, add extra widgets button
        if self.extra_widgets:
            widget_list.insert(4, self.button_show_x_w)
        # If all images are RGB/RGBA, disable buttons
        if all(c in [3, 4] for c in self.channels):
            widget_list.remove(self.slider_clim)
            widget_list.remove(self.button_hist)
            widget_list.remove(self.b_c_text)
            
        # Create view    
        self.init_view_rightb = widgets.VBox(widget_list)
        
        options_widget_list = [self.dropdown_cmap, self.button_show_axis, self.button_colorbar, self.button_joint_zoom,
                                 self.button_compare, self.button_back, self.stats_text]
        
        # If all images are RGB/RGBA, hide buttons
        if all(c in [3, 4] for c in self.channels):
            options_widget_list = [self.button_show_axis, self.button_joint_zoom, self.button_compare, self.button_back, self.stats_text]
        
        # If there is only one image in display, remove button 'Enable joint zoom'
        if self.current_image != None:
            options_widget_list.remove(self.button_joint_zoom)
        
        if not(self.number_images == 2 and self.original[0].shape == self.original[1].shape):
            options_widget_list.remove(self.button_compare)
            
        self.options_view_rightb = widgets.VBox(options_widget_list)
        
        # Extra_widgets menu (includes sliders, stats, and back button).
        if self.extra_widgets:
            # This cycle first appends the back button and the stats to the list given by the user.
            self.usr_defined_widgets.append(self.button_back)
            self.usr_defined_widgets.append(self.stats_text)
            # Then wraps the list in a VBoc widget
            self.x_widget_view = widgets.VBox(self.usr_defined_widgets)
                
        # Out widget that will hide or show the histograms, depending on use case
        self.hist_container = widgets.Output()                           
                 
        # Output widget to enclose histogram (this one will hold it permanently)
        self.out_hist = widgets.Output(layout = widgets.Layout(width = '100%'))   #(85% for V layout) 
        # Initialize histogram figure. The subplots shape is the same as in the images figure
        with self.out_hist:            
            self.fig_hist, self.axs_hist = plt.subplots(subplots[0], subplots[1], num = f'Histogram {fig_num} - SCIPER: {uid}') 
#         self.fig_hist.set_size_inches([subplots[1]*4.7*0.65, subplots[0]*4.5*0.72]) #(V layout: See self.fig)
        if self.current_image != None:
            self.fig_hist.set_size_inches([subplots[1]*4.7*0.5, subplots[0]*4.5*0.55])
        else:
            self.fig_hist.set_size_inches([subplots[1]*4.7*0.84, subplots[0]*4.5*0.75]) 
#         self.fig_hist.canvas.toolbar_visible = False    
        
        # Ensure that hist axes are iterable in one loop (1D np array)
        self.axs_hist = np.array(self.axs_hist).reshape(-1)
        
        # Get histogram information and plot (see function) 
        self.update_histogram()
    
        # Out widget to contain other widgets (buttons, sliders, etc)
        self.out = widgets.Output(layout = widgets.Layout(width = '25%'))      
        # Names self explanatory
        self.final_view_no_hist = widgets.HBox([self.out_fig, self.out])
        self.final_view_hist = widgets.HBox([self.out_fig, self.hist_container, self.out])       #H layout
#         self.final_view_hist = widgets.VBox([self.final_view_no_hist, self.hist_container])    # V layout
        display(self.final_view_hist)
    
        # Final check of use specs, and call appropriate functions
        # We use Matplotlib own handlers for changes on the x/y lims (see function) 
        self.link_axs()
        # If histogram requested, display hist and widgets (se we set self.widgets to True)
        if kwargs.get('hist', False):
            # Display histogram and change button description (see button)
            self.show_histogram(hist = True)
            self.button_hist.description = 'Close Histogram'
            self.widgets = True
        # If only widgets were requested, set to Tre
        elif kwargs.get('widgets', False):
            self.widgets = True            
        # If hist/widgets not requested, only set to visible button Show Widgets
        else:
            self.widgets = False
            display(self.button_showw)
        # Show axis only on request, or if pixel_grid was requested
        if not(kwargs.get('axis', False) or self.pixel_grid):
            self.button_show_axis.description = 'Show Axis'
            self.set_axis(axis = False)
        # Show colorbar on request
        if kwargs.get('colorbar', False): 
            self.button_colorbar.description = 'Hide Colorbar'
            self.set_colorbar()
        if kwargs.get('joint_zoom', False):
            self.joint_zoom_callback()
        # Set view and call functions to arrange statistics, widget menu, and histogram lines
        self.view = 'initial'
        self.update_stats()
        self.update_view()
        self.update_hist_lines()
        # Override matplotlib home button
#         home_func = backend_bases.NavigationToolbar2.home

#         def new_home(self, *args, **kwargs):
#             home_func(self, *args, **kwargs)
#             self.reset(change = None)
            
#         backend_bases.NavigationToolbar2.home = new_home
                 
        if self.current_image != None:
            self.change_image(0)
        # Get comparison if necessary
        if kwargs.get('compare', False):
            self.compare_button(change = None)
        
##########################################################################     
########################## End of init function ##########################
##########################################################################     
############### Event Handlers (Names are self-explanatory) ##############
##########################################################################

    def clim_callback(self, change):
        '''Callback of `slider_clim`. 

        Adjusts the the color limits of the displayed images, and updates 
        the histogram line. See matplitlib's AxesImage method `set_clim`
        for more details.
        
        Parameters
        ----------
        change : dict
            Parameter sent when an ipywidget is linked to a callback. Contains
            information on the widget and on the new and old values. See 
            iPyWidgets documentation for details.
        '''
        count = 0
        # Iterate through each AxesImage and apply new clim
        for im in self.im: 
            # If condition: make sure that if single_image is set, the limits for the correct image are chosen
            if self.current_image != None:
                count = self.current_image
            im.set_clim(change.new[0]*0.01*(self.max[count] - self.min[count]) + self.min[count], 
                        change.new[1]*0.01*(self.max[count] - self.min[count]) + self.min[count])
            count +=1
        self.update_hist_lines()
    
    ## Button Callbacks 
    def joint_zoom_callback(self, change=None):
        '''Callback of button *Enable Joint Zoom*. 

        When the zoom joint is enabled (the description of the button will)
        tell, zooming to an area on an image will zoom into the same area in
        all images. Useful for image comparison features.
        '''
        if self.button_joint_zoom.description == 'Enable Joint Zoom':
            self.button_joint_zoom.description = 'Disable Joint Zoom'
            # Share axes for joint zooming
            for i in range(len(self.axs)-1):
                self.axs[i].get_shared_x_axes().join(self.axs[i], self.axs[i+1])
                self.axs[i].get_shared_y_axes().join(self.axs[i], self.axs[i+1])
        else:
            self.button_joint_zoom.description = 'Enable Joint Zoom'
            # Unshare axes for individual zooming
            for i in range(len(self.axs)):
                g = self.axs[i].get_shared_x_axes();  
                [g.remove(a) for a in g.get_siblings(self.axs[i])]
                g = self.axs[i].get_shared_y_axes();  
                [g.remove(a) for a in g.get_siblings(self.axs[i])]
    
    def hist_button_click(self, change):
        '''Callback of button *Show Hist*, to show or hide the histogram 
        '''
        # Check the current state, change, and act accordingly
        if self.button_hist.description == 'Show Histogram':           
            self.button_hist.description = 'Close Histogram' 
            self.show_histogram(hist = True)
        else:
            self.button_hist.description = 'Show Histogram'            
            self.show_histogram(hist = False)
        
    def options_button(self, change):
        '''Callback of button *Options*, to switch to options menu 
        '''
        self.view = 'options'
        self.update_view()
        
    def compare_button(self, change):
        '''Callback of button *Compare*. 

        It is active when exactly 2 images are given. When clicked, the 
        difference between the two images will be calculated, and drawn in
        red on top of the images. Works for both single and multiple display modes. 
        '''
        # Ensure that we only have to images
        if self.number_images != 2:
            return
        # Get error array (Red RGB image of appropriate size with normalized difference as transparency)
        diff = np.abs(self.original[1] - self.original[0])
        diff = (diff - diff.min()) / (diff.max() - diff.min());
        self.error = np.dstack((np.ones_like(self.original[0]), np.zeros_like(self.original[0]), np.zeros_like(self.original[0]), diff))
        
        self.axs[0].imshow(self.error)
        try:
            # Will fail if there is only one self.axs (one image display)
            self.axs[1].imshow(self.error)
        except:
            pass

    def back_button_callback(self, change):
        '''Callback of button *Back*, to switch to main menu 
        '''
        self.view = 'initial'
        self.update_view()
        
    def x_w_button_callback(self, change):
        '''Callback of button *Extra Widgets*, to go to Extra Widgets menu
        
        It is only active when the parameters `new_widgets` and `callbacks`
        were given to the __init__ function.
        '''
        self.view = 'x_widget'
        self.update_view()
            
    def reset_clickables(self):
        '''Button to reset information on clickables.
        
        It is only active when the parameter `clickable` is set to `True`
        during initialization.
        '''
        # Reset clickable parameters
        self.click_count = 0
        self.mouse_coords = []
        # Remove clicked points
        for i in range(self.number_images):
            for j in range(len(self.axs[i].lines)):
                self.axs[i].lines[0].remove()
    
    def reset(self, change):
        '''Button to reset the state of the viewer
        
        The viewer is brought back to the state in which it was instantiated
        (except for the widgets menu, which will always stay there). This
        applies for transforms transform, color scaling, zooming, axis, 
        colorbar, etc.
        '''
        if self.clickable:
            self.reset_clickables()
            self.block_clicks = False
        
        # Get all parameters of the object to initial values    
        self.slider_clim.value = [0, 100]
        self.view == 'initial'
        self.dropdown_cmap.value = self.cmap_orig                     
        # Get images back to the originals (In case any function/ transformation has been applied)
        for i in range(self.number_images):
            self.data[i] = np.copy(self.original[i])
            self.max[i] = np.amax(self.data[i])
            self.min[i] = np.amin(self.data[i])
        # This for loop replots every image (Redefines the AxesImage in the attribute self.im)
        for i in range(self.number_images):
            # If there is only one image, act on this one (self.current_image) and break loop
#             self.axs[i].clear()
            if self.current_image != None:
                self.im[0] = self.axs[0].imshow(self.data[self.current_image], 
                                                clim = [self.min[self.current_image], self.max[self.current_image]],
                                                cmap = self.dropdown_cmap.value)
                self.xlim[0] = np.array([0 - 0.5, self.image_list[self.current_image].shape[1] - 0.5])
                self.ylim[0] = np.array([self.image_list[self.current_image].shape[0] - 0.5, 0 - 0.5])
                break
                
            if i == len(self.axs) or i == self.number_images :
#                 if len(self.axs) < len(self.data):
                break
            self.im[i] = self.axs[i].imshow(self.data[i], clim = [self.min[i], self.max[i]], cmap = self.dropdown_cmap.value)
            self.xlim[i] = np.array([0 - 0.5, self.image_list[i].shape[1] - 0.5])
            self.ylim[i] = np.array([self.image_list[i].shape[0] - 0.5, 0 - 0.5])
        
        # Hide Colorbar and axis
        self.set_colorbar(colorbar = False)
        self.button_colorbar.description = 'Set Colorbar'
        self.set_axis(axis = False)
        self.button_show_axis.description = 'Show Axis'
        # Reset zoom 
        count = 0
        for ax in self.axs:
            # If we are out of images (we may have more Axes, but they should be off)
            if count == self.number_images: 
                break            
            ax.set_xlim(self.xlim[count])
            ax.set_ylim(self.ylim[count])
            count += 1
        self.update_view()
        self.update_stats()
        self.update_histogram()
        self.update_hist_lines()
    
    def showw_button_callback(self, change):
        '''Callback of button *Back*, to show main menu. 
        '''
        # Set widgets on request
        self.set_widgets()
    
    def axis_button_click(self, change):
        '''Callback of button *Show/Hide Axis*, to show/hide the image axis. 
        '''
        # Set Axes and change description of button accordingly
        if self.button_show_axis.description == 'Show Axis':
            self.button_show_axis.description = 'Hide Axis'
            self.set_axis(axis = True)
        else:
            self.button_show_axis.description = 'Show Axis'
            self.set_axis(axis = False)
            
    def colorbar_button_click(self, change):
        '''Callback of button *Set/Hide Colorbar*, to show/hide the colorbar. 
        '''
        # Set colorbar and change description of button accordingly
        if self.button_colorbar.description == 'Set Colorbar':
            self.button_colorbar.description = 'Hide Colorbar'
            self.set_colorbar(colorbar = True)
        else:
            self.button_colorbar.description = 'Set Colorbar'
            self.set_colorbar(colorbar = False)
    
    def cmap_callback(self, change):
        '''Callback of cmap menu, to change the colormap to the selected one. 
        '''
        # Set the colormap of the images
        self.set_colormap(colormap = change.new)
        
    def prev_button_callback(self, change):
        '''Callback of *Prev* button, to browse to the previous image. 
        
        It is only active when there are several images, and the display mode
        is single image. When there is no previous image, it is disabled.
        '''
        # Change image to one before the currently plotted 
        self.change_image(-1)
        
    def next_button_callback(self, change):
        '''Callback of *Next* button, to browse to the previous image. 
        
        It is only active when there are several images, and the display mode
        is single image. When there is no further image, it is disabled.
        '''
        # Change image to one after the currently plotted
        self.change_image(1)

    # Callback used when user declares an extra widget
    def x_w_callback(self, change):
        '''Callback of user defined transforms button, to apply transform(s) 
        
        It is only active when the parameters `new_widgets` and `callbacks` 
        are included in the initialization. If there is only one callback, 
        it applies that callback to all the images in the ImageViewer. If 
        there is one callback per image, then it applies the corresponding
        callback to each image.
        '''
        # Accept both lists and np arrays
        self.usr_defined_callbacks = np.array(self.usr_defined_callbacks)
        # First, we check wether there is one or several callbacks
        multi_callback = False
        if self.usr_defined_callbacks.size > 1:
            # If more than one callback, we want a callback per image. (look at exception)
            if self.usr_defined_callbacks.size != len(self.original):
                raise Exception('Please provide either one callback, or the exact number of callbacks as images. The nth callback\
                                will be applied to the nth image')
            multi_callback = True
            
        # Iterate through all images
        for i in range(self.number_images):
            # Restore image, so to perform operation on original
            self.data[i] = np.copy(self.original[i])
            # Call the function defined by the user on the current image
            if multi_callback:
                self.data[i] = self.usr_defined_callbacks[i](self.data[i])
            else:
                if (not self.clickable ):
                    self.data[i] = self.usr_defined_callbacks[0](self.data[i])
                else:
                    # If clickable, add mouse coords to the callback function
                    if(i==0):
                        if self.line:
                            self.data[0] = self.usr_defined_callbacks[0](self.data[0], self.mouse_coords)
                        else:
                            self.data[0] = self.usr_defined_callbacks[0](self.data[0], self.mouse_coords)
                            self.reset_clickables()
                        self.block_clicks = True
            self.max[i] = np.amax(self.data[i])
            self.min[i] = np.amin(self.data[i])
            
        # Update plot. If condition checks is there is only one image being displayed
        if self.current_image != None:
            # This function will reset the image
            self.change_image(change = 0)
        else:
            # Restore attribute to store AxesImage objects
            self.im = []
            count = 0
            # Iterate through every axis
            i = 0
            for ax in self.axs:
                #Replot the results of the operation with the proper parameters
                self.im.append(ax.imshow(self.data[i], cmap = self.dropdown_cmap.value, 
                              clim = (self.slider_clim.value[0]*0.01*(self.max[i] - self.min[i]) + self.min[i], 
                                      self.slider_clim.value[1]*0.01*(self.max[i] - self.min[i]) + self.min[i],)))
                count += 1
                i += 1
        # Update statistics and histogram
        self.update_stats()
        self.update_histogram()
        
##########################################################     
## Update_stats() & update_view: Either of those get called on 
## every widget update, for the visualization to respond accordingly
########################################################## 
                
    def update_stats(self, clicks = ""):
        '''Function to update the satatistics on the images in display. 
        
        It is called in almost any event of the ImageViewer (zooming, 
        changing image, aplying a transform, changing the colorlimit),
        and always reflects the statistics of the images in display.
        '''

        # Get statistics
        _, _, _, _, _, _, _, descriptive_string = self.get_statistics()       
        # Update stats_text_widget
        self.stats_text.value = descriptive_string + clicks

    def onclick(self,event):
        '''Function to process clicking events on the images
        
        It is only called if ImageViewer was instantiated with the parameter 
        clickable = `True`. This function gets the information of where an 
        Image has been clicked, and then stores it in `self.mouse_coords`. 
        This values can later be accessed by a transform or function. 
        '''
        if not self.block_clicks:
            # need only for points for mapping
            if (not self.line):
                index=len(self.mouse_coords)%2
                # check that user in on the right subplot
                if (event.inaxes == self.axs[index]):
                    if len(self.mouse_coords) < 4:
                        # increment click count upon new entry
                        self.click_count +=1
                        # register mouse coordinates on click
                        self.mouse_coords.append({'click': self.click_count, 'x' : event.xdata,
                                      'y' : event.ydata})

                        self.txt.value =str([str("click:%d,x=%f, y=%f"%(item['click'],item['x'],item['y'])) for item in self.mouse_coords])
                        # print coordinates in statistics section
                        self.update_stats(self.txt.value)
                        # draw according points on canvas
                        self.draw_point(index)
                    else:
                        self.reset_clickables()

            else:
                if len(self.mouse_coords) <2 :

                    self.mouse_coords.append({'click': self.click_count, 'x' : event.xdata,
                                  'y' : event.ydata})
                    self.txt.value =str([str("click:%d,x=%f, y=%f"%(item['click'],item['x'],item['y'])) for item in self.mouse_coords])

                    self.update_stats(self.txt.value)

                    if len(self.mouse_coords) % 2 == 1: 
                        self.draw_point(0)
                    else: 
                        self.draw_line()
                return

    def draw_line(self):
        '''Auxiliary function called by `onclick` to draw lines between points

        If ImageViewerDraws is instantiated with the parameters `lines` and
        `clickable` set to `True`, then this function draws lines between 
        points that have been selected by the user. 
        '''
        x = [p['x'] for p in self.mouse_coords]
        y = [p['y'] for p in self.mouse_coords]

        point = self.axs[0].plot(x,y,'ro',80)

        self.lines.append(self.axs[0].plot(x,y,'r',80)[0])
        self.append(point)


        self.fig.canvas.draw()


    def draw_point(self,index):
        '''Auxiliary function to draw points when a user clicks, if clickable mode.
        '''
        color = ['ro','bo']
        x = [p['x'] for i,p in enumerate(self.mouse_coords) if i %2 ==index]
        y = [p['y'] for i,p in enumerate(self.mouse_coords) if i %2 ==index]
        self.axs[index].plot(x,y,color[index])
        self.fig.canvas.draw()
        
    def update_hist_lines(self): 
        '''Function to reflect changes of brightness and contrast on the histogram
0123456789112345678921234567893123456789412345678951234567896123456789712345 67898123456789
        Every time the b&c slider is called (to adjust the colorscaling on 
        the images) This function is called to adjust the diagonal line on 
        the histogram to reflect this changes.
        '''
        count = 0
        # Get range
        xmin = self.slider_clim.value[0]*0.01
        xmax = self.slider_clim.value[1]*0.01
        # Iterate through every axes that holds a histogram
        for i in range(len(self.axs_hist)):
            # Stop condition (in case subplots = [m, n], where m*n > num_images)
            if count == len(self.data): 
                break
            # Clear existing lines
#             ax.lines.clear()      
#             # Declare new lines that span all the yrange and the xrange given by the slider. They act on axes 'ax', and figure  'fig_hist'
#             l1 = lines.Line2D([xmin, xmax], [0, 1], transform=ax.transAxes, figure=self.fig_hist, color = 'k')
#             # Make the axes show the lines
#             ax.lines.extend([l1])
            if self.current_image != None:
                i = self.current_image
            data = [xmin * (self.max[i] - self.min[i]) +self.min[i], xmax * (self.max[i] - self.min[i]) +self.min[i]]
            try:
                self.lines[count][0].set_xdata(data)
            except:
                pass
#             else:
#                 self.lines[i][0].set_xdata([xmin*self.max[self.current_image]+self.min[self.current_image]*(xmax - xmin), xmax*self.max[self.current_image]])
            count +=1
#         plt.draw()
                   
    def update_view(self):
        '''Function that updates the widget menu as requested by a user. 
        '''
        # Note that all the widgets are held by self.out. First we check if widgets are required, and which menu is to be displayed
        if self.widgets == True:
            if self.view == 'initial':
                # Once we are in the view, we first clear the output of self.out
                self.out.clear_output()
                with self.out:
                    # And display the necessary menu
                    display(self.init_view_rightb)
            elif self.view == 'x_widget':
                self.out.clear_output()
                with self.out:
                    display(self.x_widget_view)
            elif self.view == 'options':
                self.out.clear_output()
                with self.out:
                    display(self.options_view_rightb)
        else:
            # If widgets are turned off, we just clear the output of the widgets and the hist container.
            self.out.clear_output()
            self.hist_container.clear_output()
    
    # Function to be used if single_image = True, by buttons prev and next
    def change_image(self, change = 0):
        '''Called by the buttons *Prev*/*Next* to browse through the images.
0123456789112345678921234567893123456789412345678951234567896123456789712345 67898123456789
        This image takes care of changing the images, and updating the 
        information associated with the image (statistics, histogram, 
        colorbar, axis). If the previously displayed image had the same 
        dimensions as the new one, and it was zoomed to a region, it keeps 
        the zoomed area. Otherwise, it also resets the zoom
        '''
        # Restore self.im (attribute that holds AxesImage objects)
        self.im = []
        # If image in display is to be changed (change = 1, -1, 2, ...), check that there is another image to go to. Otherwise, do nothing
        if self.current_image + change in range(self.number_images):
            # Update attribute self.current_image
            self.current_image += change
            
            # Local variable
            curr_img = self.current_image
            
            # Keep track of wether the new and the previous image have the same shape 
            if self.data[curr_img].shape == self.data[curr_img - change].shape:
                old_axis = [self.xlim[0], self.ylim[0]]
            # Start Axes from scratch
            self.axs[0].clear()
            # Attribute to store lines
            self.lines = []
            # Keep track of wether there was a colorbar before. If so, plot it later
            add_cb = False
            if len(self.cb) == 1:
                add_cb = True
                self.cb[0].remove()
            # Redefine AxesImage (plot image) with the correct clim
            self.im.append(self.axs[0].imshow(self.data[curr_img], cmap = self.dropdown_cmap.value, 
                                              clim = (self.slider_clim.value[0]*0.01*(self.max[curr_img] -
                                                      self.min[curr_img]) + self.min[curr_img], 
                                                      self.slider_clim.value[1]*0.01*(self.max[curr_img] -
                                                      self.min[curr_img]) + self.min[curr_img],)))
#             if self.compare and self.current_image == 2:
#                 self.axs[0].imshow(self.error)
            # Set correct title
            self.axs[0].set_title(self.titles[curr_img])
            # Repeat process for histogram
            if self.channels[curr_img] in [3, 4]:
                self.axs_hist[0].clear()
                self.axs_hist[0].axis('off')  
                self.lines.append(None)
            else:
                self.axs_hist[0].clear()
                self.axs_hist[0].bar(self.bins[curr_img][:-1], self.hist[curr_img], 
                                 width = (self.bins[curr_img][1] - self.bins[curr_img][0]) / 1.2) 
                            # Uncomment if condition to show y-axis
#                 if self.button_show_axis.description == 'Show Axis':
#                 self.axs_hist[0].axes.yaxis.set_visible(False)
                self.axs_hist[0].set_yticks([])
                self.axs_hist[0].set_ylabel('Count')
                self.axs_hist[0].set_xlabel('Bin')
                self.axs_hist[0].set_title(self.titles[curr_img])  
                if self.max[0] != self.min[0]:
                    # Assigning this limit is to fully visualize the first bin, otherwise, half of the bin gets lost
                    self.axs_hist[0].set_xlim(self.min[self.current_image] - 0.01  *(self.max[self.current_image] 
                                                - self.min[self.current_image]), self.max[self.current_image])
                else:
                    # If there is only one value in the image, mpl adjusts automatically but throws warning that we want to hide
                    self.axs_hist[0].set_xlim(self.min[self.current_image] - 0.05, self.min[self.current_image] + 0.05)
                self.axs_hist[0].set_ylim(0, 1.1*np.amax(self.hist[curr_img]))
            
                ### Block to set lines
                xmin = self.slider_clim.value[0]*0.01
                xmax = self.slider_clim.value[1]*0.01
    #             self.lines[0][0].set_xdata([xmin*self.max[self.current_image], xmax*self.max[self.current_image]])
                data = [xmin*(self.max[curr_img]-self.min[curr_img])+self.min[curr_img], 
                        xmax*(self.max[curr_img]-self.min[curr_img])+self.min[curr_img]]
                self.lines.append(self.axs_hist[0].plot(data, self.axs_hist[0].get_ylim(), 'k', linewidth = '0.3', linestyle = 'dashed'))
            # Add colorbar if it existed
            if add_cb:
                self.set_colorbar(colorbar = True)
            # Get the correct x/ylims
            if self.data[curr_img].shape == self.data[curr_img - change].shape:
                self.axs[0].set_xlim(old_axis[0])
                self.axs[0].set_ylim(old_axis[1])
            else:
                self.xlim[0] = np.array([0 - 0.5, self.image_list[curr_img].shape[1] - 0.5])
                self.ylim[0] = np.array([self.image_list[curr_img].shape[0] - 0.5, 0 - 0.5])
            # link new axis to callbacks (for zoom) and update stats to new image
            self.link_axs()
            self.update_stats()
        
        # Manage disabling of buttons (Disable prev if it's the first fig, next if it's the last, else enable both)
        if curr_img == self.number_images -1 :
            self.button_next.disabled = True
            self.button_prev.disabled = False
        elif curr_img == 0:
            self.button_prev.disabled = True
            self.button_next.disabled = False
        else:
            self.button_next.disabled = False
            self.button_prev.disabled = False
        
#         self.fig.tight_layout()
    
    # In case of any transformation to the image, this function will update the information
    def update_histogram(self):
        '''Auxiliary function to update the histograms.

        This function is called by `__init__`, `change_image`, `x_w_callback`
        and any function that modifies the current display. It takes ensures
        that the histogram in display matches the image. 
        '''
        # Initialize arrays to hold hist and bins
        self.hist = []
        self.bins = []
        # Iterate through each figure and get its histogram its histogram
        count = 0
        for i in range(self.number_images):
            hist, bins = np.histogram(self.data[count], bins = 70, range = (self.min[count], self.max[count]))
            # Append info to our bins and hist attributes
            self.bins.append(bins)
            self.hist.append(hist)
            count += 1
        
        # Attribute to store the lines 
        self.lines = []
        # Now, we iterate through the hist_axis's, and show the histogram, according to the use case. 
        count = 0   
        for i in range(self.number_images):
            # The first conditional is equal to the conditional when activating axis for the images
#             print(i, len(self.axs), self.number_images - 1)
            if i == len(self.axs) or i == self.number_images - 1:
                if len(self.axs) < len(self.data):
                    break
                else:
                    for j in range(i + 1, len(self.axs)):
                        self.axs_hist[j].axis('off')
            
            # Display axes, only with the x-axis visible, and with corresponding title
            if self.current_image != None:
                count = self.current_image
            if self.channels[i] in [3, 4]:
                self.axs_hist[i].clear()
                self.axs_hist[i].axis('off')
                # We add this element to make the line consistent with the histograms
                self.lines.append(None)
            else:
                self.axs_hist[i].clear()
                if self.max[count] != self.min[count]:
                    # Assigning this limit is to fully visualize the first bin, otherwise, half of the bin gets lost
                    self.axs_hist[i].set_xlim(self.min[count] - 0.01  *(self.max[count] - self.min[count]), self.max[count])
                else:
                    # If there is only one value in the image, mpl adjusts automatically but throws warning that we want to hide
                    self.axs_hist[i].set_xlim(self.min[count] - 0.05, self.min[count] + 0.05)
                self.axs_hist[i].set_ylim(0, 1.1*np.amax(self.hist[count]) + 0.05)
                self.axs_hist[i].bar(self.bins[count][:-1], self.hist[count], width = (self.bins[count][1] - self.bins[count][0]) / 1.2)
                self.lines.append(self.axs_hist[i].plot(self.axs_hist[i].get_xlim(), self.axs_hist[i].get_ylim(), 'k', linewidth = '0.3', linestyle = 'dashed'))
                # Hide only y-axis ticks by default
    #             self.axs_hist[i].axes.yaxis.set_visible(False)
                self.axs_hist[i].set_yticks([])
                self.axs_hist[i].set_title(self.titles[count])
                self.axs_hist[i].set_ylabel('Count')
                self.axs_hist[i].set_xlabel('Bin')
            count +=1
            
        self.fig_hist.tight_layout()
#         self.update_hist_lines()
            
    # Function that links all existing Axes to the Matplotlib callbacks, to act whenever axis limits change (e.i., when there is a zoom)
    def link_axs(self):
        '''Function called when there is any change in the axis to store

        This function is called when an image changes, when there is a zoom 
        event, or any event to changes the axis. If the functionality *Joint
        Zoom* is activated, it updates the axis of the rest of the images 
        also. Moreover, it updates the statistics, to get the information 
        from the image currently in display.
        '''

        def on_xlims_change(event_ax):
            # Iterate through all the images
            for i in range(self.number_images):
                # In the case of single_image == True, stop at the first
                if len(self.axs) == 1:
                    # Update xlim attribute
                    self.xlim[i] = np.round(event_ax.get_xlim(),1)
                    break
                # Check if joint zoom is on
                if self.button_joint_zoom.description == 'Disable Joint Zoom':
                    self.xlim[i] = np.round(event_ax.get_xlim(),1)
                # Else look for the Axes which had the changes
                elif event_ax == self.axs[i]:
                    # Once found, update xlimits
                    self.xlim[i] = np.round(event_ax.get_xlim(),1)
                
            self.update_stats()
            
        def on_ylims_change(event_ax):
            for i in range(self.number_images):
                if len(self.axs) == 1:
                    self.ylim[i] = np.round(event_ax.get_ylim(),1)
                    break
                if self.button_joint_zoom.description == 'Disable Joint Zoom':
                    self.ylim[i] = np.round(event_ax.get_ylim(),1)
                elif event_ax == self.axs[i]:
                    self.ylim[i] = np.round(event_ax.get_ylim(),1)
                
            self.update_stats()
            
        count = 0            
        # Connect all Axes to the same handler (The handler we just defined takes care of identifying the Axes that changed)
        for ax in self.axs:
            if self.number_images == count:
                break
            ax.set_xlim(self.xlim[count])
            ax.set_ylim(self.ylim[count])
            ax.callbacks.connect('xlim_changed', on_xlims_change)
            ax.callbacks.connect('ylim_changed', on_ylims_change)
            count += 1
        
##########################################################     
################# Utility Functions ######################
##########################################################        

    def get_histogram(self):
        #Return histogram information, bins, hist and axes in list form
        return(self.bins, self.hist)
    
    def show_histogram(self, hist = False):        
        if hist:
            self.out_fig.layout.width = '45%'
#             self.hist_container = widgets.Output()
#             self.final_view_hist = widgets.HBox([self.out_fig, self.hist_container, self.out])       #H layout
#     #         self.final_view_hist = widgets.VBox([self.final_view_no_hist, self.hist_container])    # V layout
#             display(self.final_view_hist)
            with self.hist_container:
                display(self.out_hist) # self.out_hist
        else:
            self.hist_container.clear_output()
            self.out_fig.layout.width = '80%'

    
    def set_widgets(self, widgets = True):
        if widgets:
            self.widgets = True
        else:
            self.widgets = False
            self.out.clear_output()
        self.button_showw.close()
        self.update_view()
    
    def set_axis(self, axis = False):
        count = 0
        # Iterate through the images
        for ax in self.axs:  
            # check that we have images left to plot 
            if count == len(self.data): 
                break
            ax.axes.yaxis.set_visible(axis)
            ax.axes.xaxis.set_visible(axis)
            count += 1
        
        count = 0
        # uncomment block to extrapolate behaviour to y-axis on histogram
#         # Iterate through the histograms (Hide only y-axis) 
#         for ax in self.axs_hist:  
#             # check that we have images left to plot 
#             if count == len(self.data): 
#                 break
#             ax.axes.yaxis.set_visible(axis)
#             count += 1
            
    def set_colorbar(self, colorbar = True):
        if colorbar:
            # Redefine attribute holding colorbars. Make sure we have an empty list to append our colorbars
            self.cb = []

        # Iterate through every AxesImage
        count = 0
        for ax in self.axs:
            # check that we have images left to plot (in case subplots = [m, n], where m*m > num_images), if not, break           
            if count == len(self.data): 
                break
            if colorbar:
                # If colorbar was requested, if so, show it
                self.cb.append(self.fig.colorbar(self.im[count], ax = ax))
            else:
                # If colorbar = False, check if there is an existing colorbar. If so, remove it.
                if count < len(self.cb):
                    self.cb[count].remove()
            count += 1
        
        # Now that we have removed the colorbars, empty the list, to get it ready for another call
        if not(colorbar):
            self.cb = []
        # Call plt.draw to show colorbars
        plt.draw()
            
    def set_colormap(self, colormap = 'gray'):
        # Iterate every AxesImage object in our attribute and set the colormap requested
        for im in self.im:
            im.set_cmap(cmap = colormap)   

    def get_statistics(self, images = None):
        '''Function to get extensive statistics about the displayed images
0123456789112345678921234567893123456789412345678951234567896123456789712345 67898123456789
        Function that iterates through all the images in display, and gets 
        information ony about the current region in display. It firsts 
        extracts the region, and calculates the mean and standard deviation,
        minimum and maximum values, shape, and display limits. It returns 
        this information in list form, plus a descriptive string used to 
        display the information in the viewer.
        '''
        # Initialize lists to hold information
        mean = []
        std = []
        min_value = []
        max_value = []
        shape = []
        xlim = []
        ylim = []
        rang = []
        # This conditionals are to check if a specific image is requested by the user
        if images == None:            
            # If no specific image, get all the images given when initializenf the array...
            images = range(len(self.image_list))
            # Unless single_image = True...
            if self.current_image != None:
                # Then just get the info of the image currently in display 
                images = [self.current_image]
        # Check that if the user requested a specific image, it is in the form of a list
        elif type(images) == int:
            images = [images]
        # Check that requested image actually exists
        if np.max(images) >= self.number_images:
            print('You only have {} image(s). Make your selection in the range 0-{}'.format(self.number_images, self.number_images - 1))
            return       
        # Iterate the requested images
        count = 0
        for i in images:
            if i == len(self.axs) and self.current_image == None:
                # In case there are more images than AxesImage objects, stop for loop
                break
            if self.current_image == None:
                xlim.append(list(self.xlim[i]))
                ylim.append(list(self.ylim[i]))
            else:
                # If only one image on display, get the first element
                xlim.append(list(self.xlim[0]))
                ylim.append(list(self.ylim[0]))
            # Account for matplotlibs offset
            xlim[count][0] += 0.5
            xlim[count][1] += 0.5
            ylim[count][0] += 0.5
            ylim[count][1] += 0.5            

            # Get the information actually on display (self.data, and limits (zoom region) stored in self.x/ylims )
            plotted = self.data[i][int(ylim[count][1]):int(ylim[count][0]),
                                           int(xlim[count][0]):int(xlim[count][1])]
            # Actually get statistics
            # If there is only one image being selected, range and shape do not keep the list form
            if len(images) == 1:                
                mean = float(np.round(plotted.mean(),2))                
                std = float(np.round(plotted.std(),2))
                min_value = np.round(np.amin(plotted),2)                        
                max_value = np.round(np.amax(plotted),2)    
                shape = self.original[i].shape                
                rang = (min_value, max_value)
            else:
                mean.append(np.round(plotted.mean(),2))
                std.append(np.round(plotted.std(),2))
                min_value.append(np.round(np.amin(plotted),2))                        
                max_value.append(np.round(np.amax(plotted),2))    
                shape.append(self.original[i].shape)
                rang.append((min_value[count], max_value[count]))
            count += 1     
        # Prepare string
        description = 'mean = {}\nstd_dev = {}\nrange = {}\nsize = {}'.format(np.round(mean, 2), np.round(std, 2), rang, shape)                
        return(mean, std, min_value, max_value, shape, xlim, ylim, description)
    
    def save(self, change):       
        if self.current_image != None:
            viewer_screenshot = np.copy(self.data[self.current_image])
            viewer_screenshot = viewer_screenshot[int(self.ylim[0][1]):int(self.ylim[0][0]), 
                                                  int(self.xlim[0][0]):int(self.xlim[0][1])]
        else:
            viewer_screenshot = []
            for i in range(len(self.axs)):
                viewer_screenshot.append(np.copy(self.data[i]))
                viewer_screenshot[i] = viewer_screenshot[int(self.ylim[i][1]):int(self.ylim[i][0]), 
                                                         int(self.xlim[i][0]):int(self.xlim[i][1])]                
        return viewer_screenshot
      
    def retrieve_name(self, var): 
        '''Function to retrieve the name of a variable in str form
        '''
        for fi in reversed(inspect.stack()):
            names = [var_name for var_name, var_val in fi.frame.f_locals.items() if var_val is var]
            if len(names) > 0:
                return names[0]