import cv2
import numpy as np
import random
from typing import Any, Union
import math

from ipso_phen.ipapi.tools.csv_writer import AbstractCsvWriter

import logging

logger = logging.getLogger(__name__)


class ToolFamily:
    ANCILLARY = "Ancillary"
    CLUSTERING = "Clustering"
    DEMO = "Demo"
    DEFAULT_PROCESS = "Execute default process"
    FEATURE_EXTRACTION = "Feature extraction"
    EXPOSURE_FIXING = "Exposure fixing"
    ASSERT = "Assert..."
    IMAGE_GENERATOR = "Image generator"
    IMAGE_INFO = "Image info"
    MASK_CLEANUP = "Mask cleanup"
    PRE_PROCESSING = "Pre processing"
    ROI = "Create an ROI"
    THRESHOLD = "Threshold"
    VISUALIZATION = "Visualization"
    WHITE_BALANCE = "White balance"
    UNKNOWN = "Unknown"
    PCV = "PlantCV"
    # Deprecated
    ROI_DYNAMIC = "ROI (dynamic)"
    ROI_STATIC = "ROI (static)"
    ROI_PP_IMAGE = "ROI on pre processed image"
    ROI_RAW_IMAGE = "ROI on raw image"


tool_family_hints = {
    ToolFamily.ANCILLARY: "Tools mostly used inside other tools",
    ToolFamily.CLUSTERING: "Clustering tools",
    ToolFamily.DEMO: "Demo tools, start here if you want to understand how to create/edit tools",
    ToolFamily.DEFAULT_PROCESS: """Execute a class pipeline linked to the selected image experiment,
    if no class pipeline is available an error will be reported""",
    ToolFamily.FEATURE_EXTRACTION: "Tools to extract features from a segmented image",
    ToolFamily.EXPOSURE_FIXING: "Fix image exposure, the resulting image will be used for color analysis",
    ToolFamily.IMAGE_GENERATOR: "Creates one or more images from a selected image",
    ToolFamily.IMAGE_INFO: "Gives info about current image",
    ToolFamily.MASK_CLEANUP: "Cleans a coarse mask generated by threshold tools",
    ToolFamily.PRE_PROCESSING: """Transform the image to help segmentation, 
    the image may not retain it's 
    properties. Changes here will be ignored when extracting features""",
    ToolFamily.ROI: "Create an ROI",
    ToolFamily.ASSERT: "Assert something, returns wether or not assertion is passed",
    ToolFamily.THRESHOLD: "Creates a mask that keeps only parts of the image",
    ToolFamily.VISUALIZATION: "Visualization tools",
    ToolFamily.WHITE_BALANCE: """Tools to help change white balance, depending on where those tools are set in the pipeline they or
    may not be ignored when extracting features""",
    ToolFamily.UNKNOWN: "There should be nothing here",
    ToolFamily.PCV: "UI integration of a PlantCV script",
}

tool_groups_pipeline = [
    ToolFamily.ASSERT,
    ToolFamily.FEATURE_EXTRACTION,
    ToolFamily.EXPOSURE_FIXING,
    ToolFamily.MASK_CLEANUP,
    ToolFamily.PRE_PROCESSING,
    ToolFamily.ROI,
    ToolFamily.THRESHOLD,
    ToolFamily.WHITE_BALANCE,
    ToolFamily.IMAGE_GENERATOR,
    ToolFamily.VISUALIZATION,
]

IO_IMAGE = "io_image"
IO_MASK = "io_mask"
IO_ROI = "io_roi"
IO_DATA = "io_data"
IO_NONE = "io_none"

MERGE_MODE_AND = "merge_mode_and"
MERGE_MODE_OR = "merge_mode_or"
MERGE_MODE_CHAIN = "merge_mode_chain"
MERGE_MODE_NONE = "merge_mode_none"


def io_type_to_str(io_type: str) -> str:
    if io_type == IO_IMAGE:
        return "image"
    elif io_type == IO_MASK:
        return "mask"
    elif io_type == IO_ROI:
        return "ROI"
    elif io_type == IO_DATA:
        return "data"
    elif io_type == IO_NONE:
        return "none"
    else:
        return "unknown"


def merge_mode_to_str(merge_mode: str) -> str:
    if merge_mode == MERGE_MODE_AND:
        return "AND"
    elif merge_mode == MERGE_MODE_OR:
        return "OR"
    elif merge_mode == MERGE_MODE_CHAIN:
        return "CHAIN"
    elif merge_mode == MERGE_MODE_NONE:
        return "NONE"
    else:
        return "unknown"


AVAILABLE_FEATURES = sorted(
    [
        # Header - text values
        "experiment",
        "plant",
        "date_time",
        "hist_bins",
        # Morphology
        "area",
        "hull_area",
        "width_data",
        "shape_height",
        "centroid",
        "shape_solidity",
        "shape_extend",
        "straight_bounding_rectangle",
        "rotated_bounding_rectangle",
        "minimum_enclosing_circle",
        "bound_data",
        "quantile_width_4",
        # Color descriptors
        "color_std_dev",
        "color_mean",
        "quantile_color_5",
        # Chlorophyll data
        "chlorophyll_mean",
        "chlorophyll_std_dev",
    ]
)

DEFAULT_COLOR_MAP = cv2.COLORMAP_JET

RGB = "rgb"
LAB = "lab"
HSV = "hsv"
MSP = "msp"
NDVI = "ndvi"
CHLA = "chla"

_HSV_CHANNELS = dict(
    h="hue",
    s="saturation",
    v="value",
)
_LAB_CHANNELS = dict(
    l="lightness",
    a="a_green-red",
    b="b_blue-yellow",
)
_RGB_CHANNELS = dict(
    rd="red",
    gr="green",
    bl="blue",
)
_MSP_CHANNELS = dict(
    wl_520="wl_520",
    wl_550="wl_550",
    wl_671="wl_671",
    wl_680="wl_680",
    wl_720="wl_720",
    wl_800="wl_800",
    wl_905="wl_905",
)
_NDVI_CHANNELS = dict(
    ndvi_720="ndvi_720",
    ndvi_800="ndvi_800",
    ndvi_905="ndvi_905",
)
_CHLA_CHANNELS = dict(chloro="chlorophyll")

CHANNELS_BY_SPACE = dict(
    hsv=_HSV_CHANNELS,
    lab=_LAB_CHANNELS,
    rgb=_RGB_CHANNELS,
    msp=_MSP_CHANNELS,
    ndvi=_NDVI_CHANNELS,
    chla=_CHLA_CHANNELS,
)
CHANNELS_FLAT = {
    **_HSV_CHANNELS,
    **_LAB_CHANNELS,
    **_RGB_CHANNELS,
    **_MSP_CHANNELS,
    **_NDVI_CHANNELS,
    **_CHLA_CHANNELS,
}

C_BLACK = (0, 0, 0)
C_BLUE = (255, 0, 0)
C_BLUE_VIOLET = (226, 43, 138)
C_CABIN_BLUE = (209, 133, 67)
C_CYAN = (255, 255, 0)
C_DIM_GRAY = (105, 105, 105)
C_FUCHSIA = (255, 0, 255)
C_GREEN = (0, 128, 0)
C_LIGHT_STEEL_BLUE = (222, 196, 176)
C_LIME = (0, 255, 0)
C_MAROON = (0, 0, 128)
C_ORANGE = (80, 127, 255)
C_PURPLE = (128, 0, 128)
C_RED = (0, 0, 255)
C_SILVER = (192, 192, 192)
C_TEAL = (128, 128, 0)
C_WHITE = (255, 255, 255)
C_YELLOW = (0, 255, 255)

all_colors_dict = dict(
    black=C_BLACK,
    blue=C_BLUE,
    blue_violet=C_BLUE_VIOLET,
    blue_cabin=C_CABIN_BLUE,
    cyan=C_CYAN,
    dim_grey=C_DIM_GRAY,
    fuchsia=C_FUCHSIA,
    green=C_GREEN,
    light_steel_blue=C_LIGHT_STEEL_BLUE,
    lime=C_LIME,
    maroon=C_MAROON,
    orange=C_ORANGE,
    purple=C_PURPLE,
    red=C_RED,
    silver=C_SILVER,
    teal=C_TEAL,
    white=C_WHITE,
    yellow=C_YELLOW,
)

