Repository: siapy/siapy-lib (language: python)

Issue:
Feature
## Description

<!--
    - Add 1-2 sentences explaining the purpose of this PR.
    - (Optionally] Add commit ID and [GitHub will autolink](https://help.github.com/en/github/writing-on-github/autolinked-references-and-urls).
    - (Optionally] Reference related commits, issues and pull requests. Type `#` and select from the list.
-->

## Checklist

- [ ] I have reviewed the [Guidelines for Contributing](../CONTRIBUTING.md) and the [Code of Conduct](../CODE_OF_CONDUCT.md).



Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a67f013..f921aa8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,7 +14,7 @@ repos:
       - id: end-of-file-fixer
       - id: trailing-whitespace
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.5.4
+    rev: v0.5.5
     hooks:
       - id: ruff
         args:
@@ -30,6 +30,27 @@ repos:
       - id: codespell
         additional_dependencies:
           - tomli
+  - repo: https://github.com/compilerla/conventional-pre-commit
+    rev: v3.4.0
+    hooks:
+      - id: conventional-pre-commit
+        stages: [commit-msg]
+        args:
+          [
+            --strict,
+            feat,
+            fix,
+            perf,
+            deps,
+            revert,
+            docs,
+            style,
+            chore,
+            refactor,
+            test,
+            build,
+            ci,
+          ]
 ci:
   autofix_commit_msg: "chore: [pre-commit.ci] Auto format from pre-commit.com hooks"
   autoupdate_commit_msg: "chore: [pre-commit.ci] pre-commit autoupdate"
diff --git a/_old/__init__.py b/_old/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/_old/checker/check_images.py b/_old/check_images.py
similarity index 100%
rename from _old/checker/check_images.py
rename to _old/check_images.py
diff --git a/_old/checker/check_slices.py b/_old/check_slices.py
similarity index 100%
rename from _old/checker/check_slices.py
rename to _old/check_slices.py
diff --git a/_old/checker/__init__.py b/_old/checker/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/_old/corregistrate.py b/_old/corregistrate.py
deleted file mode 100644
index 6f3fdce..0000000
--- a/_old/corregistrate.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from siapy.transformations import Corregistrator
-from siapy.entities import DataLoader
-from siapy.utils.plot_utils import pixels_select_click
-from siapy.utils.utils import get_logger
-
-logger = get_logger(name="select_signatures")
-
-
-def main(cfg):
-    data_loader_cfg = cfg.data_loader
-    if not data_loader_cfg.corregistrate_dir_name:
-        logger.warning("No data directory provided to perform corregistration")
-        return
-    if (
-        not data_loader_cfg.path_ending_camera1
-        and not data_loader_cfg.path_ending_camera2
-    ):
-        logger.warning("No camera paths endings provided to perform corregistration")
-        return
-
-    data_loader = DataLoader(cfg)
-    data_loader.change_dir("corregistrate").load_images()
-
-    images_cam1 = data_loader.images.cam1
-    images_cam2 = data_loader.images.cam2
-    image_cam1 = images_cam1[cfg.image_idx]
-    image_cam2 = images_cam2[cfg.image_idx]
-
-    selected_pixels_cam1 = pixels_select_click(image_cam1)
-    selected_pixels_cam2 = pixels_select_click(image_cam2)
-
-    corregistrator = Corregistrator(cfg)
-    corregistrator.align(
-        selected_pixels_cam2,
-        selected_pixels_cam1,
-        plot_progress=True,
-        points_ordered=True,
-    )
-    corregistrator.save_params()
diff --git a/_old/create_signatures.py b/_old/create_signatures.py
deleted file mode 100644
index fa61750..0000000
--- a/_old/create_signatures.py
+++ /dev/null
@@ -1,81 +0,0 @@
-from types import SimpleNamespace
-
-import pandas as pd
-
-from siapy.entities import DataLoader
-from siapy.utils.utils import get_logger, parse_labels, save_data
-
-logger = get_logger(name="create_signatures")
-
-
-def parse_data(images, parsed_data_out, cfg):
-    # create emply simple namespace from columns of parsed_data_out
-    parsed_data = SimpleNamespace(**dict.fromkeys(parsed_data_out.columns))
-    path_deliminator = cfg.preparator.labels_path_deliminator
-    labels_deliminator = cfg.preparator.labels_deliminator
-
-    for image in images:
-        signature_mean = image.mean(axis=(0, 1))
-        indices, labels = image.filename.split(path_deliminator)[0:2]
-        indices = indices.split(labels_deliminator)
-        labels = labels.split(labels_deliminator)
-
-        parsed_data.signatures = signature_mean.tolist()
-        parsed_data.labels_all = labels
-        parsed_data.filenames = image.filename
-        parsed_data.camera_names = image.camera_name
-
-        parsed_data.images_indices = indices[0]
-        parsed_data.objects_indices = indices[1]
-
-        # if slicing performed there are three indices, else two
-        if len(indices) == 3:
-            parsed_data.slices_indices = indices[2]
-
-        # append paresed data to parsed_data_out
-        parsed_data_out = pd.concat(
-            [parsed_data_out, pd.DataFrame.from_records([parsed_data.__dict__])]
-        )
-    return parsed_data_out.reset_index(drop=True)
-
-
-def main(cfg):
-    data_loader = DataLoader(cfg)
-    data_loader.change_dir("converted_images").load_images()
-
-    parsed_data_out = pd.DataFrame(
-        columns=[
-            "images_indices",
-            "objects_indices",
-            "slices_indices",
-            "labels_all",
-            "labels_names",
-            "signatures",
-            "filenames",
-            "camera_names",
-        ]
-    )
-
-    images_cam1 = data_loader.images.cam1
-    parsed_data_out = parse_data(images_cam1, parsed_data_out, cfg)
-
-    if cfg.camera2 is not None:
-        images_cam2 = data_loader.images.cam2
-        parsed_data_out = parse_data(images_cam2, parsed_data_out, cfg)
-
-    # check if first object represents reference panel
-    # TODO: check of ref_panel sould follow the same checking as in preparator
-    ref_panel = bool(cfg.preparator.reflectance_panel)
-    match_indices = cfg.preparator.match_labels_to_indices
-
-    # if parsed labels will be matched to object_indices
-    if match_indices:
-        parsed_data_out.labels_names = parsed_data_out.apply(
-            lambda row: parse_labels(row, ref_panel), axis=1
-        )
-
-    # save parsed data
-    save_data(cfg, data=parsed_data_out, data_file_name="files/data", saver="df_csv")
-
-    # data = load_data(cfg, data_file_name=f"files/data", loader="df_csv")
-    # pass
diff --git a/_old/eval_segmentation.py b/_old/eval_segmentation.py
deleted file mode 100644
index a6ce876..0000000
--- a/_old/eval_segmentation.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from types import SimpleNamespace
-
-import matplotlib.pyplot as plt
-
-from siapy.transformations import Corregistrator
-from siapy.entities import DataLoader
-from siapy.segmentator import Segmentator
-from siapy.utils.image_utils import limit_to_bounds
-from siapy.utils.plot_utils import display_images, pixels_select_lasso
-from siapy.utils.utils import get_logger
-
-logger = get_logger(name="test_segmentation")
-
-
-def main(cfg):
-    data_loader = DataLoader(cfg).load_images()
-    segmentator = Segmentator(cfg).init_decision_function()
-
-    # if only camera1, then corregistration is not needed
-    if cfg.camera2 is not None:
-        corregistrator = Corregistrator(cfg)
-        corregistrator.load_params()
-
-    images_cam1 = data_loader.images.cam1
-    image_cam1 = images_cam1[cfg.image_idx]
-    selected_areas_cam1 = pixels_select_lasso(image_cam1)
-
-    # save_data(cfg, data={"ss": selected_areas_cam1},
-    #                 data_file_name=f"random/selected_areas")
-    # ld = load_data(cfg, data_file_name=f"random/selected_areas")
-    # selected_areas_cam1 = ld["ss"]
-
-    images = SimpleNamespace(cam1=image_cam1, cam2=None)
-    selected_areas = SimpleNamespace(cam1=selected_areas_cam1, cam2=None)
-
-    if cfg.camera2 is not None:
-        images_cam2 = data_loader.images.cam2
-        images.cam2 = images_cam2[cfg.image_idx]
-        # tranform coordinates from cam1 to cam2
-        selected_areas_cam2 = list(map(corregistrator.transform, selected_areas_cam1))
-        # limit coordinates to the image size
-        selected_areas.cam2 = list(
-            map(limit_to_bounds(images.cam2.shape), selected_areas_cam2)
-        )
-
-    selected_areas = segmentator.run(images, selected_areas)
-
-    display_images(images, selected_areas, colors=cfg.selector.color)
-    plt.show()
diff --git a/_old/utils/plot_utils.py b/_old/plot_utils.py
similarity index 100%
rename from _old/utils/plot_utils.py
rename to _old/plot_utils.py
diff --git a/_old/preparator/preparator.py b/_old/preparator.py
similarity index 100%
rename from _old/preparator/preparator.py
rename to _old/preparator.py
diff --git a/_old/preparator/__init__.py b/_old/preparator/__init__.py
deleted file mode 100644
index 7b7d616..0000000
--- a/_old/preparator/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .panel_pool_ftns import *
diff --git a/_old/preparator/panel_pool_ftns.py b/_old/preparator/panel_pool_ftns.py
deleted file mode 100644
index 096a4c3..0000000
--- a/_old/preparator/panel_pool_ftns.py
+++ /dev/null
@@ -1,2 +0,0 @@
-def average(image_arr):
-    return image_arr.mean(axis=0)
diff --git a/_old/prepare_data.py b/_old/prepare_data.py
deleted file mode 100644
index 0455584..0000000
--- a/_old/prepare_data.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from types import SimpleNamespace
-
-from siapy.entities import DataLoader
-from siapy.preparator import Preparator
-from siapy.utils.utils import get_logger
-
-logger = get_logger(name="prepare_data")
-
-
-def main(cfg):
-    data_loader = DataLoader(cfg)
-    data_loader.change_dir("segmented_images").load_images()
-    preparator = Preparator(cfg)
-
-    # group slices of the same image into under same list
-    images_cam1 = preparator.batch_images(data_loader.images.cam1)
-
-    # check if images from camera 2 are loaded
-    if cfg.camera2 is not None:
-        images_cam2 = preparator.batch_images(data_loader.images.cam2)
-    else:
-        images_cam2 = [None] * len(images_cam1)
-
-    for image_cam1, image_cam2 in zip(images_cam1, images_cam2):
-        images_segmented = SimpleNamespace(cam1=image_cam1, cam2=image_cam2)
-        preparator.run(images_segmented)
diff --git a/_old/segmentator/segmentator.py b/_old/segmentator.py
similarity index 100%
rename from _old/segmentator/segmentator.py
rename to _old/segmentator.py
diff --git a/_old/segmentator/__init__.py b/_old/segmentator/__init__.py
deleted file mode 100644
index 8f4703d..0000000
--- a/_old/segmentator/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .decision_algos import *
diff --git a/_old/segmentator/base_decision_algo.py b/_old/segmentator/base_decision_algo.py
deleted file mode 100644
index 2d10df0..0000000
--- a/_old/segmentator/base_decision_algo.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from abc import ABC, abstractmethod
-
-import numpy as np
-import pandas as pd
-from sklearn import preprocessing
-
-
-class BaseDecisionAlgo(ABC):
-    def __init__(self, cfg):
-        self.cfg = cfg
-        self.encoder = preprocessing.LabelEncoder()
-
-    @abstractmethod
-    def _fit(self, X, y):
-        pass
-
-    @abstractmethod
-    def _predict(self, X):
-        pass
-
-    ################################
-
-    def _transform_data(self, data):
-        """Transform data to numpy array and encode labels"""
-        data_trans = []
-        for class_label, df in data.items():
-            df["target"] = class_label
-            data_trans.append(df)
-
-        data_trans = pd.concat(data_trans, ignore_index=True)
-        X = np.array(data_trans.signature.to_list())
-        y = self.encoder.fit_transform(data_trans.target)
-        return X, y
-
-    def fit(self, data):
-        X, y = self._transform_data(data)
-        self._fit(X, y)
-
-    def predict(self, X):
-        return self.encoder.inverse_transform(self._predict(X))
diff --git a/_old/segmentator/decision_algos.py b/_old/segmentator/decision_algos.py
deleted file mode 100644
index 48e38a8..0000000
--- a/_old/segmentator/decision_algos.py
+++ /dev/null
@@ -1,107 +0,0 @@
-import numpy as np
-from numba import njit, prange
-from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
-from sklearn.svm import SVC
-
-from siapy.segmentator import BaseDecisionAlgo
-
-
-class KeepAll(BaseDecisionAlgo):
-    def __init__(self, cfg):
-        super().__init__(cfg)
-        self.cls_keep_enc = None
-
-    def _fit(self, X, y):
-        self.cls_keep_enc = self.encoder.transform(self.cfg.segmentator.classes_keep)[0]
-
-    def _predict(self, X):
-        return [self.cls_keep_enc] * len(X)
-
-
-class Lda(BaseDecisionAlgo):
-    def __init__(self, cfg):
-        super().__init__(cfg)
-        self.model = LinearDiscriminantAnalysis()
-
-    def _fit(self, X, y):
-        self.model.fit(X, y)
-
-    def _predict(self, X):
-        return self.model.predict(X)
-
-
-class Svm(BaseDecisionAlgo):
-    def __init__(self, cfg):
-        super().__init__(cfg)
-        self.model = SVC()
-
-    def _fit(self, X, y):
-        self.model.fit(X, y)
-
-    def _predict(self, X):
-        return self.model.predict(X)
-
-
-class Sid(BaseDecisionAlgo):
-    def __init__(self, cfg):
-        super().__init__(cfg)
-        self.cls_remove = cfg.segmentator.classes_remove
-        self.cls_keep = cfg.segmentator.classes_keep
-        if not (len(self.cls_keep) == len(self.cls_remove) == 1):
-            raise ValueError("Sid only works with one class removed and one class kept")
-        self.cls_keep = self.cls_keep[0]
-        self.cls_remove = self.cls_remove[0]
-
-        self.cls_keep_enc = None
-        self.cls_remove_enc = None
-
-        self.background_sig = None
-        self.sid_threshold = None
-
-    def _fit(self, X, y):
-        y_inv = self.encoder.inverse_transform(y)
-        X_remove = X[y_inv == self.cls_remove]
-        X_keep = X[y_inv == self.cls_keep]
-
-        self.background_sig = np.mean(X_remove, axis=0)
-        # calculate sid for all extracted signatures
-        sid_remove = list(map(lambda sig: self.sid(sig, self.background_sig), X_remove))
-        sid_keep = list(map(lambda sig: self.sid(sig, self.background_sig), X_keep))
-
-        # make column vecotrs
-        sid_keep = np.atleast_2d(sid_keep).T
-        sid_remove = np.atleast_2d(sid_remove).T
-
-        # here svc is used to determine the threshold
-        X = np.concatenate((sid_keep, sid_remove), axis=0)
-        y = [0] * len(sid_keep) + [1] * len(sid_remove)
-        clf = SVC(kernel="linear").fit(X, y)
-        # calculate boundary by equation:
-        # w0*x + b = 0 -> x = -b/w0
-        self.sid_threshold = -clf.intercept_ / clf.coef_
-
-    def _predict(self, X):
-        self.cls_keep_enc = self.encoder.transform([self.cls_keep])[0]
-        self.cls_remove_enc = self.encoder.transform([self.cls_remove])[0]
-        targets = np.array(self.sid_multi(np.array(X), self.background_sig, self.sid))
-        # replace values above threshold with class keep and below with class remove
-        targets = np.where(
-            targets > self.sid_threshold, self.cls_keep_enc, self.cls_remove_enc
-        )[0]
-        return targets
-
-    @staticmethod
-    @njit()
-    def sid(p, q):
-        p = p + np.spacing(1)
-        q = q + np.spacing(1)
-        return np.sum(p * np.log(p / q) + q * np.log(q / p))
-
-    @staticmethod
-    @njit(parallel=True)
-    def sid_multi(X, background_sig, sid_func):
-        n_rows, _ = X.shape
-        sid_values = np.zeros(n_rows)
-        for idx in prange(n_rows):
-            sid_values[idx] = sid_func(X[idx, :], background_sig)
-        return sid_values
diff --git a/_old/select_signatures.py b/_old/select_signatures.py
deleted file mode 100644
index 3768ea3..0000000
--- a/_old/select_signatures.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from types import SimpleNamespace
-
-import matplotlib.pyplot as plt
-
-from siapy.transformations import Corregistrator
-from siapy.entities import DataLoader
-from siapy.utils.image_utils import average_signatures, limit_to_bounds
-from siapy.utils.plot_utils import display_images, pixels_select_lasso
-from siapy.utils.utils import get_logger, save_data
-
-logger = get_logger(name="select_signatures")
-
-
-def main(cfg):
-    data_loader = DataLoader(cfg)
-    data_loader.load_images()
-
-    # if only camera1, then corregistration is not needed
-    if cfg.camera2 is not None:
-        corregistrator = Corregistrator(cfg)
-        corregistrator.load_params()
-
-    images_cam1 = data_loader.images.cam1
-    image_cam1 = images_cam1[cfg.image_idx]
-
-    selected_areas_cam1 = pixels_select_lasso(image_cam1)
-
-    # create images list to display
-    # images = [image_cam1]
-    images = SimpleNamespace(cam1=image_cam1, cam2=None)
-    # create list of areas which will be shown on the images
-    # selected_areas_display = [selected_areas_cam1]
-    selected_areas = SimpleNamespace(cam1=selected_areas_cam1, cam2=None)
-    # dictionary of signatures for each image
-    selected_signatures = SimpleNamespace(
-        cam1=list(map(image_cam1.to_signatures, selected_areas_cam1)), cam2=None
-    )
-
-    # perform if images from both cameras are available
-    if cfg.camera2 is not None:
-        images_cam2 = data_loader.images.cam2
-        image_cam2 = images_cam2[cfg.image_idx]
-        # tranform coordinates from cam1 to cam2
-        selected_areas_cam2 = list(map(corregistrator.transform, selected_areas_cam1))
-        # limit coordinates to the image size
-        selected_areas_cam2 = list(
-            map(limit_to_bounds(image_cam2.shape), selected_areas_cam2)
-        )
-
-        images.cam2 = image_cam2
-        selected_areas.cam2 = selected_areas_cam2
-        selected_signatures.cam2 = list(
-            map(image_cam2.to_signatures, selected_areas_cam2)
-        )
-
-    # perform averaging of signatures per area selected
-    if cfg.selector.average:
-        selected_signatures.cam1 = list(
-            map(average_signatures, selected_signatures.cam1)
-        )
-        if cfg.camera2 is not None:
-            selected_signatures.cam2 = list(
-                map(average_signatures, selected_signatures.cam2)
-            )
-
-    # save signatures
-    save_data(
-        cfg,
-        data=selected_signatures,
-        data_file_name=f"signatures/{cfg.selector.item}/img_{cfg.image_idx}",
-    )
-
-    # colors = [["red", "blue", "blue", "blue"], ["red", "blue", "blue", "blue"]]
-    display_images(images, selected_areas, colors=cfg.selector.color)
-    plt.show()
diff --git a/_old/show_image.py b/_old/show_image.py
deleted file mode 100644
index f04925c..0000000
--- a/_old/show_image.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from types import SimpleNamespace
-
-import matplotlib.pyplot as plt
-
-from siapy.entities import DataLoader
-from siapy.utils.plot_utils import display_images
-from siapy.utils.utils import get_logger
-
-logger = get_logger(name="show_image")
-
-
-def main(cfg):
-    data_loader = DataLoader(cfg).load_images()
-    images_cam1 = data_loader.images.cam1
-    images_cam2 = data_loader.images.cam2
-
-    image_cam1 = images_cam1[cfg.image_idx]
-
-    images = SimpleNamespace(cam1=image_cam1, cam2=None)
-
-    if images_cam2:
-        images.cam2 = images_cam2[cfg.image_idx]
-
-    display_images(images)
-    plt.show()
diff --git a/_old/utils/__init__.py b/_old/utils/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/_old/utils/image_utils.py b/_old/utils/image_utils.py
deleted file mode 100644
index 65f1682..0000000
--- a/_old/utils/image_utils.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import os
-import warnings
-
-import numpy as np
-import pandas as pd
-import spectral as sp
-from skimage import transform
-
-from siapy.entities import SPImage
-from siapy.utils.utils import get_logger, parse_data_file_name, to_absolute_path
-
-logger = get_logger(name="image_utils")
-
-
-def average_signatures(area_of_signatures):
-    if area_of_signatures is not None:
-        x_center = (
-            area_of_signatures.x.min()
-            + (area_of_signatures.x.max() - area_of_signatures.x.min()) / 2
-        )
-        y_center = (
-            area_of_signatures.y.min()
-            + (area_of_signatures.y.max() - area_of_signatures.y.min()) / 2
-        )
-        signatures_mean = [list(area_of_signatures.signature.mean())]
-
-        data = {"x": int(x_center), "y": int(y_center), "signature": signatures_mean}
-        return pd.DataFrame(data, columns=["x", "y", "signature"])
-    else:
-        return list()
-
-
-def limit_to_bounds(image_shape):
-    y_max = image_shape[0]
-    x_max = image_shape[1]
-
-    def _limit(points):
-        points = points[
-            (points.x >= 0) & (points.y >= 0) & (points.x < x_max) & (points.y < y_max)
-        ]
-        return points
-
-    return _limit
-
-
-def save_image(config, image, data_file_name, metadata=None):
-    dfn = parse_data_file_name(data_file_name)
-    dir_abs_path = to_absolute_path(os.path.join("outputs", config.name, dfn.dir_name))
-    os.makedirs(dir_abs_path, exist_ok=True)
-    file_name_hdr = os.path.join(dir_abs_path, dfn.file_name + ".hdr")
-    sp.envi.save_image(
-        file_name_hdr, image, dtype=np.float32, metadata=metadata, force=True
-    )
-    logger.info(f"Images saved as:  {file_name_hdr}")
-
-
-def merge_images_by_specter(
-    dir_name, image_original, image_to_merge, data_file_name, metadata=None
-):
-    dfn = parse_data_file_name(data_file_name)
-    dir_abs_path = to_absolute_path(os.path.join("outputs", dir_name, dfn.dir_name))
-    os.makedirs(dir_abs_path, exist_ok=True)
-
-    if metadata is None:
-        metadata = {
-            "lines": image_original.rows,
-            "samples": image_original.cols,
-            "bands": image_original.bands + image_to_merge.bands,
-            "data type": image_original.metadata["data type"],
-            "default bands": image_original.metadata["default bands"],
-            "wavelength": image_original.metadata["wavelength"]
-            + image_to_merge.metadata["wavelength"],
-            "byte order": image_original.metadata["byte order"],
-            "data ignore value": image_original.metadata["data ignore value"],
-            "header offset": image_original.metadata["header offset"],
-        }
-
-    file_name_hdr = os.path.join(dir_abs_path, dfn.file_name + ".hdr")
-    image = sp.envi.create_image(
-        file_name_hdr, metadata=metadata, dtype=np.float32, force=True
-    )
-
-    image_original_arr = image_original.to_numpy()
-    image_to_merge_arr = image_to_merge.to_numpy()
-
-    image_to_merge_arr = rescale_image(image_to_merge_arr, image_original_arr.shape[:2])
-    mmap = image.open_memmap(writable=True)
-    mmap[:, :, :] = np.concatenate((image_original_arr, image_to_merge_arr), axis=2)
-
-    cfg_image = image_original.config
-    cfg_image.name = "merged"
-    cfg_image.model = "/"
-    return SPImage(image, cfg_image)
-
-
-def rescale_image(image, output_size):
-    """Rescale the image in a sample to a given size.
-
-    Args:
-        output_size (tuple or int): Desired output size. If tuple, output is
-            matched to output_size. If int, smaller of image edges is matched
-            to output_size keeping aspect ratio the same.
-    """
-    h, w = image.shape[:2]
-    if isinstance(output_size, int):
-        if h > w:
-            new_h, new_w = output_size * h / w, output_size
-        else:
-            new_h, new_w = output_size, output_size * w / h
-    else:
-        new_h, new_w = output_size
-
-    new_h, new_w = int(new_h), int(new_w)
-
-    # to suppresss the warning: "All-Nan slice encountered"
-    with warnings.catch_warnings():
-        warnings.filterwarnings("ignore", r"All-NaN slice encountered")
-        resized_image = transform.resize(image, (new_h, new_w))
-
-    return resized_image
diff --git a/_old/utils/utils.py b/_old/utils/utils.py
deleted file mode 100644
index 774c658..0000000
--- a/_old/utils/utils.py
+++ /dev/null
@@ -1,159 +0,0 @@
-import logging
-import multiprocessing
-import os
-from dataclasses import dataclass, field
-from functools import partial
-from pathlib import Path
-from timeit import default_timer
-
-from siapy.core import logger
-
-
-def get_project_root() -> Path:
-    return Path(__file__).parent.parent.parent.resolve()
-
-
-def to_absolute_path(path):
-    return os.path.join(get_project_root(), path)
-
-
-# def save_data(config, data, data_file_name, saver="pickle"):
-#     # TODO: change first subfolder to name of a project used
-#     # remove file extensions from those functions
-#     # create different pattern for load and save of data
-#     """Save data
-
-#     Args:
-#         config (DictConfig): hydra config file
-#         data: data to be stored
-#         data_dir_name (str): name of directory where data will be stored
-#     """
-#     dfn = parse_data_file_name(data_file_name)
-#     dir_abs_path = os.path.join(
-#         get_project_root(), "outputs", config.name, dfn.dir_name
-#     )
-#     os.makedirs(dir_abs_path, exist_ok=True)
-#     if saver == "pickle":
-#         file = os.path.join(dir_abs_path, dfn.file_name + ".pkl")
-#         with open(file, "wb") as f:
-#             pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
-#     elif saver == "json":
-#         file = os.path.join(dir_abs_path, dfn.file_name + ".json")
-#         with open(file, "w") as f:
-#             json.dump(data, f)
-#     elif saver == "df_csv":
-#         file = os.path.join(dir_abs_path, dfn.file_name + ".csv")
-#         data.to_csv(file, index=False)
-#     else:
-#         logger.exception("Posible loader options: pickle, json")
-#     logger.info(f"Data saved as:  {file}")
-
-
-# def load_data(config, data_file_name, loader="pickle", dir_abs_path=None):
-#     dfn = parse_data_file_name(data_file_name)
-#     if dir_abs_path is None:
-#         dir_abs_path = os.path.join(
-#             get_project_root(), "outputs", config.name, dfn.dir_name
-#         )
-#     else:
-#         dir_abs_path = os.path.join(dir_abs_path, dfn.dir_name)
-
-#     if loader == "pickle":
-#         file = os.path.join(dir_abs_path, dfn.file_name + ".pkl")
-#         with open(file, "rb") as f:
-#             data = pickle.load(f)
-#     elif loader == "json":
-#         file = os.path.join(dir_abs_path, dfn.file_name + ".json")
-#         with open(file, "r") as f:
-#             data = json.load(f)
-#     elif loader == "df_csv":
-#         file = os.path.join(dir_abs_path, dfn.file_name + ".csv")
-#         data = pd.read_csv(file)
-#     elif loader == "df_xlsx":
-#         file = os.path.join(dir_abs_path, dfn.file_name + ".xlsx")
-#         data = pd.read_excel(file)
-#     else:
-#         logger.exception("Posible loader options: pickle, json, df_csv, df_xlsx")
-
-#     logger.info(f"Data loaded from:  {file}")
-#     return data
-
-
-def init_obj(module, module_name, module_args=None, *args, **kwargs):
-    if module_args is None:
-        module_args = {}
-    assert all(
-        [k not in module_args for k in kwargs]
-    ), "Overwriting kwargs given in config file is not allowed"
-    module_args.update(kwargs)
-    return getattr(module, module_name)(*args, **module_args)
-
-
-def init_ftn(module, module_name, module_args=None, *args, **kwargs):
-    if module_args is None:
-        module_args = {}
-    assert all(
-        [k not in module_args for k in kwargs]
-    ), "Overwriting kwargs given in config file is not allowed"
-    module_args.update(kwargs)
-    return partial(getattr(module, module_name), *args, **module_args)
-
-
-@dataclass
-class Timer:
-    name: str = field(default="")
-    logger: logging.Logger = field(default=logger)
-
-    def __post_init__(self):
-        self.start = default_timer()
-        self.end = None
-
-    def stop(self):
-        self.end = default_timer()
-        self.logger.info(f"{self.name} took {self.end - self.start:.2f} seconds.")
-
-
-def get_number_cpus(parallelize=-1):
-    num_cpus = multiprocessing.cpu_count()
-    if parallelize == -1:
-        parallelize = num_cpus
-    elif 1 <= parallelize <= num_cpus:
-        pass
-    elif parallelize > num_cpus:
-        parallelize = num_cpus
-        logger.warning(f"number of cpus changed from {num_cpus} to {parallelize}")
-    else:
-        raise ValueError("Define accurate number of cpus")
-    return parallelize
-
-
-def dict_zip(*dicts):
-    if not dicts:
-        return
-
-    n = len(dicts[0])
-    if any(len(d) != n for d in dicts):
-        raise ValueError("arguments must have the same length")
-
-    for key, first_val in dicts[0].items():
-        yield key, first_val, *(other[key] for other in dicts[1:])
-
-
-def equalize_dict_len(dict1, dict2):
-    keys_both = set(list(dict1.keys()) + list(dict2.keys()))
-    for key in keys_both:
-        if key not in dict1:
-            dict1[key] = None
-        if key not in dict2:
-            dict2[key] = None
-    return dict1, dict2
-
-
-def get_increasing_seq_indices(values_list):
-    indices = []
-    last_value = 0
-    for idx, value in enumerate(values_list):
-        if value > last_value:
-            last_value = value
-            indices.append(idx)
-    return indices
diff --git a/_old/visualise_signatures.py b/_old/visualise_signatures.py
deleted file mode 100644
index b66a635..0000000
--- a/_old/visualise_signatures.py
+++ /dev/null
@@ -1,229 +0,0 @@
-import numpy as np
-
-from siapy.entities import DataLoader
-from siapy.utils.plot_utils import plot_signatures
-from siapy.utils.utils import (
-    get_increasing_seq_indices,
-    get_logger,
-    load_data,
-    save_data,
-)
-
-logger = get_logger(name="visualise_signatures")
-
-
-def modify_dataframe(data, col_name, instructions):
-    instruction = instructions[0]
-    instructions.pop(0)
-    # convert to strings even if integer values written
-    instructions = list(map(str, instructions))
-    if len(instructions):
-        if instruction == "__include_all__":
-            pass
-        elif instruction == "__exclude_only__":
-            data = data[~data[col_name].isin(instructions)]
-        elif instruction == "__include_only__":
-            data = data[data[col_name].isin(instructions)]
-    else:
-        logger.warning(f"No instructions for {col_name}")
-    return data.reset_index(drop=True)
-
-
-def append_groups_to_dataframe(data, groups, group_data_by):
-    # create new column called groups
-    data = data.assign(groups=np.nan)
-    if group_data_by in data.columns:
-        for group_name, group_list in groups.items():
-            # in case in list integer values, convert to strings
-            group_list = list(map(str, group_list))
-            # get bolean indices indicating where groups applies
-            group_indices = data[group_data_by].isin(group_list)
-            # set group name
-            data.loc[group_indices, "groups"] = group_name
-    else:
-        logger.warning(f"Data groups not set by: <{group_data_by}>")
-    return data
-
-
-def merge_cameras_rows(cfg, data):
-    cam1_name = cfg.camera1.name
-    cam2_name = cfg.camera2.name
-    data_cam1 = data[data.camera_names == cam1_name]
-    data_cam2 = data[data.camera_names == cam2_name]
-
-    # remove camera_names column
-    data_cam1 = data_cam1.drop(["camera_names"], axis=1)
-    data_cam2 = data_cam2.drop(["camera_names"], axis=1)
-
-    # check if dataframe not enabled create empty columns
-    if not cfg.visualiser.camera1:
-        data_cam1 = data_cam2.copy()
-        data_cam1[["signatures", "filenames"]] = np.nan
-    if not cfg.visualiser.camera2:
-        data_cam2 = data_cam1.copy()
-        data_cam2[["signatures", "filenames"]] = np.nan
-
-    # rename colunms of each dataframe and merge
-    data_cam1 = data_cam1.rename(
-        columns={
-            "signatures": "signatures_camera1",
-            "filenames": "filenames_camera1",
-        }
-    )
-    data_cam2 = data_cam2.rename(
-        columns={
-            "signatures": "signatures_camera2",
-            "filenames": "filenames_camera2",
-        }
-    )
-    data = data_cam1.merge(
-        data_cam2,
-        on=[
-            "images_indices",
-            "objects_indices",
-            "slices_indices",
-            "labels_all",
-            "labels_names",
-            "groups",
-        ],
-    )
-    # create signature_megred column
-    data["signatures_merged"] = np.nan
-    return data
-
-
-def merge_signatures_and_evaluate(row):
-    if str(row.signatures_camera1) == "nan":
-        row.signatures_merged = eval(row.signatures_camera2)
-    elif str(row.signatures_camera2) == "nan":
-        row.signatures_merged = eval(row.signatures_camera1)
-    else:
-        row.signatures_merged = eval(row.signatures_camera1) + eval(
-            row.signatures_camera2
-        )
-    return row
-
-
-def evaluate_signatures(row):
-    row.signatures_merged = eval(row.signatures_merged)
-    return row
-
-
-def filter_by_indices(row, indices_inc):
-    if indices_inc is not None:
-        row.signatures_merged = list(np.array(row.signatures_merged)[indices_inc])
-    return row
-
-
-def main(cfg):
-    data = load_data(cfg, data_file_name="files/data", loader="df_csv")
-    config_vis = cfg.visualiser
-
-    # remove or leave data from each camera
-    if not config_vis.camera1:
-        data = data[~(data["camera_names"] == cfg.camera1.name)]
-    if not config_vis.camera2:
-        data = data[~(data["camera_names"] == cfg.camera2.name)]
-    if not (config_vis.camera1 or config_vis.camera2):
-        logger.error("No camera selected for visualisation.")
-        return
-
-    # modify df based on config
-    temp_cfg = {
-        "images_indices": config_vis.images_indices,
-        "objects_indices": config_vis.objects_indices,
-        "slices_indices": config_vis.slices_indices,
-        "labels_names": config_vis.labels_names,
-    }
-
-    for col_name, instructions in temp_cfg.items():
-        data = modify_dataframe(data, col_name, instructions)
-
-    # define groups of which signatures are a part of
-    groups = config_vis.groups
-    group_data_by = config_vis.group_data_by
-    data = append_groups_to_dataframe(data, groups, group_data_by)
-
-    # further modify data for plotting purpose
-    # use dataloader to extract wavelengths
-    data_loader = DataLoader(cfg)
-    data_loader.change_dir("converted_images").load_images(1)
-
-    # get wavelengths
-    wavelengths_cam1 = None
-    wavelengths_cam2 = None
-    if data_loader.images.cam1 is not None and len(data_loader.images.cam1):
-        wavelengths_cam1 = data_loader.images.cam1[0].wavelengths
-    if data_loader.images.cam2 is not None and len(data_loader.images.cam2):
-        wavelengths_cam2 = data_loader.images.cam2[0].wavelengths
-
-    # merge wavelengths from both cameras
-    x_scat = []
-    if config_vis.plot.x_wavelengths:
-        if config_vis.camera1 and wavelengths_cam1 is not None:
-            x_scat += wavelengths_cam1
-        if config_vis.camera2 and wavelengths_cam2 is not None:
-            x_scat += wavelengths_cam2
-
-        # find increasing sequences in wavelengths list
-        if len(x_scat):
-            indices_inc = get_increasing_seq_indices(x_scat)
-            x_scat = np.array(x_scat)[indices_inc]
-        else:
-            indices_inc = None
-            x_scat = None
-
-    # check whether images were merged in preparation proces
-    if not cfg.preparator.merge_images_by_specter:
-        # merge dataframe and signatures from both cameras
-        data = merge_cameras_rows(cfg, data)
-        # merge signatures from both cameras
-        data = data.apply(lambda row: merge_signatures_and_evaluate(row), axis=1)
-
-    else:
-        data = data.rename(columns={"signatures": "signatures_merged"})
-        data = data.apply(lambda row: evaluate_signatures(row), axis=1)
-    # filter by wavelengts
-    data = data.apply(lambda row: filter_by_indices(row, indices_inc), axis=1)
-
-    # save modified data
-    save_data(cfg, data=data, data_file_name="files/data_parsed", saver="df_csv")
-
-    # if iteration over seperate image enabled
-    if config_vis.iterate_over_images:
-        for image_idx in data.images_indices.unique():
-            # get data part coresponding to particular image
-            data_slice = data[data.images_indices == image_idx]
-            plot_df = str(
-                data_slice[
-                    [
-                        "images_indices",
-                        "objects_indices",
-                        "slices_indices",
-                        "labels_names",
-                        "groups",
-                    ]
-                ]
-            )
-            logger.info(f"Data plotted: \n {plot_df}")
-            plot_signatures(
-                data_slice.signatures_merged.tolist(),
-                data_slice.objects_indices.tolist(),
-                x_scat=x_scat,
-            )
-    else:
-        signatures = data.signatures_merged.to_list()
-        groups_labels = data.groups.to_list()
-        plot_df = str(
-            data[
-                [
-                    "images_indices",
-                    "objects_indices",
-                    "slices_indices",
-                    "labels_names",
-                    "groups",
-                ]
-            ]
-        )
-        logger.info(f"Data plotted: \n {plot_df}")
-        plot_signatures(signatures, groups_labels, x_scat=x_scat)
diff --git a/pdm.lock b/pdm.lock
index 095a67b..9078217 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
 groups = ["default", "dev", "docs", "lint", "test"]
 strategy = ["inherit_metadata"]
 lock_version = "4.5.0"
-content_hash = "sha256:c7a00401c842468c433380e2d12cf73ee4e4a678506c84d023e46b181e7db044"
+content_hash = "sha256:794e656544e4bdce601b8254757548b783e02b4d9935fac8037389cbc04e163f"
 
 [[metadata.targets]]
 requires_python = ">=3.10"
@@ -42,6 +42,50 @@ files = [
     {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
 ]
 
+[[package]]
+name = "appdirs"
+version = "1.4.4"
+summary = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+groups = ["default"]
+files = [
+    {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
+    {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+]
+
+[[package]]
+name = "autofeat"
+version = "2.1.1"
+requires_python = ">=3.7"
+summary = "Automatic Feature Engineering and Selection Linear Prediction Model"
+groups = ["default"]
+dependencies = [
+    "joblib",
+    "numba",
+    "numpy",
+    "pandas>=0.24.0",
+    "pint",
+    "scikit-learn>=1.2.0",
+    "sympy>=1.7.1",
+]
+files = [
+    {file = "autofeat-2.1.1-py3-none-any.whl", hash = "sha256:0ff9aa40e09d2fa3134df5c1845edd704ec9a6cf224873240de7baf62a91e55a"},
+    {file = "autofeat-2.1.1.tar.gz", hash = "sha256:9a86860753bfb245a0a8b78c579a1d2d63f53ae59b178c37af6bba56ea71f9ea"},
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.12.3"
+requires_python = ">=3.6.0"
+summary = "Screen-scraping library"
+groups = ["default"]
+dependencies = [
+    "soupsieve>1.2",
+]
+files = [
+    {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
+    {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
+]
+
 [[package]]
 name = "black"
 version = "24.4.2"
@@ -74,6 +118,28 @@ files = [
     {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"},
 ]
 
+[[package]]
+name = "cachetools"
+version = "5.4.0"
+requires_python = ">=3.7"
+summary = "Extensible memoizing collections and decorators"
+groups = ["default"]
+files = [
+    {file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"},
+    {file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"},
+]
+
+[[package]]
+name = "certifi"
+version = "2024.7.4"
+requires_python = ">=3.6"
+summary = "Python package for providing Mozilla's CA Bundle."
+groups = ["default"]
+files = [
+    {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
+    {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
+]
+
 [[package]]
 name = "cfgv"
 version = "3.4.0"
@@ -85,12 +151,68 @@ files = [
     {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
 ]
 
+[[package]]
+name = "charset-normalizer"
+version = "3.3.2"
+requires_python = ">=3.7.0"
+summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+groups = ["default"]
+files = [
+    {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
+    {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+]
+
 [[package]]
 name = "click"
 version = "8.1.7"
 requires_python = ">=3.7"
 summary = "Composable command line interface toolkit"
-groups = ["docs", "lint"]
+groups = ["default", "docs", "lint"]
 dependencies = [
     "colorama; platform_system == \"Windows\"",
     "importlib-metadata; python_version < \"3.8\"",
@@ -100,6 +222,17 @@ files = [
     {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
 ]
 
+[[package]]
+name = "cloudpickle"
+version = "3.0.0"
+requires_python = ">=3.8"
+summary = "Pickler class to extend the standard pickle.Pickler functionality"
+groups = ["default"]
+files = [
+    {file = "cloudpickle-3.0.0-py3-none-any.whl", hash = "sha256:246ee7d0c295602a036e86369c77fecda4ab17b506496730f2f576d9016fd9c7"},
+    {file = "cloudpickle-3.0.0.tar.gz", hash = "sha256:996d9a482c6fb4f33c1a35335cf8afd065d2a56e973270364840712d9131a882"},
+]
+
 [[package]]
 name = "codespell"
 version = "2.3.0"
@@ -281,6 +414,77 @@ files = [
     {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"},
 ]
 
+[[package]]
+name = "dask"
+version = "2024.7.1"
+requires_python = ">=3.9"
+summary = "Parallel PyData with Task Scheduling"
+groups = ["default"]
+dependencies = [
+    "click>=8.1",
+    "cloudpickle>=1.5.0",
+    "fsspec>=2021.09.0",
+    "importlib-metadata>=4.13.0; python_version < \"3.12\"",
+    "packaging>=20.0",
+    "partd>=1.4.0",
+    "pyyaml>=5.3.1",
+    "toolz>=0.10.0",
+]
+files = [
+    {file = "dask-2024.7.1-py3-none-any.whl", hash = "sha256:dd046840050376c317de90629db5c6197adda820176cf3e2df10c3219d11951f"},
+    {file = "dask-2024.7.1.tar.gz", hash = "sha256:dbaef2d50efee841a9d981a218cfeb50392fc9a95e0403b6d680450e4f50d531"},
+]
+
+[[package]]
+name = "dask-expr"
+version = "1.1.9"
+requires_python = ">=3.9"
+summary = "High Level Expressions for Dask "
+groups = ["default"]
+dependencies = [
+    "dask==2024.7.1",
+    "pandas>=2",
+    "pyarrow>=7.0.0",
+]
+files = [
+    {file = "dask_expr-1.1.9-py3-none-any.whl", hash = "sha256:6a73d2ef8a4b697db476163ebfc661f7f4b6e12c4e483e8bf207e4d00e574f0c"},
+    {file = "dask_expr-1.1.9.tar.gz", hash = "sha256:edb4f5a38b70a15beb4cfd449c71526c728ff989d91edec210836aa05556c766"},
+]
+
+[[package]]
+name = "dask"
+version = "2024.7.1"
+extras = ["array"]
+requires_python = ">=3.9"
+summary = "Parallel PyData with Task Scheduling"
+groups = ["default"]
+dependencies = [
+    "dask==2024.7.1",
+    "numpy>=1.21",
+]
+files = [
+    {file = "dask-2024.7.1-py3-none-any.whl", hash = "sha256:dd046840050376c317de90629db5c6197adda820176cf3e2df10c3219d11951f"},
+    {file = "dask-2024.7.1.tar.gz", hash = "sha256:dbaef2d50efee841a9d981a218cfeb50392fc9a95e0403b6d680450e4f50d531"},
+]
+
+[[package]]
+name = "dask"
+version = "2024.7.1"
+extras = ["dataframe"]
+requires_python = ">=3.9"
+summary = "Parallel PyData with Task Scheduling"
+groups = ["default"]
+dependencies = [
+    "dask-expr<1.2,>=1.1",
+    "dask==2024.7.1",
+    "dask[array]",
+    "pandas>=2.0",
+]
+files = [
+    {file = "dask-2024.7.1-py3-none-any.whl", hash = "sha256:dd046840050376c317de90629db5c6197adda820176cf3e2df10c3219d11951f"},
+    {file = "dask-2024.7.1.tar.gz", hash = "sha256:dbaef2d50efee841a9d981a218cfeb50392fc9a95e0403b6d680450e4f50d531"},
+]
+
 [[package]]
 name = "distlib"
 version = "0.3.8"
@@ -291,6 +495,56 @@ files = [
     {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
 ]
 
+[[package]]
+name = "earthengine-api"
+version = "0.1.415"
+requires_python = ">=3.8"
+summary = "Earth Engine Python API"
+groups = ["default"]
+dependencies = [
+    "google-api-python-client>=1.12.1",
+    "google-auth-httplib2>=0.0.3",
+    "google-auth>=1.4.1",
+    "google-cloud-storage",
+    "httplib2<1dev,>=0.9.2",
+    "requests",
+]
+files = [
+    {file = "earthengine_api-0.1.415-py3-none-any.whl", hash = "sha256:1fd92d02087fcee6a29a68c8aa12862224b2d50257511413e3afb8d6960c80e0"},
+    {file = "earthengine_api-0.1.415.tar.gz", hash = "sha256:34a3daa87798fc1dd3f9c28ac15bdb40c52d93d1f0182d5b09b823a2fbc9e742"},
+]
+
+[[package]]
+name = "ee-extra"
+version = "0.0.15"
+summary = "A ninja Python package behind rgee, rgeeExtra and eemont."
+groups = ["default"]
+dependencies = [
+    "earthengine-api",
+]
+files = [
+    {file = "ee_extra-0.0.15.tar.gz", hash = "sha256:63f6334af747e1fe664f148343f1bad5156486c76f5266b93e8727d061abceeb"},
+]
+
+[[package]]
+name = "eemont"
+version = "0.3.6"
+summary = "A Python package that extends Google Earth Engine"
+groups = ["default"]
+dependencies = [
+    "beautifulsoup4",
+    "earthengine-api",
+    "ee-extra>=0.0.15",
+    "geopy",
+    "numpy",
+    "pandas",
+    "python-box>=6.0.0",
+    "requests",
+]
+files = [
+    {file = "eemont-0.3.6.tar.gz", hash = "sha256:49f4c1f7e2ae432ec678875177619b1fed2e99017a810aa49a4c760fc0994ef0"},
+]
+
 [[package]]
 name = "exceptiongroup"
 version = "1.2.1"
@@ -341,6 +595,34 @@ files = [
     {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"},
 ]
 
+[[package]]
+name = "flexcache"
+version = "0.3"
+requires_python = ">=3.9"
+summary = "Saves and loads to the cache a transformed versions of a source object."
+groups = ["default"]
+dependencies = [
+    "typing-extensions",
+]
+files = [
+    {file = "flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32"},
+    {file = "flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656"},
+]
+
+[[package]]
+name = "flexparser"
+version = "0.3.1"
+requires_python = ">=3.9"
+summary = "Parsing made fun ... using typing."
+groups = ["default"]
+dependencies = [
+    "typing-extensions",
+]
+files = [
+    {file = "flexparser-0.3.1-py3-none-any.whl", hash = "sha256:2e3e2936bec1f9277f777ef77297522087d96adb09624d4fe4240fd56885c013"},
+    {file = "flexparser-0.3.1.tar.gz", hash = "sha256:36f795d82e50f5c9ae2fde1c33f21f88922fdd67b7629550a3cc4d0b40a66856"},
+]
+
 [[package]]
 name = "fonttools"
 version = "4.53.0"
@@ -376,6 +658,42 @@ files = [
     {file = "fonttools-4.53.0.tar.gz", hash = "sha256:c93ed66d32de1559b6fc348838c7572d5c0ac1e4a258e76763a5caddd8944002"},
 ]
 
+[[package]]
+name = "fsspec"
+version = "2024.6.1"
+requires_python = ">=3.8"
+summary = "File-system specification"
+groups = ["default"]
+files = [
+    {file = "fsspec-2024.6.1-py3-none-any.whl", hash = "sha256:3cb443f8bcd2efb31295a5b9fdb02aee81d8452c80d28f97a6d0959e6cee101e"},
+    {file = "fsspec-2024.6.1.tar.gz", hash = "sha256:fad7d7e209dd4c1208e3bbfda706620e0da5142bebbd9c384afb95b07e798e49"},
+]
+
+[[package]]
+name = "geographiclib"
+version = "2.0"
+requires_python = ">=3.7"
+summary = "The geodesic routines from GeographicLib"
+groups = ["default"]
+files = [
+    {file = "geographiclib-2.0-py3-none-any.whl", hash = "sha256:6b7225248e45ff7edcee32becc4e0a1504c606ac5ee163a5656d482e0cd38734"},
+    {file = "geographiclib-2.0.tar.gz", hash = "sha256:f7f41c85dc3e1c2d3d935ec86660dc3b2c848c83e17f9a9e51ba9d5146a15859"},
+]
+
+[[package]]
+name = "geopy"
+version = "2.4.1"
+requires_python = ">=3.7"
+summary = "Python Geocoding Toolbox"
+groups = ["default"]
+dependencies = [
+    "geographiclib<3,>=1.52",
+]
+files = [
+    {file = "geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7"},
+    {file = "geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1"},
+]
+
 [[package]]
 name = "ghp-import"
 version = "2.1.0"
@@ -389,6 +707,165 @@ files = [
     {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
 ]
 
+[[package]]
+name = "google-api-core"
+version = "2.19.1"
+requires_python = ">=3.7"
+summary = "Google API client core library"
+groups = ["default"]
+dependencies = [
+    "google-auth<3.0.dev0,>=2.14.1",
+    "googleapis-common-protos<2.0.dev0,>=1.56.2",
+    "proto-plus<2.0.0dev,>=1.22.3",
+    "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<6.0.0.dev0,>=3.19.5",
+    "requests<3.0.0.dev0,>=2.18.0",
+]
+files = [
+    {file = "google-api-core-2.19.1.tar.gz", hash = "sha256:f4695f1e3650b316a795108a76a1c416e6afb036199d1c1f1f110916df479ffd"},
+    {file = "google_api_core-2.19.1-py3-none-any.whl", hash = "sha256:f12a9b8309b5e21d92483bbd47ce2c445861ec7d269ef6784ecc0ea8c1fa6125"},
+]
+
+[[package]]
+name = "google-api-python-client"
+version = "2.139.0"
+requires_python = ">=3.7"
+summary = "Google API Client Library for Python"
+groups = ["default"]
+dependencies = [
+    "google-api-core!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0.dev0,>=1.31.5",
+    "google-auth!=2.24.0,!=2.25.0,<3.0.0.dev0,>=1.32.0",
+    "google-auth-httplib2<1.0.0,>=0.2.0",
+    "httplib2<1.dev0,>=0.19.0",
+    "uritemplate<5,>=3.0.1",
+]
+files = [
+    {file = "google_api_python_client-2.139.0-py2.py3-none-any.whl", hash = "sha256:1850a92505d91a82e2ca1635ab2b8dff179f4b67082c2651e1db332e8039840c"},
+    {file = "google_api_python_client-2.139.0.tar.gz", hash = "sha256:ed4bc3abe2c060a87412465b4e8254620bbbc548eefc5388e2c5ff912d36a68b"},
+]
+
+[[package]]
+name = "google-auth"
+version = "2.32.0"
+requires_python = ">=3.7"
+summary = "Google Authentication Library"
+groups = ["default"]
+dependencies = [
+    "cachetools<6.0,>=2.0.0",
+    "pyasn1-modules>=0.2.1",
+    "rsa<5,>=3.1.4",
+]
+files = [
+    {file = "google_auth-2.32.0-py2.py3-none-any.whl", hash = "sha256:53326ea2ebec768070a94bee4e1b9194c9646ea0c2bd72422785bd0f9abfad7b"},
+    {file = "google_auth-2.32.0.tar.gz", hash = "sha256:49315be72c55a6a37d62819e3573f6b416aca00721f7e3e31a008d928bf64022"},
+]
+
+[[package]]
+name = "google-auth-httplib2"
+version = "0.2.0"
+summary = "Google Authentication Library: httplib2 transport"
+groups = ["default"]
+dependencies = [
+    "google-auth",
+    "httplib2>=0.19.0",
+]
+files = [
+    {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"},
+    {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"},
+]
+
+[[package]]
+name = "google-cloud-core"
+version = "2.4.1"
+requires_python = ">=3.7"
+summary = "Google Cloud API client core library"
+groups = ["default"]
+dependencies = [
+    "google-api-core!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0dev,>=1.31.6",
+    "google-auth<3.0dev,>=1.25.0",
+    "importlib-metadata>1.0.0; python_version < \"3.8\"",
+]
+files = [
+    {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"},
+    {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"},
+]
+
+[[package]]
+name = "google-cloud-storage"
+version = "2.18.0"
+requires_python = ">=3.7"
+summary = "Google Cloud Storage API client library"
+groups = ["default"]
+dependencies = [
+    "google-api-core<3.0.0dev,>=2.15.0",
+    "google-auth<3.0dev,>=2.26.1",
+    "google-cloud-core<3.0dev,>=2.3.0",
+    "google-crc32c<2.0dev,>=1.0",
+    "google-resumable-media>=2.6.0",
+    "requests<3.0.0dev,>=2.18.0",
+]
+files = [
+    {file = "google_cloud_storage-2.18.0-py2.py3-none-any.whl", hash = "sha256:e8e1a9577952143c3fca8163005ecfadd2d70ec080fa158a8b305000e2c22fbb"},
+    {file = "google_cloud_storage-2.18.0.tar.gz", hash = "sha256:0aa3f7c57f3632f81b455d91558d2b27ada96eee2de3aaa17f689db1470d9578"},
+]
+
+[[package]]
+name = "google-crc32c"
+version = "1.5.0"
+requires_python = ">=3.7"
+summary = "A python wrapper of the C library 'Google CRC32C'"
+groups = ["default"]
+files = [
+    {file = "google-crc32c-1.5.0.tar.gz", hash = "sha256:89284716bc6a5a415d4eaa11b1726d2d60a0cd12aadf5439828353662ede9dd7"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:596d1f98fc70232fcb6590c439f43b350cb762fb5d61ce7b0e9db4539654cc13"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:be82c3c8cfb15b30f36768797a640e800513793d6ae1724aaaafe5bf86f8f346"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:461665ff58895f508e2866824a47bdee72497b091c730071f2b7575d5762ab65"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2096eddb4e7c7bdae4bd69ad364e55e07b8316653234a56552d9c988bd2d61b"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:116a7c3c616dd14a3de8c64a965828b197e5f2d121fedd2f8c5585c547e87b02"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5829b792bf5822fd0a6f6eb34c5f81dd074f01d570ed7f36aa101d6fc7a0a6e4"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:64e52e2b3970bd891309c113b54cf0e4384762c934d5ae56e283f9a0afcd953e"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:02ebb8bf46c13e36998aeaad1de9b48f4caf545e91d14041270d9dca767b780c"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-win32.whl", hash = "sha256:2e920d506ec85eb4ba50cd4228c2bec05642894d4c73c59b3a2fe20346bd00ee"},
+    {file = "google_crc32c-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:07eb3c611ce363c51a933bf6bd7f8e3878a51d124acfc89452a75120bc436289"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cae0274952c079886567f3f4f685bcaf5708f0a23a5f5216fdab71f81a6c0273"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1034d91442ead5a95b5aaef90dbfaca8633b0247d1e41621d1e9f9db88c36298"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c42c70cd1d362284289c6273adda4c6af8039a8ae12dc451dcd61cdabb8ab57"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8485b340a6a9e76c62a7dce3c98e5f102c9219f4cfbf896a00cf48caf078d438"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77e2fd3057c9d78e225fa0a2160f96b64a824de17840351b26825b0848022906"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f583edb943cf2e09c60441b910d6a20b4d9d626c75a36c8fcac01a6c96c01183"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a1fd716e7a01f8e717490fbe2e431d2905ab8aa598b9b12f8d10abebb36b04dd"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:72218785ce41b9cfd2fc1d6a017dc1ff7acfc4c17d01053265c41a2c0cc39b8c"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-win32.whl", hash = "sha256:66741ef4ee08ea0b2cc3c86916ab66b6aef03768525627fd6a1b34968b4e3709"},
+    {file = "google_crc32c-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba1eb1843304b1e5537e1fca632fa894d6f6deca8d6389636ee5b4797affb968"},
+]
+
+[[package]]
+name = "google-resumable-media"
+version = "2.7.1"
+requires_python = ">=3.7"
+summary = "Utilities for Google Media Downloads and Resumable Uploads"
+groups = ["default"]
+dependencies = [
+    "google-crc32c<2.0dev,>=1.0",
+]
+files = [
+    {file = "google-resumable-media-2.7.1.tar.gz", hash = "sha256:eae451a7b2e2cdbaaa0fd2eb00cc8a1ee5e95e16b55597359cbc3d27d7d90e33"},
+    {file = "google_resumable_media-2.7.1-py2.py3-none-any.whl", hash = "sha256:103ebc4ba331ab1bfdac0250f8033627a2cd7cde09e7ccff9181e31ba4315b2c"},
+]
+
+[[package]]
+name = "googleapis-common-protos"
+version = "1.63.2"
+requires_python = ">=3.7"
+summary = "Common protobufs used in Google APIs"
+groups = ["default"]
+dependencies = [
+    "protobuf!=3.20.0,!=3.20.1,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<6.0.0.dev0,>=3.20.2",
+]
+files = [
+    {file = "googleapis-common-protos-1.63.2.tar.gz", hash = "sha256:27c5abdffc4911f28101e635de1533fb4cfd2c37fbaa9174587c799fac90aa87"},
+    {file = "googleapis_common_protos-1.63.2-py2.py3-none-any.whl", hash = "sha256:27a2499c7e8aff199665b22741997e485eccc8645aa9176c7c988e6fae507945"},
+]
+
 [[package]]
 name = "greenlet"
 version = "3.0.3"
@@ -427,6 +904,21 @@ files = [
     {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"},
 ]
 
+[[package]]
+name = "httplib2"
+version = "0.22.0"
+requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+summary = "A comprehensive HTTP client library."
+groups = ["default"]
+dependencies = [
+    "pyparsing!=3.0.0,!=3.0.1,!=3.0.2,!=3.0.3,<4,>=2.4.2; python_version > \"3.0\"",
+    "pyparsing<3,>=2.4.2; python_version < \"3.0\"",
+]
+files = [
+    {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"},
+    {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"},
+]
+
 [[package]]
 name = "identify"
 version = "2.5.36"
@@ -438,6 +930,17 @@ files = [
     {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"},
 ]
 
+[[package]]
+name = "idna"
+version = "3.7"
+requires_python = ">=3.5"
+summary = "Internationalized Domain Names in Applications (IDNA)"
+groups = ["default"]
+files = [
+    {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
+    {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
+]
+
 [[package]]
 name = "imageio"
 version = "2.34.1"
@@ -453,6 +956,22 @@ files = [
     {file = "imageio-2.34.1.tar.gz", hash = "sha256:f13eb76e4922f936ac4a7fec77ce8a783e63b93543d4ea3e40793a6cabd9ac7d"},
 ]
 
+[[package]]
+name = "importlib-metadata"
+version = "8.2.0"
+requires_python = ">=3.8"
+summary = "Read metadata from Python packages"
+groups = ["default"]
+marker = "python_version < \"3.12\""
+dependencies = [
+    "typing-extensions>=3.6.4; python_version < \"3.8\"",
+    "zipp>=0.5",
+]
+files = [
+    {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"},
+    {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"},
+]
+
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
@@ -577,6 +1096,42 @@ files = [
     {file = "lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1"},
 ]
 
+[[package]]
+name = "llvmlite"
+version = "0.43.0"
+requires_python = ">=3.9"
+summary = "lightweight wrapper around basic LLVM functionality"
+groups = ["default"]
+files = [
+    {file = "llvmlite-0.43.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a289af9a1687c6cf463478f0fa8e8aa3b6fb813317b0d70bf1ed0759eab6f761"},
+    {file = "llvmlite-0.43.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d4fd101f571a31acb1559ae1af30f30b1dc4b3186669f92ad780e17c81e91bc"},
+    {file = "llvmlite-0.43.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d434ec7e2ce3cc8f452d1cd9a28591745de022f931d67be688a737320dfcead"},
+    {file = "llvmlite-0.43.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6912a87782acdff6eb8bf01675ed01d60ca1f2551f8176a300a886f09e836a6a"},
+    {file = "llvmlite-0.43.0-cp310-cp310-win_amd64.whl", hash = "sha256:14f0e4bf2fd2d9a75a3534111e8ebeb08eda2f33e9bdd6dfa13282afacdde0ed"},
+    {file = "llvmlite-0.43.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e8d0618cb9bfe40ac38a9633f2493d4d4e9fcc2f438d39a4e854f39cc0f5f98"},
+    {file = "llvmlite-0.43.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0a9a1a39d4bf3517f2af9d23d479b4175ead205c592ceeb8b89af48a327ea57"},
+    {file = "llvmlite-0.43.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1da416ab53e4f7f3bc8d4eeba36d801cc1894b9fbfbf2022b29b6bad34a7df2"},
+    {file = "llvmlite-0.43.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977525a1e5f4059316b183fb4fd34fa858c9eade31f165427a3977c95e3ee749"},
+    {file = "llvmlite-0.43.0-cp311-cp311-win_amd64.whl", hash = "sha256:d5bd550001d26450bd90777736c69d68c487d17bf371438f975229b2b8241a91"},
+    {file = "llvmlite-0.43.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f99b600aa7f65235a5a05d0b9a9f31150c390f31261f2a0ba678e26823ec38f7"},
+    {file = "llvmlite-0.43.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:35d80d61d0cda2d767f72de99450766250560399edc309da16937b93d3b676e7"},
+    {file = "llvmlite-0.43.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eccce86bba940bae0d8d48ed925f21dbb813519169246e2ab292b5092aba121f"},
+    {file = "llvmlite-0.43.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df6509e1507ca0760787a199d19439cc887bfd82226f5af746d6977bd9f66844"},
+    {file = "llvmlite-0.43.0-cp312-cp312-win_amd64.whl", hash = "sha256:7a2872ee80dcf6b5dbdc838763d26554c2a18aa833d31a2635bff16aafefb9c9"},
+    {file = "llvmlite-0.43.0.tar.gz", hash = "sha256:ae2b5b5c3ef67354824fb75517c8db5fbe93bc02cd9671f3c62271626bc041d5"},
+]
+
+[[package]]
+name = "locket"
+version = "1.0.0"
+requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+summary = "File-based locks for Python on Linux and Windows"
+groups = ["default"]
+files = [
+    {file = "locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3"},
+    {file = "locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632"},
+]
+
 [[package]]
 name = "mako"
 version = "1.3.5"
@@ -780,6 +1335,34 @@ files = [
     {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"},
 ]
 
+[[package]]
+name = "mlxtend"
+version = "0.23.1"
+summary = "Machine Learning Library Extensions"
+groups = ["default"]
+dependencies = [
+    "joblib>=0.13.2",
+    "matplotlib>=3.0.0",
+    "numpy>=1.16.2",
+    "pandas>=0.24.2",
+    "scikit-learn>=1.0.2",
+    "scipy>=1.2.1",
+]
+files = [
+    {file = "mlxtend-0.23.1-py3-none-any.whl", hash = "sha256:38cb84f864dfdaa85c1d7e46d374be94d3d0fd0e2662ad278f42ccc4abcc785a"},
+    {file = "mlxtend-0.23.1.tar.gz", hash = "sha256:9f3075e3deac57f4452fee2c1f0befda58a24c1f9c26bed649a2303ca04baa29"},
+]
+
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+summary = "Python library for arbitrary-precision floating-point arithmetic"
+groups = ["default"]
+files = [
+    {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"},
+    {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"},
+]
+
 [[package]]
 name = "mypy"
 version = "1.10.1"
@@ -844,6 +1427,35 @@ files = [
     {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
 ]
 
+[[package]]
+name = "numba"
+version = "0.60.0"
+requires_python = ">=3.9"
+summary = "compiling Python code using LLVM"
+groups = ["default"]
+dependencies = [
+    "llvmlite<0.44,>=0.43.0dev0",
+    "numpy<2.1,>=1.22",
+]
+files = [
+    {file = "numba-0.60.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d761de835cd38fb400d2c26bb103a2726f548dc30368853121d66201672e651"},
+    {file = "numba-0.60.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:159e618ef213fba758837f9837fb402bbe65326e60ba0633dbe6c7f274d42c1b"},
+    {file = "numba-0.60.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1527dc578b95c7c4ff248792ec33d097ba6bef9eda466c948b68dfc995c25781"},
+    {file = "numba-0.60.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe0b28abb8d70f8160798f4de9d486143200f34458d34c4a214114e445d7124e"},
+    {file = "numba-0.60.0-cp310-cp310-win_amd64.whl", hash = "sha256:19407ced081d7e2e4b8d8c36aa57b7452e0283871c296e12d798852bc7d7f198"},
+    {file = "numba-0.60.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a17b70fc9e380ee29c42717e8cc0bfaa5556c416d94f9aa96ba13acb41bdece8"},
+    {file = "numba-0.60.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fb02b344a2a80efa6f677aa5c40cd5dd452e1b35f8d1c2af0dfd9ada9978e4b"},
+    {file = "numba-0.60.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f4fde652ea604ea3c86508a3fb31556a6157b2c76c8b51b1d45eb40c8598703"},
+    {file = "numba-0.60.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4142d7ac0210cc86432b818338a2bc368dc773a2f5cf1e32ff7c5b378bd63ee8"},
+    {file = "numba-0.60.0-cp311-cp311-win_amd64.whl", hash = "sha256:cac02c041e9b5bc8cf8f2034ff6f0dbafccd1ae9590dc146b3a02a45e53af4e2"},
+    {file = "numba-0.60.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7da4098db31182fc5ffe4bc42c6f24cd7d1cb8a14b59fd755bfee32e34b8404"},
+    {file = "numba-0.60.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38d6ea4c1f56417076ecf8fc327c831ae793282e0ff51080c5094cb726507b1c"},
+    {file = "numba-0.60.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:62908d29fb6a3229c242e981ca27e32a6e606cc253fc9e8faeb0e48760de241e"},
+    {file = "numba-0.60.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ebaa91538e996f708f1ab30ef4d3ddc344b64b5227b67a57aa74f401bb68b9d"},
+    {file = "numba-0.60.0-cp312-cp312-win_amd64.whl", hash = "sha256:f75262e8fe7fa96db1dca93d53a194a38c46da28b112b8a4aca168f0df860347"},
+    {file = "numba-0.60.0.tar.gz", hash = "sha256:5df6158e5584eece5fc83294b949fd30b9f1125df7708862205217e068aabf16"},
+]
+
 [[package]]
 name = "numpy"
 version = "1.26.4"
@@ -995,6 +1607,21 @@ files = [
     {file = "pandas_stubs-2.2.2.240603.tar.gz", hash = "sha256:2dcc86e8fa6ea41535a4561c1f08b3942ba5267b464eff2e99caeee66f9e4cd1"},
 ]
 
+[[package]]
+name = "partd"
+version = "1.4.2"
+requires_python = ">=3.9"
+summary = "Appendable key-value storage"
+groups = ["default"]
+dependencies = [
+    "locket",
+    "toolz",
+]
+files = [
+    {file = "partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f"},
+    {file = "partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c"},
+]
+
 [[package]]
 name = "pathspec"
 version = "0.12.1"
@@ -1063,6 +1690,23 @@ files = [
     {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"},
 ]
 
+[[package]]
+name = "pint"
+version = "0.24.3"
+requires_python = ">=3.9"
+summary = "Physical quantities module"
+groups = ["default"]
+dependencies = [
+    "appdirs>=1.4.4",
+    "flexcache>=0.3",
+    "flexparser>=0.3",
+    "typing-extensions",
+]
+files = [
+    {file = "Pint-0.24.3-py3-none-any.whl", hash = "sha256:d98667e46fd03a1b94694fbfa104ec30858684d8ab26952e2a348b48059089bb"},
+    {file = "pint-0.24.3.tar.gz", hash = "sha256:d54771093e8b94c4e0a35ac638c2444ddf3ef685652bab7675ffecfa0c5c5cdf"},
+]
+
 [[package]]
 name = "platformdirs"
 version = "4.2.2"
@@ -1103,6 +1747,95 @@ files = [
     {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"},
 ]
 
+[[package]]
+name = "proto-plus"
+version = "1.24.0"
+requires_python = ">=3.7"
+summary = "Beautiful, Pythonic protocol buffers."
+groups = ["default"]
+dependencies = [
+    "protobuf<6.0.0dev,>=3.19.0",
+]
+files = [
+    {file = "proto-plus-1.24.0.tar.gz", hash = "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445"},
+    {file = "proto_plus-1.24.0-py3-none-any.whl", hash = "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12"},
+]
+
+[[package]]
+name = "protobuf"
+version = "5.27.3"
+requires_python = ">=3.8"
+summary = ""
+groups = ["default"]
+files = [
+    {file = "protobuf-5.27.3-cp310-abi3-win32.whl", hash = "sha256:dcb307cd4ef8fec0cf52cb9105a03d06fbb5275ce6d84a6ae33bc6cf84e0a07b"},
+    {file = "protobuf-5.27.3-cp310-abi3-win_amd64.whl", hash = "sha256:16ddf3f8c6c41e1e803da7abea17b1793a97ef079a912e42351eabb19b2cffe7"},
+    {file = "protobuf-5.27.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:68248c60d53f6168f565a8c76dc58ba4fa2ade31c2d1ebdae6d80f969cdc2d4f"},
+    {file = "protobuf-5.27.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b8a994fb3d1c11156e7d1e427186662b64694a62b55936b2b9348f0a7c6625ce"},
+    {file = "protobuf-5.27.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:a55c48f2a2092d8e213bd143474df33a6ae751b781dd1d1f4d953c128a415b25"},
+    {file = "protobuf-5.27.3-py3-none-any.whl", hash = "sha256:8572c6533e544ebf6899c360e91d6bcbbee2549251643d32c52cf8a5de295ba5"},
+    {file = "protobuf-5.27.3.tar.gz", hash = "sha256:82460903e640f2b7e34ee81a947fdaad89de796d324bcbc38ff5430bcdead82c"},
+]
+
+[[package]]
+name = "pyarrow"
+version = "17.0.0"
+requires_python = ">=3.8"
+summary = "Python library for Apache Arrow"
+groups = ["default"]
+dependencies = [
+    "numpy>=1.16.6",
+]
+files = [
+    {file = "pyarrow-17.0.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a5c8b238d47e48812ee577ee20c9a2779e6a5904f1708ae240f53ecbee7c9f07"},
+    {file = "pyarrow-17.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db023dc4c6cae1015de9e198d41250688383c3f9af8f565370ab2b4cb5f62655"},
+    {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da1e060b3876faa11cee287839f9cc7cdc00649f475714b8680a05fd9071d545"},
+    {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c06d4624c0ad6674364bb46ef38c3132768139ddec1c56582dbac54f2663e2"},
+    {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:fa3c246cc58cb5a4a5cb407a18f193354ea47dd0648194e6265bd24177982fe8"},
+    {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f7ae2de664e0b158d1607699a16a488de3d008ba99b3a7aa5de1cbc13574d047"},
+    {file = "pyarrow-17.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:5984f416552eea15fd9cee03da53542bf4cddaef5afecefb9aa8d1010c335087"},
+    {file = "pyarrow-17.0.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:1c8856e2ef09eb87ecf937104aacfa0708f22dfeb039c363ec99735190ffb977"},
+    {file = "pyarrow-17.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e19f569567efcbbd42084e87f948778eb371d308e137a0f97afe19bb860ccb3"},
+    {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b244dc8e08a23b3e352899a006a26ae7b4d0da7bb636872fa8f5884e70acf15"},
+    {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b72e87fe3e1db343995562f7fff8aee354b55ee83d13afba65400c178ab2597"},
+    {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dc5c31c37409dfbc5d014047817cb4ccd8c1ea25d19576acf1a001fe07f5b420"},
+    {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:e3343cb1e88bc2ea605986d4b94948716edc7a8d14afd4e2c097232f729758b4"},
+    {file = "pyarrow-17.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:a27532c38f3de9eb3e90ecab63dfda948a8ca859a66e3a47f5f42d1e403c4d03"},
+    {file = "pyarrow-17.0.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9b8a823cea605221e61f34859dcc03207e52e409ccf6354634143e23af7c8d22"},
+    {file = "pyarrow-17.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1e70de6cb5790a50b01d2b686d54aaf73da01266850b05e3af2a1bc89e16053"},
+    {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0071ce35788c6f9077ff9ecba4858108eebe2ea5a3f7cf2cf55ebc1dbc6ee24a"},
+    {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:757074882f844411fcca735e39aae74248a1531367a7c80799b4266390ae51cc"},
+    {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9ba11c4f16976e89146781a83833df7f82077cdab7dc6232c897789343f7891a"},
+    {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b0c6ac301093b42d34410b187bba560b17c0330f64907bfa4f7f7f2444b0cf9b"},
+    {file = "pyarrow-17.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:392bc9feabc647338e6c89267635e111d71edad5fcffba204425a7c8d13610d7"},
+    {file = "pyarrow-17.0.0.tar.gz", hash = "sha256:4beca9521ed2c0921c1023e68d097d0299b62c362639ea315572a58f3f50fd28"},
+]
+
+[[package]]
+name = "pyasn1"
+version = "0.6.0"
+requires_python = ">=3.8"
+summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
+groups = ["default"]
+files = [
+    {file = "pyasn1-0.6.0-py2.py3-none-any.whl", hash = "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473"},
+    {file = "pyasn1-0.6.0.tar.gz", hash = "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c"},
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.0"
+requires_python = ">=3.8"
+summary = "A collection of ASN.1-based protocols modules"
+groups = ["default"]
+dependencies = [
+    "pyasn1<0.7.0,>=0.4.6",
+]
+files = [
+    {file = "pyasn1_modules-0.4.0-py3-none-any.whl", hash = "sha256:be04f15b66c206eed667e0bb5ab27e2b1855ea54a842e5037738099e8ca4ae0b"},
+    {file = "pyasn1_modules-0.4.0.tar.gz", hash = "sha256:831dbcea1b177b28c9baddf4c6d1013c24c3accd14a1873fffaa6a2e905f17b6"},
+]
+
 [[package]]
 name = "pycodestyle"
 version = "2.11.1"
@@ -1279,6 +2012,26 @@ files = [
     {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"},
 ]
 
+[[package]]
+name = "python-box"
+version = "7.2.0"
+requires_python = ">=3.8"
+summary = "Advanced Python dictionaries with dot notation access"
+groups = ["default"]
+files = [
+    {file = "python_box-7.2.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:6bdeec791e25258351388b3029a3ec5da302bb9ed3be175493c43cdc6c47f5e3"},
+    {file = "python_box-7.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c449f7b3756a71479fa9c61a86e344ac00ed782a66d7662590f0afa294249d18"},
+    {file = "python_box-7.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:6b0d61f182d394106d963232854e495b51edc178faa5316a797be1178212d7e0"},
+    {file = "python_box-7.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e2d752de8c1204255bf7b0c814c59ef48293c187a7e9fdcd2fefa28024b72032"},
+    {file = "python_box-7.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a6c35ea356a386077935958a5debcd5b229b9a1b3b26287a52dfe1a7e65d99"},
+    {file = "python_box-7.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:32ed58ec4d9e5475efe69f9c7d773dfea90a6a01979e776da93fd2b0a5d04429"},
+    {file = "python_box-7.2.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:2a2d664c6a27f7515469b6f1e461935a2038ee130b7d194b4b4db4e85d363618"},
+    {file = "python_box-7.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5a7365db1aaf600d3e8a2747fcf6833beb5d45439a54318548f02e302e3ec"},
+    {file = "python_box-7.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:739f827056ea148cbea3122d4617c994e829b420b1331183d968b175304e3a4f"},
+    {file = "python_box-7.2.0-py3-none-any.whl", hash = "sha256:a3c90832dd772cb0197fdb5bc06123b6e1b846899a1b53d9c39450d27a584829"},
+    {file = "python_box-7.2.0.tar.gz", hash = "sha256:551af20bdab3a60a2a21e3435120453c4ca32f7393787c3a5036e1d9fc6a0ede"},
+]
+
 [[package]]
 name = "python-dateutil"
 version = "2.9.0.post0"
@@ -1350,6 +2103,23 @@ files = [
     {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
 ]
 
+[[package]]
+name = "requests"
+version = "2.32.3"
+requires_python = ">=3.8"
+summary = "Python HTTP for Humans."
+groups = ["default"]
+dependencies = [
+    "certifi>=2017.4.17",
+    "charset-normalizer<4,>=2",
+    "idna<4,>=2.5",
+    "urllib3<3,>=1.21.1",
+]
+files = [
+    {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
+    {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
+]
+
 [[package]]
 name = "rich"
 version = "13.7.1"
@@ -1366,6 +2136,20 @@ files = [
     {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
 ]
 
+[[package]]
+name = "rsa"
+version = "4.9"
+requires_python = ">=3.6,<4"
+summary = "Pure-Python RSA implementation"
+groups = ["default"]
+dependencies = [
+    "pyasn1>=0.1.3",
+]
+files = [
+    {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"},
+    {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"},
+]
+
 [[package]]
 name = "ruff"
 version = "0.4.8"
@@ -1489,6 +2273,33 @@ files = [
     {file = "scipy-1.13.1.tar.gz", hash = "sha256:095a87a0312b08dfd6a6155cbbd310a8c51800fc931b8c0b84003014b874ed3c"},
 ]
 
+[[package]]
+name = "seaborn"
+version = "0.13.2"
+requires_python = ">=3.8"
+summary = "Statistical data visualization"
+groups = ["default"]
+dependencies = [
+    "matplotlib!=3.6.1,>=3.4",
+    "numpy!=1.24.0,>=1.20",
+    "pandas>=1.2",
+]
+files = [
+    {file = "seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987"},
+    {file = "seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7"},
+]
+
+[[package]]
+name = "setuptools"
+version = "72.1.0"
+requires_python = ">=3.8"
+summary = "Easily download, build, install, upgrade, and uninstall Python packages"
+groups = ["default"]
+files = [
+    {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"},
+    {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"},
+]
+
 [[package]]
 name = "six"
 version = "1.16.0"
@@ -1500,6 +2311,17 @@ files = [
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 
+[[package]]
+name = "soupsieve"
+version = "2.5"
+requires_python = ">=3.8"
+summary = "A modern CSS selector implementation for Beautiful Soup."
+groups = ["default"]
+files = [
+    {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"},
+    {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"},
+]
+
 [[package]]
 name = "spectral"
 version = "0.23.1"
@@ -1513,6 +2335,25 @@ files = [
     {file = "spectral-0.23.1.tar.gz", hash = "sha256:e1822273525ef3583b27a966226d55afe09e7d29a7236cfcd8bc0dfb1f968fc2"},
 ]
 
+[[package]]
+name = "spyndex"
+version = "0.6.0"
+summary = "Awesome Spectral Indices in Python"
+groups = ["default"]
+dependencies = [
+    "dask>=2023.7.0",
+    "eemont>=0.3.6",
+    "matplotlib",
+    "pandas>=2.0.3",
+    "python-box>=6.0",
+    "requests",
+    "seaborn",
+    "xarray>=2023.6.0",
+]
+files = [
+    {file = "spyndex-0.6.0.tar.gz", hash = "sha256:09e660d21ac065dca2d950adc67347fcb0e2d5e4439d02ea129ee35b10084445"},
+]
+
 [[package]]
 name = "sqlalchemy"
 version = "2.0.31"
@@ -1553,6 +2394,20 @@ files = [
     {file = "SQLAlchemy-2.0.31.tar.gz", hash = "sha256:b607489dd4a54de56984a0c7656247504bd5523d9d0ba799aef59d4add009484"},
 ]
 
+[[package]]
+name = "sympy"
+version = "1.13.1"
+requires_python = ">=3.8"
+summary = "Computer algebra system (CAS) in Python"
+groups = ["default"]
+dependencies = [
+    "mpmath<1.4,>=1.1.0",
+]
+files = [
+    {file = "sympy-1.13.1-py3-none-any.whl", hash = "sha256:db36cdc64bf61b9b24578b6f7bab1ecdd2452cf008f34faa33776680c26d66f8"},
+    {file = "sympy-1.13.1.tar.gz", hash = "sha256:9cebf7e04ff162015ce31c9c6c9144daa34a93bd082f54fd8f12deca4f47515f"},
+]
+
 [[package]]
 name = "threadpoolctl"
 version = "3.5.0"
@@ -1589,6 +2444,17 @@ files = [
     {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
 ]
 
+[[package]]
+name = "toolz"
+version = "0.12.1"
+requires_python = ">=3.7"
+summary = "List processing tools and functional utilities"
+groups = ["default"]
+files = [
+    {file = "toolz-0.12.1-py3-none-any.whl", hash = "sha256:d22731364c07d72eea0a0ad45bafb2c2937ab6fd38a3507bf55eae8744aa7d85"},
+    {file = "toolz-0.12.1.tar.gz", hash = "sha256:ecca342664893f177a13dac0e6b41cbd8ac25a358e5f215316d43e2100224f4d"},
+]
+
 [[package]]
 name = "tqdm"
 version = "4.66.4"
@@ -1636,6 +2502,28 @@ files = [
     {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
 ]
 
+[[package]]
+name = "uritemplate"
+version = "4.1.1"
+requires_python = ">=3.6"
+summary = "Implementation of RFC 6570 URI Templates"
+groups = ["default"]
+files = [
+    {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"},
+    {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.2.2"
+requires_python = ">=3.8"
+summary = "HTTP library with thread-safe connection pooling, file post, and more."
+groups = ["default"]
+files = [
+    {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"},
+    {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"},
+]
+
 [[package]]
 name = "virtualenv"
 version = "20.26.2"
@@ -1687,3 +2575,31 @@ files = [
     {file = "watchdog-4.0.1-py3-none-win_ia64.whl", hash = "sha256:d7b9f5f3299e8dd230880b6c55504a1f69cf1e4316275d1b215ebdd8187ec88d"},
     {file = "watchdog-4.0.1.tar.gz", hash = "sha256:eebaacf674fa25511e8867028d281e602ee6500045b57f43b08778082f7f8b44"},
 ]
+
+[[package]]
+name = "xarray"
+version = "2024.7.0"
+requires_python = ">=3.9"
+summary = "N-D labeled arrays and datasets in Python"
+groups = ["default"]
+dependencies = [
+    "numpy>=1.23",
+    "packaging>=23.1",
+    "pandas>=2.0",
+]
+files = [
+    {file = "xarray-2024.7.0-py3-none-any.whl", hash = "sha256:1b0fd51ec408474aa1f4a355d75c00cc1c02bd425d97b2c2e551fd21810e7f64"},
+    {file = "xarray-2024.7.0.tar.gz", hash = "sha256:4cae512d121a8522d41e66d942fb06c526bc1fd32c2c181d5fe62fe65b671638"},
+]
+
+[[package]]
+name = "zipp"
+version = "3.19.2"
+requires_python = ">=3.8"
+summary = "Backport of pathlib-compatible object wrapper for zip files"
+groups = ["default"]
+marker = "python_version < \"3.12\""
+files = [
+    {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"},
+    {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"},
+]
diff --git a/pyproject.toml b/pyproject.toml
index 3e53ef4..a10ad05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,11 @@ dependencies = [
     "scikit-image>=0.24.0",
     "matplotlib>=3.9.0",
     "optuna>=3.6.1",
+    "spyndex>=0.6.0",
+    "mlxtend>=0.23.1",
+    "autofeat>=2.1.1",
+    "setuptools>=72.1.0",
+    "dask[dataframe]>=2024.7.1",
 ]
 requires-python = ">=3.10"
 readme = "README.md"
diff --git a/scripts/install-dev.sh b/scripts/install-dev.sh
new file mode 100755
index 0000000..83a8744
--- /dev/null
+++ b/scripts/install-dev.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+set -e
+set -x
+
+# Install pdm
+curl -sSL https://pdm-project.org/install-pdm.py | python3 -
+
+# Install python libraries
+pdm install
+
+# Install pre-commit
+pdm run pre-commit install --hook-type commit-msg
diff --git a/siapy/datasets/helpers.py b/siapy/datasets/helpers.py
index 02651be..ddc755c 100644
--- a/siapy/datasets/helpers.py
+++ b/siapy/datasets/helpers.py
@@ -1,16 +1,25 @@
+from typing import TYPE_CHECKING
+
 import pandas as pd
 
-from siapy.datasets.schemas import ClassificationTarget, RegressionTarget
+if TYPE_CHECKING:
+    from .schemas import ClassificationTarget, RegressionTarget, TabularDatasetData
 
 
 def generate_classification_target(
     dataframe: pd.DataFrame,
     column_names: str | list[str],
-) -> ClassificationTarget:
+) -> "ClassificationTarget":
+    from .schemas import (
+        ClassificationTarget,  # Local import to avoid circular dependency
+    )
+
     if isinstance(column_names, str):
         column_names = [column_names]
     # create one column labels from multiple columns
     label = dataframe[column_names].apply(tuple, axis=1)
+    # Convert tuples to strings with '__' delimiter
+    label = label.apply(lambda x: "__".join(x))
     # encode to numbers
     encoded_np, encoding_np = pd.factorize(label)
     encoded = pd.Series(encoded_np, name="encoded")
@@ -21,5 +30,11 @@ def generate_classification_target(
 def generate_regression_target(
     dataframe: pd.DataFrame,
     column_name: str,
-) -> RegressionTarget:
+) -> "RegressionTarget":
+    from .schemas import RegressionTarget  # Local import to avoid circular dependency
+
     return RegressionTarget(name=column_name, value=dataframe[column_name])
+
+
+def merge_signals_from_multiple_cameras(data: "TabularDatasetData"):
+    data.signals.copy()
diff --git a/siapy/datasets/schemas.py b/siapy/datasets/schemas.py
index 6166747..c464dac 100644
--- a/siapy/datasets/schemas.py
+++ b/siapy/datasets/schemas.py
@@ -1,9 +1,11 @@
 from abc import ABC, abstractmethod
-from typing import Any
+from typing import Any, Iterable
 
 import pandas as pd
 from pydantic import BaseModel, ConfigDict
 
+from .helpers import generate_classification_target, generate_regression_target
+
 
 class Target(BaseModel, ABC):
     model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -19,6 +21,10 @@ def __len__(self) -> int: ...
     @abstractmethod
     def from_dict(cls, data: dict[str, Any]) -> "Target": ...
 
+    @classmethod
+    @abstractmethod
+    def from_iterable(cls, data: Iterable) -> "Target": ...
+
     @abstractmethod
     def to_dict(self) -> dict[str, Any]: ...
 
@@ -42,6 +48,11 @@ def __getitem__(self, indices: Any) -> "ClassificationTarget":
     def __len__(self) -> int:
         return len(self.label)
 
+    @classmethod
+    def from_iterable(cls, data: Iterable) -> "ClassificationTarget":
+        label = pd.DataFrame(data, columns=["label"])
+        return generate_classification_target(label, "label")
+
     @classmethod
     def from_dict(cls, data: dict[str, Any]) -> "ClassificationTarget":
         label = pd.Series(data["label"], name="label")
@@ -78,6 +89,11 @@ def __getitem__(self, indices: Any) -> "RegressionTarget":
     def __len__(self) -> int:
         return len(self.value)
 
+    @classmethod
+    def from_iterable(cls, data: Iterable) -> "RegressionTarget":
+        value = pd.DataFrame(data, columns=["value"])
+        return generate_regression_target(value, "value")
+
     @classmethod
     def from_dict(cls, data: dict[str, Any]) -> "RegressionTarget":
         value = pd.Series(data["value"], name="value")
@@ -104,6 +120,15 @@ class TabularDatasetData(BaseModel):
     metadata: pd.DataFrame
     target: Target | None = None
 
+    def __init__(self, *args: Any, **kwargs: Any):
+        super().__init__(*args, **kwargs)
+        self._validate_lengths()
+
+    def __setattr__(self, name: str, value: Any) -> None:
+        super().__setattr__(name, value)
+        if name in self.model_fields.keys():
+            self._validate_lengths()
+
     def __getitem__(self, indices: Any) -> "TabularDatasetData":
         pixels = self.pixels.iloc[indices]
         signals = self.signals.iloc[indices]
@@ -113,6 +138,9 @@ def __getitem__(self, indices: Any) -> "TabularDatasetData":
             pixels=pixels, signals=signals, metadata=metadata, target=target
         )
 
+    def __len__(self) -> int:
+        return len(self.pixels)
+
     @classmethod
     def from_dict(cls, data: dict[str, Any]) -> "TabularDatasetData":
         pixels = pd.DataFrame(data["pixels"])
@@ -137,6 +165,14 @@ def target_from_dict(data: dict[str, Any] | None) -> Target | None:
         else:
             raise ValueError("Invalid target dict.")
 
+    def _validate_lengths(self) -> None:
+        if not (len(self.pixels) == len(self.signals) == len(self.metadata)):
+            raise ValueError("Lengths of pixels, signals, and metadata must be equal")
+        if self.target is not None and len(self.target) != len(self):
+            raise ValueError(
+                "Target length must be equal to the length of the dataset."
+            )
+
     def to_dict(self) -> dict[str, Any]:
         return {
             "pixels": self.pixels.to_dict(),
diff --git a/siapy/datasets/tabular.py b/siapy/datasets/tabular.py
index 3ff6725..64426a6 100644
--- a/siapy/datasets/tabular.py
+++ b/siapy/datasets/tabular.py
@@ -24,7 +24,7 @@ class TabularDataEntity(MetaDataEntity):
     signatures: Signatures
 
 
-@dataclass()
+@dataclass
 class TabularDataset:
     def __init__(self, container: ImageContainerType):
         self._image_set = (
@@ -57,6 +57,7 @@ def data_entities(self) -> list[TabularDataEntity]:
         return self._data_entities
 
     def process_image_data(self):
+        self.data_entities.clear()
         for image_idx, image in enumerate(self.image_set):
             for shape_idx, shape in enumerate(image.geometric_shapes.shapes):
                 signatures = image.to_signatures(shape.convex_hull())
diff --git a/siapy/features/__init__.py b/siapy/features/__init__.py
new file mode 100644
index 0000000..f57459a
--- /dev/null
+++ b/siapy/features/__init__.py
@@ -0,0 +1,13 @@
+from .features import (
+    AutoFeatClassification,
+    AutoFeatRegression,
+    AutoSpectralIndicesClassification,
+    AutoSpectralIndicesRegression,
+)
+
+__all__ = [
+    "AutoFeatClassification",
+    "AutoFeatRegression",
+    "AutoSpectralIndicesClassification",
+    "AutoSpectralIndicesRegression",
+]
diff --git a/siapy/features/features.py b/siapy/features/features.py
new file mode 100644
index 0000000..6221203
--- /dev/null
+++ b/siapy/features/features.py
@@ -0,0 +1,296 @@
+from typing import Iterable, Literal
+
+import numpy as np
+import pandas as pd
+from autofeat import AutoFeatClassifier, AutoFeatRegressor  # type: ignore
+from sklearn.base import BaseEstimator, TransformerMixin
+
+from siapy.features.helpers import FeatureSelectorConfig, feature_selector_factory
+from siapy.features.spectral_indices import compute_spectral_indices
+from siapy.utils.general import set_random_seed
+
+
+class AutoFeatClassification(AutoFeatClassifier):
+    def __init__(
+        self,
+        *,
+        categorical_cols: list | None = None,
+        feateng_cols: list | None = None,
+        units: dict | None = None,
+        feateng_steps: int = 2,
+        featsel_runs: int = 5,
+        max_gb: int | None = None,
+        transformations: list | tuple = ("1/", "exp", "log", "abs", "sqrt", "^2", "^3"),
+        apply_pi_theorem: bool = True,
+        always_return_numpy: bool = False,
+        n_jobs: int = 1,
+        verbose: int = 0,
+        random_seed: int | None = None,
+    ):
+        self.random_seed = random_seed
+        set_random_seed(self.random_seed)
+        super().__init__(
+            categorical_cols=categorical_cols,
+            feateng_cols=feateng_cols,
+            units=units,
+            feateng_steps=feateng_steps,
+            featsel_runs=featsel_runs,
+            max_gb=max_gb,
+            transformations=transformations,
+            apply_pi_theorem=apply_pi_theorem,
+            always_return_numpy=always_return_numpy,
+            n_jobs=n_jobs,
+            verbose=verbose,
+        )
+
+    def fit(self, data: np.ndarray | pd.DataFrame, target: np.ndarray | pd.DataFrame):
+        set_random_seed(self.random_seed)
+        super().fit(data, target)
+
+    def transform(self, data: np.ndarray | pd.DataFrame) -> np.ndarray | pd.DataFrame:
+        set_random_seed(self.random_seed)
+        data_transformed = super().transform(data)
+        return data_transformed
+
+    def fit_transform(
+        self, data: np.ndarray | pd.DataFrame, target: np.ndarray | pd.DataFrame
+    ) -> np.ndarray | pd.DataFrame:
+        set_random_seed(self.random_seed)
+        data_transformed = super().fit_transform(data, target)
+        return data_transformed
+
+
+class AutoFeatRegression(AutoFeatRegressor):
+    def __init__(
+        self,
+        *,
+        categorical_cols: list | None = None,
+        feateng_cols: list | None = None,
+        units: dict | None = None,
+        feateng_steps: int = 2,
+        featsel_runs: int = 5,
+        max_gb: int | None = None,
+        transformations: list | tuple = ("1/", "exp", "log", "abs", "sqrt", "^2", "^3"),
+        apply_pi_theorem: bool = True,
+        always_return_numpy: bool = False,
+        n_jobs: int = 1,
+        verbose: int = 0,
+        random_seed: int | None = None,
+    ):
+        self.random_seed = random_seed
+        set_random_seed(self.random_seed)
+        super().__init__(
+            categorical_cols=categorical_cols,
+            feateng_cols=feateng_cols,
+            units=units,
+            feateng_steps=feateng_steps,
+            featsel_runs=featsel_runs,
+            max_gb=max_gb,
+            transformations=transformations,
+            apply_pi_theorem=apply_pi_theorem,
+            always_return_numpy=always_return_numpy,
+            n_jobs=n_jobs,
+            verbose=verbose,
+        )
+
+    def fit(self, data: np.ndarray | pd.DataFrame, target: np.ndarray | pd.DataFrame):
+        set_random_seed(self.random_seed)
+        super().fit(data, target)
+
+    def transform(self, data: np.ndarray | pd.DataFrame) -> np.ndarray | pd.DataFrame:
+        set_random_seed(self.random_seed)
+        data_transformed = super().transform(data)
+        return data_transformed
+
+    def fit_transform(
+        self, data: np.ndarray | pd.DataFrame, target: np.ndarray | pd.DataFrame
+    ) -> np.ndarray | pd.DataFrame:
+        set_random_seed(self.random_seed)
+        data_transformed = super().fit_transform(data, target)
+        return data_transformed
+
+
+class AutoSpectralIndices(BaseEstimator, TransformerMixin):
+    def __init__(
+        self,
+        problem_type: Literal["regression", "classification"],
+        spectral_indices: str | Iterable[str],
+        *,
+        selector_config: FeatureSelectorConfig = FeatureSelectorConfig(),
+        bands_map: dict[str, str] | None = None,
+        merge_with_original: bool = True,
+    ):
+        self.spectral_indices = spectral_indices
+        self.selector = feature_selector_factory(
+            problem_type=problem_type, config=selector_config
+        )
+        self.bands_map = bands_map
+        self.merge_with_original = merge_with_original
+
+    def fit(self, data: pd.DataFrame, target: pd.Series) -> BaseEstimator:
+        df_indices = compute_spectral_indices(
+            data=data, spectral_indices=self.spectral_indices, bands_map=self.bands_map
+        )
+        self.selector.fit(df_indices, target)
+        return self
+
+    def transform(self, data: pd.DataFrame) -> pd.DataFrame:
+        df_indices = compute_spectral_indices(
+            data=data, spectral_indices=self.spectral_indices, bands_map=self.bands_map
+        )
+        if hasattr(self.selector[1], "k_feature_idx_"):
+            columns_select_idx = list(self.selector[1].k_feature_idx_)
+            df_indices = df_indices.iloc[:, columns_select_idx]
+        else:
+            raise AttributeError(
+                "The attribute 'k_feature_idx_' does not exist in the selector."
+            )
+        if self.merge_with_original:
+            return pd.concat([data, df_indices], axis=1)
+        return df_indices
+
+    def fit_transform(self, data: pd.DataFrame, target: pd.Series) -> pd.DataFrame:
+        self.fit(data, target)
+        return self.transform(data)
+
+
+class AutoSpectralIndicesClassification(AutoSpectralIndices):
+    def __init__(
+        self,
+        spectral_indices: str | Iterable[str],
+        *,
+        selector_config: FeatureSelectorConfig = FeatureSelectorConfig(),
+        bands_map: dict[str, str] | None = None,
+        merge_with_original: bool = True,
+    ):
+        super().__init__(
+            problem_type="classification",
+            spectral_indices=spectral_indices,
+            selector_config=selector_config,
+            bands_map=bands_map,
+            merge_with_original=merge_with_original,
+        )
+
+
+class AutoSpectralIndicesRegression(AutoSpectralIndices):
+    def __init__(
+        self,
+        spectral_indices: str | Iterable[str],
+        *,
+        selector_config: FeatureSelectorConfig = FeatureSelectorConfig(),
+        bands_map: dict[str, str] | None = None,
+        merge_with_original: bool = True,
+    ):
+        super().__init__(
+            problem_type="regression",
+            spectral_indices=spectral_indices,
+            selector_config=selector_config,
+            bands_map=bands_map,
+            merge_with_original=merge_with_original,
+        )
+
+
+# class AutoSpectralIndicesPlusGenerated(BaseEstimator, TransformerMixin):
+#     def __init__(
+#         self,
+#         selector_spectral_indices: Union[
+#             "AutoSpectralIndicesPlusGeneratedClassification",
+#             "AutoSpectralIndicesPlusGeneratedRegression",
+#         ],
+#         selector_generated: Union["AutoFeatClassification", "AutoFeatRegression"],
+#     ):
+#         self.selector_spectral_indices = selector_spectral_indices
+#         self.selector_generated = selector_generated
+
+#         self.columns_to_drop: list[str] = []
+
+#     def __repr__(self) -> str:
+#         return (
+#             f"{self.__class__.__name__}(\n"
+#             f"    selector_spectral_indices={self.selector_spectral_indices},\n"
+#             f"    selector_generated={self.selector_generated}\n"
+#             f")"
+#         )
+
+#     def _correlation_analysis(self, data: pd.DataFrame):
+#         # Remove highly correlated features
+#         # Transform data
+#         df_generated = self.selector_generated.transform(data)
+#         df_spectral_indices = self.selector_spectral_indices.transform(data)
+#         df_merged = pd.concat([df_generated, df_spectral_indices], axis=1)
+#         # Calculate correlation between features
+#         correlation_matrix = df_merged.corr().abs()
+#         upper_triangle = correlation_matrix.where(
+#             np.triu(np.ones(correlation_matrix.shape), k=1).astype(np.bool_)
+#         )
+#         # Select columns with correlation greater than 0.99
+#         self.columns_to_drop = [
+#             column
+#             for column in upper_triangle.columns
+#             if any(upper_triangle[column] > 0.99)
+#         ]
+
+#     def fit(self, data: pd.DataFrame, target: pd.Series) -> BaseEstimator:
+#         self.selector_generated.fit(data, target)
+#         self.selector_spectral_indices.fit(data, target)
+#         self._correlation_analysis(data)
+#         return self
+
+#     def transform(self, data: pd.DataFrame) -> pd.DataFrame:
+#         df_generated = self.selector_generated.transform(data)
+#         df_spectral_indices = self.selector_spectral_indices.transform(data)
+#         df_merged = pd.concat([df_generated, df_spectral_indices], axis=1)
+#         df_merged = df_merged.drop(columns=self.columns_to_drop)
+#         return df_merged
+
+#     def fit_transform(self, data: pd.DataFrame, target: pd.Series) -> pd.DataFrame:
+#         self.fit(data, target)
+#         return self.transform(data)
+
+
+# class AutoSpectralIndicesPlusGeneratedClassification(AutoSpectralIndicesPlusGenerated):
+#     def __init__(self, verbose: int = 0, n_jobs=1, feateng_steps: int = 1, **kwargs):
+#         self.verbose = verbose
+#         self.n_jobs = n_jobs
+#         self.feateng_steps = feateng_steps
+
+#         selector_spectral_indices = AutoSpectralIndicesClassification(
+#             verbose=verbose, n_jobs=n_jobs, merge_with_original=False
+#         )
+#         selector_generated = AutoFeatClassification(
+#             verbose=verbose, feateng_steps=feateng_steps
+#         )
+#         super().__init__(
+#             selector_spectral_indices=selector_spectral_indices,
+#             selector_generated=selector_generated,
+#         )
+
+#     def __repr__(self) -> str:
+#         return (
+#             f"{self.__class__.__name__}(verbose={self.verbose}, "
+#             f"n_jobs={self.n_jobs}, feateng_steps={self.feateng_steps})"
+#         )
+
+
+# class AutoSpectralIndicesPlusGeneratedRegression(AutoSpectralIndicesPlusGenerated):
+#     def __init__(self, verbose: int = 0, n_jobs=1, feateng_steps: int = 1, **kwargs):
+#         self.verbose = verbose
+#         self.n_jobs = n_jobs
+#         self.feateng_steps = feateng_steps
+
+#         selector_spectral_indices = AutoSpectralIndicesRegression(
+#             verbose=verbose, n_jobs=n_jobs, merge_with_original=False
+#         )
+#         selector_generated = AutoFeatRegression(
+#             verbose=verbose, feateng_steps=feateng_steps
+#         )
+#         super().__init__(
+#             selector_spectral_indices=selector_spectral_indices,
+#             selector_generated=selector_generated,
+#         )
+
+#     def __repr__(self) -> str:
+#         return (
+#             f"{self.__class__.__name__}(verbose={self.verbose}, "
+#             f"n_jobs={self.n_jobs}, feateng_steps={self.feateng_steps})"
+#         )
diff --git a/siapy/features/helpers.py b/siapy/features/helpers.py
new file mode 100644
index 0000000..74d0605
--- /dev/null
+++ b/siapy/features/helpers.py
@@ -0,0 +1,90 @@
+from typing import Annotated, Literal
+
+from mlxtend.feature_selection import SequentialFeatureSelector  # type: ignore
+from pydantic import BaseModel, ConfigDict
+from sklearn.linear_model import Ridge, RidgeClassifier
+from sklearn.pipeline import Pipeline, make_pipeline
+from sklearn.preprocessing import RobustScaler
+
+
+class FeatureSelectorConfig(BaseModel):
+    k_features: Annotated[
+        int | tuple | str,
+        "can be: 'best' - most extensive, (1, n) - check range of features, n - exact number of features",
+    ] = (1, 20)
+    cv: int = 3
+    forward: Annotated[bool, "selection in forward direction"] = True
+    floating: Annotated[
+        bool, "floating algorithm - can go back and remove features once added"
+    ] = True
+    verbose: int = 2
+    n_jobs: int = 1
+    pre_dispatch: int | str = "2*n_jobs"
+
+    model_config = ConfigDict(
+        arbitrary_types_allowed=True,
+        validate_assignment=True,
+    )
+
+
+def feature_selector_factory(
+    problem_type: Literal["regression", "classification"],
+    *,
+    k_features: Annotated[
+        int | tuple | str,
+        "can be: 'best' - most extensive, (1, n) - check range of features, n - exact number of features",
+    ] = (1, 20),
+    cv: int = 3,
+    forward: Annotated[bool, "selection in forward direction"] = True,
+    floating: Annotated[
+        bool, "floating algorithm - can go back and remove features once added"
+    ] = True,
+    verbose: int = 2,
+    n_jobs: int = 1,
+    pre_dispatch: int | str = "2*n_jobs",
+    config: Annotated[
+        FeatureSelectorConfig | None,
+        "If provided, other arguments are overwritten by config values",
+    ] = None,
+) -> Pipeline:
+    if config:
+        k_features = config.k_features
+        cv = config.cv
+        forward = config.forward
+        floating = config.floating
+        verbose = config.verbose
+        n_jobs = config.n_jobs
+        pre_dispatch = config.pre_dispatch
+
+    if problem_type == "regression":
+        algo = Ridge()
+        scoring = "neg_mean_squared_error"
+    elif problem_type == "classification":
+        algo = RidgeClassifier()
+        scoring = "f1_weighted"
+    else:
+        raise ValueError(
+            f"Invalid problem type: '{problem_type}', possible values are: 'regression' or 'classification'"
+        )
+    sfs = SequentialFeatureSelector(
+        estimator=algo,
+        k_features=k_features,  # type: ignore # noqa
+        forward=forward,
+        floating=floating,
+        verbose=verbose,
+        scoring=scoring,
+        cv=cv,
+        n_jobs=n_jobs,
+        pre_dispatch=pre_dispatch,  # type: ignore
+    )
+    return make_pipeline(RobustScaler(), sfs, memory=None)
+
+
+"""
+-- Check plot: performance vs number of features --
+import matplotlib.pyplot as plt
+from mlxtend.plotting import plot_sequential_feature_selection as plot_sfs
+plot_sfs(self.selector.get_metric_dict(), kind='std_err', figsize=(30, 20))
+plt.savefig('selection.png')
+plt.close()
+"""
diff --git a/siapy/features/spectral_indices.py b/siapy/features/spectral_indices.py
new file mode 100644
index 0000000..b8dfeb3
--- /dev/null
+++ b/siapy/features/spectral_indices.py
@@ -0,0 +1,79 @@
+import warnings
+from typing import Any, Iterable
+
+import numpy as np
+import pandas as pd
+
+with warnings.catch_warnings():
+    warnings.filterwarnings(
+        "ignore",
+        category=DeprecationWarning,
+        message="pkg_resources is deprecated as an API",
+    )
+    import spyndex  # type: ignore
+
+
+def _convert_str_to_list(bands_acronym: Any) -> Any:
+    if isinstance(bands_acronym, str):
+        bands_acronym = [bands_acronym]
+    return bands_acronym
+
+
+def get_spectral_indices(
+    bands_acronym: str | Iterable[str],
+) -> dict[str, spyndex.axioms.SpectralIndex]:
+    bands_acronym = _convert_str_to_list(bands_acronym)
+    bands_acronym_set = set(bands_acronym)
+
+    if not bands_acronym_set.issubset(list(spyndex.bands)):
+        raise ValueError(
+            f"Invalid input argument for 'bands_acronym'. \n"
+            f"Received: {bands_acronym_set}. \n"
+            f"Possible options are: {list(spyndex.bands)}. \n"
+            "Please ensure that all elements in 'bands_acronym' are valid band acronyms."
+        )
+
+    spectral_indexes = {}
+    for name in spyndex.indices.to_dict():
+        index = spyndex.indices[name]
+        if set(index.bands).issubset(bands_acronym_set):
+            spectral_indexes[name] = index
+
+    return spectral_indexes
+
+
+def compute_spectral_indices(
+    data: pd.DataFrame,
+    spectral_indices: str | Iterable[str],
+    bands_map: dict[str, str] | None = None,
+    remove_nan_and_constants: bool = True,
+) -> pd.DataFrame:
+    spectral_indices = _convert_str_to_list(spectral_indices)
+
+    params = {}
+    for band in data.columns:
+        if bands_map is not None and band in bands_map.keys():
+            if bands_map[band] not in list(spyndex.bands):
+                raise ValueError(
+                    f"Invalid band mapping: '{bands_map[band]}' is not a recognized band acronym. \n"
+                    f"Received mapping: {band} -> {bands_map[band]}. \n"
+                    f"Possible options are: {list(spyndex.bands)}. \n"
+                    "Please ensure that all values in 'bands_map' are valid band acronyms."
+                )
+            params[bands_map[band]] = data[band]
+        else:
+            if band not in list(spyndex.bands):
+                raise ValueError(
+                    f"Invalid band: '{band}' is not a recognized band acronym. \n"
+                    f"Possible options are: {list(spyndex.bands)}. \n"
+                    "Please ensure that all columns in 'data' are valid band acronyms."
+                )
+            params[band] = data[band]
+
+    df = spyndex.computeIndex(index=list(spectral_indices), params=params)
+    if remove_nan_and_constants:
+        # Drop columns with inf or NaN values
+        df = df.drop(df.columns[df.isin([np.inf, -np.inf, np.nan]).any()], axis=1)
+        # Drop columns with constant values
+        df = df.drop(df.columns[df.nunique() == 1], axis=1)
+    return df
diff --git a/siapy/utils/enums.py b/siapy/utils/enums.py
new file mode 100644
index 0000000..fa1ff70
--- /dev/null
+++ b/siapy/utils/enums.py
@@ -0,0 +1,7 @@
+from enum import Enum, auto
+
+
+class InteractiveButtonsEnum(Enum):
+    SAVE = auto()
+    REPEAT = auto()
+    SKIP = auto()
diff --git a/siapy/utils/evaluators.py b/siapy/utils/evaluators.py
index 8c6e955..87dd863 100644
--- a/siapy/utils/evaluators.py
+++ b/siapy/utils/evaluators.py
@@ -12,18 +12,9 @@
 from siapy.core import logger
 from siapy.core.types import ArrayLike1dType, ArrayLike2dType
 
-ScorerFuncType = Callable[[BaseEstimator, ArrayLike2dType, ArrayLike1dType], float]
-
+from .validators import check_model_prediction_methods
 
-def _check_model_methods(model: BaseEstimator):
-    if (
-        not hasattr(model, "fit")
-        or not hasattr(model, "score")
-        or not hasattr(model, "predict")
-    ):
-        raise AttributeError(
-            "The model must have methods: 'fit', 'predict', and 'score'."
-        )
+ScorerFuncType = Callable[[BaseEstimator, ArrayLike2dType, ArrayLike1dType], float]
 
 
 def cross_validation(
@@ -47,7 +38,7 @@ def cross_validation(
             "Specification of X_val and y_val is redundant for cross_validation."
             "These parameters are ignored."
         )
-    _check_model_methods(model)
+    check_model_prediction_methods(model)
     score = cross_val_score(
         estimator=model,
         X=X,  # type: ignore
@@ -92,7 +83,7 @@ def hold_out_validation(
             shuffle=shuffle,
             stratify=stratify,
         )
-    _check_model_methods(model)
+    check_model_prediction_methods(model)
     model.fit(x_train, y_train)  # type: ignore
 
     if scoring:
diff --git a/siapy/utils/general.py b/siapy/utils/general.py
index fc01780..c013e01 100644
--- a/siapy/utils/general.py
+++ b/siapy/utils/general.py
@@ -1,13 +1,16 @@
 import inspect
 import multiprocessing
 import random
+import re
 import types
 from functools import partial
 from pathlib import Path
-from typing import Any, Callable, Generator, Optional
+from typing import Any, Callable, Generator, Iterable, Optional
 
 import numpy as np
 
+from siapy.core import logger
+
 
 def initialize_object(
     module: types.ModuleType | Any,
@@ -81,7 +84,7 @@ def get_increasing_seq_indices(values_list: list[int]) -> list[int]:
     return indices
 
 
-def set_random_seed(seed: int):
+def set_random_seed(seed: int | None):
     random.seed(seed)
     np.random.seed(seed)
 
@@ -92,3 +95,24 @@ def get_classmethods(class_obj: Any) -> list[str]:
         for member in inspect.getmembers(class_obj, predicate=inspect.ismethod)
         if member[1].__self__ == class_obj
     ]
+
+
+def match_iterable_items_by_regex(
+    iterable1: Iterable[str], iterable2: Iterable[str], regex: str = r""
+) -> tuple[list[tuple[str, str]], list[tuple[int, int]]]:
+    pattern = re.compile(regex)
+    matches = []
+    indices = []
+    for idx1, item1 in enumerate(iterable1):
+        match1 = pattern.search(item1)
+        logger.debug("match1: %s", match1)
+        if match1:
+            substring1 = match1.group()
+            for idx2, item2 in enumerate(iterable2):
+                match2 = pattern.search(item2)
+                logger.debug("match2: %s", match2)
+                if match2 and substring1 == match2.group():
+                    matches.append((item1, item2))
+                    indices.append((idx1, idx2))
+                    logger.info("Matched items: %s -> %s", item1, item2)
+    return matches, indices
diff --git a/siapy/utils/images.py b/siapy/utils/images.py
index c5b37f8..80a9bca 100644
--- a/siapy/utils/images.py
+++ b/siapy/utils/images.py
@@ -6,9 +6,10 @@
 import spectral as sp
 
 from siapy.core import logger
-from siapy.core.types import ImageDataType
+from siapy.core.types import ImageDataType, ImageType
 from siapy.entities import SpectralImage
 from siapy.transformations.image import rescale
+from siapy.utils.validators import validate_image_to_numpy
 
 
 def save_image(
@@ -187,3 +188,46 @@ def calculate_correction_factor_from_panel(
     panel_reflectance_mean = np.full(image.bands, panel_reference_reflectance)
     panel_correction = panel_reflectance_mean / panel_radiance_mean
     return panel_correction
+
+
+def blockfy_image(
+    image: ImageType,
+    p: Annotated[int, "block row size"],
+    q: Annotated[int, "block column size"],
+) -> list[np.ndarray]:
+    image_np = validate_image_to_numpy(image)
+    # Calculate how many blocks can cover the entire image
+    bpr = (image_np.shape[0] - 1) // p + 1  # blocks per row
+    bpc = (image_np.shape[1] - 1) // q + 1  # blocks per column
+
+    # Pad array with NaNs so it can be divided by p row-wise and by q column-wise
+    image_pad = np.nan * np.ones([p * bpr, q * bpc, image_np.shape[2]])
+    image_pad[: image_np.shape[0], : image_np.shape[1], : image_np.shape[2]] = image_np
+
+    image_slices = []
+    row_prev = 0
+
+    for row_block in range(bpc):
+        row_prev = row_block * p
+        column_prev = 0
+
+        for column_block in range(bpr):
+            column_prev = column_block * q
+            block = image_pad[
+                row_prev : row_prev + p,
+                column_prev : column_prev + q,
+            ]
+
+            if block.shape == (p, q, image_np.shape[2]):
+                image_slices.append(block)
+
+    return image_slices
+
+
+def calculate_image_background_percentage(image: ImageType):
+    image_np = validate_image_to_numpy(image)
+    # Check where any of bands include nan values (axis=2) to get positions of background
+    mask_nan = np.any(np.isnan(image_np), axis=2)
+    # Calculate percentage of background
+    percentage = np.sum(mask_nan) / mask_nan.size * 100
+    return percentage
diff --git a/siapy/utils/plots.py b/siapy/utils/plots.py
index c103692..b989272 100644
--- a/siapy/utils/plots.py
+++ b/siapy/utils/plots.py
@@ -2,12 +2,15 @@
 
 import matplotlib.pyplot as plt
 import numpy as np
+from matplotlib.lines import Line2D
 from matplotlib.path import Path
-from matplotlib.widgets import LassoSelector
+from matplotlib.widgets import Button, LassoSelector
 
 from siapy.core.logger import logger
 from siapy.core.types import ImageType
+from siapy.datasets.schemas import ClassificationTarget, TabularDatasetData
 from siapy.entities import Pixels
+from siapy.utils.enums import InteractiveButtonsEnum
 from siapy.utils.validators import validate_image_to_numpy_3channels
 
 
@@ -112,20 +115,20 @@ def accept(event):
     return selected_areas
 
 
-def display_selected_areas(
+def display_image_with_areas(
     image: ImageType,
-    selected_areas: Pixels | list[Pixels],
+    areas: Pixels | list[Pixels],
     *,
     color: str = "red",
 ):
-    if not isinstance(selected_areas, list):
-        selected_areas = [selected_areas]
+    if not isinstance(areas, list):
+        areas = [areas]
 
     image_display = validate_image_to_numpy_3channels(image)
     fig, ax = plt.subplots()
     ax.imshow(image_display)
 
-    for pixels in selected_areas:
+    for pixels in areas:
         ax.scatter(
             pixels.u(),
             pixels.v(),
@@ -136,3 +139,146 @@ def display_selected_areas(
         )
 
     plt.show()
+
+
+def display_multiple_images_with_areas(
+    images_with_areas: list[tuple[ImageType, Pixels | list[Pixels]]],
+    *,
+    color: str = "red",
+    plot_interactive_buttons: bool = True,
+) -> InteractiveButtonsEnum | None:
+    num_images = len(images_with_areas)
+    fig, axes = plt.subplots(1, num_images, figsize=(num_images * 5, 5))
+
+    if num_images == 1:
+        axes = [axes]
+
+    for ax, (image, selected_areas) in zip(axes, images_with_areas):
+        if not isinstance(selected_areas, list):
+            selected_areas = [selected_areas]
+
+        image_display = validate_image_to_numpy_3channels(image)
+        ax.imshow(image_display)
+
+        for pixels in selected_areas:
+            ax.scatter(
+                pixels.u(),
+                pixels.v(),
+                lw=0,
+                marker="o",
+                c=color,
+                s=(72.0 / fig.dpi) ** 2,
+            )
+    if plot_interactive_buttons:
+        return interactive_buttons()
+
+    plt.show()
+    return None
+
+
+def interactive_buttons():
+    flag = InteractiveButtonsEnum.REPEAT
+
+    def repeat(event):
+        nonlocal flag
+        logger.info("Pressed repeat button.")
+        plt.close()
+        flag = InteractiveButtonsEnum.REPEAT
+
+    def save(event):
+        nonlocal flag
+        logger.info("Pressed save button.")
+        plt.close()
+        flag = InteractiveButtonsEnum.SAVE
+
+    def skip(event):
+        nonlocal flag
+        logger.info("Pressed skip button.")
+        plt.close()
+        flag = InteractiveButtonsEnum.SKIP
+
+    axcolor = "lightgoldenrodyellow"
+    position = plt.axes((0.9, 0.1, 0.1, 0.04))
+    button_save = Button(position, "Save", color=axcolor, hovercolor="0.975")
+    button_save.on_clicked(save)
+    position = plt.axes((0.9, 0.15, 0.1, 0.04))
+    button_repeat = Button(position, "Repeat", color=axcolor, hovercolor="0.975")
+    button_repeat.on_clicked(repeat)
+    position = plt.axes((0.9, 0.2, 0.1, 0.04))
+    button_skip = Button(position, "Skip", color=axcolor, hovercolor="0.975")
+    button_skip.on_clicked(skip)
+    plt.show()
+    return flag
+
+
+def display_signals(
+    data: TabularDatasetData,
+    *,
+    figsize: tuple[int, int] = (6, 4),
+    dpi: int = 150,
+    colormap: str = "viridis",
+    x_label: str = "Spectral bands",
+    y_label: str = "",
+    label_fontsize: int = 14,
+    tick_params_label_size: int = 12,
+    legend_fontsize: int = 10,
+    legend_frameon: bool = True,
+):
+    if not isinstance(data.target, ClassificationTarget):
+        raise ValueError("The target must be an instance of ClassificationTarget.")
+
+    signals = data.signals.copy()
+    target = data.target.model_copy()
+    y_data_encoded = target.value
+    classes = list(target.encoding.to_dict().values())
+
+    fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
+    cmap = plt.get_cmap(colormap)
+    unique_labels = np.unique(y_data_encoded)
+    no_colors = len(unique_labels)
+
+    if no_colors > 2:
+        colors = list(cmap(np.linspace(0, 1, no_colors)))
+    else:
+        colors = ["darkgoldenrod", "forestgreen"]
+
+    x_values = list(range(len(signals.columns)))
+
+    grouped_data = signals.groupby(y_data_encoded.to_numpy())
+    mean_values = grouped_data.mean()
+    std_values = grouped_data.std()
+
+    for idx in unique_labels:
+        mean = mean_values.loc[idx].tolist()
+        std = std_values.loc[idx].tolist()
+        ax.plot(x_values, mean, color=colors[idx], label=classes[idx], alpha=0.6)
+        ax.fill_between(
+            x_values,
+            [m - s for m, s in zip(mean, std)],
+            [m + s for m, s in zip(mean, std)],
+            color=colors[idx],
+            alpha=0.2,
+        )
+
+    custom_lines = []
+    for idx in unique_labels:
+        custom_lines.append(Line2D([0], [0], color=colors[idx], lw=2))
+
+    ax.set_ylabel(y_label, fontsize=label_fontsize)
+    ax.set_xlabel(x_label, fontsize=label_fontsize)
+    ax.tick_params(axis="both", which="major", labelsize=tick_params_label_size)
+    ax.tick_params(axis="both", which="minor", labelsize=tick_params_label_size)
+    # ax.set_ylim([0, 1])
+    # ax.spines["bottom"].set_linewidth(2)
+    # ax.spines["left"].set_linewidth(2)
+    ax.spines["right"].set_linewidth(0)
+    ax.spines["top"].set_linewidth(0)
+    ax.set_xticks(x_values)
+    ax.set_xticklabels(signals.columns, rotation=0)
+    ax.legend(
+        loc="upper left",
+        fontsize=legend_fontsize,
+        framealpha=1,
+        frameon=legend_frameon,
+    )
+    plt.show()
diff --git a/siapy/utils/validators.py b/siapy/utils/validators.py
index c2359b1..9fe58c7 100644
--- a/siapy/utils/validators.py
+++ b/siapy/utils/validators.py
@@ -1,5 +1,6 @@
 import numpy as np
 from PIL.Image import Image
+from sklearn.base import BaseEstimator
 
 from siapy.core.types import ImageSizeType, ImageType
 from siapy.entities import SpectralImage
@@ -43,3 +44,14 @@ def validate_image_size(output_size: ImageSizeType) -> tuple[int, int]:
             "Argument output_size tuple must have 2 elements and contain only integers."
         )
     return output_size
+
+
+def check_model_prediction_methods(model: BaseEstimator):
+    if (
+        not hasattr(model, "fit")
+        or not hasattr(model, "score")
+        or not hasattr(model, "predict")
+    ):
+        raise AttributeError(
+            "The model must have methods: 'fit', 'predict', and 'score'."
+        )


Test patch:
diff --git a/tests/datasets/test_datasets_helpers.py b/tests/datasets/test_datasets_helpers.py
index 2ed8a9d..b6c8428 100644
--- a/tests/datasets/test_datasets_helpers.py
+++ b/tests/datasets/test_datasets_helpers.py
@@ -6,6 +6,7 @@
 from siapy.datasets.helpers import (
     generate_classification_target,
     generate_regression_target,
+    merge_signals_from_multiple_cameras,
 )
 from siapy.datasets.schemas import ClassificationTarget, RegressionTarget
 from siapy.entities import Pixels
@@ -37,15 +38,14 @@ def test_dataframe_generate_classification_target_single_column(sample_dataframe
     )
     assert isinstance(classification_target, ClassificationTarget)
     assert all(
-        classification_target.label
-        == pd.Series([("rectangle",), ("circle",)], name="label")
+        classification_target.label == pd.Series(["rectangle", "circle"], name="label")
     )
     assert classification_target.value.name == "encoded"
     assert classification_target.encoding.name == "encoding"
     assert list(classification_target.value) == [0, 1]
     assert classification_target.encoding.to_dict() == {
-        0: ("rectangle",),
-        1: ("circle",),
+        0: "rectangle",
+        1: "circle",
     }
 
 
@@ -56,12 +56,12 @@ def test_dataframe_generate_classification_target_multiple_columns(sample_datafr
     assert isinstance(classification_target, ClassificationTarget)
     assert all(
         classification_target.label
-        == pd.Series([("rectangle", "c"), ("circle", "d")], name="label")
+        == pd.Series(["rectangle__c", "circle__d"], name="label")
     )
     assert list(classification_target.value) == [0, 1]
     assert classification_target.encoding.to_dict() == {
-        0: ("rectangle", "c"),
-        1: ("circle", "d"),
+        0: "rectangle__c",
+        1: "circle__d",
     }
 
 
@@ -70,3 +70,7 @@ def test_dataframe_generate_regression_target(sample_dataframe):
     assert isinstance(regression_target, RegressionTarget)
     assert regression_target.name == "0"
     assert all(regression_target.value == sample_dataframe["0"])
+
+
+def test_merge_signals_from_multiple_cameras(spectral_tabular_dataset):
+    merge_signals_from_multiple_cameras(spectral_tabular_dataset.dataset_data)
diff --git a/tests/datasets/test_datasets_schemas.py b/tests/datasets/test_datasets_schemas.py
index e554fa5..c7c8ade 100644
--- a/tests/datasets/test_datasets_schemas.py
+++ b/tests/datasets/test_datasets_schemas.py
@@ -8,6 +8,15 @@
 )
 
 
+def test_classification_target_from_iterable():
+    data = ["a", "b", "c"]
+    classification_target = ClassificationTarget.from_iterable(data)
+    assert isinstance(classification_target, ClassificationTarget)
+    pd.testing.assert_series_equal(
+        classification_target.label, pd.Series(["a", "b", "c"])
+    )
+
+
 def test_classification_target_from_dict():
     data = {"label": ["a", "b", "c"], "value": [1, 2, 3], "encoding": ["x", "y", "z"]}
     classification_target = ClassificationTarget.from_dict(data)
@@ -61,6 +70,15 @@ def test_classification_target_reset_index():
     assert all(reset_target.encoding == classification_target.encoding)
 
 
+def test_regression_target_from_iterable():
+    data = [1.0, 2.5, 3.3]
+    regression_target = RegressionTarget.from_iterable(data)
+    assert isinstance(regression_target, RegressionTarget)
+    pd.testing.assert_series_equal(
+        regression_target.value, pd.Series([1.0, 2.5, 3.3], name="value")
+    )
+
+
 def test_regression_target_from_dict():
     data = {"value": [1.0, 2.5, 3.3], "name": "test_series"}
     regression_target = RegressionTarget.from_dict(data)
@@ -109,9 +127,9 @@ def test_regression_target_reset_index():
 
 def test_tabular_dataset_data_from_dict():
     data = {
-        "pixels": {"0": [255, 255, 255], "1": [0, 0, 0]},
+        "pixels": {"0": [255, 255], "1": [0, 0]},
         "signals": {"0": [1.0, 2.0], "1": [3.0, 4.0]},
-        "metadata": {"0": ["meta1"], "1": ["meta2"]},
+        "metadata": {"0": ["meta1", "meta1"], "1": ["meta2", "meta2"]},
     }
     tabular_dataset_data = TabularDatasetData.from_dict(data)
     assert isinstance(tabular_dataset_data, TabularDatasetData)
@@ -128,9 +146,9 @@ def test_tabular_dataset_data_from_dict():
 
 def test_tabular_dataset_data_to_dict():
     data = {
-        "pixels": {"0": [255, 255, 255], "1": [0, 0, 0]},
+        "pixels": {"0": [255, 255], "1": [0, 0]},
         "signals": {"0": [1.0, 2.0], "1": [3.0, 4.0]},
-        "metadata": {"0": ["meta1"], "1": ["meta2"]},
+        "metadata": {"0": ["meta1", "meta1"], "1": ["meta2", "meta2"]},
         "target": {"label": ["a", "b"], "value": [1, 2], "encoding": ["x", "y"]},
     }
     tabular_dataset_data = TabularDatasetData.from_dict(data)
@@ -145,9 +163,9 @@ def test_tabular_dataset_data_to_dict():
 
 def test_tabular_dataset_data_to_dataframe():
     data = {
-        "pixels": {"0": [255, 255, 255], "1": [0, 0, 0]},
+        "pixels": {"0": [255, 255], "1": [0, 0]},
         "signals": {"0": [1.0, 2.0], "1": [3.0, 4.0]},
-        "metadata": {"0": ["meta1"], "1": ["meta2"]},
+        "metadata": {"0": ["meta1", "meta1"], "1": ["meta2", "meta2"]},
         "target": {"label": ["a", "b"], "value": [1, 2], "encoding": ["x", "y"]},
     }
     tabular_dataset_data = TabularDatasetData.from_dict(data)
@@ -166,13 +184,17 @@ def test_tabular_dataset_data_to_dataframe():
 
 def test_tabular_dataset_data_getitem():
     data = {
-        "pixels": {"0": [255, 255, 255], "1": [0, 0, 0], "2": [128, 128, 128]},
+        "pixels": {"0": [255, 255], "1": [0, 0], "2": [128, 128]},
         "signals": {"0": [1.0, 2.0], "1": [3.0, 4.0], "2": [5.0, 6.0]},
-        "metadata": {"0": ["meta1"], "1": ["meta2"], "2": ["meta3"]},
+        "metadata": {
+            "0": ["meta1", "meta1"],
+            "1": ["meta2", "meta2"],
+            "2": ["meta3", "meta3"],
+        },
         "target": {
-            "label": ["a", "b", "c"],
-            "value": [1, 2, 3],
-            "encoding": ["x", "y", "z"],
+            "label": ["a", "b"],
+            "value": [1, 2],
+            "encoding": ["x", "y"],
         },
     }
     tabular_dataset_data = TabularDatasetData.from_dict(data)
@@ -187,9 +209,7 @@ def test_tabular_dataset_data_getitem():
     pd.testing.assert_frame_equal(
         sliced_data.metadata, pd.DataFrame(data["metadata"]).iloc[1:3]
     )
-    assert all(
-        sliced_data.target.label == pd.Series(["b", "c"], index=[1, 2], name="label")
-    )
+    assert all(sliced_data.target.label == pd.Series(["b"], index=[1], name="label"))
 
 
 def test_tabular_dataset_data_reset_index():
@@ -252,3 +272,104 @@ def test_tabular_dataset_data_target_from_dict_invalid():
     data = {"unknown_key": "some_value"}
     with pytest.raises(ValueError):
         TabularDatasetData.target_from_dict(data)
+
+
+def test_tabular_dataset_data_init():
+    data = {
+        "pixels": pd.DataFrame({"a": [1, 2, 3]}),
+        "signals": pd.DataFrame({"b": [4, 5, 6]}),
+        "metadata": pd.DataFrame({"c": [7, 8, 9]}),
+        "target": {
+            "label": ["a", "b", "c"],
+            "value": [1, 2, 3],
+            "encoding": ["x", "y", "z"],
+        },
+    }
+    target = ClassificationTarget.from_dict(data["target"])
+
+    # Valid setattr
+    dataset = TabularDatasetData(
+        pixels=data["pixels"],
+        signals=data["signals"],
+        metadata=data["metadata"],
+        target=target,
+    )
+    assert isinstance(dataset, TabularDatasetData)
+
+    # Invalid setattr -> signals
+    data = {
+        "pixels": pd.DataFrame({"a": [1, 2, 3]}),
+        "signals": pd.DataFrame({"b": [4, 5]}),
+        "metadata": pd.DataFrame({"c": [7, 8, 9]}),
+    }
+
+    with pytest.raises(
+        ValueError, match="Lengths of pixels, signals, and metadata must be equal"
+    ):
+        TabularDatasetData(
+            pixels=data["pixels"],
+            signals=data["signals"],
+            metadata=data["metadata"],
+        )
+
+
+def test_tabular_dataset_data_setattr():
+    data = {
+        "pixels": pd.DataFrame({"a": [1, 2, 3]}),
+        "signals": pd.DataFrame({"b": [4, 5, 6]}),
+        "metadata": pd.DataFrame({"c": [7, 8, 9]}),
+        "target": {
+            "label": ["a", "b", "c"],
+            "value": [1, 2, 3],
+            "encoding": ["x", "y", "z"],
+        },
+    }
+    target = ClassificationTarget.from_dict(data["target"])
+    dataset_ = TabularDatasetData(
+        pixels=data["pixels"],
+        signals=data["signals"],
+        metadata=data["metadata"],
+        target=target,
+    )
+
+    # Valid setattr
+    new_pixels = pd.DataFrame({"a": [10, 11, 12]})
+    dataset = dataset_.model_copy()
+    dataset.pixels = new_pixels
+    assert dataset.pixels.equals(new_pixels)
+
+    # Invalid setattr -> signals
+    invalid_signals = pd.DataFrame({"b": [4, 5]})
+    with pytest.raises(
+        ValueError, match="Lengths of pixels, signals, and metadata must be equal"
+    ):
+        dataset.signals = invalid_signals
+
+    # Invalid setattr -> target
+    dataset = dataset_.model_copy()
+    invalid_target = target[:2]
+    with pytest.raises(
+        ValueError, match="Target length must be equal to the length of the dataset."
+    ):
+        dataset.target = invalid_target
+
+
+def test_tabular_dataset_data_len():
+    data = {
+        "pixels": pd.DataFrame({"a": [1, 2, 3]}),
+        "signals": pd.DataFrame({"b": [4, 5, 6]}),
+        "metadata": pd.DataFrame({"c": [7, 8, 9]}),
+        "target": {
+            "label": ["a", "b", "c"],
+            "value": [1, 2, 3],
+            "encoding": ["x", "y", "z"],
+        },
+    }
+    target = ClassificationTarget.from_dict(data["target"])
+    dataset = TabularDatasetData(
+        pixels=data["pixels"],
+        signals=data["signals"],
+        metadata=data["metadata"],
+        target=target,
+    )
+    assert len(dataset) == len(data["pixels"])
diff --git a/tests/entities/test_entities_shapes.py b/tests/entities/test_entities_shapes.py
index b52b310..ff7edd3 100644
--- a/tests/entities/test_entities_shapes.py
+++ b/tests/entities/test_entities_shapes.py
@@ -10,7 +10,7 @@
     Point,
     Rectangle,
 )
-from siapy.utils.plots import display_selected_areas
+from siapy.utils.plots import display_image_with_areas
 
 
 @pytest.mark.manual
@@ -20,7 +20,7 @@ def test_freedraw_convex_hull_manual():
     pixels = Pixels.from_iterable(pixels_input)
     freedraw = FreeDraw(pixels=pixels)
     convex_hull_output = freedraw.convex_hull()
-    display_selected_areas(image_mock, convex_hull_output, color="red")
+    display_image_with_areas(image_mock, convex_hull_output, color="red")
 
 
 def test_from_shape_type():
diff --git a/tests/features/test_features_features.py b/tests/features/test_features_features.py
new file mode 100644
index 0000000..30b8605
--- /dev/null
+++ b/tests/features/test_features_features.py
@@ -0,0 +1,46 @@
+import pandas as pd
+from sklearn.datasets import make_classification, make_regression
+
+from siapy.features import (
+    AutoSpectralIndicesClassification,
+    AutoSpectralIndicesRegression,
+)
+from siapy.features.helpers import FeatureSelectorConfig
+from siapy.features.spectral_indices import (
+    compute_spectral_indices,
+    get_spectral_indices,
+)
+
+
+def test_auto_spectral_indices_classification():
+    columns = ["R", "G"]
+    spectral_indices = get_spectral_indices(columns)
+    X, y = make_classification(
+        n_samples=100, n_features=2, n_classes=2, random_state=0, n_redundant=0
+    )
+    data = pd.DataFrame(X, columns=columns)
+    target = pd.Series(y)
+    df_direct = compute_spectral_indices(data, spectral_indices.keys())
+
+    config = FeatureSelectorConfig(k_features=5)
+    auto_clf = AutoSpectralIndicesClassification(
+        spectral_indices, selector_config=config, merge_with_original=False
+    )
+    df_selected = auto_clf.fit_transform(data, target)
+    pd.testing.assert_frame_equal(df_selected, df_direct[df_selected.columns])
+
+
+def test_auto_spectral_indices_regression():
+    columns = ["R", "G"]
+    spectral_indices = get_spectral_indices(columns)
+    X, y = make_regression(n_samples=100, n_features=2, noise=0.1, random_state=0)
+    data = pd.DataFrame(X, columns=columns)
+    target = pd.Series(y)
+    df_direct = compute_spectral_indices(data, spectral_indices.keys())
+
+    config = FeatureSelectorConfig(k_features=5)
+    auto_reg = AutoSpectralIndicesRegression(
+        spectral_indices, selector_config=config, merge_with_original=False
+    )
+    df_selected = auto_reg.fit_transform(data, target)
+    pd.testing.assert_frame_equal(df_selected, df_direct[df_selected.columns])
diff --git a/tests/features/test_features_helpers.py b/tests/features/test_features_helpers.py
new file mode 100644
index 0000000..c0f7e77
--- /dev/null
+++ b/tests/features/test_features_helpers.py
@@ -0,0 +1,93 @@
+import pytest
+from sklearn.pipeline import Pipeline
+
+from siapy.features.helpers import FeatureSelectorConfig, feature_selector_factory
+from tests.utils import assert_pipelines_parameters_equal
+
+
+def test_feature_selector_factory_regression_pipeline():
+    pipeline = feature_selector_factory(problem_type="regression")
+    assert isinstance(pipeline, Pipeline)
+    assert (
+        pipeline.named_steps["sequentialfeatureselector"].scoring
+        == "neg_mean_squared_error"
+    )
+    assert (
+        pipeline.named_steps["sequentialfeatureselector"].estimator.__class__.__name__
+        == "Ridge"
+    )
+
+
+def test_feature_selector_factory_classification_pipeline():
+    pipeline = feature_selector_factory(problem_type="classification")
+    assert isinstance(pipeline, Pipeline)
+    assert pipeline.named_steps["sequentialfeatureselector"].scoring == "f1_weighted"
+    assert (
+        pipeline.named_steps["sequentialfeatureselector"].estimator.__class__.__name__
+        == "RidgeClassifier"
+    )
+
+
+def test_feature_selector_factory_invalid_problem_type():
+    with pytest.raises(ValueError):
+        feature_selector_factory(problem_type="invalid")
+
+
+def test_feature_selector_factory_custom_args():
+    pipeline = feature_selector_factory(problem_type="regression", k_features=5)
+    assert pipeline.named_steps["sequentialfeatureselector"].k_features == 5
+
+    pipeline = feature_selector_factory(problem_type="regression", cv=5)
+    assert pipeline.named_steps["sequentialfeatureselector"].cv == 5
+
+    pipeline = feature_selector_factory(problem_type="regression", forward=False)
+    assert not pipeline.named_steps["sequentialfeatureselector"].forward
+
+    pipeline = feature_selector_factory(problem_type="regression", floating=False)
+    assert not pipeline.named_steps["sequentialfeatureselector"].floating
+
+    pipeline = feature_selector_factory(problem_type="regression", verbose=0)
+    assert pipeline.named_steps["sequentialfeatureselector"].verbose == 0
+
+    pipeline = feature_selector_factory(problem_type="regression", n_jobs=2)
+    assert pipeline.named_steps["sequentialfeatureselector"].n_jobs == 2
+
+
+def test_feature_selector_factory_config_vs_args():
+    config = FeatureSelectorConfig()
+    pipeline_reg_with_args = feature_selector_factory(problem_type="regression")
+    pipeline_reg_with_config = feature_selector_factory(
+        problem_type="regression", config=config
+    )
+    pipeline_clf_with_args = feature_selector_factory(problem_type="classification")
+    pipeline_clf_with_config = feature_selector_factory(
+        problem_type="classification", config=config
+    )
+
+    assert assert_pipelines_parameters_equal(
+        pipeline_reg_with_args, pipeline_reg_with_config
+    )
+    assert assert_pipelines_parameters_equal(
+        pipeline_clf_with_args, pipeline_clf_with_config
+    )
+    assert not assert_pipelines_parameters_equal(
+        pipeline_reg_with_args, pipeline_clf_with_args
+    )
+    assert not assert_pipelines_parameters_equal(
+        pipeline_reg_with_config, pipeline_clf_with_config
+    )
+
+    config2 = FeatureSelectorConfig(cv=2)
+    pipeline_reg_with_config = feature_selector_factory(
+        problem_type="regression", config=config2
+    )
+    pipeline_clf_with_config = feature_selector_factory(
+        problem_type="classification", config=config2
+    )
+
+    assert not assert_pipelines_parameters_equal(
+        pipeline_reg_with_args, pipeline_reg_with_config
+    )
+    assert not assert_pipelines_parameters_equal(
+        pipeline_clf_with_args, pipeline_clf_with_config
+    )
diff --git a/tests/features/test_features_spectral_indices.py b/tests/features/test_features_spectral_indices.py
new file mode 100644
index 0000000..3583da7
--- /dev/null
+++ b/tests/features/test_features_spectral_indices.py
@@ -0,0 +1,59 @@
+import numpy as np
+import pandas as pd
+import pytest
+
+from siapy.features.spectral_indices import (
+    _convert_str_to_list,
+    compute_spectral_indices,
+    get_spectral_indices,
+)
+
+
+def test_convert_str_to_list():
+    input_data = "R"
+    expected_output = ["R"]
+    assert _convert_str_to_list(input_data) == expected_output
+    input_data = ["R", "G", "B"]
+    expected_output = ["R", "G", "B"]
+    assert _convert_str_to_list(input_data) == expected_output
+
+
+def test_get_spectral_indices_valid():
+    bands_acronym = ["R", "G", "B"]
+    spectral_indices = get_spectral_indices(bands_acronym)
+    for _, meta in spectral_indices.items():
+        assert set(meta.bands).issubset(set(bands_acronym))
+
+
+def test_get_spectral_indices_invalid():
+    bands_acronym = ["not-correct"]
+    with pytest.raises(ValueError):
+        get_spectral_indices(bands_acronym)
+
+
+def test_compute_spectral_indices():
+    columns = ["R", "G"]
+    spectral_indices = get_spectral_indices(columns)
+    data = pd.DataFrame(np.random.default_rng(seed=0).random((5, 2)), columns=columns)
+    compute_spectral_indices(data, spectral_indices.keys())
+    columns[1] = "not-correct"
+    data = pd.DataFrame(np.random.default_rng(seed=0).random((5, 2)), columns=columns)
+    with pytest.raises(ValueError):
+        compute_spectral_indices(
+            data,
+            spectral_indices.keys(),
+        )
+
+
+def test_compute_spectral_indices_with_map():
+    data = pd.DataFrame(
+        np.random.default_rng(seed=0).random((5, 2)), columns=["R", "not-correct"]
+    )
+    spectral_indices = get_spectral_indices(["R", "G"])
+    with pytest.raises(ValueError):
+        compute_spectral_indices(
+            data,
+            spectral_indices.keys(),
+            {"not-correct": "not-correct2"},
+        )
+    compute_spectral_indices(data, spectral_indices.keys(), {"not-correct": "G"})
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..0854356
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,45 @@
+from sklearn.base import BaseEstimator
+from sklearn.pipeline import Pipeline
+
+from siapy.utils.general import dict_zip
+
+
+def assert_estimators_parameters_equal(
+    estimator1: BaseEstimator, estimator2: BaseEstimator
+) -> bool:
+    if len(estimator1.get_params()) != len(estimator2.get_params()):
+        return False
+
+    for params_key, params1_val, params2_val in dict_zip(
+        estimator1.get_params(), estimator2.get_params()
+    ):
+        if isinstance(params1_val, BaseEstimator) and isinstance(
+            params2_val, BaseEstimator
+        ):
+            if not assert_estimators_parameters_equal(params1_val, params2_val):
+                return False
+        elif params1_val != params2_val:
+            print(
+                f"Values not equal for key: {params_key}",
+                f"Value1: {params1_val}, Value2: {params2_val}",
+            )
+            return False
+
+    return True
+
+
+def assert_pipelines_parameters_equal(pipeline1: Pipeline, pipeline2: Pipeline) -> bool:
+    if len(pipeline1.steps) != len(pipeline2.steps):
+        return False
+
+    for step1, step2 in zip(pipeline1.steps, pipeline2.steps):
+        if step1[0] != step2[0] or step1[1].__class__ != step2[1].__class__:
+            return False
+
+    # Check if the parameters of each step are the same
+    for step_name, step1 in pipeline1.named_steps.items():
+        step2 = pipeline2.named_steps[step_name]
+        if not assert_estimators_parameters_equal(step1, step2):
+            return False
+
+    return True
diff --git a/tests/utils/test_utils_evaluators.py b/tests/utils/test_utils_evaluators.py
index a3fdb20..a220f87 100644
--- a/tests/utils/test_utils_evaluators.py
+++ b/tests/utils/test_utils_evaluators.py
@@ -1,49 +1,17 @@
 import logging
 
 import pytest
-from sklearn.base import BaseEstimator
 from sklearn.dummy import DummyClassifier
 from sklearn.metrics import accuracy_score, make_scorer
 from sklearn.model_selection import KFold, train_test_split
 from sklearn.svm import SVC
 
 from siapy.utils.evaluators import (
-    _check_model_methods,
     cross_validation,
     hold_out_validation,
 )
 
 
-class MockModelValid(BaseEstimator):
-    def fit(self, X, y):
-        pass
-
-    def predict(self, X):
-        pass
-
-    def score(self, X, y):
-        pass
-
-
-class MockModelInvalid(BaseEstimator):
-    def fit(self, X, y):
-        pass
-
-
-def test_check_model_methods_valid():
-    model = MockModelValid()
-    _check_model_methods(model)
-
-
-def test_check_model_methods_invalid():
-    model = MockModelInvalid()
-    with pytest.raises(
-        AttributeError,
-        match="The model must have methods: 'fit', 'predict', and 'score'.",
-    ):
-        _check_model_methods(model)
-
-
 def test_cross_validation(mock_sklearn_dataset):
     X, y = mock_sklearn_dataset
     mean_score = cross_validation(model=SVC(random_state=0), X=X, y=y)
diff --git a/tests/utils/test_utils_general.py b/tests/utils/test_utils_general.py
index c10263d..84097ca 100644
--- a/tests/utils/test_utils_general.py
+++ b/tests/utils/test_utils_general.py
@@ -14,6 +14,7 @@
     get_number_cpus,
     initialize_function,
     initialize_object,
+    match_iterable_items_by_regex,
 )
 
 
@@ -114,3 +115,32 @@ def test_get_class_methods():
     expected_methods = ["class_method1", "class_method2"]
     actual_methods = get_classmethods(SampleClass)
     assert sorted(actual_methods) == sorted(expected_methods)
+
+
+def test_match_iterable_items_by_regex():
+    iterable1 = [
+        "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr_rad_f32.hdr",
+        "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101527_corr_rad_f32.hdr",
+    ]
+    iterable2 = [
+        "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr",
+        "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr",
+    ]
+    # Regex: Define the regex pattern r"^[^_]+_[^_]+_[^_]+__" to match the labels until __.
+    regex = r"^[^_]+_[^_]+_[^_]+__"
+
+    expected_matches = [
+        (
+            "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr_rad_f32.hdr",
+            "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr",
+        ),
+        (
+            "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101527_corr_rad_f32.hdr",
+            "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr",
+        ),
+    ]
+    expected_indices = [(0, 1), (1, 0)]
+
+    matches, indices = match_iterable_items_by_regex(iterable1, iterable2, regex)
+    assert matches == expected_matches
+    assert indices == expected_indices
diff --git a/tests/utils/test_utils_images.py b/tests/utils/test_utils_images.py
index 6da4bcf..179954e 100644
--- a/tests/utils/test_utils_images.py
+++ b/tests/utils/test_utils_images.py
@@ -11,7 +11,9 @@
 from siapy.entities.pixels import Pixels
 from siapy.entities.shapes import Shape
 from siapy.utils.images import (
+    blockfy_image,
     calculate_correction_factor_from_panel,
+    calculate_image_background_percentage,
     convert_radiance_image_to_reflectance,
     create_image,
     merge_images_by_specter,
@@ -192,3 +194,49 @@ def test_convert_radiance_image_to_reflectance_with_saving(spectral_images, tmp_
             result.to_numpy(),
             np.array(image_vnir.to_numpy() * panel_correction).astype("float32"),
         )
+
+
+def test_calculate_image_background_percentage_mixed_background():
+    image = np.random.default_rng(0).random((100, 100, 3))
+    image[0:25, 0:25, :] = np.nan
+    image[75:100, 75:100, :] = np.nan
+    image[55, 50, 1] = np.nan
+    percentage = calculate_image_background_percentage(image)
+    assert percentage == pytest.approx(12.5099999)
+
+    image = np.full((100, 100, 3), np.nan)
+    percentage = calculate_image_background_percentage(image)
+    assert percentage == 100
+
+
+def test_blockfy_image():
+    image = np.random.default_rng(0).random((100, 100, 3))
+
+    p, q = 25, 25
+
+    blocks = blockfy_image(image, p, q)
+
+    expected_blocks_per_row = (image.shape[0] - 1) // p + 1
+    expected_blocks_per_column = (image.shape[1] - 1) // q + 1
+    expected_num_blocks = expected_blocks_per_row * expected_blocks_per_column
+
+    assert len(blocks) == expected_num_blocks
+
+    for block in blocks:
+        assert block.shape == (p, q, image.shape[2])
+
+    # Concatenate image blocks back to original image
+    reconstructed_image = np.block(
+        [
+            [
+                # col_idx * i + j -> position in a list
+                # , where i is the row index and j is the column index of the block, sliced from original image
+                [blocks[expected_blocks_per_column * i + j]]
+                for j in range(expected_blocks_per_column)
+            ]
+            for i in range(expected_blocks_per_row)
+        ]
+    )
+    np.testing.assert_array_almost_equal(
+        reconstructed_image[: image.shape[0], : image.shape[1]], image
+    )
diff --git a/tests/utils/test_utils_plots.py b/tests/utils/test_utils_plots.py
index 6f9d69b..b12252e 100644
--- a/tests/utils/test_utils_plots.py
+++ b/tests/utils/test_utils_plots.py
@@ -1,7 +1,14 @@
 import pytest
 
+from siapy.datasets.schemas import (
+    RegressionTarget,
+    TabularDatasetData,
+)
+from siapy.utils.enums import InteractiveButtonsEnum
 from siapy.utils.plots import (
-    display_selected_areas,
+    display_image_with_areas,
+    display_multiple_images_with_areas,
+    display_signals,
     pixels_select_click,
     pixels_select_lasso,
 )
@@ -17,4 +24,74 @@ def test_pixels_select_click_manual(spectral_images):
 def test_pixels_select_lasso_manual(spectral_images):
     image_vnir = spectral_images.vnir
     selected_areas = pixels_select_lasso(image_vnir)
-    display_selected_areas(image_vnir, selected_areas, color="blue")
+    display_image_with_areas(image_vnir, selected_areas, color="blue")
+
+
+@pytest.mark.manual
+def test_display_multiple_images_with_areas(spectral_images, corresponding_pixels):
+    image_vnir = spectral_images.vnir
+    image_swir = spectral_images.swir
+    selected_areas_vnir = corresponding_pixels.vnir
+    selected_areas_swir = corresponding_pixels.swir
+    # selected_areas_vnir = pixels_select_lasso(image_vnir)
+    # selected_areas_swir = pixels_select_lasso(image_swir)
+    out = display_multiple_images_with_areas(
+        images_with_areas=[
+            (image_vnir, selected_areas_vnir),
+            (image_swir, selected_areas_swir),
+        ],
+        color="blue",
+    )
+    assert isinstance(out, InteractiveButtonsEnum)
+
+
+@pytest.mark.manual
+def test_display_signals():
+    data = {
+        "pixels": {
+            "0": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+            "1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        },
+        "signals": {
+            "0": [1, 2, 3, 1, 1, 5, 7, 6, 7, 8],
+            "1": [3, 4, 5, 6, 4, 10, 11, 12, 11, 12],
+        },
+        "metadata": {
+            "0": [
+                "meta1",
+                "meta1",
+                "meta1",
+                "meta1",
+                "meta1",
+                "meta1",
+                "meta1",
+                "meta1",
+                "meta1",
+                "meta1",
+            ],
+            "1": [
+                "meta2",
+                "meta2",
+                "meta2",
+                "meta2",
+                "meta2",
+                "meta2",
+                "meta2",
+                "meta2",
+                "meta2",
+                "meta2",
+            ],
+        },
+        "target": {
+            "label": ["a", "a", "a", "a", "a", "b", "b", "b", "b", "b"],
+            "value": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
+            "encoding": ["x", "y"],
+        },
+    }
+    dataset = TabularDatasetData.from_dict(data)
+    display_signals(dataset)
+    dataset.target = RegressionTarget.from_iterable(
+        [0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
+    )
+    with pytest.raises(ValueError):
+        display_signals(dataset)
diff --git a/tests/utils/test_utils_validators.py b/tests/utils/test_utils_validators.py
index 71e92c8..09a23a4 100644
--- a/tests/utils/test_utils_validators.py
+++ b/tests/utils/test_utils_validators.py
@@ -1,8 +1,10 @@
 import numpy as np
 import pytest
 from PIL import Image as PILImage
+from sklearn.base import BaseEstimator
 
 from siapy.utils.validators import (
+    check_model_prediction_methods,
     validate_image_size,
     validate_image_to_numpy,
     validate_image_to_numpy_3channels,
@@ -77,3 +79,33 @@ def test_validate_image_size():
 
     with pytest.raises(ValueError):
         validate_image_size((100, "150"))
+
+
+class MockModelValid(BaseEstimator):
+    def fit(self, X, y):
+        pass
+
+    def predict(self, X):
+        pass
+
+    def score(self, X, y):
+        pass
+
+
+class MockModelInvalid(BaseEstimator):
+    def fit(self, X, y):
+        pass
+
+
+def test_check_model_prediction_methods_valid():
+    model = MockModelValid()
+    check_model_prediction_methods(model)
+
+
+def test_check_model_prediction_methods_invalid():
+    model = MockModelInvalid()
+    with pytest.raises(
+        AttributeError,
+        match="The model must have methods: 'fit', 'predict', and 'score'.",
+    ):
+        check_model_prediction_methods(model)