ALL_COLORS = [v for v in all_colors_dict.values()]


def bgr_to_rgb(color: tuple) -> tuple:
    """Converts from BGR to RGB

    Arguments:
        color {tuple} -- Source color

    Returns:
        tuple -- Converted color
    """
    return (color[2], color[1], color[0])


def rgb_to_bgr(color: tuple) -> tuple:
    """Converts from RGB to BGR

    Arguments:
        color {tuple} -- Source color

    Returns:
        tuple -- Converted color
    """
    return (color[0], color[1], color[2])


def get_hr_channel_name(channel):
    """Returns human readable channel name

    Arguments:
        channel {str} -- channel short name

    Raises:
        NameError -- raise exception if unknown channel

    Returns:
        str -- human readable channel name
    """

    if channel in CHANNELS_BY_SPACE[HSV]:
        return f"hsv: {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[LAB]:
        return f"lab: {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[RGB]:
        return f"rgb: {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[MSP]:
        return f"msp {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[NDVI]:
        return f"ndvi {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[CHLA]:
        return f"chla {CHANNELS_FLAT[channel]}"
    else:
        raise NameError("Unknown channel")


def get_channel_name(channel):
    """Returns human readable channel name

    Arguments:
        channel {str} -- channel short name

    Raises:
        NameError -- raise exception if unknown channel

    Returns:
        str -- human readable channel name
    """

    if channel in CHANNELS_BY_SPACE[HSV]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[LAB]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[RGB]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[MSP]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[NDVI]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[CHLA]:
        return CHANNELS_FLAT[channel]
    else:
        raise NameError("Unknown channel")


def create_channel_generator(
    channels=(),
    include_vis: bool = True,
    include_ndvi: bool = False,
    include_msp: bool = False,
    include_chla: bool = False,
):
    """Create channel iterator, selection possible

    Keyword Arguments:
        channels {list} -- accepted channels, if None then all (default: {[]})
    """
    if channels:
        for cs_name, cs_data in CHANNELS_BY_SPACE.items():
            for channel_id, channel_name in cs_data.items():
                if (channel_id in channels) or not channels:
                    yield cs_name, channel_id, channel_name
    else:
        groups = []
        if include_vis:
            groups.extend((RGB, HSV, LAB))
        if include_msp:
            groups.append(MSP)
        if include_ndvi:
            groups.append(NDVI)
        if include_chla:
            groups.append(CHLA)

        for cs in groups:
            for k, v in CHANNELS_BY_SPACE[cs].items():
                if (k in channels) or not channels:
                    yield cs, k, v


def channel_color(channel: str):
    """
    Retruns a color cooresponding to the channel
    :param channel:
    :return:
    """
    if channel == "rd":
        return C_RED
    elif channel == "gr":
        return C_GREEN
    elif channel == "bl":
        return C_BLUE
    elif channel == "l":
        return C_DIM_GRAY
    elif channel == "a":
        return C_FUCHSIA
    elif channel == "b":
        return C_YELLOW
    elif channel == "h":
        return C_BLUE_VIOLET
    elif channel == "s":
        return C_CYAN
    elif channel == "v":
        return C_ORANGE
    elif channel == "wl_520":
        return (0x00, 0xFF, 0x36)
    elif channel == "wl_550":
        return (0x00, 0xFF, 0xA3)
    elif channel == "wl_671":
        return (0, 0, 200)
    elif channel == "wl_680":
        return (0x0, 0x0, 150)
    elif channel == "wl_720":
        return (125, 0x0, 150)
    elif channel == "wl_800":
        return (200, 0x0, 150)
    elif channel == "wl_905":
        return (255, 0x0, 150)
    elif channel == "ndvi_720":
        return (0x0, 180, 0x0)
    elif channel == "ndvi_800":
        return (0x0, 200, 0x0)
    elif channel == "ndvi_905":
        return (0x0, 220, 0x0)
    elif channel == "chlorophyll":
        return C_LIME
    else:
        return C_BLACK


def random_color(restrained: bool = True) -> tuple:
    """
    Retrurns a random color
    :param restrained: if True color will be selected from a list
    :return: tuple, BGR color
    """
    if restrained:
        return ALL_COLORS[random.randrange(0, len(ALL_COLORS))]
    else:
        return (
            random.randrange(0, 255),
            random.randrange(0, 255),
            random.randrange(0, 255),
        )


def scaled_rgb(mag, color_min, color_max):
    """ Return a tuple of integers, as used in AWT/Java plots. """
    try:
        x = float(mag - color_min) / (color_max - color_min)
    except ZeroDivisionError:
        logger.exception("Scaled rgb tried to divide by 0")
        x = 0.5  # color_max == color_min
    blue = min((max((4 * (0.75 - x), 0.0)), 1.0))
    red = min((max((4 * (x - 0.25), 0.0)), 1.0))
    green = min((max((4 * math.fabs(x - 0.5) - 1.0, 0.0)), 1.0))
    return int(red * 255), int(green * 255), int(blue * 255)


def build_color_steps(step_count: int) -> list:
    """
    Builds a list containing step_count different colors from blue to yellow
    :param step_count:
    :return:
    """
    if step_count <= 1:
        return [C_LIGHT_STEEL_BLUE]
    else:
        return [
            scaled_rgb(mag=i, color_min=0, color_max=step_count)
            for i in range(step_count)
        ]


def ensure_odd(
    i: int, min_val: Union[None, int] = None, max_val: Union[None, int] = None
) -> int:
    """Transforms an odd number into pair number by adding one

    Arguments:
        i {int} -- number

    Returns:
        int -- Odd number
    """
    if (i > 0) and (i % 2 == 0):
        i += 1
    if min_val is not None:
        return max(i, min_val)
    if max_val is not None:
        return min(i, max_val)
    return i


def resize_image(src_img, **kwargs):
    """
    Resizes image may keep aspect ration
    :param src_img:
    """

    target_rect = kwargs.get("target_rect", None)
    if target_rect is not None:
        w, h, ar = target_rect.width, target_rect.height, target_rect.ar
    else:
        w = kwargs.get("width", 100)
        h = kwargs.get("height", 100)
        ar = w / h
    keep_aspect_ratio = kwargs.get("keep_aspect_ratio", False) is True

    i_h, i_w = src_img.shape[:2]
    if keep_aspect_ratio and (ar > 0):
        i_ar = i_w / i_h
        if ar > i_ar:
            n_h = int(h)
            n_w = int(n_h * i_ar)
        else:
            n_w = int(w)
            n_h = int(n_w / i_ar)
    else:
        n_w = int(w)
        n_h = int(h)

    if (len(src_img.shape) == 2) and kwargs.get("output_as_bgr", True):
        src_img = cv2.cvtColor(src_img, cv2.COLOR_GRAY2BGR)

    src_img = cv2.resize(src_img, (n_w, n_h), interpolation=cv2.INTER_CUBIC)

    return src_img


def enclose_image(a_cnv, img, rect, frame_width: int = 0):
    """Resize and copy source image to canvas rect while conserving aspect ratio

    Arguments:
        a_cnv {numpy array} -- target canvas
        img {numpy array} -- src image
        rect {TRect} -- enclosing rectangle

    Returns:
        numpy array -- modified target canvas
    """

    # old_h, old_w = img.shape[:2]
    # if (rect.width == old_w) and (rect.height == old_h):
    #     a_cnv = img.copy()
    # else:
    resized_image = resize_image(img.copy(), target_rect=rect, keep_aspect_ratio=True)
    n_h, n_w = resized_image.shape[:2]

    dx = int((rect.width - n_w) / 2)
    dy = int((rect.height - n_h) / 2)
    rect.inflate(-dx, -dx, -dy, -dy)
    rect.width = n_w
    rect.height = n_h
    a_cnv[rect.top : rect.bottom, rect.left : rect.right] = resized_image

    if frame_width:
        cv2.rectangle(
            img=a_cnv,
            pt1=(rect.left, rect.top),
            pt2=(rect.right, rect.bottom),
            color=(255, 255, 255),
            thickness=frame_width,
        )

    return a_cnv


def multi_and(image_list: tuple):
    """Performs an AND with all the images in the tuple

    :param image_list:
    :return: image
    """
    img_lst = [i for i in image_list if i is not None]
    list_len_ = len(img_lst)

    if list_len_ == 0:
        return None
    elif list_len_ == 1:
        return img_lst[0]
    else:
        res = cv2.bitwise_and(img_lst[0], img_lst[1])
        if len(img_lst) > 2:
            for current_image in img_lst[2:]:
                res = cv2.bitwise_and(res, current_image)

        return res


def multi_or(image_list: tuple):
    """Performs an OR with all the images in the tuple

    :param image_list:
    :return: image
    """
    img_lst = [i for i in image_list if i is not None]
    list_len_ = len(img_lst)

    if list_len_ == 0:
        return None
    elif list_len_ == 1:
        return img_lst[0]
    else:
        res = cv2.bitwise_or(img_lst[0], img_lst[1])
        if list_len_ > 2:
            for current_image in img_lst[2:]:
                res = cv2.bitwise_or(res, current_image)
        return res


def get_kernel(size: int, shape: int):
    """Builds morphology kernel

    :param size: kernel size, must be odd number
    :param shape: select shape of kernel
    :return: Morphology kernel
    """
    return cv2.getStructuringElement(shape, (size, size))


def open(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Open wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- opened image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            r = roi.as_rect()
            result[r.top : r.bottom, r.left : r.right] = cv2.morphologyEx(
                result[r.top : r.bottom, r.left : r.right],
                cv2.MORPH_OPEN,
                morph_kernel,
                iterations=proc_times,
            )
    else:
        result = cv2.morphologyEx(
            image, cv2.MORPH_OPEN, morph_kernel, iterations=proc_times
        )
    return result


def close(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Close wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interest
        proc_times {int} -- iterations

    Returns:
        numpy array -- closed image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            r = roi.as_rect()
            result[r.top : r.bottom, r.left : r.right] = cv2.morphologyEx(
                result[r.top : r.bottom, r.left : r.right],
                cv2.MORPH_CLOSE,
                morph_kernel,
                iterations=proc_times,
            )
    else:
        result = cv2.morphologyEx(
            image, cv2.MORPH_CLOSE, morph_kernel, iterations=proc_times
        )
    return result


def dilate(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Dilate wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- dilated image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            if roi is not None:
                r = roi.as_rect()
                result[r.top : r.bottom, r.left : r.right] = cv2.dilate(
                    result[r.top : r.bottom, r.left : r.right],
                    morph_kernel,
                    iterations=proc_times,
                )
    else:
        result = cv2.dilate(image, morph_kernel, iterations=proc_times)
    return result


def erode(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Erode wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- eroded image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            if roi is not None:
                r = roi.as_rect()
                result[r.top : r.bottom, r.left : r.right] = cv2.erode(
                    result[r.top : r.bottom, r.left : r.right],
                    morph_kernel,
                    iterations=proc_times,
                )
    else:
        result = cv2.erode(image, morph_kernel, iterations=proc_times)
    return result


def morphological_gradient(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
):
    """Morphology - Erode wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- eroded image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            if roi is not None:
                r = roi.as_rect()
                result[r.top : r.bottom, r.left : r.right] = cv2.morphologyEx(
                    result[r.top : r.bottom, r.left : r.right],
                    cv2.MORPH_GRADIENT,
                    morph_kernel,
                )
    else:
        result = cv2.morphologyEx(image, cv2.MORPH_GRADIENT, morph_kernel)
    return result


def get_contours(mask, retrieve_mode, method):
    return cv2.findContours(mask.copy(), retrieve_mode, method)[-2:-1][0]


def get_contours_and_hierarchy(mask, retrieve_mode, method):
    return cv2.findContours(mask.copy(), retrieve_mode, method)[-2:]


def group_contours(mask):
    """Gets contours from mask and merges them into single one"""
    id_objects, obj_hierarchy = get_contours_and_hierarchy(
        mask=mask, retrieve_mode=cv2.RETR_TREE, method=cv2.CHAIN_APPROX_NONE
    )

    stack = np.zeros((len(id_objects), 1))

    for c, cnt in enumerate(id_objects):
        if obj_hierarchy[0][c][2] == -1 and obj_hierarchy[0][c][3] > -1:
            stack[c] = 0
        else:
            stack[c] = 1
    ids = np.where(stack == 1)[0]
    if len(ids) > 0:
        return np.vstack([id_objects[i] for i in ids])
    else:
        return []


class MaskLineData(object):
    __slots__ = [
        "height_pos",
        "nz_span",
        "nz_count",
        "solidity",
        "last_span",
        "nz_pos",
        "tag",
    ]

    def __init__(self, line_number: int, line_data: list, last_span):
        ln_dt = np.nonzero(line_data)[0]
        self.height_pos = line_number
        self.nz_span = ln_dt[-1] - ln_dt[0] + 1 if len(ln_dt) > 0 else 0
        self.nz_count = len(ln_dt)
        self.solidity = self.nz_count / self.nz_span if self.nz_span != 0 else 0
        self.last_span = last_span
        self.nz_pos = ln_dt
        self.tag = "none"

    def __str__(self):
        return f"[height: {self.height_pos}][width: {self.nz_span}][solidity: {self.solidity}]"

    def __eq__(self, other):
        return np.array_equal(self.nz_pos, other.nz_pos)

    def is_inside(self, other):
        if other is None:
            return False
        if len(self.nz_pos) == 0:
            return True
        for i in self.nz_pos:
            if i not in other.nz_pos:
                return False
        return True

    def merge_and(self, line1, line2):
        if (line1 is None) or (line2 is None):
            ln_dt = np.array([])
        else:
            ln_dt = np.array([idx for idx in line1.nz_pos if idx in line2.nz_pos])
        self.nz_span = ln_dt[-1] - ln_dt[0] + 1 if len(ln_dt) > 0 else 0
        self.nz_count = len(ln_dt)
        self.solidity = self.nz_count / self.nz_span if self.nz_span != 0 else 0
        self.nz_pos = ln_dt

    def merge_or(self, line1, line2):
        ln_dt = []
        if line1 is not None:
            ln_dt = ln_dt + [idx for idx in line1.nz_pos]
        if line2 is not None:
            ln_dt = ln_dt + [idx for idx in line2.nz_pos]
        ln_dt = np.array(sorted(list(set(ln_dt))))
        self.nz_span = ln_dt[-1] - ln_dt[0] + 1 if len(ln_dt) > 0 else 0
        self.nz_count = len(ln_dt)
        self.solidity = self.nz_count / self.nz_span if self.nz_span != 0 else 0
        self.nz_pos = ln_dt

    def clear(self):
        self.nz_span = 0
        self.nz_count = 0
        self.solidity = 0
        self.nz_pos = []
        self.tag = "none"


class MaskData(object):
    def __init__(self, mask):
        self._height = None
        self._width = None
        self._is_continuous = True
        self._lines_data = []
        self._mask = mask
        last_span_ = 0
        for line_number, line in enumerate(mask):
            # Build line data
            cur_ln_dt = MaskLineData(line_number, line, last_span_)
            if (cur_ln_dt.nz_count == 0) and (len(self._lines_data) == 0):
                continue
            last_span_ = cur_ln_dt.nz_span
            self._lines_data.append(cur_ln_dt)
        if self.lines_data:
            while self.lines_data[-1].nz_count == 0:
                del self.lines_data[-1]

    def to_mask(self, first_line=-1, last_line=-1, colour=255):
        msk_out = np.zeros_like(self.mask)
        if first_line == -1:
            first_line = 0
        if last_line == -1:
            last_line = self.mask_height
        for line in self.lines_data[first_line:last_line]:
            for i in line.nz_pos:
                msk_out[line.height_pos][i] = colour
        return msk_out

    def find_by_height(self, height):
        for ld in self._lines_data:
            if ld.height_pos == height:
                return ld
        return None

    def find_top_bottom_non_full_lines(self, height):
        ld = self.find_by_height(height=height)
        if ld is None:
            return None, None
        ht = height - 1
        ld_up = self.find_by_height(height=ht)
        while (ld_up is not None) and ld.is_inside(ld_up) and (ht > self.top_index):
            ht -= 1
            ld_up = self.find_by_height(height=ht)
        ht = height + 1
        ld_down = self.find_by_height(height=ht)
        while (
            (ld_down is not None) and ld.is_inside(ld_down) and (ht < self.bottom_index)
        ):
            ht += 1
            ld_down = self.find_by_height(height=ht)
        return ld_up, ld_down

    def horizontal_lines_at(
        self, height: int, min_length: int = 1, fully_isolated: bool = True
    ):
        res = False
        try:
            # Get the lines we need
            ld = self.find_by_height(height=height)
            if ld is None or (ld.nz_count < min_length):
                res = None

            if ld.solidity >= 0.99:
                ld_up, ld_down = self.find_top_bottom_non_full_lines(height)
            else:
                ld_up = self.find_by_height(height=height - 1)
                ld_down = self.find_by_height(height=height + 1)

            # Build line candidates spans
            run = []
            runs = [run]
            expect = None
            top_nok = False
            bottom_nok = False
            for v in ld.nz_pos:
                is_continuous = (v == expect) or (expect is None)
                if (ld_up is not None) and (v in ld_up.nz_pos):
                    top_nok = True
                if (ld_down is not None) and (v in ld_down.nz_pos):
                    bottom_nok = True
                neighbors_nok = (fully_isolated and (top_nok or bottom_nok)) or (
                    top_nok and bottom_nok
                )
                if is_continuous and not neighbors_nok:
                    run.append(v)
                else:
                    run = [v]
                    runs.append(run)
                    top_nok = False
                    bottom_nok = False
                expect = v + 1

            res = [
                (ld.height_pos, run[0], run[-1]) for run in runs if len(run) >= min_length
            ]
        except Exception as e:
            logger.exception(f"Raised {repr(e)}")
            res = None
        finally:
            return res

    def percent_height(self, percent: float):
        return self.bottom_index - self.height * percent

    def get_pos_height(self, value):
        if isinstance(value, int):
            return value
        elif isinstance(value, float) and (value <= 1):
            return self.percent_height(value)
        else:
            return None

    def line_at(self, i):
        i = self.get_pos_height(i)
        if i is None:
            return 0
        return self.find_by_height(i)

    def width_at(self, i):
        ld = self.line_at(i)
        if ld:
            return ld.nz_span
        else:
            return 0

    def nz_count_at(self, i):
        ld = self.line_at(i)
        if ld:
            return ld.nz_count
        else:
            return 0

    def is_full_line(self, height):
        ld = self.find_by_height(height=height)
        return (ld is not None) and (ld.nz_count == self.width)

    def height_quantile_mask(self, total, index, colour=255):
        """
        Returns portion of mask in quantile target
        :param total: quantile total
        :param index: quantile index
        :param tag: update parsed lines tags with this value
        :return: numpy array
        """
        splits = np.array_split(
            np.array(range(self.top_index, self.bottom_index + 1)), total
        )
        start, stop = splits[index][0], splits[index][-1] + 1

        return self.to_mask(
            first_line=start - self.top_index,
            last_line=stop - self.top_index,
            colour=colour,
        )

    def width_quantile_stats(self, total, index, tag=None):
        """
        Returns width statistics for the plant
        :param total: quantile total
        :param index: quantile index
        :param tag: update parsed lines tags with this value
        :return: total, solidity, min, max, average & standard deviation
        """
        splits = np.array_split(
            np.array(range(self.top_index, self.bottom_index + 1)), total
        )
        start, stop = splits[index][0], splits[index][-1] + 1

        dt = np.array([self.width_at(idx) for idx in range(start, stop)])
        means, std_devs = cv2.meanStdDev(dt)

        area_ = np.sum(np.array([self.nz_count_at(idx) for idx in range(start, stop)]))
        hullish_ = np.sum(np.array([self.width_at(idx) for idx in range(start, stop)]))

        if tag is not None:
            for idx in range(start, stop):
                ld = self.line_at(idx)
                if ld is not None:
                    ld.tag = tag

        return (
            area_,
            hullish_,
            area_ / hullish_,
            np.min(dt),
            np.max(dt),
            means[0][0],
            std_devs[0][0],
        )

    @property
    def lines_data(self):
        return self._lines_data

    @property
    def height(self):
        if self._height is None:
            if self._lines_data:
                self._height = self.bottom_index - self.top_index
            else:
                self._height = 0
        return self._height

    @property
    def width(self):
        if self._width is None:
            if self._lines_data:
                max_width_ = 0
                for ld in self._lines_data:
                    if ld.nz_span > max_width_:
                        max_width_ = ld.nz_span
                self._width = max_width_
            else:
                self._width = 0
        return self._width

    @property
    def mask_width(self):
        return self._mask.shape[1] if self._mask is not None else 0

    @property
    def mask_height(self):
        return self._mask.shape[0] if self._mask is not None else 0

    @property
    def is_continuous(self):
        return self._is_continuous

    @property
    def top_index(self):
        if len(self._lines_data) > 0:
            return self._lines_data[0].height_pos
        else:
            return 0

    @property
    def bottom_index(self):
        if len(self._lines_data) > 0:
            return self._lines_data[-1].height_pos
        else:
            return 0

    @property
    def mask(self):
        return self._mask

    @property
    def area(self):
        return (
            np.sum(np.array([l.nz_count for l in self.lines_data]))
            if len(self._lines_data) > 0
            else 0
        )


class DefaultCsvWriter(AbstractCsvWriter):
    def __init__(self):
        super().__init__()
        self.data_list = dict.fromkeys(AVAILABLE_FEATURES)

import cv2
import numpy as np
import random
from typing import Any, Union
import math

from ipso_phen.ipapi.tools.csv_writer import AbstractCsvWriter

import logging

logger = logging.getLogger(__name__)


class ToolFamily:
    ANCILLARY = "Ancillary"
    CLUSTERING = "Clustering"
    DEMO = "Demo"
    DEFAULT_PROCESS = "Execute default process"
    FEATURE_EXTRACTION = "Feature extraction"
    EXPOSURE_FIXING = "Exposure fixing"
    ASSERT = "Assert..."
    IMAGE_GENERATOR = "Image generator"
    IMAGE_INFO = "Image info"
    MASK_CLEANUP = "Mask cleanup"
    PRE_PROCESSING = "Pre processing"
    ROI = "Create an ROI"
    THRESHOLD = "Threshold"
    VISUALIZATION = "Visualization"
    WHITE_BALANCE = "White balance"
    UNKNOWN = "Unknown"
    PCV = "PlantCV"
    # Deprecated
    ROI_DYNAMIC = "ROI (dynamic)"
    ROI_STATIC = "ROI (static)"
    ROI_PP_IMAGE = "ROI on pre processed image"
    ROI_RAW_IMAGE = "ROI on raw image"


tool_family_hints = {
    ToolFamily.ANCILLARY: "Tools mostly used inside other tools",
    ToolFamily.CLUSTERING: "Clustering tools",
    ToolFamily.DEMO: "Demo tools, start here if you want to understand how to create/edit tools",
    ToolFamily.DEFAULT_PROCESS: """Execute a class pipeline linked to the selected image experiment,
    if no class pipeline is available an error will be reported""",
    ToolFamily.FEATURE_EXTRACTION: "Tools to extract features from a segmented image",
    ToolFamily.EXPOSURE_FIXING: "Fix image exposure, the resulting image will be used for color analysis",
    ToolFamily.IMAGE_GENERATOR: "Creates one or more images from a selected image",
    ToolFamily.IMAGE_INFO: "Gives info about current image",
    ToolFamily.MASK_CLEANUP: "Cleans a coarse mask generated by threshold tools",
    ToolFamily.PRE_PROCESSING: """Transform the image to help segmentation, 
    the image may not retain it's 
    properties. Changes here will be ignored when extracting features""",
    ToolFamily.ROI: "Create an ROI",
    ToolFamily.ASSERT: "Assert something, returns wether or not assertion is passed",
    ToolFamily.THRESHOLD: "Creates a mask that keeps only parts of the image",
    ToolFamily.VISUALIZATION: "Visualization tools",
    ToolFamily.WHITE_BALANCE: """Tools to help change white balance, depending on where those tools are set in the pipeline they or
    may not be ignored when extracting features""",
    ToolFamily.UNKNOWN: "There should be nothing here",
    ToolFamily.PCV: "UI integration of a PlantCV script",
}

tool_groups_pipeline = [
    ToolFamily.ASSERT,
    ToolFamily.FEATURE_EXTRACTION,
    ToolFamily.EXPOSURE_FIXING,
    ToolFamily.MASK_CLEANUP,
    ToolFamily.PRE_PROCESSING,
    ToolFamily.ROI,
    ToolFamily.THRESHOLD,
    ToolFamily.WHITE_BALANCE,
    ToolFamily.IMAGE_GENERATOR,
    ToolFamily.VISUALIZATION,
]

IO_IMAGE = "io_image"
IO_MASK = "io_mask"
IO_ROI = "io_roi"
IO_DATA = "io_data"
IO_NONE = "io_none"

MERGE_MODE_AND = "merge_mode_and"
MERGE_MODE_OR = "merge_mode_or"
MERGE_MODE_CHAIN = "merge_mode_chain"
MERGE_MODE_NONE = "merge_mode_none"


def io_type_to_str(io_type: str) -> str:
    if io_type == IO_IMAGE:
        return "image"
    elif io_type == IO_MASK:
        return "mask"
    elif io_type == IO_ROI:
        return "ROI"
    elif io_type == IO_DATA:
        return "data"
    elif io_type == IO_NONE:
        return "none"
    else:
        return "unknown"


def merge_mode_to_str(merge_mode: str) -> str:
    if merge_mode == MERGE_MODE_AND:
        return "AND"
    elif merge_mode == MERGE_MODE_OR:
        return "OR"
    elif merge_mode == MERGE_MODE_CHAIN:
        return "CHAIN"
    elif merge_mode == MERGE_MODE_NONE:
        return "NONE"
    else:
        return "unknown"


AVAILABLE_FEATURES = sorted(
    [
        # Header - text values
        "experiment",
        "plant",
        "date_time",
        "hist_bins",
        # Morphology
        "area",
        "hull_area",
        "width_data",
        "shape_height",
        "centroid",
        "shape_solidity",
        "shape_extend",
        "straight_bounding_rectangle",
        "rotated_bounding_rectangle",
        "minimum_enclosing_circle",
        "bound_data",
        "quantile_width_4",
        # Color descriptors
        "color_std_dev",
        "color_mean",
        "quantile_color_5",
        # Chlorophyll data
        "chlorophyll_mean",
        "chlorophyll_std_dev",
    ]
)

DEFAULT_COLOR_MAP = cv2.COLORMAP_JET

RGB = "rgb"
LAB = "lab"
HSV = "hsv"
MSP = "msp"
NDVI = "ndvi"
CHLA = "chla"

_HSV_CHANNELS = dict(
    h="hue",
    s="saturation",
    v="value",
)
_LAB_CHANNELS = dict(
    l="lightness",
    a="a_green-red",
    b="b_blue-yellow",
)
_RGB_CHANNELS = dict(
    rd="red",
    gr="green",
    bl="blue",
)
_MSP_CHANNELS = dict(
    wl_520="wl_520",
    wl_550="wl_550",
    wl_671="wl_671",
    wl_680="wl_680",
    wl_720="wl_720",
    wl_800="wl_800",
    wl_905="wl_905",
)
_NDVI_CHANNELS = dict(
    ndvi_720="ndvi_720",
    ndvi_800="ndvi_800",
    ndvi_905="ndvi_905",
)
_CHLA_CHANNELS = dict(chloro="chlorophyll")

CHANNELS_BY_SPACE = dict(
    hsv=_HSV_CHANNELS,
    lab=_LAB_CHANNELS,
    rgb=_RGB_CHANNELS,
    msp=_MSP_CHANNELS,
    ndvi=_NDVI_CHANNELS,
    chla=_CHLA_CHANNELS,
)
CHANNELS_FLAT = {
    **_HSV_CHANNELS,
    **_LAB_CHANNELS,
    **_RGB_CHANNELS,
    **_MSP_CHANNELS,
    **_NDVI_CHANNELS,
    **_CHLA_CHANNELS,
}

C_BLACK = (0, 0, 0)
C_BLUE = (255, 0, 0)
C_BLUE_VIOLET = (226, 43, 138)
C_CABIN_BLUE = (209, 133, 67)
C_CYAN = (255, 255, 0)
C_DIM_GRAY = (105, 105, 105)
C_FUCHSIA = (255, 0, 255)
C_GREEN = (0, 128, 0)
C_LIGHT_STEEL_BLUE = (222, 196, 176)
C_LIME = (0, 255, 0)
C_MAROON = (0, 0, 128)
C_ORANGE = (80, 127, 255)
C_PURPLE = (128, 0, 128)
C_RED = (0, 0, 255)
C_SILVER = (192, 192, 192)
C_TEAL = (128, 128, 0)
C_WHITE = (255, 255, 255)
C_YELLOW = (0, 255, 255)

all_colors_dict = dict(
    black=C_BLACK,
    blue=C_BLUE,
    blue_violet=C_BLUE_VIOLET,
    blue_cabin=C_CABIN_BLUE,
    cyan=C_CYAN,
    dim_grey=C_DIM_GRAY,
    fuchsia=C_FUCHSIA,
    green=C_GREEN,
    light_steel_blue=C_LIGHT_STEEL_BLUE,
    lime=C_LIME,
    maroon=C_MAROON,
    orange=C_ORANGE,
    purple=C_PURPLE,
    red=C_RED,
    silver=C_SILVER,
    teal=C_TEAL,
    white=C_WHITE,
    yellow=C_YELLOW,
)

ALL_COLORS = [v for v in all_colors_dict.values()]


def bgr_to_rgb(color: tuple) -> tuple:
    """Converts from BGR to RGB

    Arguments:
        color {tuple} -- Source color

    Returns:
        tuple -- Converted color
    """
    return (color[2], color[1], color[0])


def rgb_to_bgr(color: tuple) -> tuple:
    """Converts from RGB to BGR

    Arguments:
        color {tuple} -- Source color

    Returns:
        tuple -- Converted color
    """
    return (color[0], color[1], color[2])


def get_hr_channel_name(channel):
    """Returns human readable channel name

    Arguments:
        channel {str} -- channel short name

    Raises:
        NameError -- raise exception if unknown channel

    Returns:
        str -- human readable channel name
    """

    if channel in CHANNELS_BY_SPACE[HSV]:
        return f"hsv: {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[LAB]:
        return f"lab: {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[RGB]:
        return f"rgb: {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[MSP]:
        return f"msp {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[NDVI]:
        return f"ndvi {CHANNELS_FLAT[channel]}"
    elif channel in CHANNELS_BY_SPACE[CHLA]:
        return f"chla {CHANNELS_FLAT[channel]}"
    else:
        raise NameError("Unknown channel")


def get_channel_name(channel):
    """Returns human readable channel name

    Arguments:
        channel {str} -- channel short name

    Raises:
        NameError -- raise exception if unknown channel

    Returns:
        str -- human readable channel name
    """

    if channel in CHANNELS_BY_SPACE[HSV]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[LAB]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[RGB]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[MSP]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[NDVI]:
        return CHANNELS_FLAT[channel]
    elif channel in CHANNELS_BY_SPACE[CHLA]:
        return CHANNELS_FLAT[channel]
    else:
        raise NameError("Unknown channel")


def create_channel_generator(
    channels=(),
    include_vis: bool = True,
    include_ndvi: bool = False,
    include_msp: bool = False,
    include_chla: bool = False,
):
    """Create channel iterator, selection possible

    Keyword Arguments:
        channels {list} -- accepted channels, if None then all (default: {[]})
    """
    if channels:
        for cs_name, cs_data in CHANNELS_BY_SPACE.items():
            for channel_id, channel_name in cs_data.items():
                if (channel_id in channels) or not channels:
                    yield cs_name, channel_id, channel_name
    else:
        groups = []
        if include_vis:
            groups.extend((RGB, HSV, LAB))
        if include_msp:
            groups.append(MSP)
        if include_ndvi:
            groups.append(NDVI)
        if include_chla:
            groups.append(CHLA)

        for cs in groups:
            for k, v in CHANNELS_BY_SPACE[cs].items():
                if (k in channels) or not channels:
                    yield cs, k, v


def channel_color(channel: str):
    """
    Retruns a color cooresponding to the channel
    :param channel:
    :return:
    """
    if channel == "rd":
        return C_RED
    elif channel == "gr":
        return C_GREEN
    elif channel == "bl":
        return C_BLUE
    elif channel == "l":
        return C_DIM_GRAY
    elif channel == "a":
        return C_FUCHSIA
    elif channel == "b":
        return C_YELLOW
    elif channel == "h":
        return C_BLUE_VIOLET
    elif channel == "s":
        return C_CYAN
    elif channel == "v":
        return C_ORANGE
    elif channel == "wl_520":
        return (0x00, 0xFF, 0x36)
    elif channel == "wl_550":
        return (0x00, 0xFF, 0xA3)
    elif channel == "wl_671":
        return (0, 0, 200)
    elif channel == "wl_680":
        return (0x0, 0x0, 150)
    elif channel == "wl_720":
        return (125, 0x0, 150)
    elif channel == "wl_800":
        return (200, 0x0, 150)
    elif channel == "wl_905":
        return (255, 0x0, 150)
    elif channel == "ndvi_720":
        return (0x0, 180, 0x0)
    elif channel == "ndvi_800":
        return (0x0, 200, 0x0)
    elif channel == "ndvi_905":
        return (0x0, 220, 0x0)
    elif channel == "chlorophyll":
        return C_LIME
    else:
        return C_BLACK


def random_color(restrained: bool = True) -> tuple:
    """
    Retrurns a random color
    :param restrained: if True color will be selected from a list
    :return: tuple, BGR color
    """
    if restrained:
        return ALL_COLORS[random.randrange(0, len(ALL_COLORS))]
    else:
        return (
            random.randrange(0, 255),
            random.randrange(0, 255),
            random.randrange(0, 255),
        )


def scaled_rgb(mag, color_min, color_max):
    """ Return a tuple of integers, as used in AWT/Java plots. """
    try:
        x = float(mag - color_min) / (color_max - color_min)
    except ZeroDivisionError:
        logger.exception("Scaled rgb tried to divide by 0")
        x = 0.5  # color_max == color_min
    blue = min((max((4 * (0.75 - x), 0.0)), 1.0))
    red = min((max((4 * (x - 0.25), 0.0)), 1.0))
    green = min((max((4 * math.fabs(x - 0.5) - 1.0, 0.0)), 1.0))
    return int(red * 255), int(green * 255), int(blue * 255)


def build_color_steps(step_count: int) -> list:
    """
    Builds a list containing step_count different colors from blue to yellow
    :param step_count:
    :return:
    """
    if step_count <= 1:
        return [C_LIGHT_STEEL_BLUE]
    else:
        return [
            scaled_rgb(mag=i, color_min=0, color_max=step_count)
            for i in range(step_count)
        ]


def ensure_odd(
    i: int, min_val: Union[None, int] = None, max_val: Union[None, int] = None
) -> int:
    """Transforms an odd number into pair number by adding one

    Arguments:
        i {int} -- number

    Returns:
        int -- Odd number
    """
    if (i > 0) and (i % 2 == 0):
        i += 1
    if min_val is not None:
        return max(i, min_val)
    if max_val is not None:
        return min(i, max_val)
    return i


def resize_image(src_img, **kwargs):
    """
    Resizes image may keep aspect ration
    :param src_img:
    """

    target_rect = kwargs.get("target_rect", None)
    if target_rect is not None:
        w, h, ar = target_rect.width, target_rect.height, target_rect.ar
    else:
        w = kwargs.get("width", 100)
        h = kwargs.get("height", 100)
        ar = w / h
    keep_aspect_ratio = kwargs.get("keep_aspect_ratio", False) is True

    i_h, i_w = src_img.shape[:2]
    if keep_aspect_ratio and (ar > 0):
        i_ar = i_w / i_h
        if ar > i_ar:
            n_h = int(h)
            n_w = int(n_h * i_ar)
        else:
            n_w = int(w)
            n_h = int(n_w / i_ar)
    else:
        n_w = int(w)
        n_h = int(h)

    if (len(src_img.shape) == 2) and kwargs.get("output_as_bgr", True):
        src_img = cv2.cvtColor(src_img, cv2.COLOR_GRAY2BGR)

    src_img = cv2.resize(src_img, (n_w, n_h), interpolation=cv2.INTER_CUBIC)

    return src_img


def enclose_image(a_cnv, img, rect, frame_width: int = 0):
    """Resize and copy source image to canvas rect while conserving aspect ratio

    Arguments:
        a_cnv {numpy array} -- target canvas
        img {numpy array} -- src image
        rect {TRect} -- enclosing rectangle

    Returns:
        numpy array -- modified target canvas
    """

    # old_h, old_w = img.shape[:2]
    # if (rect.width == old_w) and (rect.height == old_h):
    #     a_cnv = img.copy()
    # else:
    resized_image = resize_image(img.copy(), target_rect=rect, keep_aspect_ratio=True)
    n_h, n_w = resized_image.shape[:2]

    dx = int((rect.width - n_w) / 2)
    dy = int((rect.height - n_h) / 2)
    rect.inflate(-dx, -dx, -dy, -dy)
    rect.width = n_w
    rect.height = n_h
    a_cnv[rect.top : rect.bottom, rect.left : rect.right] = resized_image

    if frame_width:
        cv2.rectangle(
            img=a_cnv,
            pt1=(rect.left, rect.top),
            pt2=(rect.right, rect.bottom),
            color=(255, 255, 255),
            thickness=frame_width,
        )

    return a_cnv


def multi_and(image_list: tuple):
    """Performs an AND with all the images in the tuple

    :param image_list:
    :return: image
    """
    img_lst = [i for i in image_list if i is not None]
    list_len_ = len(img_lst)

    if list_len_ == 0:
        return None
    elif list_len_ == 1:
        return img_lst[0]
    else:
        res = cv2.bitwise_and(img_lst[0], img_lst[1])
        if len(img_lst) > 2:
            for current_image in img_lst[2:]:
                res = cv2.bitwise_and(res, current_image)

        return res


def multi_or(image_list: tuple):
    """Performs an OR with all the images in the tuple

    :param image_list:
    :return: image
    """
    img_lst = [i for i in image_list if i is not None]
    list_len_ = len(img_lst)

    if list_len_ == 0:
        return None
    elif list_len_ == 1:
        return img_lst[0]
    else:
        res = cv2.bitwise_or(img_lst[0], img_lst[1])
        if list_len_ > 2:
            for current_image in img_lst[2:]:
                res = cv2.bitwise_or(res, current_image)
        return res


def get_kernel(size: int, shape: int):
    """Builds morphology kernel

    :param size: kernel size, must be odd number
    :param shape: select shape of kernel
    :return: Morphology kernel
    """
    return cv2.getStructuringElement(shape, (size, size))


def open(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Open wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- opened image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            r = roi.as_rect()
            result[r.top : r.bottom, r.left : r.right] = cv2.morphologyEx(
                result[r.top : r.bottom, r.left : r.right],
                cv2.MORPH_OPEN,
                morph_kernel,
                iterations=proc_times,
            )
    else:
        result = cv2.morphologyEx(
            image, cv2.MORPH_OPEN, morph_kernel, iterations=proc_times
        )
    return result


def close(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Close wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interest
        proc_times {int} -- iterations

    Returns:
        numpy array -- closed image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            r = roi.as_rect()
            result[r.top : r.bottom, r.left : r.right] = cv2.morphologyEx(
                result[r.top : r.bottom, r.left : r.right],
                cv2.MORPH_CLOSE,
                morph_kernel,
                iterations=proc_times,
            )
    else:
        result = cv2.morphologyEx(
            image, cv2.MORPH_CLOSE, morph_kernel, iterations=proc_times
        )
    return result


def dilate(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Dilate wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- dilated image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            if roi is not None:
                r = roi.as_rect()
                result[r.top : r.bottom, r.left : r.right] = cv2.dilate(
                    result[r.top : r.bottom, r.left : r.right],
                    morph_kernel,
                    iterations=proc_times,
                )
    else:
        result = cv2.dilate(image, morph_kernel, iterations=proc_times)
    return result


def erode(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
    proc_times: int = 1,
):
    """Morphology - Erode wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- eroded image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            if roi is not None:
                r = roi.as_rect()
                result[r.top : r.bottom, r.left : r.right] = cv2.erode(
                    result[r.top : r.bottom, r.left : r.right],
                    morph_kernel,
                    iterations=proc_times,
                )
    else:
        result = cv2.erode(image, morph_kernel, iterations=proc_times)
    return result


def morphological_gradient(
    image: Any,
    kernel_size: int = 3,
    kernel_shape: int = cv2.MORPH_ELLIPSE,
    rois: tuple = (),
):
    """Morphology - Erode wrapper

    Arguments:
        image {numpy array} -- Source image
        kernel_size {int} -- kernel size
        kernel_shape {int} -- cv2 constant
        roi -- Region of Interrest
        proc_times {int} -- iterations

    Returns:
        numpy array -- eroded image
    """
    morph_kernel = get_kernel(kernel_size, kernel_shape)
    if rois:
        result = image.copy()
        for roi in rois:
            if roi is not None:
                r = roi.as_rect()
                result[r.top : r.bottom, r.left : r.right] = cv2.morphologyEx(
                    result[r.top : r.bottom, r.left : r.right],
                    cv2.MORPH_GRADIENT,
                    morph_kernel,
                )
    else:
        result = cv2.morphologyEx(image, cv2.MORPH_GRADIENT, morph_kernel)
    return result


def get_contours(mask, retrieve_mode, method):
    return cv2.findContours(mask.copy(), retrieve_mode, method)[-2:-1][0]


def get_contours_and_hierarchy(mask, retrieve_mode, method):
    return cv2.findContours(mask.copy(), retrieve_mode, method)[-2:]


def group_contours(mask):
    """Gets contours from mask and merges them into single one"""
    id_objects, obj_hierarchy = get_contours_and_hierarchy(
        mask=mask, retrieve_mode=cv2.RETR_TREE, method=cv2.CHAIN_APPROX_NONE
    )

    stack = np.zeros((len(id_objects), 1))

    for c, cnt in enumerate(id_objects):
        if obj_hierarchy[0][c][2] == -1 and obj_hierarchy[0][c][3] > -1:
            stack[c] = 0
        else:
            stack[c] = 1
    ids = np.where(stack == 1)[0]
    if len(ids) > 0:
        return np.vstack([id_objects[i] for i in ids])
    else:
        return []


class MaskLineData(object):
    __slots__ = [
        "height_pos",
        "nz_span",
        "nz_count",
        "solidity",
        "last_span",
        "nz_pos",
        "tag",
    ]

    def __init__(self, line_number: int, line_data: list, last_span):
        ln_dt = np.nonzero(line_data)[0]
        self.height_pos = line_number
        self.nz_span = ln_dt[-1] - ln_dt[0] + 1 if len(ln_dt) > 0 else 0
        self.nz_count = len(ln_dt)
        self.solidity = self.nz_count / self.nz_span if self.nz_span != 0 else 0
        self.last_span = last_span
        self.nz_pos = ln_dt
        self.tag = "none"

    def __str__(self):
        return f"[height: {self.height_pos}][width: {self.nz_span}][solidity: {self.solidity}]"

    def __eq__(self, other):
        return np.array_equal(self.nz_pos, other.nz_pos)

    def is_inside(self, other):
        if other is None:
            return False
        if len(self.nz_pos) == 0:
            return True
        for i in self.nz_pos:
            if i not in other.nz_pos:
                return False
        return True

    def merge_and(self, line1, line2):
        if (line1 is None) or (line2 is None):
            ln_dt = np.array([])
        else:
            ln_dt = np.array([idx for idx in line1.nz_pos if idx in line2.nz_pos])
        self.nz_span = ln_dt[-1] - ln_dt[0] + 1 if len(ln_dt) > 0 else 0
        self.nz_count = len(ln_dt)
        self.solidity = self.nz_count / self.nz_span if self.nz_span != 0 else 0
        self.nz_pos = ln_dt

    def merge_or(self, line1, line2):
        ln_dt = []
        if line1 is not None:
            ln_dt = ln_dt + [idx for idx in line1.nz_pos]
        if line2 is not None:
            ln_dt = ln_dt + [idx for idx in line2.nz_pos]
        ln_dt = np.array(sorted(list(set(ln_dt))))
        self.nz_span = ln_dt[-1] - ln_dt[0] + 1 if len(ln_dt) > 0 else 0
        self.nz_count = len(ln_dt)
        self.solidity = self.nz_count / self.nz_span if self.nz_span != 0 else 0
        self.nz_pos = ln_dt

    def clear(self):
        self.nz_span = 0
        self.nz_count = 0
        self.solidity = 0
        self.nz_pos = []
        self.tag = "none"


class MaskData(object):
    def __init__(self, mask):
        self._height = None
        self._width = None
        self._is_continuous = True
        self._lines_data = []
        self._mask = mask
        last_span_ = 0
        for line_number, line in enumerate(mask):
            # Build line data
            cur_ln_dt = MaskLineData(line_number, line, last_span_)
            if (cur_ln_dt.nz_count == 0) and (len(self._lines_data) == 0):
                continue
            last_span_ = cur_ln_dt.nz_span
            self._lines_data.append(cur_ln_dt)
        if self.lines_data:
            while self.lines_data[-1].nz_count == 0:
                del self.lines_data[-1]

    def to_mask(self, first_line=-1, last_line=-1, colour=255):
        msk_out = np.zeros_like(self.mask)
        if first_line == -1:
            first_line = 0
        if last_line == -1:
            last_line = self.mask_height
        for line in self.lines_data[first_line:last_line]:
            for i in line.nz_pos:
                msk_out[line.height_pos][i] = colour
        return msk_out

    def find_by_height(self, height):
        for ld in self._lines_data:
            if ld.height_pos == height:
                return ld
        return None

    def find_top_bottom_non_full_lines(self, height):
        ld = self.find_by_height(height=height)
        if ld is None:
            return None, None
        ht = height - 1
        ld_up = self.find_by_height(height=ht)
        while (ld_up is not None) and ld.is_inside(ld_up) and (ht > self.top_index):
            ht -= 1
            ld_up = self.find_by_height(height=ht)
        ht = height + 1
        ld_down = self.find_by_height(height=ht)
        while (
            (ld_down is not None) and ld.is_inside(ld_down) and (ht < self.bottom_index)
        ):
            ht += 1
            ld_down = self.find_by_height(height=ht)
        return ld_up, ld_down

    def horizontal_lines_at(
        self, height: int, min_length: int = 1, fully_isolated: bool = True
    ):
        res = False
        try:
            # Get the lines we need
            ld = self.find_by_height(height=height)
            if ld is None or (ld.nz_count < min_length):
                res = None

            if ld.solidity >= 0.99:
                ld_up, ld_down = self.find_top_bottom_non_full_lines(height)
            else:
                ld_up = self.find_by_height(height=height - 1)
                ld_down = self.find_by_height(height=height + 1)

            # Build line candidates spans
            run = []
            runs = [run]
            expect = None
            top_nok = False
            bottom_nok = False
            for v in ld.nz_pos:
                is_continuous = (v == expect) or (expect is None)
                if (ld_up is not None) and (v in ld_up.nz_pos):
                    top_nok = True
                if (ld_down is not None) and (v in ld_down.nz_pos):
                    bottom_nok = True
                neighbors_nok = (fully_isolated and (top_nok or bottom_nok)) or (
                    top_nok and bottom_nok
                )
                if is_continuous and not neighbors_nok:
                    run.append(v)
                else:
                    run = [v]
                    runs.append(run)
                    top_nok = False
                    bottom_nok = False
                expect = v + 1

            res = [
                (ld.height_pos, run[0], run[-1]) for run in runs if len(run) >= min_length
            ]
        except Exception as e:
            logger.exception(f"Raised {repr(e)}")
            res = None
        finally:
            return res

    def percent_height(self, percent: float):
        return self.bottom_index - self.height * percent

    def get_pos_height(self, value):
        if isinstance(value, int):
            return value
        elif isinstance(value, float) and (value <= 1):
            return self.percent_height(value)
        else:
            return None

    def line_at(self, i):
        i = self.get_pos_height(i)
        if i is None:
            return 0
        return self.find_by_height(i)

    def width_at(self, i):
        ld = self.line_at(i)
        if ld:
            return ld.nz_span
        else:
            return 0

    def nz_count_at(self, i):
        ld = self.line_at(i)
        if ld:
            return ld.nz_count
        else:
            return 0

    def is_full_line(self, height):
        ld = self.find_by_height(height=height)
        return (ld is not None) and (ld.nz_count == self.width)

    def height_quantile_mask(self, total, index, colour=255):
        """
        Returns portion of mask in quantile target
        :param total: quantile total
        :param index: quantile index
        :param tag: update parsed lines tags with this value
        :return: numpy array
        """
        splits = np.array_split(
            np.array(range(self.top_index, self.bottom_index + 1)), total
        )
        start, stop = splits[index][0], splits[index][-1] + 1

        return self.to_mask(
            first_line=start - self.top_index,
            last_line=stop - self.top_index,
            colour=colour,
        )

    def width_quantile_stats(self, total, index, tag=None):
        """
        Returns width statistics for the plant
        :param total: quantile total
        :param index: quantile index
        :param tag: update parsed lines tags with this value
        :return: total, solidity, min, max, average & standard deviation
        """
        splits = np.array_split(
            np.array(range(self.top_index, self.bottom_index + 1)), total
        )
        start, stop = splits[index][0], splits[index][-1] + 1

        dt = np.array([self.width_at(idx) for idx in range(start, stop)])
        means, std_devs = cv2.meanStdDev(dt)

        area_ = np.sum(np.array([self.nz_count_at(idx) for idx in range(start, stop)]))
        hullish_ = np.sum(np.array([self.width_at(idx) for idx in range(start, stop)]))

        if tag is not None:
            for idx in range(start, stop):
                ld = self.line_at(idx)
                if ld is not None:
                    ld.tag = tag

        return (
            area_,
            hullish_,
            area_ / hullish_,
            np.min(dt),
            np.max(dt),
            means[0][0],
            std_devs[0][0],
        )

    @property
    def lines_data(self):
        return self._lines_data

    @property
    def height(self):
        if self._height is None:
            if self._lines_data:
                self._height = self.bottom_index - self.top_index
            else:
                self._height = 0
        return self._height

    @property
    def width(self):
        if self._width is None:
            if self._lines_data:
                max_width_ = 0
                for ld in self._lines_data:
                    if ld.nz_span > max_width_:
                        max_width_ = ld.nz_span
                self._width = max_width_
            else:
                self._width = 0
        return self._width

    @property
    def mask_width(self):
        return self._mask.shape[1] if self._mask is not None else 0

    @property
    def mask_height(self):
        return self._mask.shape[0] if self._mask is not None else 0

    @property
    def is_continuous(self):
        return self._is_continuous

    @property
    def top_index(self):
        if len(self._lines_data) > 0:
            return self._lines_data[0].height_pos
        else:
            return 0

    @property
    def bottom_index(self):
        if len(self._lines_data) > 0:
            return self._lines_data[-1].height_pos
        else:
            return 0

    @property
    def mask(self):
        return self._mask

    @property
    def area(self):
        return (
            np.sum(np.array([l.nz_count for l in self.lines_data]))
            if len(self._lines_data) > 0
            else 0
        )


class DefaultCsvWriter(AbstractCsvWriter):
    def __init__(self):
        super().__init__()
        self.data_list = dict.fromkeys(AVAILABLE_FEATURES)

# Deprecated Execute a class pipeline linked to the selected image experiment,
     if no class pipeline is available an error will be reported Transform the image to help segmentation, 
     the image may not retain it's 
     properties. Changes here will be ignored when extracting features Tools to help change white balance, depending on where those tools are set in the pipeline they or
     may not be ignored when extracting features # Header - text values # Morphology # Color descriptors # Chlorophyll data Converts from BGR to RGB
 
     Arguments:
         color {tuple} -- Source color
 
     Returns:
         tuple -- Converted color Converts from RGB to BGR
 
     Arguments:
         color {tuple} -- Source color
 
     Returns:
         tuple -- Converted color Returns human readable channel name
 
     Arguments:
         channel {str} -- channel short name
 
     Raises:
         NameError -- raise exception if unknown channel
 
     Returns:
         str -- human readable channel name Returns human readable channel name
 
     Arguments:
         channel {str} -- channel short name
 
     Raises:
         NameError -- raise exception if unknown channel
 
     Returns:
         str -- human readable channel name Create channel iterator, selection possible
 
     Keyword Arguments:
         channels {list} -- accepted channels, if None then all (default: {[]}) Retruns a color cooresponding to the channel
     :param channel:
     :return: Retrurns a random color
     :param restrained: if True color will be selected from a list
     :return: tuple, BGR color Return a tuple of integers, as used in AWT/Java plots. # color_max == color_min Builds a list containing step_count different colors from blue to yellow
     :param step_count:
     :return: Transforms an odd number into pair number by adding one
 
     Arguments:
         i {int} -- number
 
     Returns:
         int -- Odd number Resizes image may keep aspect ration
     :param src_img: Resize and copy source image to canvas rect while conserving aspect ratio
 
     Arguments:
         a_cnv {numpy array} -- target canvas
         img {numpy array} -- src image
         rect {TRect} -- enclosing rectangle
 
     Returns:
         numpy array -- modified target canvas # old_h, old_w = img.shape[:2] # if (rect.width == old_w) and (rect.height == old_h): #     a_cnv = img.copy() # else: Performs an AND with all the images in the tuple
 
     :param image_list:
     :return: image Performs an OR with all the images in the tuple
 
     :param image_list:
     :return: image Builds morphology kernel
 
     :param size: kernel size, must be odd number
     :param shape: select shape of kernel
     :return: Morphology kernel Morphology - Open wrapper
 
     Arguments:
         image {numpy array} -- Source image
         kernel_size {int} -- kernel size
         kernel_shape {int} -- cv2 constant
         roi -- Region of Interrest
         proc_times {int} -- iterations
 
     Returns:
         numpy array -- opened image Morphology - Close wrapper
 
     Arguments:
         image {numpy array} -- Source image
         kernel_size {int} -- kernel size
         kernel_shape {int} -- cv2 constant
         roi -- Region of Interest
         proc_times {int} -- iterations
 
     Returns:
         numpy array -- closed image Morphology - Dilate wrapper
 
     Arguments:
         image {numpy array} -- Source image
         kernel_size {int} -- kernel size
         kernel_shape {int} -- cv2 constant
         roi -- Region of Interrest
         proc_times {int} -- iterations
 
     Returns:
         numpy array -- dilated image Morphology - Erode wrapper
 
     Arguments:
         image {numpy array} -- Source image
         kernel_size {int} -- kernel size
         kernel_shape {int} -- cv2 constant
         roi -- Region of Interrest
         proc_times {int} -- iterations
 
     Returns:
         numpy array -- eroded image Morphology - Erode wrapper
 
     Arguments:
         image {numpy array} -- Source image
         kernel_size {int} -- kernel size
         kernel_shape {int} -- cv2 constant
         roi -- Region of Interrest
         proc_times {int} -- iterations
 
     Returns:
         numpy array -- eroded image Gets contours from mask and merges them into single one # Build line data # Get the lines we need # Build line candidates spans Returns portion of mask in quantile target
         :param total: quantile total
         :param index: quantile index
         :param tag: update parsed lines tags with this value
         :return: numpy array Returns width statistics for the plant
         :param total: quantile total
         :param index: quantile index
         :param tag: update parsed lines tags with this value
         :return: total, solidity, min, max, average & standard deviation