Repository: BioimageAnalysisCoreWEHI/napari_lattice (language: python)

Issue:
1. Separating out batch processing and napari options
Currently the interactive napari bit and batch processing are in the same library. 
It would make sense to split it out and perhaps have the batch processing as an independent package so any napari imports can be avoided. For example, currently even if not using napari, we need to add the Xvfb flag in batch processing. 

Batch processing is mainly here: 
https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/tree/master/src/napari_lattice/cmds

The main __init__ file contains initialisation of the logger and enums so that both batch processing and napari could use it. 
https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/blob/master/src/napari_lattice/__init__.py

The parameters for batch processing are here:
https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/wiki/6.-Batch-Processing

Interactive widgets in napari: 
https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/blob/master/src/napari_lattice/_dock_widget.py


Discussion / hints:

Fix patch:
diff --git a/src/napari_lattice/__init__.py b/core/lls_core/__init__.py
similarity index 100%
rename from src/napari_lattice/__init__.py
rename to core/lls_core/__init__.py
diff --git a/src/napari_lattice/cmds/__init__.py b/core/lls_core/cmds/__init__.py
similarity index 100%
rename from src/napari_lattice/cmds/__init__.py
rename to core/lls_core/cmds/__init__.py
diff --git a/src/napari_lattice/cmds/__main__.py b/core/lls_core/cmds/__main__.py
similarity index 92%
rename from src/napari_lattice/cmds/__main__.py
rename to core/lls_core/cmds/__main__.py
index 2169aeb5..66ae8d74 100644
--- a/src/napari_lattice/cmds/__main__.py
+++ b/core/lls_core/cmds/__main__.py
@@ -8,25 +8,26 @@
 import glob
 import sys
 import re
-from napari_lattice.io import LatticeData, save_img, save_img_workflow
-from napari_lattice.utils import read_imagej_roi, get_all_py_files, get_first_last_image_and_task, modify_workflow_task, check_dimensions
-from napari_lattice.llsz_core import crop_volume_deskew
+from lls_core.io import save_img, save_img_workflow
+from lls_core.lattice_data import lattice_from_aics
+from lls_core.utils import read_imagej_roi, get_all_py_files, get_first_last_image_and_task, modify_workflow_task, check_dimensions
+from lls_core.llsz_core import crop_volume_deskew
 from aicsimageio import AICSImage
+from aicsimageio.types import PhysicalPixelSizes
 import pyclesperanto_prototype as cle
 from tqdm import tqdm
 import dask.array as da
-from napari_workflows import Workflow, WorkflowManager
 from napari_workflows._io_yaml_v1 import load_workflow
 from pathlib import Path
 import yaml
-from .. import config
+from typing import Sequence, TYPE_CHECKING
 
-from .._dock_widget import _napari_lattice_widget_wrapper
-
-from ..ui_core import _read_psf
-from .. import DeskewDirection, DeconvolutionChoice, SaveFileType
+from lls_core.deconvolution import read_psf
+from lls_core import DeskewDirection, DeconvolutionChoice, SaveFileType
 from enum import Enum
 
+from napari_workflows import Workflow
+
 
 # define parser class so as to print help message
 class ArgParser(argparse.ArgumentParser):
@@ -43,7 +44,7 @@ class ProcessingOptions(Enum):
     workflow_crop = "workflow_crop"
 
 
-def args_parse():
+def make_parser():
     """ Parse input arguments"""
     parser = argparse.ArgumentParser(description="Lattice Data Analysis")
     parser.add_argument('--input', type=str, nargs=1, help="Enter input file",
@@ -73,7 +74,7 @@ def args_parse():
                         help="Enter paths to psf file/s separated by commas or you can enter each path with double quotes")  # use + for nargs for flexible no of args
     parser.add_argument('--roi_file', type=str, nargs=1,
                         help="Enter the path to the ROI file for performing cropping (only valid for -processing where crop or workflow_crop is specified")
-    parser.add_argument('--voxel_sizes', type=tuple, nargs=1,
+    parser.add_argument('--voxel_sizes', type=float, nargs=3,
                         help="Enter the voxel sizes as (dz,dy,dx). Make sure they are in brackets",
                         default=(0.3, 0.1499219272808386, 0.1499219272808386))
     parser.add_argument('--file_extension', type=str, nargs=1,
@@ -100,13 +101,11 @@ def args_parse():
                         help="Process an individual ROI, loop all if unset", default=None)
     parser.add_argument('--set_logging', type=str, nargs=1,
                         help="Set logging level [INFO,DEBUG]", default=["INFO"])
+    return parser
 
-    args = parser.parse_args()
-    return args
-
-
-def main():
-    args = args_parse()
+def main(argv: Sequence[str] = sys.argv[1:]):
+    parser = make_parser()
+    args = parser.parse_args(argv)
 
     # Enable Logging
     import logging
@@ -212,6 +211,7 @@ def main():
         else:
             deconvolution = False
 
+        roi_file: str
         if processing == ProcessingOptions.crop or processing == ProcessingOptions.workflow_crop:
             if 'roi_file' in processing_parameters:
                 roi_file = processing_parameters.get('roi_file', False)
@@ -266,7 +266,7 @@ def main():
                     psf_ch2_path = psf_paths[1]
                     psf_ch3_path = psf_paths[2]
                     psf_ch4_path = psf_paths[3]
-                except IndexError as e:
+                except IndexError:
                     pass
             else:
                 logging.error("PSF paths not set option not set.")
@@ -288,8 +288,9 @@ def main():
             assert args.roi_file, "Specify roi_file (ImageJ/FIJI ROI Zip file)"
             roi_file = args.roi_file[0]
             if os.path.isfile(roi_file):  # if file make sure it is a zip file or roi file
+                
                 roi_file_extension = os.path.splitext(roi_file)[1]
-                assert roi_file_extension == ".zip" or roi_file_extension == "roi", "ROI file is not a zip or .roi file"
+                assert roi_file_extension == ".zip" or roi_file_extension == ".roi", "ROI file is not a zip or .roi file"
 
         # Check if input and output paths exist
         assert os.path.exists(input_path), "Cannot find input " + input_path
@@ -341,6 +342,8 @@ def main():
 
     # loop through the list of images and rois
     for img, roi_path in zip(img_list, roi_list):
+        save_name = os.path.splitext(os.path.basename(img))[0]
+
         print("Processing Image " + img)
         if processing == ProcessingOptions.crop or processing == ProcessingOptions.workflow_crop:
             print("Processing ROI " + roi_path)
@@ -356,21 +359,11 @@ def main():
                 if test_max:
                     print(f"Scene {scene} is valid")
                     break
-            except Exception as e:
+            except Exception:
                 print(f"Scene {scene} not valid")
 
         # Initialize Lattice class
-        lattice = LatticeData(aics_img, deskew_angle,
-                              skew_dir, dx, dy, dz, channel_dimension)
-
-        # Chance deskew function absed on skew direction
-        if lattice.skew == DeskewDirection.Y:
-            lattice.deskew_func = cle.deskew_y
-            lattice.skew_dir = DeskewDirection.Y
-
-        elif lattice.skew == DeskewDirection.X:
-            lattice.deskew_func = cle.deskew_x
-            lattice.skew_dir = DeskewDirection.X
+        lattice = lattice_from_aics(aics_img, angle=deskew_angle, skew=skew_dir, save_name=save_name, physical_pixel_sizes=PhysicalPixelSizes(dx, dy, dz))
 
         if time_start is None or time_end is None:
             time_start, time_end = 0, lattice.time - 1
@@ -387,16 +380,11 @@ def main():
             lattice.decon_processing = deconvolution
             lattice.psf_num_iter = deconvolution_num_iter
             logging.debug(f"Num of iterations decon, {lattice.psf_num_iter}")
-            logging.info("DECONVOLUTIONING!")
+            logging.info("Performing Deconvolution")
             lattice.psf = []
             lattice.otf_path = []
-            # todo this should maybe go somewhere else?
-            _read_psf(psf_ch1_path,
-                      psf_ch2_path,
-                      psf_ch3_path,
-                      psf_ch4_path,
-                      decon_option=lattice.decon_processing,
-                      lattice_class=lattice)
+            # Remove empty values and convert to Path
+            lattice.psf = list(read_psf([Path(it) for it in [psf_ch1_path, psf_ch2_path, psf_ch3_path, psf_ch4_path] if it is not None], decon_option=lattice.decon_processing, lattice_class=lattice))
 
         else:
             lattice.decon_processing = None
@@ -416,7 +404,7 @@ def main():
 
             # load custom modules (*.py) in same directory as workflow file
             import importlib
-            parent_dir = workflow_path.resolve().parents[0].__str__() + os.sep
+            parent_dir = Path(workflow_path).resolve().parents[0].__str__() + os.sep
 
             sys.path.append(parent_dir)
             custom_py_files = get_all_py_files(parent_dir)
@@ -427,8 +415,8 @@ def main():
 
             # workflow has to be reloaded for each image and reinitialised
             user_workflow = load_workflow(workflow_path.__str__())
-            assert type(
-                user_workflow) is Workflow, "Workflow file is not a napari workflow object. Check file!"
+            if not isinstance(user_workflow, Workflow):
+                raise ValueError("Workflow file is not a napari workflow object. Check file!")
 
             input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
                 user_workflow)
@@ -497,8 +485,6 @@ def main():
 
         img_data = lattice.data
 
-        save_name = os.path.splitext(os.path.basename(img))[0]
-
         # Create save directory for each image
         save_path = output_path + os.sep + \
             os.path.basename(os.path.splitext(img)[0]) + os.sep
diff --git a/src/napari_lattice/config.py b/core/lls_core/config.py
similarity index 100%
rename from src/napari_lattice/config.py
rename to core/lls_core/config.py
diff --git a/core/lls_core/deconvolution.py b/core/lls_core/deconvolution.py
new file mode 100644
index 00000000..779a7c0a
--- /dev/null
+++ b/core/lls_core/deconvolution.py
@@ -0,0 +1,260 @@
+from pathlib import Path
+from lls_core import DeconvolutionChoice
+from lls_core.lattice_data import LatticeData
+import pyclesperanto_prototype as cle
+import logging
+import importlib.util
+from typing import Collection, Iterable,Union,Literal
+from aicsimageio import AICSImage
+from aicspylibczi import CziFile
+from numpy.typing import NDArray
+import os
+import numpy as np
+import dask.array as da
+from dask.array.core import Array as DaskArray
+from resource_backed_dask_array import ResourceBackedDaskArray 
+
+from lls_core.utils import pad_image_nearest_multiple
+
+logger = logging.getLogger(__name__)
+
+def read_psf(psf_paths: Collection[Path],
+              decon_option: DeconvolutionChoice,
+              lattice_class: LatticeData) -> Iterable[NDArray]:
+    """Read PSF files and return a list of PSF arrays appended to lattice_class.psf
+
+    Args:
+        decon_option (enum): Enum option from DeconvolutionChoice
+        lattice_class: lattice class object, either LLSZWidget.LlszMenu.lattice or lattice class from batch processing
+    """
+
+    # remove empty paths; pathlib returns current directory as "." if None or empty str specified
+    # When running batch processing,empty directory will be an empty string
+
+    logging.debug(f"PSF paths are {psf_paths}")
+    # total no of psf images
+    psf_channels = len(psf_paths)
+    assert psf_channels > 0, f"No images detected for PSF. Check the psf paths -> {psf_paths}"
+
+    # Use CUDA for deconvolution
+    if decon_option == DeconvolutionChoice.cuda_gpu:
+        pycudadecon_import = importlib.util.find_spec("pycudadecon")
+        assert pycudadecon_import, f"Pycudadecon not detected. Please install using: conda install -c conda-forge pycudadecon"
+        otf_names = ["ch1", "ch2", "ch3", "ch4"]
+        channels = [488, 561, 640, 123]
+        # get temp directory to save generated otf
+        import tempfile
+        temp_dir = tempfile.gettempdir()+os.sep
+
+    for psf in psf_paths:
+        if psf.exists() and psf.is_file():
+            if psf.suffix == ".czi":
+                psf_czi = CziFile(psf.__str__())
+                psf_aics = psf_czi.read_image()
+                # make sure shape is 3D
+                psf_aics = psf_aics[0][0]  # np.expand_dims(psf_aics[0],axis=0)
+                # if len(psf_aics[0])>=1:
+                #psf_channels = len(psf_aics[0])
+                assert len(
+                    psf_aics.shape) == 3, f"PSF should be a 3D image (shape of 3), but got {psf_aics.shape}"
+                # pad psf to multiple of 16 for decon
+                yield pad_image_nearest_multiple(img=psf_aics, nearest_multiple=16)
+            else:
+                psf_aics = AICSImage(psf.__str__())
+                psf_aics_data = psf_aics.data[0][0]
+                psf_aics_data = pad_image_nearest_multiple(
+                    img=psf_aics_data, nearest_multiple=16)
+
+                if psf_aics.dims.C != lattice_class.channels:
+                    logger.warn(
+                        f"PSF image has {psf_channels} channel/s, whereas image has {lattice_class.channels}")
+
+                yield psf_aics_data
+
+# Ideally we want to use OpenCL, but in the case of deconvolution most CUDA based
+# libraries are better designed.. Atleast until  RL deconvolution is available in pyclesperant
+# Talley Lamberts pycudadecon is a great library and highly optimised.
+def pycuda_decon(
+    image,
+    otf_path=None,
+    dzdata=0.3,
+    dxdata=0.1449922,
+    dzpsf=0.3,
+    dxpsf=0.1449922,
+    psf=None,
+    num_iter: int = 10,
+    cropping: bool = False,
+    background: Union[float,Literal["auto","second_last"]] = 0 
+):
+    """Perform deconvolution using pycudadecon
+    pycudadecon can return cropped images, so we pad the images with dimensions that are a multiple of 64
+
+    Args:
+        image (np.array): _description_
+        otf_path : (path to the generated otf file, if available. Otherwise psf needs to be provided)
+        dzdata : (pixel size in z in microns)
+        dxdata : (pixel size in xy  in microns)
+        dzpsf : (pixel size of original psf file in z  microns)
+        dxpsf : (pixel size of original psf file in xy microns)
+        psf (tiff): option to provide psf instead of the otfpath, this can be used when calling decon function
+        num_iter (int): number of iterations
+        cropping (bool): option to specify if cropping option is enabled
+        background (float,"auto","second_last"): background value to use for deconvolution. Can specify a value. If using auto, defaults to pycudadecon settings which is median of last slice
+        'second_last' means median of second last slice. This option is because last slice can sometimes be incomplete
+    Returns:
+        np.array: _description_
+    """
+    image = np.squeeze(image)
+    assert image.ndim == 3, f"Image needs to be 3D. Got {image.ndim}"
+
+    #Option for median of second last slices to be used
+    #Similar to pycudadecon
+    if isinstance(background, str) and background == "second_last":
+        background = np.median(image[-2])
+    
+    # if dask array, convert to numpy array
+    if type(image) in [
+        da.core.Array,
+        ResourceBackedDaskArray,
+    ]:
+        image = np.array(image)
+
+    orig_img_shape = image.shape
+
+    # if cropping, add extra padding to avoid edge artefact
+    # not doing this with with large images as images can be quite large with all the padding leading to memory issues
+    if cropping:
+        # pad image y dimensionswith half of psf shape
+        z_psf_pad, y_psf_pad, x_psf_pad = np.array(psf.shape) // 2
+        image = np.pad(
+            image,
+            (
+                (z_psf_pad, z_psf_pad),
+                (y_psf_pad, y_psf_pad),
+                (x_psf_pad, x_psf_pad),
+            ),
+            mode="reflect",
+        )
+
+    # pad image to a multiple of 64
+    image = pad_image_nearest_multiple(img=image, nearest_multiple=64)
+
+    if type(psf) in [
+        np.ndarray,
+        np.array,
+        DaskArray,
+        ResourceBackedDaskArray,
+        cle._tier0._pycl.OCLArray,
+    ]:
+        from pycudadecon import RLContext, TemporaryOTF, rl_decon
+
+        psf = np.squeeze(psf)  # remove unit dimensions
+        assert psf.ndim == 3, f"PSF needs to be 3D. Got {psf.ndim}"
+        # Temporary OTF generation; RLContext ensures memory cleanup (runs rl_init and rl_cleanup)
+        with TemporaryOTF(psf) as otf:
+            with RLContext(
+                rawdata_shape=image.shape,
+                otfpath=otf.path,
+                dzdata=dzdata,
+                dxdata=dxdata,
+                dzpsf=dzpsf,
+                dxpsf=dxpsf,
+            ) as ctx:
+                decon_res = rl_decon(
+                    im=image, output_shape=ctx.out_shape, n_iters=num_iter,background=background
+                )
+
+    else:
+        from pycudadecon import rl_decon, rl_init, rl_cleanup
+
+        rl_init(
+            rawdata_shape=image.shape,
+            otfpath=otf_path,
+            dzdata=dzdata,
+            dxdata=dxdata,
+            dzpsf=dzpsf,
+            dxpsf=dxpsf,
+        )
+        decon_res = rl_decon(image)
+        rl_cleanup()
+
+    if cropping:
+        # remove psf padding if cropping was chosen
+        decon_res = decon_res[
+            z_psf_pad:-z_psf_pad, y_psf_pad:-y_psf_pad, x_psf_pad:-x_psf_pad
+        ]
+
+    # remove padding; get shape difference and use this shape difference to remove padding
+    shape_diff = np.array(decon_res.shape) - np.array(orig_img_shape)
+    # if above is negative,
+    if shape_diff[0] == 0:
+        shape_diff[0] = -orig_img_shape[0]
+    if shape_diff[1] == 0:
+        shape_diff[1] = -orig_img_shape[1]
+    if shape_diff[2] == 0:
+        shape_diff[2] = -orig_img_shape[2]
+
+    decon_res = decon_res[: -shape_diff[0], : -shape_diff[1], : -shape_diff[2]]
+    # print(orig_img_shape)
+    # make sure the decon image and the original image shapes are the same
+    assert (
+        decon_res.shape == orig_img_shape
+    ), f"Deconvolved {decon_res.shape} and original image shape {orig_img_shape} do not match."
+    return decon_res
+
+
+def skimage_decon(
+    vol_zyx, psf, num_iter: int, clip: bool, filter_epsilon, boundary: str
+):
+    """Deconvolution using scikit image
+
+    Args:
+        vol_zyx (_type_): _description_
+        psf (_type_): _description_
+        num_iter (_type_): _description_
+        clip (_type_): _description_
+        filter_epsilon (_type_): _description_
+        boundary (_type_): _description_
+
+    Returns:
+        _type_: _description_
+    """
+    from skimage.restoration import richardson_lucy as rl_decon_skimage
+
+    depth = tuple(np.array(psf.shape) // 2)
+    if not isinstance(vol_zyx, (
+        DaskArray,
+        ResourceBackedDaskArray,
+    )):
+        vol_zyx = da.asarray(vol_zyx)
+    decon_data = vol_zyx.map_overlap(
+        rl_decon_skimage,
+        psf=psf,
+        num_iter=num_iter,
+        clip=clip,
+        filter_epsilon=filter_epsilon,
+        boundary=boundary,
+        depth=depth,
+        trim=True,
+    )
+    return decon_data
+
+
+def pyopencl_decon(vol_zyx, psf, num_iter: int, clip: bool, filter_epsilon):
+    """Deconvolution using RedFishLion library for pyopencl based deconvolution
+    This is slower than pycudadecon
+
+    Args:
+        vol_zyx (_type_): _description_
+        psf (_type_): _description_
+        num_iter (_type_): _description_
+        clip (_type_): _description_
+        filter_epsilon (_type_): _description_
+        boundary (_type_): _description_
+
+    Returns:
+        _type_: _description_
+    """
+    # CROP PSF; Crop psfs when reading and initialising psfs
+
+    return
diff --git a/src/napari_lattice/io.py b/core/lls_core/io.py
similarity index 72%
rename from src/napari_lattice/io.py
rename to core/lls_core/io.py
index d85410f7..ca4fdbb9 100644
--- a/src/napari_lattice/io.py
+++ b/core/lls_core/io.py
@@ -1,41 +1,45 @@
+from __future__ import annotations
 # Opening and saving files
 import aicsimageio
+from aicsimageio.types import ImageLike, ArrayLike
+from aicsimageio import AICSImage
 
 from pathlib import Path
 
 import pyclesperanto_prototype as cle
 import sys
 import dask
+from resource_backed_dask_array import ResourceBackedDaskArray
 import dask.array as da
-import resource_backed_dask_array
-from napari.layers import image
-import dask.array as da
+from dask.array.core import Array as DaskArray
 import pandas as pd
+from typing import TYPE_CHECKING, Callable, Optional, Literal, Any
+from os import PathLike
 
 from dask.distributed import Client
 from dask.cache import Cache
 
-from napari_lattice.utils import etree_to_dict
-from napari_lattice.utils import get_deskewed_shape
-from napari_lattice.llsz_core import crop_volume_deskew, skimage_decon, pycuda_decon
-
-from napari_lattice import config
-
+from lls_core.utils import etree_to_dict
+from lls_core.llsz_core import crop_volume_deskew
+from lls_core.deconvolution import skimage_decon, pycuda_decon
+from lls_core import config, DeconvolutionChoice, SaveFileType
+from lls_core.lattice_data import LatticeData
 
 import os
 import numpy as np
-from napari.types import ImageData
-from napari_workflows import Workflow
 from tqdm import tqdm
 from tifffile import imwrite, TiffWriter
 
 import npy2bdv
-from . import DeskewDirection, DeconvolutionChoice, SaveFileType
 
 # Enable Logging
 import logging
 logger = logging.getLogger(__name__)
 
+if TYPE_CHECKING:
+    from napari.types import ImageData
+    from magicclass import MagicTemplate
+    from napari_workflows import Workflow
 
 def convert_imgdata_aics(img_data: ImageData):
     """Return AICSimage object from napari ImageData type
@@ -52,7 +56,7 @@ def convert_imgdata_aics(img_data: ImageData):
         # using AICSImage will read data as STCZYX
         stack = aicsimageio.AICSImage(img_data)
         # stack_meta=aicsimageio.imread_dask(img_location)
-    except Exception as e:
+    except Exception:
         print("Error: A ", sys.exc_info()[
               0], "has occurred. See below for details.")
         raise
@@ -73,7 +77,7 @@ def convert_imgdata_aics(img_data: ImageData):
 
 
 # will flesh this out once Zeiss lattice has more relevant metadata in the czi file
-def check_metadata(img_path):
+def check_metadata(img_path: ImageLike):
     print("Checking CZI metadata")
     metadatadict_czi = etree_to_dict(aicsimageio.AICSImage(img_path).metadata)
     metadatadict_czi = metadatadict_czi["ImageDocument"]["Metadata"]
@@ -82,28 +86,27 @@ def check_metadata(img_path):
     print(acquisition_mode_setup["Detector"]["ImageOrientation"])
     print("Image Orientation: If any post processing has been applied, it will appear here.\n \
       For example, Zen 3.2 flipped the image so the coverslip was towards the bottom of Z stack. So, value will be 'Flip'")
-    return
 
 # TODO: write save function for deskew and for crop
 
 
-def save_img(vol,
-             func: callable,
+def save_img(vol: ArrayLike,
+             func: Callable,
              time_start: int,
              time_end: int,
              channel_start: int,
              channel_end: int,
-             save_file_type,
-             save_path: Path,
+             save_file_type: SaveFileType,
+             save_path: PathLike,
              save_name_prefix: str = "",
              save_name: str = "img",
              dx: float = 1,
              dy: float = 1,
              dz: float = 1,
-             angle: float = None,
-             LLSZWidget=None,
+             angle: Optional[float] = None,
+             LLSZWidget: Optional[MagicTemplate]=None,
              terminal: bool = False,
-             lattice=None,
+             lattice: Optional[LatticeData]=None,
              *args, **kwargs):
     """
     Applies a function as described in callable
@@ -125,10 +128,11 @@ def save_img(vol,
         LLSZWidget(class,optional) = LLSZWidget class
     """
 
-    save_path = save_path.__str__()
+    # save_path = save_path.__str__()
+    _save_path: Path = Path(save_path)
 
     # replace any : with _ and remove spaces in case it hasn't been processed/skipped
-    save_name = save_name.replace(":", "_").replace(" ", "")
+    save_name = str(save_name).replace(":", "_").replace(" ", "")
 
     time_range = range(time_start, time_end+1)
 
@@ -152,11 +156,11 @@ def save_img(vol,
     # setup bdvwriter
     if save_file_type == SaveFileType.h5:
         if func is crop_volume_deskew:
-            save_path_h5 = save_path + os.sep + save_name_prefix + "_" + save_name + ".h5"
+            save_path_h5 = _save_path / (save_name_prefix + "_" + save_name + ".h5")
         else:
-            save_path_h5 = save_path + os.sep + save_name + ".h5"
+            save_path_h5 = _save_path / (save_name + ".h5")
 
-        bdv_writer = npy2bdv.BdvWriter(save_path_h5,
+        bdv_writer = npy2bdv.BdvWriter(str(save_path_h5),
                                        compression='gzip',
                                        nchannels=len(channel_range),
                                        subsamp=(
@@ -280,8 +284,7 @@ def save_img(vol,
         # otherwise, append it into im_final
 
         if func != crop_volume_deskew and save_file_type == SaveFileType.tiff:
-            final_name = save_path + os.sep + save_name_prefix + "C" + str(ch) + "T" + str(
-                time_point) + "_" + save_name+".tif"
+            final_name = _save_path / (save_name_prefix + "C" + str(ch) + "T" + str( time_point) + "_" + save_name+".tif")
             images_array = np.array(images_array)
             images_array = np.expand_dims(images_array, axis=0)
             images_array = np.swapaxes(images_array, 1, 2)
@@ -302,7 +305,7 @@ def save_img(vol,
     elif func is crop_volume_deskew and save_file_type == SaveFileType.tiff:
 
         im_final = np.array(im_final)
-        final_name = save_path + os.sep + save_name_prefix + "_" + save_name + ".tif"
+        final_name = _save_path / (save_name_prefix + "_" + save_name + ".tif")
 
         im_final = np.swapaxes(im_final, 1, 2)
         # imagej=True; ImageJ hyperstack axes must be in TZCYXS order
@@ -315,8 +318,6 @@ def save_img(vol,
                 imagej=True)
         im_final = None
 
-    return
-
 
 def save_img_workflow(vol,
                       workflow: Workflow,
@@ -460,10 +461,10 @@ def save_img_workflow(vol,
             image_element_index = []
 
             # single output and is just dictionary
-            if type(processed_vol) in [dict]:
+            if isinstance(processed_vol, dict):
                 dict_element_index = [0]
             # if image
-            elif type(processed_vol) in [np.ndarray, cle._tier0._pycl.OCLArray, da.core.Array, resource_backed_dask_array.ResourceBackedDaskArray]:
+            elif isinstance(processed_vol, (np.ndarray, cle._tier0._pycl.OCLArray, DaskArray, ResourceBackedDaskArray)):
                 image_element_index = [0]
                 no_elements = 1
             # multiple elements
@@ -615,171 +616,3 @@ def save_img_workflow(vol,
             # close the writers (applies for both tiff and h5)
             writer_list[writer_idx].close()
 
-    return
-
-# class for initializing lattice data and setting metadata
-# TODO: handle scenes
-
-
-class LatticeData():
-    def __init__(self, img, angle, skew, dx, dy, dz, last_dimension) -> None:
-        self.angle = angle
-        self.skew = skew
-
-        # if image layer
-
-        if type(img) is image.Image:  # napari layer image
-            # check if its an aicsimageio object and has voxel size info
-            if 'aicsimage' in img.metadata.keys() and img.metadata['aicsimage'].physical_pixel_sizes != (None, None, None):
-                img_data_aics = img.metadata['aicsimage']
-                self.data = img_data_aics.dask_data
-                self.dims = img_data_aics.dims
-                self.time = img_data_aics.dims.T
-                self.channels = img_data_aics.dims.C
-                self.dz, self.dy, self.dx = img_data_aics.physical_pixel_sizes
-            else:
-                print("Cannot read voxel size from metadata")
-                if 'aicsimage' in img.metadata.keys():
-                    img_data_aics = img.metadata['aicsimage']
-                    self.data = img_data_aics.dask_data
-                    self.dims = img_data_aics.dims
-                    # if aicsimageio tiffreader assigns last dim as time when it should be channel, user can override this
-                    if last_dimension:
-                        if len(img.data.shape) == 4:
-                            if last_dimension.lower() == "channel":
-                                self.channels = img.data.shape[0]
-                                self.time = 0
-                            elif last_dimension.lower() == "time":
-                                self.time = img.data.shape[0]
-                                self.channels = 0
-                        elif len(img.data.shape) == 5:
-                            if last_dimension.lower() == "channel":
-                                self.channels = img.data.shape[0]
-                                self.time = img.data.shape[1]
-                            elif last_dimension.lower() == "time":
-                                self.time = img.data.shape[0]
-                                self.channels = img.data.shape[1]
-                    else:
-                        self.time = img_data_aics.dims.T
-                        self.channels = img_data_aics.dims.C
-                else:
-                    # if no aicsimageio key in metadata
-                    # get the data and convert it into an aicsimage object
-                    img_data_aics = aicsimageio.AICSImage(img.data)
-                    self.data = img_data_aics.dask_data
-                    # if user has specified ch
-                    if last_dimension:
-                        if len(img.data.shape) == 4:
-                            if last_dimension.lower() == "channel":
-                                self.channels = img.data.shape[0]
-                                self.time = 0
-                            elif last_dimension.lower() == "time":
-                                self.time = img.data.shape[0]
-                                self.channels = 0
-                        elif len(img.data.shape) == 5:
-                            if last_dimension.lower() == "channel":
-                                self.channels = img.data.shape[0]
-                                self.time = img.data.shape[1]
-                            elif last_dimension.lower() == "time":
-                                self.time = img.data.shape[0]
-                                self.channels = img.data.shape[1]
-                    else:
-                        if last_dimension:
-                            if len(img.data.shape) == 4:
-                                if last_dimension.lower() == "channel":
-                                    self.channels = img.data.shape[0]
-                                    self.time = 0
-                                elif last_dimension.lower() == "time":
-                                    self.time = img.data.shape[0]
-                                    self.channels = 0
-                            elif len(img.data.shape) == 5:
-                                if last_dimension.lower() == "channel":
-                                    self.channels = img.data.shape[0]
-                                    self.time = img.data.shape[1]
-                                elif last_dimension.lower() == "time":
-                                    self.time = img.data.shape[0]
-                                    self.channels = img.data.shape[1]
-                        else:
-
-                            self.time = img.data.shape[0]
-                            self.channels = img.data.shape[1]
-
-                # read metadata for pixel sizes
-                if None in img_data_aics.physical_pixel_sizes or img_data_aics.physical_pixel_sizes == False:
-                    self.dx = dx
-                    self.dy = dy
-                    self.dz = dz
-                else:
-                    self.dz, self.dy, self.dx = img.data.physical_pixel_sizes
-                # if not last_dimension:
-                # if xarray, access data using .data method
-                    # if type(img.data) in [xarray.core.dataarray.DataArray,np.ndarray]:
-                    #img = img.data
-                # img = dask_expand_dims(img,axis=1) ##if no channel dimension specified, then expand axis at index 1
-            # if no path returned by source.path, get image name with colon and spaces removed
-            # if last axes of "aicsimage data" shape is not equal to time, then swap channel and time
-            if self.data.shape[0] != self.time or self.data.shape[1] != self.channels:
-                self.data = np.swapaxes(self.data, 0, 1)
-
-            if img.source.path is None:
-                # remove colon (:) and any leading spaces
-                self.save_name = img.name.replace(":", "").strip()
-                # replace any group of spaces with "_"
-                self.save_name = '_'.join(self.save_name.split())
-
-            else:
-                file_name_noext = os.path.basename(img.source.path)
-                file_name = os.path.splitext(file_name_noext)[0]
-                # remove colon (:) and any leading spaces
-                self.save_name = file_name.replace(":", "").strip()
-                # replace any group of spaces with "_"
-                self.save_name = '_'.join(self.save_name.split())
-
-        elif type(img) in [np.ndarray, da.core.Array, resource_backed_dask_array.ResourceBackedDaskArray]:
-            img_data_aics = aicsimageio.AICSImage(img.data)
-            self.data = img_data_aics.dask_data
-            self.dx = dx
-            self.dy = dy
-            self.dz = dz
-
-        elif type(img) is aicsimageio.aics_image.AICSImage:
-
-            if img.physical_pixel_sizes != (None, None, None):
-                self.data = img.dask_data
-                self.dims = img.dims
-                self.time = img.dims.T
-                self.channels = img.dims.C
-                self.dz, self.dy, self.dx = img.physical_pixel_sizes
-
-            else:
-                self.data = img.dask_data
-                self.dims = img.dims
-                self.time = img.dims.T
-                self.channels = img.dims.C
-                self.dz, self.dy, self.dx = dz, dy, dx
-
-        else:
-            raise Exception(
-                "Has to be an image layer or array, got type: ", type(img))
-
-        # set new z voxel size
-        if self.skew == DeskewDirection.Y or self.skew == DeskewDirection.X:
-            import math
-            self.new_dz = math.sin(self.angle * math.pi / 180.0) * self.dz
-
-        # process the file to get shape of final deskewed image
-        self.deskew_vol_shape, self.deskew_affine_transform = get_deskewed_shape(
-            self.data, self.angle, self.dx, self.dy, self.dz)
-        print(f"Channels: {self.channels}, Time: {self.time}")
-        print("If channel and time need to be swapped, you can enforce this by choosing 'Last dimension is channel' when initialising the plugin")
-
-        pass
-
-    def get_angle(self):
-        return self.angle
-
-    def set_angle(self, angle: float):
-        self.angle = angle
-
-    def set_skew(self, skew):
-        self.skew = skew
diff --git a/core/lls_core/lattice_data.py b/core/lls_core/lattice_data.py
new file mode 100644
index 00000000..45efa05d
--- /dev/null
+++ b/core/lls_core/lattice_data.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+# class for initializing lattice data and setting metadata
+# TODO: handle scenes
+from dataclasses import dataclass, field
+from aicsimageio.aics_image import AICSImage
+from aicsimageio.dimensions import Dimensions
+from numpy.typing import NDArray
+from dataclasses import dataclass
+import math
+
+from typing import Any, List, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar
+
+from aicsimageio.types import ArrayLike, PhysicalPixelSizes
+import pyclesperanto_prototype as cle
+
+from lls_core import DeskewDirection, DeconvolutionChoice
+from lls_core.utils import get_deskewed_shape
+
+if TYPE_CHECKING:
+    import pyclesperanto_prototype as cle
+
+T = TypeVar("T")
+def raise_if_none(obj: Optional[T], message: str) -> T:
+    if obj is None:
+        raise TypeError(message)
+    return obj
+
+@dataclass
+class DefinedPixelSizes:
+    """
+    Like PhysicalPixelSizes, but it's a dataclass, and
+    none of its fields are None
+    """
+    X: float = 0.14
+    Y: float = 0.14
+    Z: float = 0.3
+
+@dataclass
+class LatticeData:
+    """
+    Holds data and metadata for a given image in a consistent format
+    """
+    #: 3-5D array
+    data: ArrayLike
+    dims: Dimensions
+
+    #: The filename of this data when it is saved
+    save_name: str
+
+    #: Geometry of the light path
+    skew: DeskewDirection = DeskewDirection.Y
+    angle: float = 30.0
+
+    decon_processing: Optional[DeconvolutionChoice] = None
+
+    #: Pixel size in microns
+    physical_pixel_sizes: DefinedPixelSizes = field(default_factory=DefinedPixelSizes)
+
+    new_dz: Optional[float] = None
+
+    # Dimensions of the deskewed output
+    deskew_vol_shape: Optional[Tuple[int, ...]] = None
+    deskew_affine_transform: Optional[cle.AffineTransform3D] = None
+
+    # PSF data that should be refactored into another class eventually
+    psf: Optional[List[NDArray]] = None
+    psf_num_iter: Optional[int] = None
+    otf_path: Optional[List] = None
+
+    #: Number of time points
+    time: int = 0
+    #: Number of channels
+    channels: int = 0
+
+    # TODO: add defaults here, rather than in the CLI
+    # Hack to ensure that .skew_dir behaves identically to .skew
+    @property
+    def skew_dir(self) -> DeskewDirection:
+        return self.skew
+
+    @skew_dir.setter
+    def skew_dir(self, value: DeskewDirection):
+        self.skew = value
+
+    @property
+    def deskew_func(self):
+        # Chance deskew function absed on skew direction
+        if self.skew == DeskewDirection.Y:
+            return cle.deskew_y
+        elif self.skew == DeskewDirection.X:
+            return cle.deskew_x
+        else:
+            raise ValueError()
+
+    @property
+    def dx(self) -> float:
+        return self.physical_pixel_sizes.X
+
+    @dx.setter
+    def dx(self, value: float):
+        self.physical_pixel_sizes.X = value
+
+    @property
+    def dy(self) -> float:
+        return self.physical_pixel_sizes.Y
+
+    @dy.setter
+    def dy(self, value: float):
+        self.physical_pixel_sizes.Y = value
+
+    @property
+    def dz(self) -> float:
+        return self.physical_pixel_sizes.Z
+
+    @dz.setter
+    def dz(self, value: float):
+        self.physical_pixel_sizes.Z = value
+
+    def get_angle(self) -> float:
+        return self.angle
+
+    def set_angle(self, angle: float) -> None:
+        self.angle = angle
+
+    def set_skew(self, skew: DeskewDirection) -> None:
+        self.skew = skew
+
+    def __post_init__(self):
+        # set new z voxel size
+        if self.skew == DeskewDirection.Y or self.skew == DeskewDirection.X:
+            self.new_dz = math.sin(self.angle * math.pi / 180.0) * self.dz
+
+        # process the file to get shape of final deskewed image
+        self.deskew_vol_shape, self.deskew_affine_transform = get_deskewed_shape(self.data, self.angle, self.dx, self.dy, self.dz)
+        print(f"Channels: {self.channels}, Time: {self.time}")
+        print("If channel and time need to be swapped, you can enforce this by choosing 'Last dimension is channel' when initialising the plugin")
+
+def lattice_from_aics(img: AICSImage, physical_pixel_sizes: PhysicalPixelSizes = PhysicalPixelSizes(None, None, None), **kwargs: Any) -> LatticeData:
+    # Note: The reason we copy all of these fields rather than just storing the AICSImage is because that class is mostly immutable and so not suitable
+
+    pixel_sizes = DefinedPixelSizes(
+        X = physical_pixel_sizes[0] or img.physical_pixel_sizes.X or LatticeData.physical_pixel_sizes.X,
+        Y = physical_pixel_sizes[1] or img.physical_pixel_sizes.Y or LatticeData.physical_pixel_sizes.Y, 
+        Z = physical_pixel_sizes[2] or img.physical_pixel_sizes.Z or LatticeData.physical_pixel_sizes.Z 
+    )
+
+    return LatticeData(
+        data = img.dask_data,
+        dims = img.dims,
+        time = img.dims.T,
+        channels = img.dims.C,
+        physical_pixel_sizes = pixel_sizes,
+        **kwargs
+    )
+
+def img_from_array(arr: ArrayLike, last_dimension: Optional[Literal["channel", "time"]] = None, **kwargs: Any) -> AICSImage:
+    """
+    Creates an AICSImage from an array without metadata
+
+    Args:
+        arr (ArrayLike): An array
+        last_dimension: How to handle the dimension order
+        kwargs: Additional arguments to pass to the AICSImage constructor
+    """    
+    dim_order: str
+
+    if len(arr.shape) < 3 or len(arr.shape) > 5:
+        raise ValueError("Array dimensions must be in the range [3, 5]")
+
+    # if aicsimageio tiffreader assigns last dim as time when it should be channel, user can override this
+    if len(arr.shape) == 3:
+        dim_order="ZYX"
+    else:
+        if last_dimension not in ["channel", "time"]:
+            raise ValueError("last_dimension must be either channel or time")
+        if len(arr.shape) == 4:
+            if last_dimension == "channel":
+                dim_order = "CZYX"
+            elif last_dimension == "time":
+                dim_order = "TZYX"
+        elif len(arr.shape) == 5:
+            if last_dimension == "channel":
+                dim_order = "CTZYX"
+            elif last_dimension == "time":
+                dim_order = "TCZYX"
+        else:
+            raise ValueError()
+
+    img = AICSImage(image=arr, dim_order=dim_order, **kwargs)
+
+    # if last axes of "aicsimage data" shape is not equal to time, then swap channel and time
+    if img.data.shape[0] != img.dims.T or img.data.shape[1] != img.dims.C:
+        arr = np.swapaxes(arr, 0, 1)
+    return AICSImage(image=arr, dim_order=dim_order, **kwargs)
+
+
+def lattice_fom_array(arr: ArrayLike, last_dimension: Optional[Literal["channel", "time"]] = None, **kwargs: Any) -> LatticeData:
+    """
+    Creates a `LatticeData` from an array
+
+    Args:
+        arr: Array to use as the data source
+        last_dimension: See img_from_array
+    """   
+    aics = img_from_array(arr, last_dimension)
+    return lattice_from_aics(aics, **kwargs)
\ No newline at end of file
diff --git a/src/napari_lattice/llsz_core.py b/core/lls_core/llsz_core.py
similarity index 70%
rename from src/napari_lattice/llsz_core.py
rename to core/lls_core/llsz_core.py
index 3d3728a4..b28e03dc 100644
--- a/src/napari_lattice/llsz_core.py
+++ b/core/lls_core/llsz_core.py
@@ -1,15 +1,21 @@
+from __future__ import annotations
+
+from aicsimageio.types import ArrayLike
 import numpy as np
 import pyclesperanto_prototype as cle
+from dask.array.core import Array as DaskArray
 import dask.array as da
-import resource_backed_dask_array
-from typing import Union
-from napari.layers.shapes import shapes
+from resource_backed_dask_array import ResourceBackedDaskArray
+from typing import Optional, Union, TYPE_CHECKING
 from pyclesperanto_prototype._tier8._affine_transform_deskew_3d import (
     affine_transform_deskew_3d,
 )
+from numpy.typing import NDArray
 
-from .utils import calculate_crop_bbox, pad_image_nearest_multiple
-from . import config, DeskewDirection, DeconvolutionChoice, SaveFileType
+ 
+from lls_core.utils import calculate_crop_bbox, check_subclass, is_napari_shape, pad_image_nearest_multiple
+from lls_core import config, DeskewDirection, DeconvolutionChoice
+from lls_core.deconvolution import pycuda_decon
 
 # Enable Logging
 import logging
@@ -18,21 +24,20 @@
 logger.setLevel(config.log_level)
 # pass shapes data from single ROI to crop the volume from original data
 
+if TYPE_CHECKING:
+    from napari.layers.shapes import Shapes
 
-def crop_volume_deskew(
-    original_volume: Union[
-        da.core.Array,
-        np.ndarray,
-        cle._tier0._pycl.OCLArray,
-        resource_backed_dask_array.ResourceBackedDaskArray,
-    ],
-    deskewed_volume: Union[
-        da.core.Array,
-        np.ndarray,
+Psf = Union[
+        NDArray,
+        DaskArray,
+        ResourceBackedDaskArray,
         cle._tier0._pycl.OCLArray,
-        resource_backed_dask_array.ResourceBackedDaskArray,
-    ] = None,
-    roi_shape: Union[shapes.Shapes, list, np.array] = None,
+]
+
+def crop_volume_deskew(
+    original_volume: ArrayLike,
+    deskewed_volume: Union[ ArrayLike, None ] = None,
+    roi_shape: Union[Shapes, list, NDArray, None] = None,
     angle_in_degrees: float = 30,
     voxel_size_x: float = 1,
     voxel_size_y: float = 1,
@@ -41,11 +46,11 @@ def crop_volume_deskew(
     z_end: int = 1,
     debug: bool = False,
     deconvolution: bool = False,
-    decon_processing=None,
-    psf=None,
+    decon_processing: Optional[str]=None,
+    psf: Union[Psf, None]=None,
     num_iter: int = 10,
-    linear_interpolation=True,
-    skew_dir=DeskewDirection.Y,
+    linear_interpolation: bool=True,
+    skew_dir: DeskewDirection=DeskewDirection.Y,
     get_deskew_and_decon: bool = False,
 ):
     """Crop the volume from original data and deskew the cropped volume
@@ -80,7 +85,8 @@ def crop_volume_deskew(
     shape = None
 
     # if shapes layer, get first one
-    if type(roi_shape) is shapes.Shapes:
+    # TODO: test this
+    if is_napari_shape(roi_shape):
         shape = roi_shape.data[0]
     # if its a list and each element has a shape of 4, its a list of rois
     elif type(roi_shape) is list and len(roi_shape[0]) == 4:
@@ -162,10 +168,10 @@ def crop_volume_deskew(
 
     # After getting the coordinates, crop from original volume and deskew only the cropped volume
 
-    if type(original_volume) in [
-        da.core.Array,
-        resource_backed_dask_array.ResourceBackedDaskArray,
-    ]:
+    if isinstance(original_volume, (
+        DaskArray,
+        ResourceBackedDaskArray,
+    )):
         # If using dask, use .map_blocks(np.copy) to copy subset (faster)
         crop_volume = (
             original_volume[
@@ -502,182 +508,3 @@ def _yield_arr_slice(img):
 
     for slice in img:
         yield slice
-
-
-# Ideally we want to use OpenCL, but in the case of deconvolution most CUDA based
-# libraries are better designed.. Atleast until  RL deconvolution is available in pyclesperant
-# Talley Lamberts pycudadecon is a great library and highly optimised.
-def pycuda_decon(
-    image,
-    otf_path=None,
-    dzdata=0.3,
-    dxdata=0.1449922,
-    dzpsf=0.3,
-    dxpsf=0.1449922,
-    psf=None,
-    num_iter: int = 10,
-    cropping: bool = False,
-):
-    """Perform deconvolution using pycudadecon
-    pycudadecon can return cropped images, so we pad the images with dimensions that are a multiple of 64
-
-    Args:
-        image (np.array): _description_
-        otf_path : (path to the generated otf file, if available. Otherwise psf needs to be provided)
-        dzdata : (pixel size in z in microns)
-        dxdata : (pixel size in xy  in microns)
-        dzpsf : (pixel size of original psf file in z  microns)
-        dxpsf : (pixel size of original psf file in xy microns)
-        psf (tiff): option to provide psf instead of the otfpath, this can be used when calling decon function
-    Returns:
-        np.array: _description_
-    """
-    image = np.squeeze(image)
-    assert image.ndim == 3, f"Image needs to be 3D. Got {image.ndim}"
-
-    # if dask array, convert to numpy array
-    if type(image) in [
-        da.core.Array,
-        resource_backed_dask_array.ResourceBackedDaskArray,
-    ]:
-        image = np.array(image)
-
-    orig_img_shape = image.shape
-
-    # if cropping, add extra padding to avoid edge artefact
-    # not doing this with with large images as images can be quite large with all the padding leading to memory issues
-    if cropping:
-        # pad image y dimensionswith half of psf shape
-        z_psf_pad, y_psf_pad, x_psf_pad = np.array(psf.shape) // 2
-        image = np.pad(
-            image,
-            (
-                (z_psf_pad, z_psf_pad),
-                (y_psf_pad, y_psf_pad),
-                (x_psf_pad, x_psf_pad),
-            ),
-            mode="reflect",
-        )
-
-    # pad image to a multiple of 64
-    image = pad_image_nearest_multiple(img=image, nearest_multiple=64)
-
-    if type(psf) in [
-        np.ndarray,
-        np.array,
-        da.core.Array,
-        resource_backed_dask_array.ResourceBackedDaskArray,
-        cle._tier0._pycl.OCLArray,
-    ]:
-        from pycudadecon import RLContext, TemporaryOTF, rl_decon
-
-        psf = np.squeeze(psf)  # remove unit dimensions
-        assert psf.ndim == 3, f"PSF needs to be 3D. Got {psf.ndim}"
-        # Temporary OTF generation; RLContext ensures memory cleanup (runs rl_init and rl_cleanup)
-        with TemporaryOTF(psf) as otf:
-            with RLContext(
-                rawdata_shape=image.shape,
-                otfpath=otf.path,
-                dzdata=dzdata,
-                dxdata=dxdata,
-                dzpsf=dzpsf,
-                dxpsf=dxpsf,
-            ) as ctx:
-                decon_res = rl_decon(
-                    im=image, output_shape=ctx.out_shape, n_iters=num_iter
-                )
-
-    else:
-        from pycudadecon import rl_decon, rl_init, rl_cleanup
-
-        rl_init(
-            rawdata_shape=image.shape,
-            otfpath=otf_path,
-            dzdata=dzdata,
-            dxdata=dxdata,
-            dzpsf=dzpsf,
-            dxpsf=dxpsf,
-        )
-        decon_res = rl_decon(image)
-        rl_cleanup()
-
-    if cropping:
-        # remove psf padding if cropping was chosen
-        decon_res = decon_res[
-            z_psf_pad:-z_psf_pad, y_psf_pad:-y_psf_pad, x_psf_pad:-x_psf_pad
-        ]
-
-    # remove padding; get shape difference and use this shape difference to remove padding
-    shape_diff = np.array(decon_res.shape) - np.array(orig_img_shape)
-    # if above is negative,
-    if shape_diff[0] == 0:
-        shape_diff[0] = -orig_img_shape[0]
-    if shape_diff[1] == 0:
-        shape_diff[1] = -orig_img_shape[1]
-    if shape_diff[2] == 0:
-        shape_diff[2] = -orig_img_shape[2]
-
-    decon_res = decon_res[: -shape_diff[0], : -shape_diff[1], : -shape_diff[2]]
-    # print(orig_img_shape)
-    # make sure the decon image and the original image shapes are the same
-    assert (
-        decon_res.shape == orig_img_shape
-    ), f"Deconvolved {decon_res.shape} and original image shape {orig_img_shape} do not match."
-    return decon_res
-
-
-def skimage_decon(
-    vol_zyx, psf, num_iter: int, clip: bool, filter_epsilon, boundary: str
-):
-    """Deconvolution using scikit image
-
-    Args:
-        vol_zyx (_type_): _description_
-        psf (_type_): _description_
-        num_iter (_type_): _description_
-        clip (_type_): _description_
-        filter_epsilon (_type_): _description_
-        boundary (_type_): _description_
-
-    Returns:
-        _type_: _description_
-    """
-    from skimage.restoration import richardson_lucy as rl_decon_skimage
-
-    depth = tuple(np.array(psf.shape) // 2)
-    if type(vol_zyx) not in [
-        da.core.Array,
-        resource_backed_dask_array.ResourceBackedDaskArray,
-    ]:
-        vol_zyx = da.asarray(vol_zyx)
-    decon_data = vol_zyx.map_overlap(
-        rl_decon_skimage,
-        psf=psf,
-        num_iter=num_iter,
-        clip=clip,
-        filter_epsilon=filter_epsilon,
-        boundary=boundary,
-        depth=depth,
-        trim=True,
-    )
-    return decon_data
-
-
-def pyopencl_decon(vol_zyx, psf, num_iter: int, clip: bool, filter_epsilon):
-    """Deconvolution using RedFishLion library for pyopencl based deconvolution
-    This is slower than pycudadecon
-
-    Args:
-        vol_zyx (_type_): _description_
-        psf (_type_): _description_
-        num_iter (_type_): _description_
-        clip (_type_): _description_
-        filter_epsilon (_type_): _description_
-        boundary (_type_): _description_
-
-    Returns:
-        _type_: _description_
-    """
-    # CROP PSF; Crop psfs when reading and initialising psfs
-
-    return
diff --git a/src/napari_lattice/utils.py b/core/lls_core/utils.py
similarity index 89%
rename from src/napari_lattice/utils.py
rename to core/lls_core/utils.py
index b2a1536f..84c014fe 100644
--- a/src/napari_lattice/utils.py
+++ b/core/lls_core/utils.py
@@ -1,8 +1,13 @@
+from __future__ import annotations
+
 import numpy as np
 from collections import defaultdict
 from contextlib import contextmanager, redirect_stderr, redirect_stdout
 from os import devnull, path
 import os
+from typing_extensions import Any, TYPE_CHECKING, TypeGuard
+from typing import List, Tuple, Union, Collection
+from numpy.typing import NDArray
 
 import pandas as pd
 import dask.array as da
@@ -11,9 +16,14 @@
 from read_roi import read_roi_zip
 from read_roi import read_roi_file
 
-from napari_workflows import Workflow
 from tifffile import imsave
-from . import config, DeskewDirection, DeconvolutionChoice, SaveFileType
+from . import config, DeskewDirection
+from aicsimageio.types import ArrayLike
+
+if TYPE_CHECKING:
+    from xml.etree.ElementTree import Element
+    from napari.layers import Shapes
+    from napari_workflows import Workflow
 
 # Enable Logging
 import logging
@@ -22,8 +32,19 @@
 
 # get bounding box of ROI in 3D and the shape of the ROI
 
+def check_subclass(obj: Any, pkg_name: str, cls_name: str) -> bool:
+    """
+    Like `isinstance`, but doesn't require that the class in question is imported
+    """
+    # TODO: make this work for subclasses
+    cls = obj.__class__
+    module = cls.__module__
+    return cls.__name__ == cls_name and module.__qualname__ == pkg_name
+
+def is_napari_shape(obj: Any) -> TypeGuard[Shapes]:
+    return check_subclass(obj, "napari.shapes", "Shapes")
 
-def calculate_crop_bbox(shape, z_start: int, z_end: int):
+def calculate_crop_bbox(shape: int, z_start: int, z_end: int) -> tuple[List[List[Any]], List[int]]:
     """Get bounding box as vertices in 3D in the form xyz
 
     Args:
@@ -61,12 +82,12 @@ def calculate_crop_bbox(shape, z_start: int, z_end: int):
     return crop_bounding_box, crop_shape
 
 
-def get_deskewed_shape(volume,
-                       angle,
+def get_deskewed_shape(volume: ArrayLike,
+                       angle: float,
                        voxel_size_x_in_microns: float,
                        voxel_size_y_in_microns: float,
                        voxel_size_z_in_microns: float,
-                       skew_dir=DeskewDirection.Y):
+                       skew_dir: DeskewDirection=DeskewDirection.Y) -> Tuple[Tuple[int], cle.AffineTransform3D]:
     """
     Calculate shape of deskewed volume 
     Also, returns affine transform
@@ -110,7 +131,7 @@ def get_deskewed_shape(volume,
 # https://stackoverflow.com/a/10076823
 # Credit: Antoine Pinsard
 # Convert xml elementree to dict
-def etree_to_dict(t):
+def etree_to_dict(t: Element) -> dict:
     """Parse an XML file and convert to dictionary 
     This can be used to access the Zeiss metadata
     Access it from ["ImageDocument"]["Metadata"]
@@ -156,19 +177,23 @@ def suppress_stdout_stderr():
 # https://github.com/dask/dask/blob/dca10398146c6091a55c54db3778a06b485fc5ce/dask/array/routines.py#L1889
 
 
-def dask_expand_dims(a, axis):
-    if type(axis) not in (tuple, list):
-        axis = (axis,)
+def dask_expand_dims(a: ArrayLike, axis: Union[Collection[int], int]):
+    _axis: Collection[int]
 
-    out_ndim = len(axis) + a.ndim
+    if isinstance(axis, int):
+        _axis = (axis,)
+    else:
+        _axis = axis
+
+    out_ndim = len(_axis) + a.ndim
 
     shape_it = iter(a.shape)
-    shape = [1 if ax in axis else next(shape_it) for ax in range(out_ndim)]
+    shape = [1 if ax in _axis else next(shape_it) for ax in range(out_ndim)]
 
     return a.reshape(shape)
 
 
-def read_imagej_roi(roi_zip_path):
+def read_imagej_roi(roi_zip_path: str):
     """Read an ImageJ ROI zip file so it loaded into napari shapes layer
         If non rectangular ROI, will convert into a rectangle based on extreme points
     Args:
@@ -178,14 +203,14 @@ def read_imagej_roi(roi_zip_path):
         list: List of ROIs
     """
     roi_extension = path.splitext(roi_zip_path)[1]
-    assert roi_extension == ".roi" or roi_extension == ".zip", "ImageJ ROI file needs to be a zip/roi file"
 
     # handle reading single roi or collection of rois in zip file
     if roi_extension == ".zip":
         ij_roi = read_roi_zip(roi_zip_path)
-
-    if roi_extension == ".roi":
+    elif roi_extension == ".roi":
         ij_roi = read_roi_file(roi_zip_path)
+    else:
+        raise Exception("ImageJ ROI file needs to be a zip/roi file")
 
     # initialise list of rois
     roi_list = []
@@ -279,7 +304,7 @@ def load_custom_py_modules(custom_py_files):
     
 
 # TODO: CHANGE so user can select modules? Safer
-def get_all_py_files(directory):
+def get_all_py_files(directory: str) -> list[str]:
     """get all py files within directory and return as a list of filenames
     Args:
         directory: Directory with .py files
@@ -287,8 +312,6 @@ def get_all_py_files(directory):
     from os.path import dirname, basename, isfile, join
     import glob
     
-    import sys
-
     modules = glob.glob(join(dirname(directory), "*.py"))
     all = [basename(f)[:-3] for f in modules if isfile(f)
            and not f.endswith('__init__.py')]
@@ -456,7 +479,7 @@ def _process_custom_workflow_output_batch(ref_vol,
     return
 
 
-def pad_image_nearest_multiple(img: np.ndarray, nearest_multiple: int):
+def pad_image_nearest_multiple(img: NDArray, nearest_multiple: int) -> NDArray:
     """pad an Image to the nearest multiple of provided number
 
     Args:
diff --git a/core/pyproject.toml b/core/pyproject.toml
new file mode 100644
index 00000000..3d6f3a4f
--- /dev/null
+++ b/core/pyproject.toml
@@ -0,0 +1,79 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.black]
+line-length = 79
+
+[tool.isort]
+profile = "black"
+line_length = 79
+
+[project]
+name = "lls_core"
+version = "0.2.7"
+authors = [
+    {name = "Pradeep Rajasekhar"},
+    {name = "Lachlan Whitehead"},
+    {name = "Robert Haase"}
+]
+license = {text = "GPL-3.0-only"}
+description = "Core functionality for analysing and visualizing lattice lightsheet and Oblique Plane Microscopy data."
+# readme = { file = "README.md", content-type = "text/markdown" }
+classifiers = [
+    "Development Status :: 2 - Pre-Alpha",
+    "Intended Audience :: Developers",
+    "Framework :: napari",
+    "Topic :: Software Development :: Testing",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Operating System :: OS Independent",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)"
+]
+requires-python = ">=3.8"
+dependencies = [
+    "aicsimageio>=4.6.3",
+    # Earlier versions don't have Python 3.11 binaries, and the sdist
+    # is misconfigured: https://github.com/AllenCellModeling/aicspylibczi/issues/90
+    "aicspylibczi>=3.1.1",
+    "dask",
+    "dask-image",
+    "dask[distributed]",
+    "pyopencl",
+    "read-roi",
+    "gputools",
+    "pyclesperanto_prototype>=0.20.0",
+    "npy2bdv",
+    "redlionfish",
+    # Older tifffile are not compatible with aicsimageio, see: https://github.com/AllenCellModeling/aicsimageio/issues/518
+    "tifffile>=2023.3.15", #>=2022.8.12
+    "fsspec>=2022.8.2",
+    "napari-workflows>=0.2.8",
+    "resource-backed-dask-array>=0.1.0"
+]
+
+[project.urls]
+Repository = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice"
+BugTracker = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/issues"
+Documentation = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/wiki"
+SourceCode = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice"
+UserSupport = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/issues"
+
+[tool.setuptools.package-data]
+mypkg = ["*.yaml"]
+
+[project.optional-dependencies]
+testing = [
+    "tox",
+    "pytest",  # https://docs.pytest.org/en/latest/contents.html
+    "pytest-cov",  # https://pytest-cov.readthedocs.io/en/latest/
+]
+psf = [
+    "pycudadecon"
+]
+
+[project.scripts]
+lls-pipeline = "lls_core.cmds.__main__:main"
diff --git a/plugin/napari_lattice/_dock_widget.py b/plugin/napari_lattice/_dock_widget.py
new file mode 100644
index 00000000..251752c5
--- /dev/null
+++ b/plugin/napari_lattice/_dock_widget.py
@@ -0,0 +1,1154 @@
+import os
+import sys
+import yaml
+import numpy as np
+from pathlib import Path
+import dask.array as da
+import pandas as pd
+from typing import Union, Optional, Callable, Literal
+from aicsimageio import AICSImage
+from enum import Enum
+
+from magicclass.wrappers import set_design
+from magicgui import magicgui
+from magicclass import magicclass, field, vfield, set_options, MagicTemplate
+from magicclass.utils import click
+from qtpy.QtCore import Qt
+
+from napari.layers import Layer, Shapes
+from napari.types import ImageData
+from napari.utils import history
+
+import pyclesperanto_prototype as cle
+
+from napari.types import ImageData, ShapesData
+
+from tqdm import tqdm
+
+from napari_workflows import Workflow, WorkflowManager
+from napari_workflows._io_yaml_v1 import load_workflow
+
+from lls_core import config, DeskewDirection, DeconvolutionChoice, SaveFileType, Log_Levels
+from lls_core.io import LatticeData,  save_img, save_img_workflow
+from lls_core.utils import read_imagej_roi, get_first_last_image_and_task, modify_workflow_task, get_all_py_files, as_type, process_custom_workflow_output, check_dimensions, load_custom_py_modules
+from lls_core.llsz_core import crop_volume_deskew
+from lls_core.deconvolution import read_psf, pycuda_decon, skimage_decon
+
+from napari_lattice.ui_core import _Preview, _Deskew_Save
+from napari_lattice._reader import lattice_from_napari
+
+# Enable Logging
+import logging
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+
+class LastDimensionOptions(Enum):
+    channel = "Channel"
+    time = "Time"
+    get_from_metadata = "Get from Metadata"
+
+@magicclass(widget_type="split")
+class LLSZWidget(MagicTemplate):
+
+    @magicclass(widget_type="split")
+    class LlszMenu(MagicTemplate):
+        open_file: bool = False
+        lattice: LatticeData = None
+        skew_dir: DeskewDirection
+        angle_value: float
+        deskew_func: Callable
+
+        main_heading = field("<h3>Napari Lattice: Visualization & Analysis</h3>", widget_type="Label")
+        heading1 = field("Drag and drop an image file onto napari.\nOnce image has opened, initialize the\nplugin by clicking the button below.\nEnsure the image layer and voxel sizes are accurate in the prompt.\n If everything initalises properly, the button turns green.", widget_type="Label")
+
+        @set_design(background_color="magenta", font_family="Consolas", visible=True, text="Initialize Plugin", max_height=75, font_size=13)
+        @set_options(pixel_size_dx={"widget_type": "FloatSpinBox", "value": 0.1449922, "step": 0.000000001},
+                        pixel_size_dy={"widget_type": "FloatSpinBox",
+                                    "value": 0.1449922, "step": 0.000000001},
+                        pixel_size_dz={"widget_type": "FloatSpinBox",
+                                    "value": 0.3, "step": 0.000000001},
+                        angle={"widget_type": "FloatSpinBox",
+                            "value": 30, "step": 0.1},
+                        select_device={"widget_type": "ComboBox", "choices": cle.available_device_names(
+                        ), "value": cle.available_device_names()[0]},
+                        last_dimension_channel={"widget_type": "ComboBox", 
+                                                "label": "Set Last dimension (channel/time)", "tooltip": "If the last dimension is initialised incorrectly, you can assign it as either channel/time"},
+                        merge_all_channel_layers={"widget_type": "CheckBox", "value": True, "label": "Merge all napari layers as channels",
+                                                "tooltip": "Use this option if the channels are in separate layers. napari-lattice requires all channels to be in same layer"},
+                        skew_dir={"widget_type": "ComboBox", "choices": DeskewDirection, "value": DeskewDirection.Y,
+                                "label": "Direction of skew (Y or X)", "tooltip": "Skew direction when image is acquired. Ask your microscopist for details"},
+                        set_logging={"widget_type": "ComboBox", "choices": Log_Levels, "value": Log_Levels.INFO,
+                                    "label": "Log Level", "tooltip": "Only use for debugging. Leave it as INFO for regular operation"}
+                        )
+        def Choose_Image_Layer(self,
+                                img_layer: Layer,
+                                pixel_size_dx: float = 0.1449922,
+                                pixel_size_dy: float = 0.1449922,
+                                pixel_size_dz: float = 0.3,
+                                angle: float = 30,
+                                select_device: str = cle.available_device_names()[
+                                    0],
+                                last_dimension_channel: LastDimensionOptions = LastDimensionOptions.get_from_metadata,
+                                merge_all_channel_layers: bool = False,
+                                skew_dir: DeskewDirection=DeskewDirection.Y,
+                                set_logging: Log_Levels=Log_Levels.INFO):
+
+            logger.setLevel(set_logging.value)
+            config.log_level = set_logging.value
+            logger.info(f"Logging set to {set_logging}")
+            logger.info("Using existing image layer")
+
+            if self.parent_viewer is None:
+                raise Exception("This function can only be used when inside of a Napari viewer")
+
+            # Select device for processing
+            cle.select_device(select_device)
+
+            #assert skew_dir in DeskewDirection, "Skew direction not recognised. Enter either Y or X"
+            LLSZWidget.LlszMenu.skew_dir = skew_dir
+            LLSZWidget.LlszMenu.angle_value = angle
+
+            if LLSZWidget.LlszMenu.skew_dir == DeskewDirection.Y:
+                LLSZWidget.LlszMenu.deskew_func = cle.deskew_y
+                #LLSZWidget.LlszMenu.skew_dir = DeskewDirection.Y
+            elif LLSZWidget.LlszMenu.skew_dir == DeskewDirection.X:
+                LLSZWidget.LlszMenu.deskew_func = cle.deskew_x
+                #LLSZWidget.LlszMenu.skew_dir = DeskewDirection.X
+            
+            # merge all napari image layers as one multidimensional image
+            if merge_all_channel_layers:
+                from napari.layers.utils.stack_utils import images_to_stack
+                # get list of napari layers as a list
+                layer_list = list(self.parent_viewer.layers)
+                # if more than one layer
+                if len(layer_list) > 1:
+                    # convert the list of images into a stack
+                    new_layer = images_to_stack(layer_list)
+                    # select all current layers
+                    self.parent_viewer.layers.select_all()
+                    # remove selected layers
+                    self.parent_viewer.layers.remove_selected()
+                    # add the new composite image layer
+                    self.parent_viewer.add_layer(new_layer)
+                    img_layer = new_layer
+
+            LLSZWidget.LlszMenu.lattice = lattice_from_napari(
+                img=img_layer,
+                last_dimension=None if last_dimension_channel == LastDimensionOptions.get_from_metadata else last_dimension_channel,
+                angle=angle,
+                skew=LLSZWidget.LlszMenu.skew_dir,
+                physical_pixel_sizes=(pixel_size_dx, pixel_size_dy, pixel_size_dz)
+            )
+            #LLSZWidget.LlszMenu.aics = LLSZWidget.LlszMenu.lattice.data
+
+            # LLSZWidget.LlszMenu.dask = False  # Use GPU by default
+
+            # We initialise these variables here, but they can be changed in the deconvolution section
+            # list to store psf images for each channel
+            LLSZWidget.LlszMenu.lattice.psf = []
+            LLSZWidget.LlszMenu.lattice.psf_num_iter = 10
+            LLSZWidget.LlszMenu.lattice.decon_processing = DeconvolutionChoice.cpu
+            # list to store otf paths for each channel (Deprecated)
+            LLSZWidget.LlszMenu.lattice.otf_path = []
+            # if not using GPU
+            #LLSZWidget.LlszMenu.dask = not use_GPU
+
+            # flag for ensuring a file has been opened and plugin initialised
+            LLSZWidget.LlszMenu.open_file = True
+
+            logger.info(
+                f"Pixel size (ZYX) in microns: {LLSZWidget.LlszMenu.lattice.dz,LLSZWidget.LlszMenu.lattice.dy,LLSZWidget.LlszMenu.lattice.dx}")
+            logger.info(
+                f"Dimensions of image layer (ZYX): {list(LLSZWidget.LlszMenu.lattice.data.shape[-3:])}")
+            logger.info(
+                f"Dimensions of deskewed image (ZYX): {LLSZWidget.LlszMenu.lattice.deskew_vol_shape}")
+            logger.info(
+                f"Deskewing angle is :{LLSZWidget.LlszMenu.lattice.angle}")
+            logger.info(
+                f"Deskew Direction :{LLSZWidget.LlszMenu.lattice.skew}")
+            # Add dimension labels correctly
+            # if channel, and not time
+            if LLSZWidget.LlszMenu.lattice.time == 0 and (last_dimension_channel or LLSZWidget.LlszMenu.lattice.channels > 0):
+                self.parent_viewer.dims.axis_labels = ('Channel', "Z", "Y", "X")
+            # if no channel, but has time
+            elif LLSZWidget.LlszMenu.lattice.channels == 0 and LLSZWidget.LlszMenu.lattice.time > 0:
+                self.parent_viewer.dims.axis_labels = ('Time', "Z", "Y", "X")
+            # if it has channels
+            elif LLSZWidget.LlszMenu.lattice.channels > 1:
+                # If merge to stack is used, channel slider goes to the bottom
+                if int(self.parent_viewer.dims.dict()["range"][0][1]) == LLSZWidget.LlszMenu.lattice.channels:
+                    self.parent_viewer.dims.axis_labels = ('Channel', "Time", "Z", "Y", "X")
+                else:
+                    self.parent_viewer.dims.axis_labels = ('Time', "Channel", "Z", "Y", "X")
+            # if channels initialized by aicsimagio, then channels is 1
+            elif LLSZWidget.LlszMenu.lattice.channels == 1 and LLSZWidget.LlszMenu.lattice.time > 1:
+                self.parent_viewer.dims.axis_labels = ('Time', "Z", "Y", "X")
+
+            logger.info(f"Initialised")
+            self["Choose_Image_Layer"].background_color = "green"
+            self["Choose_Image_Layer"].text = "Plugin Initialised"
+
+            return
+
+        # Pycudadecon library for deconvolution
+        # options={"enabled": True},
+        deconvolution = vfield(bool, name="Use Deconvolution")
+        deconvolution.value = False
+
+        @deconvolution.connect
+        def _set_decon(self):
+            if self.deconvolution:
+                logger.info("Deconvolution Activated")
+                LLSZWidget.LlszMenu.deconvolution.value = True
+            else:
+                logger.info("Deconvolution Disabled")
+                LLSZWidget.LlszMenu.deconvolution.value = False
+            return
+
+        @set_design(background_color="magenta", font_family="Consolas", visible=True, text="Click to select PSFs for deconvolution", max_height=75, font_size=11)
+        @set_options(header=dict(widget_type="Label", label="<h3>Enter path to the PSF images</h3>"),
+                        psf_ch1_path={"widget_type": "FileEdit",
+                                    "label": "Channel 1:"},
+                        psf_ch2_path={"widget_type": "FileEdit",
+                                    "label": "Channel 2"},
+                        psf_ch3_path={"widget_type": "FileEdit",
+                                    "label": "Channel 3"},
+                        psf_ch4_path={"widget_type": "FileEdit",
+                                    "label": "Channel 4"},
+                        device_option={
+                            "widget_type": "ComboBox", "label": "Choose processing device", "choices": DeconvolutionChoice},
+                        no_iter={
+                            "widget_type": "SpinBox", "label": "No of iterations (Deconvolution)", "value": 10, "min": 1, "max": 50, "step": 1}
+                        )
+        def deconvolution_gui(self,
+                                header,
+                                psf_ch1_path: Path,
+                                psf_ch2_path: Path,
+                                psf_ch3_path: Path,
+                                psf_ch4_path: Path,
+                                device_option,
+                                no_iter: int):
+            """GUI for Deconvolution button"""
+            LLSZWidget.LlszMenu.lattice.decon_processing = device_option
+            assert LLSZWidget.LlszMenu.deconvolution.value == True, "Deconvolution is set to False. Tick the box to activate deconvolution."
+            LLSZWidget.LlszMenu.lattice.psf = list(read_psf([
+                    psf_ch1_path,
+                    psf_ch2_path,
+                    psf_ch3_path,
+                    psf_ch4_path,
+                ],
+                device_option,
+                lattice_class=LLSZWidget.LlszMenu.lattice
+            ))
+            LLSZWidget.LlszMenu.lattice.psf_num_iter = no_iter
+            self["deconvolution_gui"].background_color = "green"
+            self["deconvolution_gui"].text = "PSFs added"
+
+    @magicclass(widget_type="collapsible")
+    class Preview:
+        @magicgui(header=dict(widget_type="Label", label="<h3>Preview Deskew</h3>"),
+                    time=dict(label="Time:", max=2**15),
+                    channel=dict(label="Channel:"),
+                    call_button="Preview")
+        def Preview_Deskew(self,
+                            header,
+                            time: int,
+                            channel: int,
+                            img_data: ImageData):
+            """
+            Preview deskewed data for a single timepoint and channel
+
+            """
+            _Preview(LLSZWidget,
+                        self,
+                        time,
+                        channel,
+                        img_data)
+            return
+
+    # Tabbed Widget container to house all the widgets
+    @magicclass(widget_type="tabbed", name="Functions")
+    class WidgetContainer(MagicTemplate):
+
+        @magicclass(name="Deskew", widget_type="scrollable", properties={"min_width": 100})
+        class DeskewWidget(MagicTemplate):
+
+            @magicgui(header=dict(widget_type="Label", label="<h3>Deskew and Save</h3>"),
+                        time_start=dict(label="Time Start:", max=2**20),
+                        time_end=dict(label="Time End:", value=1, max=2**20),
+                        ch_start=dict(label="Channel Start:"),
+                        ch_end=dict(label="Channel End:", value=1),
+                        save_as_type={
+                            "label": "Save as filetype:", "choices": SaveFileType, "value": SaveFileType.h5},
+                        save_path=dict(mode='d', label="Directory to save"),
+                        call_button="Save")
+            def Deskew_Save(self,
+                            header,
+                            time_start: int,
+                            time_end: int,
+                            ch_start: int,
+                            ch_end: int,
+                            save_as_type: str,
+                            save_path: Path = Path(history.get_save_history()[0])):
+                """ Widget to Deskew and Save Data"""
+                _Deskew_Save(LLSZWidget,
+                                time_start,
+                                time_end,
+                                ch_start,
+                                ch_end,
+                                save_as_type,
+                                save_path)
+                return
+
+        @magicclass(name="Crop and Deskew", widget_type="scrollable")
+        class CropWidget(MagicTemplate):
+            
+            # add function for previewing cropped image
+            @magicclass(name="Cropping Preview", widget_type="scrollable", properties={
+                "min_width": 100,
+                "shapes_layer": Shapes
+            })
+            class Preview_Crop_Menu(MagicTemplate):
+                shapes_layer: Shapes
+
+                @set_design(font_size=10, text="Click to activate Cropping Layer", background_color="magenta")
+                @click(enables=["Import_ImageJ_ROI", "Crop_Preview"])
+                def activate_cropping(self):
+                    LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer = self.parent_viewer.add_shapes(shape_type='polygon', edge_width=1, edge_color='white',
+                                                                                                                            face_color=[1, 1, 1, 0], name="Cropping BBOX layer")
+                    # TO select ROIs if needed
+                    LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.mode = "SELECT"
+                    self["activate_cropping"].text = "Cropping layer active"
+                    self["activate_cropping"].background_color = "green"
+                    return
+
+                heading2 = field("You can either import ImageJ ROI (.zip) files or manually define ROIs using the shape layer", widget_type="Label")
+
+                @click(enabled=False)
+                def Import_ImageJ_ROI(self, path: Path = Path(history.get_open_history()[0])):
+                    logger.info(f"Opening{path}")
+                    roi_list = read_imagej_roi(path)
+                    # convert to canvas coordinates
+                    roi_list = (np.array(roi_list) *
+                                LLSZWidget.LlszMenu.lattice.dy).tolist()
+                    LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.add(roi_list, shape_type='polygon', edge_width=1, edge_color='yellow',
+                                                                                                face_color=[1, 1, 1, 0])
+                    return
+
+                time_crop = field(
+                    int, options={"min": 0, "step": 1, "max": 2**20}, name="Time")
+                chan_crop = field(
+                    int, options={"min": 0, "step": 1}, name="Channels")
+                heading_roi = field("If there are multiple ROIs, select the ROI before clicking button below", widget_type="Label")
+                #roi_idx = field(int, options={"min": 0, "step": 1}, name="ROI number")
+
+                @click(enabled=False)
+                # -> LayerDataTuple:
+                def Crop_Preview(self, roi_layer: ShapesData):
+                    assert roi_layer, "No coordinates found for cropping. Check if right shapes layer or initialise shapes layer and draw ROIs."
+                    # TODO: Add assertion to check if bbox layer or coordinates
+                    time = self.time_crop.value
+                    channel = self.chan_crop.value
+
+                    assert time < LLSZWidget.LlszMenu.lattice.time, "Time is out of range"
+                    assert channel < LLSZWidget.LlszMenu.lattice.channels, "Channel is out of range"
+
+                    logger.info(f"Using channel {channel} and time {time}")
+
+                    vol = LLSZWidget.LlszMenu.lattice.data
+                    vol_zyx = vol[time, channel, ...]
+                    vol_zyx = np.array(vol_zyx)
+
+                    deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
+                    # Create a dask array same shape as deskewed image
+                    deskewed_volume = da.zeros(deskewed_shape)
+
+                    # Option for entering custom z start value?
+                    z_start = 0
+                    z_end = deskewed_shape[0]
+
+                    # if only one roi drawn, use the first ROI for cropping
+                    if len(roi_layer) == 1:
+                        roi_idx = 0
+                    else:
+                        assert len(
+                            LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data) > 0, "Please select an ROI"
+                        roi_idx = list(
+                            LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data)[0]
+
+                    roi_choice = roi_layer[roi_idx]
+                    # As the original image is scaled, the coordinates are in microns, so we need to convert
+                    # roi from micron to canvas/world coordinates
+                    roi_choice = [
+                        x/LLSZWidget.LlszMenu.lattice.dy for x in roi_choice]
+                    logger.info(f"Previewing ROI {roi_idx}")
+
+                    # crop
+                    if LLSZWidget.LlszMenu.deconvolution.value:
+                        logger.info(
+                            f"Deskewing for Time:{time} and Channel: {channel} with deconvolution")
+                        #psf = LLSZWidget.LlszMenu.lattice.psf[channel]
+                        if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
+                            crop_roi_vol_desk = crop_volume_deskew(original_volume=vol_zyx,
+                                                                    deskewed_volume=deskewed_volume,
+                                                                    roi_shape=roi_choice,
+                                                                    angle_in_degrees=LLSZWidget.LlszMenu.angle_value,
+                                                                    voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
+                                                                    voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
+                                                                    voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
+                                                                    z_start=z_start,
+                                                                    z_end=z_end,
+                                                                    deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
+                                                                    decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
+                                                                    psf=LLSZWidget.LlszMenu.lattice.psf[channel],
+                                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter).astype(vol_zyx.dtype)
+                        else:
+                            crop_roi_vol_desk = crop_volume_deskew(original_volume=vol_zyx,
+                                                                    deskewed_volume=deskewed_volume,
+                                                                    roi_shape=roi_choice,
+                                                                    angle_in_degrees=LLSZWidget.LlszMenu.angle_value,
+                                                                    voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
+                                                                    voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
+                                                                    voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
+                                                                    z_start=z_start,
+                                                                    z_end=z_end,
+                                                                    deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
+                                                                    decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
+                                                                    psf=LLSZWidget.LlszMenu.lattice.psf[channel],
+                                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter).astype(vol_zyx.dtype)
+                    else:
+                        crop_roi_vol_desk = crop_volume_deskew(original_volume=vol_zyx,
+                                                                deskewed_volume=deskewed_volume,
+                                                                roi_shape=roi_choice,
+                                                                angle_in_degrees=LLSZWidget.LlszMenu.angle_value,
+                                                                voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
+                                                                voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
+                                                                voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
+                                                                z_start=z_start,
+                                                                z_end=z_end,
+                                                                skew_dir=LLSZWidget.LlszMenu.skew_dir).astype(vol_zyx.dtype)
+                    crop_roi_vol_desk = cle.pull(crop_roi_vol_desk)
+
+                    # get array back from gpu or addding cle array to napari can throw errors
+
+                    scale = (LLSZWidget.LlszMenu.lattice.new_dz,
+                                LLSZWidget.LlszMenu.lattice.dy,
+                                LLSZWidget.LlszMenu.lattice.dx)
+                    self.parent_viewer.add_image(
+                        crop_roi_vol_desk, scale=scale)
+
+                @magicclass(name="Crop and Save Data")
+                class CropSaveData(MagicTemplate):
+                    @magicgui(header=dict(widget_type="Label", label="<h3>Crop and Save Data</h3>"),
+                                time_start=dict(label="Time Start:"),
+                                time_end=dict(label="Time End:", value=1),
+                                ch_start=dict(label="Channel Start:"),
+                                ch_end=dict(label="Channel End:", value=1),
+                                save_as_type={
+                                    "label": "Save as filetype:", "choices": SaveFileType},
+                                save_path=dict(mode='d', label="Directory to save "))
+                    def Crop_Save(self,
+                                    header,
+                                    time_start: int,
+                                    time_end: int,
+                                    ch_start: int,
+                                    ch_end: int,
+                                    save_as_type: str,
+                                    roi_layer_list: ShapesData,
+                                    save_path: Path = Path(history.get_save_history()[0])):
+
+                        if not roi_layer_list:
+                            logger.error(
+                                "No coordinates found or cropping. Initialise shapes layer and draw ROIs.")
+                        else:
+                            assert LLSZWidget.LlszMenu.open_file, "Image not initialised"
+
+                            check_dimensions(time_start, time_end, ch_start, ch_end,
+                                                LLSZWidget.LlszMenu.lattice.channels, LLSZWidget.LlszMenu.lattice.time)
+
+                            angle = LLSZWidget.LlszMenu.lattice.angle
+                            dx = LLSZWidget.LlszMenu.lattice.dx
+                            dy = LLSZWidget.LlszMenu.lattice.dy
+                            dz = LLSZWidget.LlszMenu.lattice.dz
+
+                            # get image data
+                            img_data = LLSZWidget.LlszMenu.lattice.data
+                            # Get shape of deskewed image
+                            deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
+                            deskewed_volume = da.zeros(deskewed_shape)
+                            z_start = 0
+                            z_end = deskewed_shape[0]
+
+                            logger.info("Cropping and saving files...")
+
+                            # necessary when scale is used for napari.viewer.add_image operations
+                            roi_layer_list = [
+                                x/LLSZWidget.LlszMenu.lattice.dy for x in roi_layer_list]
+
+                            for idx, roi_layer in enumerate(tqdm(roi_layer_list, desc="ROI:", position=0)):
+                                # pass arguments for save tiff, callable and function arguments
+                                logger.info("Processing ROI ", idx)
+                                # pass parameters for the crop_volume_deskew function
+
+                                save_img(vol=img_data,
+                                            func=crop_volume_deskew,
+                                            time_start=time_start,
+                                            time_end=time_end,
+                                            channel_start=ch_start,
+                                            channel_end=ch_end,
+                                            save_name_prefix="ROI_" +
+                                            str(idx),
+                                            save_path=save_path,
+                                            save_file_type=save_as_type,
+                                            save_name=LLSZWidget.LlszMenu.lattice.save_name,
+                                            dx=dx,
+                                            dy=dy,
+                                            dz=dz,
+                                            angle=angle,
+                                            deskewed_volume=deskewed_volume,
+                                            roi_shape=roi_layer,
+                                            angle_in_degrees=angle,
+                                            z_start=z_start,
+                                            z_end=z_end,
+                                            voxel_size_x=dx,
+                                            voxel_size_y=dy,
+                                            voxel_size_z=dz,
+                                            LLSZWidget=LLSZWidget
+                                            )
+
+                            logger.info(
+                                f"Cropping and Saving Complete -> {save_path}")
+                            return
+
+        @magicclass(name="Workflow", widget_type="scrollable")
+        class WorkflowWidget:
+            @magicclass(name="Preview Workflow", widget_type="scrollable")
+            class PreviewWorkflow:
+                #time_preview= field(int, options={"min": 0, "step": 1}, name="Time")
+                #chan_preview = field(int, options={"min": 0, "step": 1}, name="Channels")
+                @magicgui(header=dict(widget_type="Label", label="<h3>Preview Workflow</h3>"),
+                            time_preview=dict(label="Time:", max=2**20),
+                            chan_preview=dict(label="Channel:"),
+                            get_active_workflow=dict(
+                                widget_type="Checkbox", label="Get active workflow in napari-workflow", value=False),
+                            workflow_path=dict(
+                                mode='r', label="Load custom workflow (.yaml/yml)"),
+                            Use_Cropping=dict(
+                                widget_type="Checkbox", label="Crop Data", value=False),
+                            #custom_module=dict(widget_type="Checkbox",label="Load custom module (looks for *.py files in the workflow directory)",value = False),
+                            call_button="Apply and Preview Workflow")
+                def Workflow_Preview(self,
+                                        header,
+                                        time_preview: int,
+                                        chan_preview: int,
+                                        get_active_workflow: bool,
+                                        Use_Cropping: bool,
+                                        roi_layer_list: ShapesData,
+                                        workflow_path: Path = Path.home()):
+                    """
+                        Apply napari_workflows to the processing pipeline
+                        User can define a pipeline which can be inspected in napari workflow inspector
+                        and then execute it by ticking  the get active workflow checkbox, 
+                        OR
+                        Use a predefined workflow
+
+                        In both cases, if deskewing is not present as first step, it will be added on
+                        and rest of the task will be made followers
+                        Args:
+
+                    """
+                    print("Previewing deskewed channel and time with workflow")
+                    if get_active_workflow:
+                        # installs the workflow to napari
+                        user_workflow = WorkflowManager.install(
+                            self.parent_viewer).workflow
+                        parent_dir = workflow_path.resolve(
+                        ).parents[0].__str__()+os.sep
+                        logger.info("Workflow loaded from napari")
+                    else:
+
+                        try:
+                            # Automatically scan workflow file directory for *.py files.
+                            # If it findss one, load it as a module
+
+                            parent_dir = workflow_path.resolve(
+                            ).parents[0].__str__()+os.sep
+                            sys.path.append(parent_dir)
+                            custom_py_files = get_all_py_files(parent_dir)
+                            if len(custom_py_files) == 0:
+                                logger.error(
+                                    f"No custom modules imported. If you'd like to use a cusotm module, place a *.py file in same folder as the workflow file {parent_dir}")
+                            else:
+                                modules = load_custom_py_modules(
+                                    custom_py_files)
+
+                                logger.info(
+                                    f"Custom modules imported {modules}")
+                            user_workflow = load_workflow(
+                                workflow_path.__str__())
+                        except yaml.loader.ConstructorError as e:
+                            logger.error(
+                                "\033[91m While loading workflow, got the following error which may mean you need to install the corresponding module in your Python environment: \033[0m")
+                            logger.error(e)
+
+                        #user_workflow = load_workflow(workflow_path)
+                        logger.info("Workflow loaded from file")
+
+                    assert type(
+                        user_workflow) is Workflow, "Workflow loading error. Check if file is workflow or if required libraries are installed"
+
+                    input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
+                        user_workflow)
+                    #print(input_arg_first, input_arg_last, first_task_name,last_task_name )
+                    # get list of tasks
+                    task_list = list(user_workflow._tasks.keys())
+                    logger.info(f"Workflow loaded:{user_workflow}")
+                    # logger.info()
+
+                    # when using fields, self.time_preview.value
+                    assert time_preview < LLSZWidget.LlszMenu.lattice.time, "Time is out of range"
+                    assert chan_preview < LLSZWidget.LlszMenu.lattice.channels, "Channel is out of range"
+
+                    time = time_preview
+                    channel = chan_preview
+
+                    # to access current time and channel and pass it to workflow file
+                    config.channel = channel
+                    config.time = time
+
+                    logger.info(
+                        f"Processing for Time: {time} and Channel: {channel}")
+
+                    vol = LLSZWidget.LlszMenu.lattice.data
+                    vol_zyx = vol[time, channel, ...]
+                    vol_zyx = np.array(vol_zyx)
+
+                    task_name_start = first_task_name[0]
+                    try:
+                        task_name_last = last_task_name[0]
+                    except IndexError:
+                        task_name_last = task_name_start
+
+                    # variables to hold task name, initialize it as None
+                    # if gpu, set otf_path, otherwise use psf
+                    psf = None
+                    otf_path = None
+
+                    if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
+                        otf_path = "otf_path"
+                    else:
+                        psf = "psf"
+
+                    # if cropping, set that as first task
+                    # get the function associated with the first task and check if its deskewing
+                    if Use_Cropping:
+                        # use deskewed volume for cropping function
+                        deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
+                        deskewed_volume = da.zeros(deskewed_shape)
+                        z_start = 0
+                        z_end = deskewed_shape[0]
+                        if user_workflow.get_task(task_name_start)[0] not in [crop_volume_deskew]:
+                            # if only one roi drawn, use the first ROI for cropping
+                            if len(roi_layer_list) == 1:
+                                roi_idx = 0
+                            else:  # else get the user selection
+                                assert len(
+                                    LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data) > 0, "Please select an ROI"
+                                roi_idx = list(
+                                    LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data)[0]
+
+                            roi_choice = roi_layer_list[roi_idx]
+                            # As the original image is scaled, the coordinates are in microns, so we need to convert
+                            # roi to from micron to canvas/world coordinates
+                            roi_choice = [
+                                x/LLSZWidget.LlszMenu.lattice.dy for x in roi_choice]
+                            logger.info(f"Previewing ROI {roi_idx}")
+                            if LLSZWidget.LlszMenu.deconvolution.value:
+                                user_workflow.set("crop_deskew_image", crop_volume_deskew,
+                                                    original_volume=vol_zyx,
+                                                    deskewed_volume=deskewed_volume,
+                                                    roi_shape=roi_choice,
+                                                    angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
+                                                    voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
+                                                    voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
+                                                    voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
+                                                    z_start=z_start,
+                                                    z_end=z_end,
+                                                    deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
+                                                    decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
+                                                    otf_path=otf_path,
+                                                    psf=LLSZWidget.LlszMenu.lattice.psf[channel],
+                                                    skew_dir=LLSZWidget.LlszMenu.skew_dir)
+                            else:
+                                user_workflow.set("crop_deskew_image", crop_volume_deskew,
+                                                    original_volume=vol_zyx,
+                                                    deskewed_volume=deskewed_volume,
+                                                    roi_shape=roi_choice,
+                                                    angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
+                                                    voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
+                                                    voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
+                                                    voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
+                                                    z_start=z_start,
+                                                    z_end=z_end,
+                                                    skew_dir=LLSZWidget.LlszMenu.skew_dir)
+
+                            # Set input of the workflow to be  crop_deskewing, i.e., the original first operation will now have crop_deskew_image as an input (becoming second instead)
+                            user_workflow.set(
+                                input_arg_first, "crop_deskew_image")
+                        else:
+                            user_workflow.set(input_arg_first, vol_zyx)
+                    # Not cropping; If deskew not in workflow, append to start
+                    elif user_workflow.get_task(task_name_start)[0] not in (cle.deskew_y, cle.deskew_x):
+                        # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
+                        if LLSZWidget.LlszMenu.deconvolution.value:
+                            psf = LLSZWidget.LlszMenu.lattice.psf[channel]
+                            input_arg_first_decon, input_arg_last_decon, first_task_name_decon, last_task_name_decon = get_first_last_image_and_task(
+                                user_workflow)
+
+                            if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
+                                user_workflow.set("deconvolution",
+                                                    pycuda_decon,
+                                                    image=vol_zyx,
+                                                    psf=LLSZWidget.LlszMenu.lattice.psf[channel],
+                                                    dzdata=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxdata=LLSZWidget.LlszMenu.lattice.dx,
+                                                    dzpsf=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxpsf=LLSZWidget.LlszMenu.lattice.dx,
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
+                                # user_workflow.set(input_arg_first_decon,"deconvolution")
+                            else:
+                                user_workflow.set("deconvolution",
+                                                    skimage_decon,
+                                                    vol_zyx=vol_zyx,
+                                                    psf=LLSZWidget.LlszMenu.lattice.psf[channel],
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
+                                                    clip=False,
+                                                    filter_epsilon=0,
+                                                    boundary='nearest')
+                                # user_workflow.set(input_arg_first_decon,"deconvolution")
+
+                            user_workflow.set("deskew_image",
+                                                LLSZWidget.LlszMenu.deskew_func,
+                                                "deconvolution",
+                                                angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
+                                                voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
+                                                voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
+                                                voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
+                                                linear_interpolation=True)
+
+                            # user_workflow.set("change_bitdepth",as_type,"deskew_image",vol_zyx)
+                            # Set input of the workflow to be from deskewing output with same bit depth as original volume
+                            # user_workflow.set(input_arg_first,"change_bitdepth")
+
+                        else:
+                            user_workflow.set("deskew_image",
+                                                LLSZWidget.LlszMenu.deskew_func,
+                                                vol_zyx,
+                                                angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
+                                                voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
+                                                voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
+                                                voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
+                                                linear_interpolation=True)
+                            # Set input of the workflow to be from deskewing
+                            # user_workflow.set(input_arg_first,"deskew_image")
+
+                        user_workflow.set(
+                            "change_bitdepth", as_type, "deskew_image", vol_zyx)
+                        # Set input of the workflow to be from deskewing with same bit depth as original volume
+                        user_workflow.set(
+                            input_arg_first, "change_bitdepth")
+
+                    else:
+                        # if deskew already in workflow, just check if deconvolution needs to be added
+                        # repitition of above (maybe create a function?)
+                        # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
+                        if LLSZWidget.LlszMenu.deconvolution.value:
+                            psf = LLSZWidget.LlszMenu.lattice.psf[channel]
+                            input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
+                                user_workflow)
+
+                            if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
+                                user_workflow.set("deconvolution",
+                                                    pycuda_decon,
+                                                    image=vol_zyx,
+                                                    psf=LLSZWidget.LlszMenu.lattice.psf[channel],
+                                                    dzdata=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxdata=LLSZWidget.LlszMenu.lattice.dx,
+                                                    dzpsf=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxpsf=LLSZWidget.LlszMenu.lattice.dx,
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
+                                # user_workflow.set(input_arg_first,"deconvolution")
+                            else:
+                                user_workflow.set("deconvolution",
+                                                    skimage_decon,
+                                                    vol_zyx=vol_zyx,
+                                                    psf=LLSZWidget.LlszMenu.lattice.psf[channel],
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
+                                                    clip=False,
+                                                    filter_epsilon=0,
+                                                    boundary='nearest')
+
+                            # set input to subsequent task as deconvolution output
+                            user_workflow.set(
+                                input_arg_first, "deconvolution")
+
+                    logger.info("Workflow to be executed:")
+                    logger.info(user_workflow)
+                    # Execute workflow
+                    processed_vol = user_workflow.get(task_name_last)
+
+                    # check if a measurement table (usually a dictionary or list)  or a tuple with different data types
+                    # The function below saves the tables and adds any images to napari window
+                    if type(processed_vol) in [dict, list, tuple]:
+                        if (len(processed_vol) > 1):
+                            df = pd.DataFrame()
+                            for idx, i in enumerate(processed_vol):
+                                df_temp = process_custom_workflow_output(
+                                    i, parent_dir, idx, LLSZWidget, self, channel, time, preview=True)
+                                final_df = pd.concat([df, df_temp])
+                                # append dataframes from every loop and have table command outside loop?
+                            # TODO: Figure out why table is not displaying
+                            from napari_spreadsheet import _widget
+                            table_viewer = _widget.TableViewerWidget(
+                                show=True)
+                            table_viewer.add_spreadsheet(final_df)
+                            # widgets.Table(value=final_df).show()
+
+                    else:
+                        # add image to napari window
+                        # TODO: check if its an image napari supports?
+                        process_custom_workflow_output(
+                            processed_vol, parent_dir, 0, LLSZWidget, self, channel, time)
+
+                    print("Workflow complete")
+                    pass
+
+                @magicgui(header=dict(widget_type="Label", label="<h3>Apply Workflow and Save Output</h3>"),
+                            time_start=dict(label="Time Start:", max=2**20),
+                            time_end=dict(label="Time End:",
+                                        value=1, max=2**20),
+                            ch_start=dict(label="Channel Start:"),
+                            ch_end=dict(label="Channel End:", value=1),
+                            Use_Cropping=dict(
+                                widget_type="Checkbox", label="Crop Data", value=False),
+                            get_active_workflow=dict(
+                                widget_type="Checkbox", label="Get active workflow in napari-workflow", value=False),
+                            workflow_path=dict(
+                                mode='r', label="Load custom workflow (.yaml/yml)"),
+                            save_as_type={
+                                "label": "Save as filetype:", "choices": SaveFileType},
+                            save_path=dict(
+                                mode='d', label="Directory to save "),
+                            #custom_module=dict(widget_type="Checkbox",label="Load custom module (same dir as workflow)",value = False),
+                            call_button="Apply Workflow and Save Result")
+                def Apply_Workflow_and_Save(self,
+                                            header,
+                                            time_start: int,
+                                            time_end: int,
+                                            ch_start: int,
+                                            ch_end: int,
+                                            Use_Cropping,
+                                            roi_layer_list: ShapesData,
+                                            get_active_workflow: bool = False,
+                                            workflow_path: Path = Path.home(),
+                                            save_as_type: str = SaveFileType.tiff,
+                                            save_path: Path = Path(history.get_save_history()[0])):
+                    """
+                    Apply a user-defined analysis workflow using napari-workflows
+
+                    Args:
+                        time_start (int): Start Time
+                        time_end (int): End Time
+                        ch_start (int): Start Channel
+                        ch_end (int): End Channel
+                        Use_Cropping (_type_): Use cropping based on ROIs in the shapes layer
+                        roi_layer_list (ShapesData): Shapes layer to use for cropping; can be a list of shapes
+                        get_active_workflow (bool, optional): Gets active workflow in napari. Defaults to False.
+                        workflow_path (Path, optional): User can also choose a custom workflow defined in a yaml file.
+                        save_path (Path, optional): Path to save resulting data
+                    """
+                    assert LLSZWidget.LlszMenu.open_file, "Image not initialised"
+
+                    check_dimensions(time_start, time_end, ch_start, ch_end,
+                                        LLSZWidget.LlszMenu.lattice.channels, LLSZWidget.LlszMenu.lattice.time)
+
+                    # Get parameters
+                    angle = LLSZWidget.LlszMenu.lattice.angle
+                    dx = LLSZWidget.LlszMenu.lattice.dx
+                    dy = LLSZWidget.LlszMenu.lattice.dy
+                    dz = LLSZWidget.LlszMenu.lattice.dz
+
+                    if get_active_workflow:
+                        # installs the workflow to napari
+                        user_workflow = WorkflowManager.install(
+                            self.parent_viewer).workflow
+                        print("Workflow installed")
+                    else:
+                        # Automatically scan workflow file directory for *.py files.
+                        # If it findss one, load it as a module
+                        import importlib
+                        parent_dir = workflow_path.resolve(
+                        ).parents[0].__str__()+os.sep
+                        sys.path.append(parent_dir)
+                        custom_py_files = get_all_py_files(parent_dir)
+                        if len(custom_py_files) == 0:
+                            print(
+                                f"No custom modules imported. If you'd like to use a cusotm module, place a *.py file in same folder as the workflow file {parent_dir}")
+                        else:
+                            modules = map(
+                                importlib.import_module, custom_py_files)
+                            print(f"Custom modules imported {modules}")
+                        user_workflow = load_workflow(workflow_path)
+
+                    assert type(
+                        user_workflow) is Workflow, "Workflow file is not a napari workflow object. Check file! You can use workflow inspector if needed"
+
+                    input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
+                        user_workflow)
+                    print(input_arg_first, input_arg_last,
+                            first_task_name, last_task_name)
+                    # get list of tasks
+                    task_list = list(user_workflow._tasks.keys())
+                    print("Workflow loaded:")
+                    print(user_workflow)
+
+                    vol = LLSZWidget.LlszMenu.lattice.data
+
+                    #vol_zyx= vol[time,channel,...]
+
+                    task_name_start = first_task_name[0]
+
+                    try:
+                        task_name_last = last_task_name[0]
+                    except IndexError:
+                        task_name_last = task_name_start
+
+                    # variables to hold task name, initialize it as None
+                    # if gpu, set otf_path, otherwise use psf
+                    psf = None
+                    otf_path = None
+
+                    if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
+                        #otf_path = "otf_path"
+                        psf_arg = "psf"
+                        psf = LLSZWidget.LlszMenu.lattice.psf
+                    else:
+                        psf_arg = "psf"
+                        psf = LLSZWidget.LlszMenu.lattice.psf
+                    # if cropping, set that as first task
+
+                    if Use_Cropping:
+                        # convert Roi pixel coordinates to canvas coordinates
+                        # necessary only when scale is used for napari.viewer.add_image operations
+                        roi_layer_list = [
+                            x/LLSZWidget.LlszMenu.lattice.dy for x in roi_layer_list]
+
+                        deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
+                        deskewed_volume = da.zeros(deskewed_shape)
+                        z_start = 0
+                        z_end = deskewed_shape[0]
+                        roi = "roi"
+                        volume = "volume"
+                        # Check if decon ticked, if so set as first and crop as second?
+
+                        # Create workflow for cropping and deskewing
+                        # volume and roi used will be set dynamically
+                        user_workflow.set("crop_deskew_image", crop_volume_deskew,
+                                            original_volume=volume,
+                                            deskewed_volume=deskewed_volume,
+                                            roi_shape=roi,
+                                            angle_in_degrees=angle,
+                                            voxel_size_x=dx,
+                                            voxel_size_y=dy,
+                                            voxel_size_z=dz,
+                                            z_start=z_start,
+                                            z_end=z_end,
+                                            deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
+                                            decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
+                                            psf=psf_arg,
+                                            skew_dir=LLSZWidget.LlszMenu.skew_dir)
+
+                        # change the first task so it accepts "crop_deskew as input"
+                        new_task = modify_workflow_task(
+                            old_arg=input_arg_first, task_key=task_name_start, new_arg="crop_deskew_image", workflow=user_workflow)
+                        user_workflow.set(task_name_start, new_task)
+
+                        for idx, roi_layer in enumerate(tqdm(roi_layer_list, desc="ROI:", position=0)):
+                            print("Processing ROI ", idx)
+                            user_workflow.set(roi, roi_layer)
+                            save_img_workflow(vol=vol,
+                                                workflow=user_workflow,
+                                                input_arg=volume,
+                                                first_task="crop_deskew_image",
+                                                last_task=task_name_last,
+                                                time_start=time_start,
+                                                time_end=time_end,
+                                                channel_start=ch_start,
+                                                channel_end=ch_end,
+                                                save_file_type=save_as_type,
+                                                save_path=save_path,
+                                                #roi_layer = roi_layer,
+                                                save_name_prefix="ROI_" + \
+                                                str(idx),
+                                                save_name=LLSZWidget.LlszMenu.lattice.save_name,
+                                                dx=dx,
+                                                dy=dy,
+                                                dz=dz,
+                                                angle=angle,
+                                                deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
+                                                decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
+                                                otf_path=otf_path,
+                                                psf_arg=psf_arg,
+                                                psf=psf)
+
+                    # IF just deskewing and its not in the tasks, add that as first task
+                    elif user_workflow.get_task(task_name_start)[0] not in (cle.deskew_y, cle.deskew_x):
+                        input = "input"
+                        # add task to the workflow
+                        user_workflow.set("deskew_image",
+                                            LLSZWidget.LlszMenu.deskew_func,
+                                            input_image=input,
+                                            angle_in_degrees=angle,
+                                            voxel_size_x=dx,
+                                            voxel_size_y=dy,
+                                            voxel_size_z=dz,
+                                            linear_interpolation=True)
+                        # Set input of the workflow to be from deskewing
+                        # change workflow task starts from is "deskew_image" and
+                        new_task = modify_workflow_task(
+                            old_arg=input_arg_first, task_key=task_name_start, new_arg="deskew_image", workflow=user_workflow)
+                        user_workflow.set(task_name_start, new_task)
+
+                        # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
+                        if LLSZWidget.LlszMenu.deconvolution.value:
+                            psf = "psf"
+                            otf_path = "otf_path"
+                            input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
+                                user_workflow)
+
+                            if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
+                                user_workflow.set("deconvolution",
+                                                    pycuda_decon,
+                                                    image=input,
+                                                    psf=psf_arg,
+                                                    dzdata=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxdata=LLSZWidget.LlszMenu.lattice.dx,
+                                                    dzpsf=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxpsf=LLSZWidget.LlszMenu.lattice.dx,
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
+                                # user_workflow.set(input_arg_first,"deconvolution")
+                            else:
+                                user_workflow.set("deconvolution",
+                                                    skimage_decon,
+                                                    vol_zyx=input,
+                                                    psf=psf_arg,
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
+                                                    clip=False,
+                                                    filter_epsilon=0,
+                                                    boundary='nearest')
+                            # modify the user workflow so "deconvolution" is accepted
+                            new_task = modify_workflow_task(
+                                old_arg=input_arg_first, task_key=task_name_start, new_arg="deconvolution", workflow=user_workflow)
+                            user_workflow.set(task_name_start, new_task)
+                            input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
+                                user_workflow)
+                            task_name_start = first_task_name[0]
+
+                        save_img_workflow(vol=vol,
+                                            workflow=user_workflow,
+                                            input_arg=input,
+                                            first_task=task_name_start,
+                                            last_task=task_name_last,
+                                            time_start=time_start,
+                                            time_end=time_end,
+                                            channel_start=ch_start,
+                                            channel_end=ch_end,
+                                            save_file_type=save_as_type,
+                                            save_path=save_path,
+                                            save_name=LLSZWidget.LlszMenu.lattice.save_name,
+                                            dx=dx,
+                                            dy=dy,
+                                            dz=dz,
+                                            angle=angle,
+                                            deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
+                                            decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
+                                            otf_path=otf_path,
+                                            psf_arg=psf_arg,
+                                            psf=psf)
+
+                    # If deskewing is already as a task, then set the first argument to input so we can modify that later
+                    else:
+                        # if deskewing is already first task, then check if deconvolution needed
+                        # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
+                        if LLSZWidget.LlszMenu.deconvolution.value:
+                            psf = "psf"
+                            otf_path = "otf_path"
+                            input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
+                                user_workflow)
+
+                            if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
+                                user_workflow.set("deconvolution",
+                                                    pycuda_decon,
+                                                    image=input,
+                                                    psf=psf_arg,
+                                                    dzdata=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxdata=LLSZWidget.LlszMenu.lattice.dx,
+                                                    dzpsf=LLSZWidget.LlszMenu.lattice.dz,
+                                                    dxpsf=LLSZWidget.LlszMenu.lattice.dx,
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
+                                # user_workflow.set(input_arg_first,"deconvolution")
+                            else:
+                                user_workflow.set("deconvolution",
+                                                    skimage_decon,
+                                                    vol_zyx=input,
+                                                    psf=psf_arg,
+                                                    num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
+                                                    clip=False,
+                                                    filter_epsilon=0,
+                                                    boundary='nearest')
+                            # modify the user workflow so "deconvolution" is accepted
+                            new_task = modify_workflow_task(
+                                old_arg=input_arg_first, task_key=task_name_start, new_arg="deconvolution", workflow=user_workflow)
+                            user_workflow.set(task_name_start, new_task)
+                            input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
+                                user_workflow)
+                            task_name_start = first_task_name[0]
+
+                        # we pass first argument as input
+                        save_img_workflow(vol=vol,
+                                            workflow=user_workflow,
+                                            input_arg=input_arg_first,
+                                            first_task=task_name_start,
+                                            last_task=task_name_last,
+                                            time_start=time_start,
+                                            time_end=time_end,
+                                            channel_start=ch_start,
+                                            channel_end=ch_end,
+                                            save_file_type=save_as_type,
+                                            save_path=save_path,
+                                            save_name=LLSZWidget.LlszMenu.lattice.save_name,
+                                            dx=dx,
+                                            dy=dy,
+                                            dz=dz,
+                                            angle=angle,
+                                            deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
+                                            decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
+                                            otf_path=otf_path,
+                                            psf_arg=psf_arg,
+                                            psf=psf)
+
+                    print("Workflow complete")
+                    return
+
+            pass
+
+def _napari_lattice_widget_wrapper() -> LLSZWidget:
+    # split widget type enables a resizable widget
+    #max_height = 50
+    # Important to have this or napari won't recognize the classes and magicclass qidgets
+    widget = LLSZWidget()
+    # aligning collapsible widgets at the top instead of having them centered vertically
+    widget._widget._layout.setAlignment(Qt.AlignTop)
+
+    # widget._widget._layout.setWidgetResizable(True)
+    return widget
diff --git a/src/napari_lattice/_reader.py b/plugin/napari_lattice/_reader.py
similarity index 58%
rename from src/napari_lattice/_reader.py
rename to plugin/napari_lattice/_reader.py
index 418b5aab..64469e8e 100644
--- a/src/napari_lattice/_reader.py
+++ b/plugin/napari_lattice/_reader.py
@@ -6,13 +6,61 @@
 #pass a list of images with different resolution for pyramid; use is_pyramid=True flag in napari.add_image
 , however pyramidal support for 3D not available yet
 """
+from __future__ import annotations
 
 import dask.array as da
 import dask.delayed as delayed
 import os 
 import numpy as np
+from napari.layers import image, Layer
+from napari.layers._data_protocols import LayerDataProtocol
+from aicsimageio.types import ArrayLike
+from aicsimageio.dimensions import Dimensions
+from aicsimageio.aics_image import AICSImage
 
-def napari_get_reader(path):
+from typing_extensions import Literal
+from typing import Any, Optional, cast, Tuple
+
+from lls_core.lattice_data import lattice_from_aics, LatticeData, img_from_array
+
+def lattice_from_napari(
+    img: Layer,
+    last_dimension: Optional[Literal["channel", "time"]],
+    **kwargs: Any
+) -> LatticeData:
+    """
+    Factory function for generating a LatticeData from a Napari Image
+
+    Arguments:
+        kwargs: Extra arguments to pass to the LatticeData constructor
+    """
+
+    img_data_aics: AICSImage
+
+    if 'aicsimage' in img.metadata.keys():
+        img_data_aics = img.metadata['aicsimage']
+    else:
+        if not last_dimension:
+            raise ValueError("Either the Napari image must have dimensional metadata, or last_dimension must be provided")
+        img_data_aics = img_from_array(cast(ArrayLike, img.data), last_dimension=last_dimension, physical_pixel_sizes=kwargs.get("physical_pixel_sizes"))
+
+    save_name: str
+    if img.source.path is None:
+        # remove colon (:) and any leading spaces
+        save_name = img.name.replace(":", "").strip()
+        # replace any group of spaces with "_"
+        save_name = '_'.join(save_name.split())
+    else:
+        file_name_noext = os.path.basename(img.source.path)
+        file_name = os.path.splitext(file_name_noext)[0]
+        # remove colon (:) and any leading spaces
+        save_name = file_name.replace(":", "").strip()
+        # replace any group of spaces with "_"
+        save_name = '_'.join(save_name.split())
+
+    return lattice_from_aics(img_data_aics, save_name=save_name, **kwargs)
+
+def napari_get_reader(path: list[str] | str):
     """Check if file ends with h5 and returns reader function if true
     Parameters
     ----------
diff --git a/src/napari_lattice/napari.yaml b/plugin/napari_lattice/napari.yaml
similarity index 100%
rename from src/napari_lattice/napari.yaml
rename to plugin/napari_lattice/napari.yaml
diff --git a/src/napari_lattice/ui_core.py b/plugin/napari_lattice/ui_core.py
similarity index 60%
rename from src/napari_lattice/ui_core.py
rename to plugin/napari_lattice/ui_core.py
index a62f3230..1e9af07e 100644
--- a/src/napari_lattice/ui_core.py
+++ b/plugin/napari_lattice/ui_core.py
@@ -1,17 +1,17 @@
-import os
+from __future__ import annotations
+
 import numpy as np
 from pathlib import Path
-
-from aicsimageio import AICSImage
-from napari.types import ImageData
+from typing import TYPE_CHECKING
 
 import pyclesperanto_prototype as cle
-from napari_lattice.io import save_img
-from .utils import pad_image_nearest_multiple, check_dimensions
-
-from napari_lattice.llsz_core import pycuda_decon, skimage_decon
-from . import config, DeskewDirection, DeconvolutionChoice, SaveFileType
+from lls_core.utils import check_dimensions
+from lls_core.deconvolution import pycuda_decon, skimage_decon
+from lls_core import config, DeskewDirection, DeconvolutionChoice
+from lls_core .io import save_img
 
+if TYPE_CHECKING:
+    from napari.types import ImageData
 
 # Enable Logging
 import logging
@@ -19,7 +19,6 @@
 # inherit log level from config
 logger.setLevel(config.log_level)
 
-
 def _Preview(LLSZWidget,
              self_class,
              time: int,
@@ -142,79 +141,3 @@ def _Deskew_Save(LLSZWidget,
     print("Deskewing and Saving Complete -> ", save_path)
     return
 
-
-def _read_psf(psf_ch1_path: Path,
-              psf_ch2_path: Path,
-              psf_ch3_path: Path,
-              psf_ch4_path: Path,
-              decon_option,
-              lattice_class):
-    """Read PSF files and return a list of PSF arrays appended to lattice_class.psf
-
-    Args:
-        decon_option (enum): Enum option from DeconvolutionChoice
-        lattice_class: lattice class object, either LLSZWidget.LlszMenu.lattice or lattice class from batch processing
-    """
-    # get the psf paths into a list
-    psf_paths = [psf_ch1_path, psf_ch2_path, psf_ch3_path, psf_ch4_path]
-
-    # remove empty paths; pathlib returns current directory as "." if None or empty str specified
-    # When running batch processing,empty directory will be an empty string
-
-    import platform
-    from pathlib import PureWindowsPath, PosixPath
-
-    if platform.system() == "Linux":
-        psf_paths = [Path(x) for x in psf_paths if x !=
-                     PosixPath(".") and x != ""]
-    elif platform.system() == "Windows":
-        psf_paths = [Path(x) for x in psf_paths if x !=
-                     PureWindowsPath(".") and x != ""]
-
-    logging.debug(f"PSF paths are {psf_paths}")
-    # total no of psf images
-    psf_channels = len(psf_paths)
-    assert psf_channels > 0, f"No images detected for PSF. Check the psf paths -> {psf_paths}"
-
-    # Use CUDA for deconvolution
-    if decon_option == DeconvolutionChoice.cuda_gpu:
-        import importlib
-        pycudadecon_import = importlib.util.find_spec("pycudadecon")
-        assert pycudadecon_import, f"Pycudadecon not detected. Please install using: conda install -c conda-forge pycudadecon"
-        otf_names = ["ch1", "ch2", "ch3", "ch4"]
-        channels = [488, 561, 640, 123]
-        # get temp directory to save generated otf
-        import tempfile
-        temp_dir = tempfile.gettempdir()+os.sep
-
-    for idx, psf in enumerate(psf_paths):
-        if os.path.exists(psf) and psf.is_file():
-            if psf.suffix == ".czi":
-                from aicspylibczi import CziFile
-                psf_czi = CziFile(psf.__str__())
-                psf_aics = psf_czi.read_image()
-                # make sure shape is 3D
-                psf_aics = psf_aics[0][0]  # np.expand_dims(psf_aics[0],axis=0)
-                # if len(psf_aics[0])>=1:
-                #psf_channels = len(psf_aics[0])
-                assert len(
-                    psf_aics.shape) == 3, f"PSF should be a 3D image (shape of 3), but got {psf_aics.shape}"
-                # pad psf to multiple of 16 for decon
-                psf_aics = pad_image_nearest_multiple(
-                    img=psf_aics, nearest_multiple=16)
-                lattice_class.psf.append(psf_aics)
-            else:
-                psf_aics = AICSImage(psf.__str__())
-                psf_aics_data = psf_aics.data[0][0]
-                psf_aics_data = pad_image_nearest_multiple(
-                    img=psf_aics_data, nearest_multiple=16)
-                lattice_class.psf.append(psf_aics_data)
-                if psf_aics.dims.C >= 1:
-                    psf_channels = psf_aics.dims.C
-
-    #LLSZWidget.LlszMenu.lattice.channels =3
-    if psf_channels != lattice_class.channels:
-        logger.warn(
-            f"PSF image has {psf_channels} channel/s, whereas image has {lattice_class.channels}")
-
-    return
diff --git a/plugin/pyproject.toml b/plugin/pyproject.toml
new file mode 100644
index 00000000..2b80c708
--- /dev/null
+++ b/plugin/pyproject.toml
@@ -0,0 +1,80 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.black]
+line-length = 79
+
+[tool.isort]
+profile = "black"
+line_length = 79
+
+[project]
+name = "napari-lattice"
+version = "0.2.7"
+authors = [
+    {name = "Pradeep Rajasekhar"},
+    {name = "Lachlan Whitehead"},
+    {name = "Robert Haase"}
+]
+license = {text = "GPL-3.0-only"}
+description = "Napari plugin for analysing and visualizing lattice lightsheet and Oblique Plane Microscopy data."
+readme = { file = "README.md", content-type = "text/markdown" }
+classifiers = [
+    "Development Status :: 2 - Pre-Alpha",
+    "Intended Audience :: Developers",
+    "Framework :: napari",
+    "Topic :: Software Development :: Testing",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Operating System :: OS Independent",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)"
+]
+requires-python = ">=3.8"
+dependencies = [
+    "aicsimageio>=4.6.3",
+    "aicspylibczi>=3.0.5",
+    "dask",
+    "dask-image",
+    "dask[distributed]",
+    # We need this Python 3.8 fix: https://github.com/hanjinliu/magic-class/pull/108
+    "magic-class>=0.7.4",
+    "magicgui",
+    "pyopencl",
+    "read-roi",
+    "gputools",
+    "pyclesperanto_prototype>=0.20.0",
+    "npy2bdv",
+    "redlionfish",
+    "tifffile", #>=2022.8.12
+    "fsspec>=2022.8.2",
+    "napari[all]",
+    "napari-aicsimageio>=0.7.2",
+    "napari-spreadsheet",
+    "napari-workflows>=0.2.8",
+    "napari-workflow-inspector"
+]
+
+[project.urls]
+Repository = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice"
+BugTracker = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/issues"
+Documentation = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/wiki"
+SourceCode = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice"
+UserSupport = "https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/issues"
+
+[tool.setuptools.package-data]
+napari_lattice = ["*.yaml"]
+
+[project.optional-dependencies]
+testing = [
+    "tox",
+    "pytest",  # https://docs.pytest.org/en/latest/contents.html
+    "pytest-cov",  # https://pytest-cov.readthedocs.io/en/latest/
+    "pytest-qt"  #for qtbot fixture
+]
+
+[project.entry-points."napari.manifest"]
+napari_lattice = "napari_lattice:napari.yaml"
diff --git a/pyrightconfig.json b/pyrightconfig.json
new file mode 100644
index 00000000..6703ee29
--- /dev/null
+++ b/pyrightconfig.json
@@ -0,0 +1,11 @@
+{
+  "reportMissingTypeStubs": false,
+  "reportUnknownVariableType": false,
+  "reportUnknownArgumentType": false,
+  "reportUnknownLambdaType": false,
+  "reportUnknownMemberType": false,
+  "reportUnknownParameterType": false,
+  "reportUntypedFunctionDecorator": false,
+  "reportMissingTypeArgument": false,
+  "reportPrivateUsage": false
+}
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index e05e96f0..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,79 +0,0 @@
-[metadata]
-name = napari-lattice
-version = 0.2.7
-author = Pradeep Rajasekhar, Lachlan Whitehead,Robert Haase
-author_email = bioimageanalysis@wehi.edu.au
-url = https://github.com/BioimageAnalysisCoreWEHI/napari_lattice
-
-license = GPL-3.0-only
-description = Napari plugin for analysing and visualizing lattice lightsheet and Oblique Plane Microscopy data.
-long_description = file: README.md
-long_description_content_type = text/markdown
-classifiers =
-    Development Status :: 2 - Pre-Alpha
-    Intended Audience :: Developers
-    Framework :: napari
-    Topic :: Software Development :: Testing
-    Programming Language :: Python
-    Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.8
-    Programming Language :: Python :: 3.9
-    Programming Language :: Python :: 3.10
-    Operating System :: OS Independent
-    License :: OSI Approved :: GNU General Public License v3 (GPLv3)
-
-project_urls =
-    Bug Tracker = https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/issues
-    Documentation = https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/wiki
-    Source Code = https://github.com/BioimageAnalysisCoreWEHI/napari_lattice
-    User Support = https://github.com/BioimageAnalysisCoreWEHI/napari_lattice/issues
-
-[options]
-packages = find:
-include_package_data = True
-python_requires = >=3.8
-package_dir =
-    =src
-
-# add your package requirements here
-install_requires =
-    aicsimageio>=4.9.1
-    aicspylibczi>=3.0.5
-    dask
-    dask-image
-    dask[distributed]
-    magic-class>=0.6.13
-    magicgui
-    napari[all]
-    pyopencl
-    read-roi
-    gputools
-    pyclesperanto_prototype>=0.20.0
-    napari-aicsimageio>=0.7.2
-    napari-spreadsheet
-    napari-workflows>=0.2.8
-    napari-workflow-inspector
-    npy2bdv
-    redlionfish
-    tifffile #>=2022.8.12
-    fsspec>=2022.8.2
-    
-
-[options.extras_require]
-testing =
-    tox
-    pytest  # https://docs.pytest.org/en/latest/contents.html
-    pytest-cov  # https://pytest-cov.readthedocs.io/en/latest/
-    pytest-qt  #for qtbot fixture
-
-[options.packages.find]
-where = src
-
-[options.package_data]
-napari-lattice = napari.yaml
-
-[options.entry_points]
-napari.manifest =
-    napari_lattice = napari_lattice:napari.yaml
-console_scripts =
-    napari_lattice = napari_lattice.cmds.__main__:main 
diff --git a/setup.py b/setup.py
deleted file mode 100644
index bfae448c..00000000
--- a/setup.py
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-from setuptools import setup
-
-setup()
\ No newline at end of file
diff --git a/src/napari_lattice/_dock_widget.py b/src/napari_lattice/_dock_widget.py
deleted file mode 100644
index 56a07e72..00000000
--- a/src/napari_lattice/_dock_widget.py
+++ /dev/null
@@ -1,1149 +0,0 @@
-import os
-import sys
-import yaml
-import numpy as np
-from pathlib import Path
-import dask.array as da
-import pandas as pd
-
-from magicclass.wrappers import set_design
-from magicgui import magicgui, widgets
-from magicclass import magicclass, field, vfield, set_options
-from magicclass.utils import click
-from qtpy.QtCore import Qt
-
-from napari.layers import Layer
-from napari.types import ImageData
-from napari.utils import history
-
-import pyclesperanto_prototype as cle
-from .ui_core import _Preview, _Deskew_Save, _read_psf
-
-from napari.types import ImageData, ShapesData
-from .llsz_core import crop_volume_deskew, pycuda_decon, skimage_decon
-
-from tqdm import tqdm
-
-from napari_lattice.io import LatticeData,  save_img, save_img_workflow
-from .utils import read_imagej_roi, get_first_last_image_and_task, modify_workflow_task, get_all_py_files, as_type, process_custom_workflow_output, check_dimensions, load_custom_py_modules
-from . import config, DeskewDirection, DeconvolutionChoice, SaveFileType, Log_Levels
-from napari_workflows import Workflow, WorkflowManager
-from napari_workflows._io_yaml_v1 import load_workflow
-
-
-# Enable Logging
-import logging
-logger = logging.getLogger(__name__)
-logger.setLevel(logging.INFO)
-
-
-def _napari_lattice_widget_wrapper():
-    # split widget type enables a resizable widget
-    @magicclass(widget_type="split")
-    class LLSZWidget:
-        @magicclass(widget_type="split")
-        class LlszMenu:
-            open_file = False
-            lattice = None
-            aics = None
-            dask = False
-            file_name = ""
-            save_name = ""
-
-            main_heading = widgets.Label(
-                value="<h3>Napari Lattice: Visualization & Analysis</h3>")
-            heading1 = widgets.Label(
-                value="Drag and drop an image file onto napari.\nOnce image has opened, initialize the\nplugin by clicking the button below.\nEnsure the image layer and voxel sizes are accurate in the prompt.\n If everything initalises properly, the button turns green.")
-
-            @set_design(background_color="magenta", font_family="Consolas", visible=True, text="Initialize Plugin", max_height=75, font_size=13)
-            @set_options(pixel_size_dx={"widget_type": "FloatSpinBox", "value": 0.1449922, "step": 0.000000001},
-                         pixel_size_dy={"widget_type": "FloatSpinBox",
-                                        "value": 0.1449922, "step": 0.000000001},
-                         pixel_size_dz={"widget_type": "FloatSpinBox",
-                                        "value": 0.3, "step": 0.000000001},
-                         angle={"widget_type": "FloatSpinBox",
-                                "value": 30, "step": 0.1},
-                         select_device={"widget_type": "ComboBox", "choices": cle.available_device_names(
-                         ), "value": cle.available_device_names()[0]},
-                         last_dimension_channel={"widget_type": "ComboBox", "choices": ["Channel", "Time", "Get_from_metadata"], "value": "Get_from_metadata",
-                                                 "label": "Set Last dimension (channel/time)", "tooltip": "If the last dimension is initialised incorrectly, you can assign it as either channel/time"},
-                         merge_all_channel_layers={"widget_type": "CheckBox", "value": True, "label": "Merge all napari layers as channels",
-                                                   "tooltip": "Use this option if the channels are in separate layers. napari-lattice requires all channels to be in same layer"},
-                         skew_dir={"widget_type": "ComboBox", "choices": DeskewDirection, "value": DeskewDirection.Y,
-                                   "label": "Direction of skew (Y or X)", "tooltip": "Skew direction when image is acquired. Ask your microscopist for details"},
-                         set_logging={"widget_type": "ComboBox", "choices": Log_Levels, "value": Log_Levels.INFO,
-                                      "label": "Log Level", "tooltip": "Only use for debugging. Leave it as INFO for regular operation"}
-                         )
-            def Choose_Image_Layer(self,
-                                   img_layer: Layer,
-                                   pixel_size_dx: float = 0.1449922,
-                                   pixel_size_dy: float = 0.1449922,
-                                   pixel_size_dz: float = 0.3,
-                                   angle: float = 30,
-                                   select_device: str = cle.available_device_names()[
-                                       0],
-                                   last_dimension_channel: bool = False,
-                                   merge_all_channel_layers: bool = False,
-                                   skew_dir=DeskewDirection.Y,
-                                   set_logging=Log_Levels.INFO):
-
-                logger.setLevel(set_logging.value)
-                config.log_level = set_logging.value
-                logger.info(f"Logging set to {set_logging}")
-                logger.info("Using existing image layer")
-
-                # Select device for processing
-                cle.select_device(select_device)
-
-                #assert skew_dir in DeskewDirection, "Skew direction not recognised. Enter either Y or X"
-                LLSZWidget.LlszMenu.skew_dir = skew_dir
-                LLSZWidget.LlszMenu.angle_value = angle
-
-                if LLSZWidget.LlszMenu.skew_dir == DeskewDirection.Y:
-                    LLSZWidget.LlszMenu.deskew_func = cle.deskew_y
-                    #LLSZWidget.LlszMenu.skew_dir = DeskewDirection.Y
-                elif LLSZWidget.LlszMenu.skew_dir == DeskewDirection.X:
-                    LLSZWidget.LlszMenu.deskew_func = cle.deskew_x
-                    #LLSZWidget.LlszMenu.skew_dir = DeskewDirection.X
-
-                if last_dimension_channel == "Get_from_metadata":
-                    last_dimension_channel = None
-
-                # merge all napari image layers as one multidimensional image
-                if merge_all_channel_layers:
-                    from napari.layers.utils.stack_utils import images_to_stack
-                    # get list of napari layers as a list
-                    layer_list = list(self.parent_viewer.layers)
-                    # if more than one layer
-                    if len(layer_list) > 1:
-                        # convert the list of images into a stack
-                        new_layer = images_to_stack(layer_list)
-                        # select all current layers
-                        self.parent_viewer.layers.select_all()
-                        # remove selected layers
-                        self.parent_viewer.layers.remove_selected()
-                        # add the new composite image layer
-                        self.parent_viewer.add_layer(new_layer)
-                        img_layer = new_layer
-
-                LLSZWidget.LlszMenu.lattice = LatticeData(img=img_layer,
-                                                          angle=angle,
-                                                          skew=LLSZWidget.LlszMenu.skew_dir,
-                                                          dx=pixel_size_dx,
-                                                          dy=pixel_size_dy,
-                                                          dz=pixel_size_dz,
-                                                          last_dimension=last_dimension_channel)
-                #LLSZWidget.LlszMenu.aics = LLSZWidget.LlszMenu.lattice.data
-
-                # LLSZWidget.LlszMenu.dask = False  # Use GPU by default
-
-                # We initialise these variables here, but they can be changed in the deconvolution section
-                # list to store psf images for each channel
-                LLSZWidget.LlszMenu.lattice.psf = []
-                LLSZWidget.LlszMenu.lattice.psf_num_iter = 10
-                LLSZWidget.LlszMenu.lattice.decon_processing = DeconvolutionChoice.cpu
-                # list to store otf paths for each channel (Deprecated)
-                LLSZWidget.LlszMenu.lattice.otf_path = []
-                # if not using GPU
-                #LLSZWidget.LlszMenu.dask = not use_GPU
-
-                # flag for ensuring a file has been opened and plugin initialised
-                LLSZWidget.LlszMenu.open_file = True
-
-                logger.info(
-                    f"Pixel size (ZYX) in microns: {LLSZWidget.LlszMenu.lattice.dz,LLSZWidget.LlszMenu.lattice.dy,LLSZWidget.LlszMenu.lattice.dx}")
-                logger.info(
-                    f"Dimensions of image layer (ZYX): {list(LLSZWidget.LlszMenu.lattice.data.shape[-3:])}")
-                logger.info(
-                    f"Dimensions of deskewed image (ZYX): {LLSZWidget.LlszMenu.lattice.deskew_vol_shape}")
-                logger.info(
-                    f"Deskewing angle is :{LLSZWidget.LlszMenu.lattice.angle}")
-                logger.info(
-                    f"Deskew Direction :{LLSZWidget.LlszMenu.lattice.skew}")
-                # Add dimension labels correctly
-                # if channel, and not time
-                if LLSZWidget.LlszMenu.lattice.time == 0 and (last_dimension_channel or LLSZWidget.LlszMenu.lattice.channels > 0):
-                    self.parent_viewer.dims.axis_labels = list(
-                        ('Channel', "Z", "Y", "X"))
-                # if no channel, but has time
-                elif LLSZWidget.LlszMenu.lattice.channels == 0 and LLSZWidget.LlszMenu.lattice.time > 0:
-                    self.parent_viewer.dims.axis_labels = list(
-                        ('Time', "Z", "Y", "X"))
-                # if it has channels
-                elif LLSZWidget.LlszMenu.lattice.channels > 1:
-                    # If merge to stack is used, channel slider goes to the bottom
-                    if int(self.parent_viewer.dims.dict()["range"][0][1]) == LLSZWidget.LlszMenu.lattice.channels:
-                        self.parent_viewer.dims.axis_labels = list(
-                            ('Channel', "Time", "Z", "Y", "X"))
-                    else:
-                        self.parent_viewer.dims.axis_labels = list(
-                            ('Time', "Channel", "Z", "Y", "X"))
-                # if channels initialized by aicsimagio, then channels is 1
-                elif LLSZWidget.LlszMenu.lattice.channels == 1 and LLSZWidget.LlszMenu.lattice.time > 1:
-                    self.parent_viewer.dims.axis_labels = list(
-                        ('Time', "Z", "Y", "X"))
-
-                logger.info(f"Initialised")
-                self["Choose_Image_Layer"].background_color = "green"
-                self["Choose_Image_Layer"].text = "Plugin Initialised"
-
-                return
-
-            # Pycudadecon library for deconvolution
-            # options={"enabled": True},
-            deconvolution = vfield(bool, name="Use Deconvolution")
-            deconvolution.value = False
-
-            @deconvolution.connect
-            def _set_decon(self):
-                if self.deconvolution:
-                    logger.info("Deconvolution Activated")
-                    LLSZWidget.LlszMenu.deconvolution.value = True
-                else:
-                    logger.info("Deconvolution Disabled")
-                    LLSZWidget.LlszMenu.deconvolution.value = False
-                return
-
-            @set_design(background_color="magenta", font_family="Consolas", visible=True, text="Click to select PSFs for deconvolution", max_height=75, font_size=11)
-            @set_options(header=dict(widget_type="Label", label="<h3>Enter path to the PSF images</h3>"),
-                         psf_ch1_path={"widget_type": "FileEdit",
-                                       "label": "Channel 1:"},
-                         psf_ch2_path={"widget_type": "FileEdit",
-                                       "label": "Channel 2"},
-                         psf_ch3_path={"widget_type": "FileEdit",
-                                       "label": "Channel 3"},
-                         psf_ch4_path={"widget_type": "FileEdit",
-                                       "label": "Channel 4"},
-                         device_option={
-                             "widget_type": "ComboBox", "label": "Choose processing device", "choices": DeconvolutionChoice},
-                         no_iter={
-                             "widget_type": "SpinBox", "label": "No of iterations (Deconvolution)", "value": 10, "min": 1, "max": 50, "step": 1}
-                         )
-            def deconvolution_gui(self,
-                                  header,
-                                  psf_ch1_path: Path,
-                                  psf_ch2_path: Path,
-                                  psf_ch3_path: Path,
-                                  psf_ch4_path: Path,
-                                  device_option,
-                                  no_iter: int):
-                """GUI for Deconvolution button"""
-                LLSZWidget.LlszMenu.lattice.decon_processing = device_option
-                assert LLSZWidget.LlszMenu.deconvolution.value == True, "Deconvolution is set to False. Tick the box to activate deconvolution."
-                _read_psf(psf_ch1_path,
-                          psf_ch2_path,
-                          psf_ch3_path,
-                          psf_ch4_path,
-                          device_option,
-                          LLSZWidget.LlszMenu.lattice)
-                LLSZWidget.LlszMenu.lattice.psf_num_iter = no_iter
-                self["deconvolution_gui"].background_color = "green"
-                self["deconvolution_gui"].text = "PSFs added"
-
-                return
-
-        @magicclass(widget_type="collapsible")
-        class Preview:
-            @magicgui(header=dict(widget_type="Label", label="<h3>Preview Deskew</h3>"),
-                      time=dict(label="Time:", max=2**15),
-                      channel=dict(label="Channel:"),
-                      call_button="Preview")
-            def Preview_Deskew(self,
-                               header,
-                               time: int,
-                               channel: int,
-                               img_data: ImageData):
-                """
-                Preview deskewed data for a single timepoint and channel
-
-                """
-                _Preview(LLSZWidget,
-                         self,
-                         time,
-                         channel,
-                         img_data)
-                return
-
-        # Tabbed Widget container to house all the widgets
-        @magicclass(widget_type="tabbed", name="Functions")
-        class WidgetContainer:
-            @magicclass(name="Deskew", widget_type="scrollable")
-            class DeskewWidget:
-
-                @magicgui(header=dict(widget_type="Label", label="<h3>Deskew and Save</h3>"),
-                          time_start=dict(label="Time Start:", max=2**20),
-                          time_end=dict(label="Time End:", value=1, max=2**20),
-                          ch_start=dict(label="Channel Start:"),
-                          ch_end=dict(label="Channel End:", value=1),
-                          save_as_type={
-                              "label": "Save as filetype:", "choices": SaveFileType, "value": SaveFileType.h5},
-                          save_path=dict(mode='d', label="Directory to save"),
-                          call_button="Save")
-                def Deskew_Save(self,
-                                header,
-                                time_start: int,
-                                time_end: int,
-                                ch_start: int,
-                                ch_end: int,
-                                save_as_type: str,
-                                save_path: Path = Path(history.get_save_history()[0])):
-                    """ Widget to Deskew and Save Data"""
-                    _Deskew_Save(LLSZWidget,
-                                 time_start,
-                                 time_end,
-                                 ch_start,
-                                 ch_end,
-                                 save_as_type,
-                                 save_path)
-                    return
-
-            @magicclass(name="Crop and Deskew", widget_type="scrollable")
-            class CropWidget:
-
-                # add function for previewing cropped image
-                @magicclass(name="Cropping Preview", widget_type="scrollable")
-                class Preview_Crop_Menu:
-
-                    @set_design(font_size=10, text="Click to activate Cropping Layer", background_color="magenta")
-                    @click(enables=["Import_ImageJ_ROI", "Crop_Preview"])
-                    def activate_cropping(self):
-                        LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer = self.parent_viewer.add_shapes(shape_type='polygon', edge_width=1, edge_color='white',
-                                                                                                                             face_color=[1, 1, 1, 0], name="Cropping BBOX layer")
-                        # TO select ROIs if needed
-                        LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.mode = "SELECT"
-                        self["activate_cropping"].text = "Cropping layer active"
-                        self["activate_cropping"].background_color = "green"
-                        return
-
-                    heading2 = widgets.Label(
-                        value="You can either import ImageJ ROI (.zip) files or manually define ROIs using the shape layer")
-
-                    @click(enabled=False)
-                    def Import_ImageJ_ROI(self, path: Path = Path(history.get_open_history()[0])):
-                        logger.info(f"Opening{path}")
-                        roi_list = read_imagej_roi(path)
-                        # convert to canvas coordinates
-                        roi_list = (np.array(roi_list) *
-                                    LLSZWidget.LlszMenu.lattice.dy).tolist()
-                        LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.add(roi_list, shape_type='polygon', edge_width=1, edge_color='yellow',
-                                                                                                 face_color=[1, 1, 1, 0])
-                        return
-
-                    time_crop = field(
-                        int, options={"min": 0, "step": 1, "max": 2**20}, name="Time")
-                    chan_crop = field(
-                        int, options={"min": 0, "step": 1}, name="Channels")
-                    heading_roi = widgets.Label(
-                        value="If there are multiple ROIs, select the ROI before clicking button below")
-                    #roi_idx = field(int, options={"min": 0, "step": 1}, name="ROI number")
-
-                    @click(enabled=False)
-                    # -> LayerDataTuple:
-                    def Crop_Preview(self, roi_layer: ShapesData):
-                        assert roi_layer, "No coordinates found for cropping. Check if right shapes layer or initialise shapes layer and draw ROIs."
-                        # TODO: Add assertion to check if bbox layer or coordinates
-                        time = self.time_crop.value
-                        channel = self.chan_crop.value
-
-                        assert time < LLSZWidget.LlszMenu.lattice.time, "Time is out of range"
-                        assert channel < LLSZWidget.LlszMenu.lattice.channels, "Channel is out of range"
-
-                        logger.info(f"Using channel {channel} and time {time}")
-
-                        vol = LLSZWidget.LlszMenu.lattice.data
-                        vol_zyx = vol[time, channel, ...]
-                        vol_zyx = np.array(vol_zyx)
-
-                        deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
-                        # Create a dask array same shape as deskewed image
-                        deskewed_volume = da.zeros(deskewed_shape)
-
-                        # Option for entering custom z start value?
-                        z_start = 0
-                        z_end = deskewed_shape[0]
-
-                        # if only one roi drawn, use the first ROI for cropping
-                        if len(roi_layer) == 1:
-                            roi_idx = 0
-                        else:
-                            assert len(
-                                LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data) > 0, "Please select an ROI"
-                            roi_idx = list(
-                                LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data)[0]
-
-                        roi_choice = roi_layer[roi_idx]
-                        # As the original image is scaled, the coordinates are in microns, so we need to convert
-                        # roi from micron to canvas/world coordinates
-                        roi_choice = [
-                            x/LLSZWidget.LlszMenu.lattice.dy for x in roi_choice]
-                        logger.info(f"Previewing ROI {roi_idx}")
-
-                        # crop
-                        if LLSZWidget.LlszMenu.deconvolution.value:
-                            logger.info(
-                                f"Deskewing for Time:{time} and Channel: {channel} with deconvolution")
-                            #psf = LLSZWidget.LlszMenu.lattice.psf[channel]
-                            if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
-                                crop_roi_vol_desk = crop_volume_deskew(original_volume=vol_zyx,
-                                                                       deskewed_volume=deskewed_volume,
-                                                                       roi_shape=roi_choice,
-                                                                       angle_in_degrees=LLSZWidget.LlszMenu.angle_value,
-                                                                       voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
-                                                                       voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
-                                                                       voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
-                                                                       z_start=z_start,
-                                                                       z_end=z_end,
-                                                                       deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
-                                                                       decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
-                                                                       psf=LLSZWidget.LlszMenu.lattice.psf[channel],
-                                                                       num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter).astype(vol_zyx.dtype)
-                            else:
-                                crop_roi_vol_desk = crop_volume_deskew(original_volume=vol_zyx,
-                                                                       deskewed_volume=deskewed_volume,
-                                                                       roi_shape=roi_choice,
-                                                                       angle_in_degrees=LLSZWidget.LlszMenu.angle_value,
-                                                                       voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
-                                                                       voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
-                                                                       voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
-                                                                       z_start=z_start,
-                                                                       z_end=z_end,
-                                                                       deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
-                                                                       decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
-                                                                       psf=LLSZWidget.LlszMenu.lattice.psf[channel],
-                                                                       num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter).astype(vol_zyx.dtype)
-                        else:
-                            crop_roi_vol_desk = crop_volume_deskew(original_volume=vol_zyx,
-                                                                   deskewed_volume=deskewed_volume,
-                                                                   roi_shape=roi_choice,
-                                                                   angle_in_degrees=LLSZWidget.LlszMenu.angle_value,
-                                                                   voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
-                                                                   voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
-                                                                   voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
-                                                                   z_start=z_start,
-                                                                   z_end=z_end,
-                                                                   skew_dir=LLSZWidget.LlszMenu.skew_dir).astype(vol_zyx.dtype)
-                        crop_roi_vol_desk = cle.pull(crop_roi_vol_desk)
-
-                        # get array back from gpu or addding cle array to napari can throw errors
-
-                        scale = (LLSZWidget.LlszMenu.lattice.new_dz,
-                                 LLSZWidget.LlszMenu.lattice.dy,
-                                 LLSZWidget.LlszMenu.lattice.dx)
-                        self.parent_viewer.add_image(
-                            crop_roi_vol_desk, scale=scale)
-                        return
-
-                    @magicclass(name="Crop and Save Data")
-                    class CropSaveData:
-                        @magicgui(header=dict(widget_type="Label", label="<h3>Crop and Save Data</h3>"),
-                                  time_start=dict(label="Time Start:"),
-                                  time_end=dict(label="Time End:", value=1),
-                                  ch_start=dict(label="Channel Start:"),
-                                  ch_end=dict(label="Channel End:", value=1),
-                                  save_as_type={
-                                      "label": "Save as filetype:", "choices": SaveFileType},
-                                  save_path=dict(mode='d', label="Directory to save "))
-                        def Crop_Save(self,
-                                      header,
-                                      time_start: int,
-                                      time_end: int,
-                                      ch_start: int,
-                                      ch_end: int,
-                                      save_as_type: str,
-                                      roi_layer_list: ShapesData,
-                                      save_path: Path = Path(history.get_save_history()[0])):
-
-                            if not roi_layer_list:
-                                logger.error(
-                                    "No coordinates found or cropping. Initialise shapes layer and draw ROIs.")
-                            else:
-                                assert LLSZWidget.LlszMenu.open_file, "Image not initialised"
-
-                                check_dimensions(time_start, time_end, ch_start, ch_end,
-                                                 LLSZWidget.LlszMenu.lattice.channels, LLSZWidget.LlszMenu.lattice.time)
-
-                                angle = LLSZWidget.LlszMenu.lattice.angle
-                                dx = LLSZWidget.LlszMenu.lattice.dx
-                                dy = LLSZWidget.LlszMenu.lattice.dy
-                                dz = LLSZWidget.LlszMenu.lattice.dz
-
-                                # get image data
-                                img_data = LLSZWidget.LlszMenu.lattice.data
-                                # Get shape of deskewed image
-                                deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
-                                deskewed_volume = da.zeros(deskewed_shape)
-                                z_start = 0
-                                z_end = deskewed_shape[0]
-
-                                logger.info("Cropping and saving files...")
-
-                                # necessary when scale is used for napari.viewer.add_image operations
-                                roi_layer_list = [
-                                    x/LLSZWidget.LlszMenu.lattice.dy for x in roi_layer_list]
-
-                                for idx, roi_layer in enumerate(tqdm(roi_layer_list, desc="ROI:", position=0)):
-                                    # pass arguments for save tiff, callable and function arguments
-                                    logger.info("Processing ROI ", idx)
-                                    # pass parameters for the crop_volume_deskew function
-
-                                    save_img(vol=img_data,
-                                             func=crop_volume_deskew,
-                                             time_start=time_start,
-                                             time_end=time_end,
-                                             channel_start=ch_start,
-                                             channel_end=ch_end,
-                                             save_name_prefix="ROI_" +
-                                             str(idx),
-                                             save_path=save_path,
-                                             save_file_type=save_as_type,
-                                             save_name=LLSZWidget.LlszMenu.lattice.save_name,
-                                             dx=dx,
-                                             dy=dy,
-                                             dz=dz,
-                                             angle=angle,
-                                             deskewed_volume=deskewed_volume,
-                                             roi_shape=roi_layer,
-                                             angle_in_degrees=angle,
-                                             z_start=z_start,
-                                             z_end=z_end,
-                                             voxel_size_x=dx,
-                                             voxel_size_y=dy,
-                                             voxel_size_z=dz,
-                                             LLSZWidget=LLSZWidget
-                                             )
-
-                                logger.info(
-                                    f"Cropping and Saving Complete -> {save_path}")
-                                return
-
-            @magicclass(name="Workflow", widget_type="scrollable")
-            class WorkflowWidget:
-                @magicclass(name="Preview Workflow", widget_type="scrollable")
-                class PreviewWorkflow:
-                    #time_preview= field(int, options={"min": 0, "step": 1}, name="Time")
-                    #chan_preview = field(int, options={"min": 0, "step": 1}, name="Channels")
-                    @magicgui(header=dict(widget_type="Label", label="<h3>Preview Workflow</h3>"),
-                              time_preview=dict(label="Time:", max=2**20),
-                              chan_preview=dict(label="Channel:"),
-                              get_active_workflow=dict(
-                                  widget_type="Checkbox", label="Get active workflow in napari-workflow", value=False),
-                              workflow_path=dict(
-                                  mode='r', label="Load custom workflow (.yaml/yml)"),
-                              Use_Cropping=dict(
-                                  widget_type="Checkbox", label="Crop Data", value=False),
-                              #custom_module=dict(widget_type="Checkbox",label="Load custom module (looks for *.py files in the workflow directory)",value = False),
-                              call_button="Apply and Preview Workflow")
-                    def Workflow_Preview(self,
-                                         header,
-                                         time_preview: int,
-                                         chan_preview: int,
-                                         get_active_workflow: bool,
-                                         Use_Cropping: bool,
-                                         roi_layer_list: ShapesData,
-                                         workflow_path: Path = Path.home()):
-                        """
-                            Apply napari_workflows to the processing pipeline
-                            User can define a pipeline which can be inspected in napari workflow inspector
-                            and then execute it by ticking  the get active workflow checkbox, 
-                            OR
-                            Use a predefined workflow
-
-                            In both cases, if deskewing is not present as first step, it will be added on
-                            and rest of the task will be made followers
-                            Args:
-
-                        """
-                        print("Previewing deskewed channel and time with workflow")
-                        if get_active_workflow:
-                            # installs the workflow to napari
-                            user_workflow = WorkflowManager.install(
-                                self.parent_viewer).workflow
-                            parent_dir = workflow_path.resolve(
-                            ).parents[0].__str__()+os.sep
-                            logger.info("Workflow loaded from napari")
-                        else:
-
-                            try:
-                                # Automatically scan workflow file directory for *.py files.
-                                # If it findss one, load it as a module
-
-                                parent_dir = workflow_path.resolve(
-                                ).parents[0].__str__()+os.sep
-                                sys.path.append(parent_dir)
-                                custom_py_files = get_all_py_files(parent_dir)
-                                if len(custom_py_files) == 0:
-                                    logger.error(
-                                        f"No custom modules imported. If you'd like to use a cusotm module, place a *.py file in same folder as the workflow file {parent_dir}")
-                                else:
-                                    modules = load_custom_py_modules(
-                                        custom_py_files)
-
-                                    logger.info(
-                                        f"Custom modules imported {modules}")
-                                user_workflow = load_workflow(
-                                    workflow_path.__str__())
-                            except yaml.loader.ConstructorError as e:
-                                logger.error(
-                                    "\033[91m While loading workflow, got the following error which may mean you need to install the corresponding module in your Python environment: \033[0m")
-                                logger.error(e)
-
-                            #user_workflow = load_workflow(workflow_path)
-                            logger.info("Workflow loaded from file")
-
-                        assert type(
-                            user_workflow) is Workflow, "Workflow loading error. Check if file is workflow or if required libraries are installed"
-
-                        input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
-                            user_workflow)
-                        #print(input_arg_first, input_arg_last, first_task_name,last_task_name )
-                        # get list of tasks
-                        task_list = list(user_workflow._tasks.keys())
-                        logger.info(f"Workflow loaded:{user_workflow}")
-                        # logger.info()
-
-                        # when using fields, self.time_preview.value
-                        assert time_preview < LLSZWidget.LlszMenu.lattice.time, "Time is out of range"
-                        assert chan_preview < LLSZWidget.LlszMenu.lattice.channels, "Channel is out of range"
-
-                        time = time_preview
-                        channel = chan_preview
-
-                        # to access current time and channel and pass it to workflow file
-                        config.channel = channel
-                        config.time = time
-
-                        logger.info(
-                            f"Processing for Time: {time} and Channel: {channel}")
-
-                        vol = LLSZWidget.LlszMenu.lattice.data
-                        vol_zyx = vol[time, channel, ...]
-                        vol_zyx = np.array(vol_zyx)
-
-                        task_name_start = first_task_name[0]
-                        try:
-                            task_name_last = last_task_name[0]
-                        except IndexError:
-                            task_name_last = task_name_start
-
-                        # variables to hold task name, initialize it as None
-                        # if gpu, set otf_path, otherwise use psf
-                        psf = None
-                        otf_path = None
-
-                        if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
-                            otf_path = "otf_path"
-                        else:
-                            psf = "psf"
-
-                        # if cropping, set that as first task
-                        # get the function associated with the first task and check if its deskewing
-                        if Use_Cropping:
-                            # use deskewed volume for cropping function
-                            deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
-                            deskewed_volume = da.zeros(deskewed_shape)
-                            z_start = 0
-                            z_end = deskewed_shape[0]
-                            if user_workflow.get_task(task_name_start)[0] not in [crop_volume_deskew]:
-                                # if only one roi drawn, use the first ROI for cropping
-                                if len(roi_layer_list) == 1:
-                                    roi_idx = 0
-                                else:  # else get the user selection
-                                    assert len(
-                                        LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data) > 0, "Please select an ROI"
-                                    roi_idx = list(
-                                        LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.shapes_layer.selected_data)[0]
-
-                                roi_choice = roi_layer_list[roi_idx]
-                                # As the original image is scaled, the coordinates are in microns, so we need to convert
-                                # roi to from micron to canvas/world coordinates
-                                roi_choice = [
-                                    x/LLSZWidget.LlszMenu.lattice.dy for x in roi_choice]
-                                logger.info(f"Previewing ROI {roi_idx}")
-                                if LLSZWidget.LlszMenu.deconvolution.value:
-                                    user_workflow.set("crop_deskew_image", crop_volume_deskew,
-                                                      original_volume=vol_zyx,
-                                                      deskewed_volume=deskewed_volume,
-                                                      roi_shape=roi_choice,
-                                                      angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
-                                                      voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
-                                                      voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
-                                                      voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
-                                                      z_start=z_start,
-                                                      z_end=z_end,
-                                                      deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
-                                                      decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
-                                                      otf_path=otf_path,
-                                                      psf=LLSZWidget.LlszMenu.lattice.psf[channel],
-                                                      skew_dir=LLSZWidget.LlszMenu.skew_dir)
-                                else:
-                                    user_workflow.set("crop_deskew_image", crop_volume_deskew,
-                                                      original_volume=vol_zyx,
-                                                      deskewed_volume=deskewed_volume,
-                                                      roi_shape=roi_choice,
-                                                      angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
-                                                      voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
-                                                      voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
-                                                      voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
-                                                      z_start=z_start,
-                                                      z_end=z_end,
-                                                      skew_dir=LLSZWidget.LlszMenu.skew_dir)
-
-                                # Set input of the workflow to be  crop_deskewing, i.e., the original first operation will now have crop_deskew_image as an input (becoming second instead)
-                                user_workflow.set(
-                                    input_arg_first, "crop_deskew_image")
-                            else:
-                                user_workflow.set(input_arg_first, vol_zyx)
-                        # Not cropping; If deskew not in workflow, append to start
-                        elif user_workflow.get_task(task_name_start)[0] not in (cle.deskew_y, cle.deskew_x):
-                            # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
-                            if LLSZWidget.LlszMenu.deconvolution.value:
-                                psf = LLSZWidget.LlszMenu.lattice.psf[channel]
-                                input_arg_first_decon, input_arg_last_decon, first_task_name_decon, last_task_name_decon = get_first_last_image_and_task(
-                                    user_workflow)
-
-                                if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
-                                    user_workflow.set("deconvolution",
-                                                      pycuda_decon,
-                                                      image=vol_zyx,
-                                                      psf=LLSZWidget.LlszMenu.lattice.psf[channel],
-                                                      dzdata=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxdata=LLSZWidget.LlszMenu.lattice.dx,
-                                                      dzpsf=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxpsf=LLSZWidget.LlszMenu.lattice.dx,
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
-                                    # user_workflow.set(input_arg_first_decon,"deconvolution")
-                                else:
-                                    user_workflow.set("deconvolution",
-                                                      skimage_decon,
-                                                      vol_zyx=vol_zyx,
-                                                      psf=LLSZWidget.LlszMenu.lattice.psf[channel],
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
-                                                      clip=False,
-                                                      filter_epsilon=0,
-                                                      boundary='nearest')
-                                    # user_workflow.set(input_arg_first_decon,"deconvolution")
-
-                                user_workflow.set("deskew_image",
-                                                  LLSZWidget.LlszMenu.deskew_func,
-                                                  "deconvolution",
-                                                  angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
-                                                  voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
-                                                  voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
-                                                  voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
-                                                  linear_interpolation=True)
-
-                                # user_workflow.set("change_bitdepth",as_type,"deskew_image",vol_zyx)
-                                # Set input of the workflow to be from deskewing output with same bit depth as original volume
-                                # user_workflow.set(input_arg_first,"change_bitdepth")
-
-                            else:
-                                user_workflow.set("deskew_image",
-                                                  LLSZWidget.LlszMenu.deskew_func,
-                                                  vol_zyx,
-                                                  angle_in_degrees=LLSZWidget.LlszMenu.lattice.angle,
-                                                  voxel_size_x=LLSZWidget.LlszMenu.lattice.dx,
-                                                  voxel_size_y=LLSZWidget.LlszMenu.lattice.dy,
-                                                  voxel_size_z=LLSZWidget.LlszMenu.lattice.dz,
-                                                  linear_interpolation=True)
-                                # Set input of the workflow to be from deskewing
-                                # user_workflow.set(input_arg_first,"deskew_image")
-
-                            user_workflow.set(
-                                "change_bitdepth", as_type, "deskew_image", vol_zyx)
-                            # Set input of the workflow to be from deskewing with same bit depth as original volume
-                            user_workflow.set(
-                                input_arg_first, "change_bitdepth")
-
-                        else:
-                            # if deskew already in workflow, just check if deconvolution needs to be added
-                            # repitition of above (maybe create a function?)
-                            # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
-                            if LLSZWidget.LlszMenu.deconvolution.value:
-                                psf = LLSZWidget.LlszMenu.lattice.psf[channel]
-                                input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
-                                    user_workflow)
-
-                                if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
-                                    user_workflow.set("deconvolution",
-                                                      pycuda_decon,
-                                                      image=vol_zyx,
-                                                      psf=LLSZWidget.LlszMenu.lattice.psf[channel],
-                                                      dzdata=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxdata=LLSZWidget.LlszMenu.lattice.dx,
-                                                      dzpsf=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxpsf=LLSZWidget.LlszMenu.lattice.dx,
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
-                                    # user_workflow.set(input_arg_first,"deconvolution")
-                                else:
-                                    user_workflow.set("deconvolution",
-                                                      skimage_decon,
-                                                      vol_zyx=vol_zyx,
-                                                      psf=LLSZWidget.LlszMenu.lattice.psf[channel],
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
-                                                      clip=False,
-                                                      filter_epsilon=0,
-                                                      boundary='nearest')
-
-                                # set input to subsequent task as deconvolution output
-                                user_workflow.set(
-                                    input_arg_first, "deconvolution")
-
-                        logger.info("Workflow to be executed:")
-                        logger.info(user_workflow)
-                        # Execute workflow
-                        processed_vol = user_workflow.get(task_name_last)
-
-                        # check if a measurement table (usually a dictionary or list)  or a tuple with different data types
-                        # The function below saves the tables and adds any images to napari window
-                        if type(processed_vol) in [dict, list, tuple]:
-                            if (len(processed_vol) > 1):
-                                df = pd.DataFrame()
-                                for idx, i in enumerate(processed_vol):
-                                    df_temp = process_custom_workflow_output(
-                                        i, parent_dir, idx, LLSZWidget, self, channel, time, preview=True)
-                                    final_df = pd.concat([df, df_temp])
-                                    # append dataframes from every loop and have table command outside loop?
-                                # TODO: Figure out why table is not displaying
-                                from napari_spreadsheet import _widget
-                                table_viewer = _widget.TableViewerWidget(
-                                    show=True)
-                                table_viewer.add_spreadsheet(final_df)
-                                # widgets.Table(value=final_df).show()
-
-                        else:
-                            # add image to napari window
-                            # TODO: check if its an image napari supports?
-                            process_custom_workflow_output(
-                                processed_vol, parent_dir, 0, LLSZWidget, self, channel, time)
-
-                        print("Workflow complete")
-                        pass
-
-                    @magicgui(header=dict(widget_type="Label", label="<h3>Apply Workflow and Save Output</h3>"),
-                              time_start=dict(label="Time Start:", max=2**20),
-                              time_end=dict(label="Time End:",
-                                            value=1, max=2**20),
-                              ch_start=dict(label="Channel Start:"),
-                              ch_end=dict(label="Channel End:", value=1),
-                              Use_Cropping=dict(
-                                  widget_type="Checkbox", label="Crop Data", value=False),
-                              get_active_workflow=dict(
-                                  widget_type="Checkbox", label="Get active workflow in napari-workflow", value=False),
-                              workflow_path=dict(
-                                  mode='r', label="Load custom workflow (.yaml/yml)"),
-                              save_as_type={
-                                  "label": "Save as filetype:", "choices": SaveFileType},
-                              save_path=dict(
-                                  mode='d', label="Directory to save "),
-                              #custom_module=dict(widget_type="Checkbox",label="Load custom module (same dir as workflow)",value = False),
-                              call_button="Apply Workflow and Save Result")
-                    def Apply_Workflow_and_Save(self,
-                                                header,
-                                                time_start: int,
-                                                time_end: int,
-                                                ch_start: int,
-                                                ch_end: int,
-                                                Use_Cropping,
-                                                roi_layer_list: ShapesData,
-                                                get_active_workflow: bool = False,
-                                                workflow_path: Path = Path.home(),
-                                                save_as_type: str = SaveFileType.tiff,
-                                                save_path: Path = Path(history.get_save_history()[0])):
-                        """
-                        Apply a user-defined analysis workflow using napari-workflows
-
-                        Args:
-                            time_start (int): Start Time
-                            time_end (int): End Time
-                            ch_start (int): Start Channel
-                            ch_end (int): End Channel
-                            Use_Cropping (_type_): Use cropping based on ROIs in the shapes layer
-                            roi_layer_list (ShapesData): Shapes layer to use for cropping; can be a list of shapes
-                            get_active_workflow (bool, optional): Gets active workflow in napari. Defaults to False.
-                            workflow_path (Path, optional): User can also choose a custom workflow defined in a yaml file.
-                            save_path (Path, optional): Path to save resulting data
-                        """
-                        assert LLSZWidget.LlszMenu.open_file, "Image not initialised"
-
-                        check_dimensions(time_start, time_end, ch_start, ch_end,
-                                         LLSZWidget.LlszMenu.lattice.channels, LLSZWidget.LlszMenu.lattice.time)
-
-                        # Get parameters
-                        angle = LLSZWidget.LlszMenu.lattice.angle
-                        dx = LLSZWidget.LlszMenu.lattice.dx
-                        dy = LLSZWidget.LlszMenu.lattice.dy
-                        dz = LLSZWidget.LlszMenu.lattice.dz
-
-                        if get_active_workflow:
-                            # installs the workflow to napari
-                            user_workflow = WorkflowManager.install(
-                                self.parent_viewer).workflow
-                            print("Workflow installed")
-                        else:
-                            # Automatically scan workflow file directory for *.py files.
-                            # If it findss one, load it as a module
-                            import importlib
-                            parent_dir = workflow_path.resolve(
-                            ).parents[0].__str__()+os.sep
-                            sys.path.append(parent_dir)
-                            custom_py_files = get_all_py_files(parent_dir)
-                            if len(custom_py_files) == 0:
-                                print(
-                                    f"No custom modules imported. If you'd like to use a cusotm module, place a *.py file in same folder as the workflow file {parent_dir}")
-                            else:
-                                modules = map(
-                                    importlib.import_module, custom_py_files)
-                                print(f"Custom modules imported {modules}")
-                            user_workflow = load_workflow(workflow_path)
-
-                        assert type(
-                            user_workflow) is Workflow, "Workflow file is not a napari workflow object. Check file! You can use workflow inspector if needed"
-
-                        input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
-                            user_workflow)
-                        print(input_arg_first, input_arg_last,
-                              first_task_name, last_task_name)
-                        # get list of tasks
-                        task_list = list(user_workflow._tasks.keys())
-                        print("Workflow loaded:")
-                        print(user_workflow)
-
-                        vol = LLSZWidget.LlszMenu.lattice.data
-
-                        #vol_zyx= vol[time,channel,...]
-
-                        task_name_start = first_task_name[0]
-
-                        try:
-                            task_name_last = last_task_name[0]
-                        except IndexError:
-                            task_name_last = task_name_start
-
-                        # variables to hold task name, initialize it as None
-                        # if gpu, set otf_path, otherwise use psf
-                        psf = None
-                        otf_path = None
-
-                        if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
-                            #otf_path = "otf_path"
-                            psf_arg = "psf"
-                            psf = LLSZWidget.LlszMenu.lattice.psf
-                        else:
-                            psf_arg = "psf"
-                            psf = LLSZWidget.LlszMenu.lattice.psf
-                        # if cropping, set that as first task
-
-                        if Use_Cropping:
-                            # convert Roi pixel coordinates to canvas coordinates
-                            # necessary only when scale is used for napari.viewer.add_image operations
-                            roi_layer_list = [
-                                x/LLSZWidget.LlszMenu.lattice.dy for x in roi_layer_list]
-
-                            deskewed_shape = LLSZWidget.LlszMenu.lattice.deskew_vol_shape
-                            deskewed_volume = da.zeros(deskewed_shape)
-                            z_start = 0
-                            z_end = deskewed_shape[0]
-                            roi = "roi"
-                            volume = "volume"
-                            # Check if decon ticked, if so set as first and crop as second?
-
-                            # Create workflow for cropping and deskewing
-                            # volume and roi used will be set dynamically
-                            user_workflow.set("crop_deskew_image", crop_volume_deskew,
-                                              original_volume=volume,
-                                              deskewed_volume=deskewed_volume,
-                                              roi_shape=roi,
-                                              angle_in_degrees=angle,
-                                              voxel_size_x=dx,
-                                              voxel_size_y=dy,
-                                              voxel_size_z=dz,
-                                              z_start=z_start,
-                                              z_end=z_end,
-                                              deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
-                                              decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
-                                              psf=psf_arg,
-                                              skew_dir=LLSZWidget.LlszMenu.skew_dir)
-
-                            # change the first task so it accepts "crop_deskew as input"
-                            new_task = modify_workflow_task(
-                                old_arg=input_arg_first, task_key=task_name_start, new_arg="crop_deskew_image", workflow=user_workflow)
-                            user_workflow.set(task_name_start, new_task)
-
-                            for idx, roi_layer in enumerate(tqdm(roi_layer_list, desc="ROI:", position=0)):
-                                print("Processing ROI ", idx)
-                                user_workflow.set(roi, roi_layer)
-                                save_img_workflow(vol=vol,
-                                                  workflow=user_workflow,
-                                                  input_arg=volume,
-                                                  first_task="crop_deskew_image",
-                                                  last_task=task_name_last,
-                                                  time_start=time_start,
-                                                  time_end=time_end,
-                                                  channel_start=ch_start,
-                                                  channel_end=ch_end,
-                                                  save_file_type=save_as_type,
-                                                  save_path=save_path,
-                                                  #roi_layer = roi_layer,
-                                                  save_name_prefix="ROI_" + \
-                                                  str(idx),
-                                                  save_name=LLSZWidget.LlszMenu.lattice.save_name,
-                                                  dx=dx,
-                                                  dy=dy,
-                                                  dz=dz,
-                                                  angle=angle,
-                                                  deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
-                                                  decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
-                                                  otf_path=otf_path,
-                                                  psf_arg=psf_arg,
-                                                  psf=psf)
-
-                        # IF just deskewing and its not in the tasks, add that as first task
-                        elif user_workflow.get_task(task_name_start)[0] not in (cle.deskew_y, cle.deskew_x):
-                            input = "input"
-                            # add task to the workflow
-                            user_workflow.set("deskew_image",
-                                              LLSZWidget.LlszMenu.deskew_func,
-                                              input_image=input,
-                                              angle_in_degrees=angle,
-                                              voxel_size_x=dx,
-                                              voxel_size_y=dy,
-                                              voxel_size_z=dz,
-                                              linear_interpolation=True)
-                            # Set input of the workflow to be from deskewing
-                            # change workflow task starts from is "deskew_image" and
-                            new_task = modify_workflow_task(
-                                old_arg=input_arg_first, task_key=task_name_start, new_arg="deskew_image", workflow=user_workflow)
-                            user_workflow.set(task_name_start, new_task)
-
-                            # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
-                            if LLSZWidget.LlszMenu.deconvolution.value:
-                                psf = "psf"
-                                otf_path = "otf_path"
-                                input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
-                                    user_workflow)
-
-                                if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
-                                    user_workflow.set("deconvolution",
-                                                      pycuda_decon,
-                                                      image=input,
-                                                      psf=psf_arg,
-                                                      dzdata=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxdata=LLSZWidget.LlszMenu.lattice.dx,
-                                                      dzpsf=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxpsf=LLSZWidget.LlszMenu.lattice.dx,
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
-                                    # user_workflow.set(input_arg_first,"deconvolution")
-                                else:
-                                    user_workflow.set("deconvolution",
-                                                      skimage_decon,
-                                                      vol_zyx=input,
-                                                      psf=psf_arg,
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
-                                                      clip=False,
-                                                      filter_epsilon=0,
-                                                      boundary='nearest')
-                                # modify the user workflow so "deconvolution" is accepted
-                                new_task = modify_workflow_task(
-                                    old_arg=input_arg_first, task_key=task_name_start, new_arg="deconvolution", workflow=user_workflow)
-                                user_workflow.set(task_name_start, new_task)
-                                input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
-                                    user_workflow)
-                                task_name_start = first_task_name[0]
-
-                            save_img_workflow(vol=vol,
-                                              workflow=user_workflow,
-                                              input_arg=input,
-                                              first_task=task_name_start,
-                                              last_task=task_name_last,
-                                              time_start=time_start,
-                                              time_end=time_end,
-                                              channel_start=ch_start,
-                                              channel_end=ch_end,
-                                              save_file_type=save_as_type,
-                                              save_path=save_path,
-                                              save_name=LLSZWidget.LlszMenu.lattice.save_name,
-                                              dx=dx,
-                                              dy=dy,
-                                              dz=dz,
-                                              angle=angle,
-                                              deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
-                                              decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
-                                              otf_path=otf_path,
-                                              psf_arg=psf_arg,
-                                              psf=psf)
-
-                        # If deskewing is already as a task, then set the first argument to input so we can modify that later
-                        else:
-                            # if deskewing is already first task, then check if deconvolution needed
-                            # if deconvolution checked, add it to start of workflow (add upstream of deskewing)
-                            if LLSZWidget.LlszMenu.deconvolution.value:
-                                psf = "psf"
-                                otf_path = "otf_path"
-                                input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
-                                    user_workflow)
-
-                                if LLSZWidget.LlszMenu.lattice.decon_processing == DeconvolutionChoice.cuda_gpu:
-                                    user_workflow.set("deconvolution",
-                                                      pycuda_decon,
-                                                      image=input,
-                                                      psf=psf_arg,
-                                                      dzdata=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxdata=LLSZWidget.LlszMenu.lattice.dx,
-                                                      dzpsf=LLSZWidget.LlszMenu.lattice.dz,
-                                                      dxpsf=LLSZWidget.LlszMenu.lattice.dx,
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter)
-                                    # user_workflow.set(input_arg_first,"deconvolution")
-                                else:
-                                    user_workflow.set("deconvolution",
-                                                      skimage_decon,
-                                                      vol_zyx=input,
-                                                      psf=psf_arg,
-                                                      num_iter=LLSZWidget.LlszMenu.lattice.psf_num_iter,
-                                                      clip=False,
-                                                      filter_epsilon=0,
-                                                      boundary='nearest')
-                                # modify the user workflow so "deconvolution" is accepted
-                                new_task = modify_workflow_task(
-                                    old_arg=input_arg_first, task_key=task_name_start, new_arg="deconvolution", workflow=user_workflow)
-                                user_workflow.set(task_name_start, new_task)
-                                input_arg_first, input_arg_last, first_task_name, last_task_name = get_first_last_image_and_task(
-                                    user_workflow)
-                                task_name_start = first_task_name[0]
-
-                            # we pass first argument as input
-                            save_img_workflow(vol=vol,
-                                              workflow=user_workflow,
-                                              input_arg=input_arg_first,
-                                              first_task=task_name_start,
-                                              last_task=task_name_last,
-                                              time_start=time_start,
-                                              time_end=time_end,
-                                              channel_start=ch_start,
-                                              channel_end=ch_end,
-                                              save_file_type=save_as_type,
-                                              save_path=save_path,
-                                              save_name=LLSZWidget.LlszMenu.lattice.save_name,
-                                              dx=dx,
-                                              dy=dy,
-                                              dz=dz,
-                                              angle=angle,
-                                              deconvolution=LLSZWidget.LlszMenu.deconvolution.value,
-                                              decon_processing=LLSZWidget.LlszMenu.lattice.decon_processing,
-                                              otf_path=otf_path,
-                                              psf_arg=psf_arg,
-                                              psf=psf)
-
-                        print("Workflow complete")
-                        return
-
-                pass
-    LLSZWidget.WidgetContainer.DeskewWidget.max_width = 100
-    LLSZWidget.WidgetContainer.CropWidget.Preview_Crop_Menu.max_width = 100
-    #max_height = 50
-    # Important to have this or napari won't recognize the classes and magicclass qidgets
-    widget = LLSZWidget()
-    # aligning collapsible widgets at the top instead of having them centered vertically
-    widget._widget._layout.setAlignment(Qt.AlignTop)
-
-    # widget._widget._layout.setWidgetResizable(True)
-    return widget


Test patch:
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index 9c82b6e3..b4326d8f 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -5,6 +5,9 @@
 name: tests
 
 on:
+  pull_request:
+    paths-ignore:
+      - "**/README.md"
   push:
     paths-ignore:
       - "**/README.md"
@@ -15,7 +18,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.8", "3.9", "3.10"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
     env:
       DISPLAY: ":99.0"
     steps:
@@ -30,20 +33,35 @@ jobs:
         shell: bash -l {0}
         run: conda info
 
+      - name: Install core
+        shell: bash -l {0}
+        run: |
+          conda install -y pyopencl pocl
+          python --version
+          pip install --upgrade pip setuptools wheel
+          pip install --use-pep517 -e './core[testing]'
+
+      - name: Test core
+        shell: bash -l {0}
+        run: pytest -v --cov=napari_lattice --cov-report=xml core/
+
+      - name: Test core with drivers
+        shell: bash -l {0}
+        run: pytest -v --cov=napari_lattice --cov-report=xml core/
+
       - uses: tlambert03/setup-qt-libs@v1
 
-      - name: Install dependencies
+      - name: Install plugin
         shell: bash -l {0}
         run: |
           /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX
-          python --version
-          conda install -y pyopencl pocl
-          python -m pip install --upgrade pip
-          python -m pip install setuptools wheel pytest pytest-cov pytest-benchmark pytest-qt PyQt5
-          pip install -e .
-      - name: Test
+          pip install pytest-qt PyQt5
+          pip install -e './plugin[testing]'
+
+      - name: Test plugin
         shell: bash -l {0}
-        run: pytest -v --cov=napari_lattice --cov-report=xml
+        run: pytest -v --cov=napari_lattice --cov-report=xml plugin
+
       - name: Coverage
         uses: codecov/codecov-action@v3
 
diff --git a/src/napari_lattice/_tests/__init__.py b/core/tests/__init__.py
similarity index 100%
rename from src/napari_lattice/_tests/__init__.py
rename to core/tests/__init__.py
diff --git a/core/tests/data/deconvolved.tif b/core/tests/data/deconvolved.tif
new file mode 100644
index 00000000..4f1efd72
Binary files /dev/null and b/core/tests/data/deconvolved.tif differ
diff --git a/src/napari_lattice/_tests/data/psf.tif b/core/tests/data/psf.tif
similarity index 100%
rename from src/napari_lattice/_tests/data/psf.tif
rename to core/tests/data/psf.tif
diff --git a/src/napari_lattice/_tests/data/raw.tif b/core/tests/data/raw.tif
similarity index 100%
rename from src/napari_lattice/_tests/data/raw.tif
rename to core/tests/data/raw.tif
diff --git a/core/tests/test_arg_parser.py b/core/tests/test_arg_parser.py
new file mode 100644
index 00000000..afcf0af1
--- /dev/null
+++ b/core/tests/test_arg_parser.py
@@ -0,0 +1,13 @@
+from lls_core.cmds.__main__ import make_parser
+
+def test_voxel_parsing():
+    # Tests that we can parse voxel lists correctly
+    parser = make_parser()
+    args = parser.parse_args([
+            "--input", "input",
+            "--output", "output",
+            "--processing", "deskew",
+            "--output_file_type", "tiff",
+            "--voxel_sizes", "1", "1", "1"
+    ])
+    assert args.voxel_sizes == [1.0, 1.0, 1.0]
\ No newline at end of file
diff --git a/core/tests/test_batch_deskew_args.py b/core/tests/test_batch_deskew_args.py
new file mode 100644
index 00000000..eb09bc35
--- /dev/null
+++ b/core/tests/test_batch_deskew_args.py
@@ -0,0 +1,53 @@
+# Tests for napari_lattice using arguments and saving output files as h5, as well as tiff
+
+from skimage.io import imsave
+import numpy as np
+from pathlib import Path
+import tempfile
+from lls_core.cmds.__main__ import main as run_cli
+
+def create_image(path: Path):
+    # Create a zero array of shape 5x5x5 with a value of 10 at (2,4,2)
+    raw = np.zeros((5, 5, 5))
+    raw[2, 4, 2] = 10
+    # Save image as a tif filw in home directory
+    imsave(str(path), raw)
+    assert path.exists()
+
+
+def test_batch_deskew_h5():
+    """Write image to disk and then execute napari_lattice from terminal
+       Checks if an deskewed output file is created for both tif and h5
+    """
+    with tempfile.TemporaryDirectory() as out_dir:
+        out_dir = Path(out_dir)
+        input_file = out_dir / 'raw.tiff'
+        create_image(input_file)
+        # Batch deskew and save as h5
+        run_cli([
+            "--input", str(input_file),
+            "--output", str(out_dir),
+            "--processing", "deskew",
+            "--output_file_type", "h5"
+        ])
+
+        # checks if h5 files written
+        assert (out_dir / "raw" / "raw.h5").exists()
+        assert (out_dir / "raw" / "raw.xml").exists()
+
+
+def test_batch_deskew_tiff():
+    # tiff file deskew
+    with tempfile.TemporaryDirectory() as out_dir:
+        out_dir = Path(out_dir)
+        input_file = out_dir / 'raw.tiff'
+        create_image(input_file)
+        run_cli([
+            "--input", str(input_file),
+            "--output", str(out_dir),
+            "--processing", "deskew",
+            "--output_file_type", "tiff"
+        ])
+
+        # checks if tiff written
+        assert (out_dir / "raw" / "C0T0_raw.tif").exists()
diff --git a/src/napari_lattice/_tests/test_batch_deskew_yaml.py b/core/tests/test_batch_deskew_yaml.py
similarity index 50%
rename from src/napari_lattice/_tests/test_batch_deskew_yaml.py
rename to core/tests/test_batch_deskew_yaml.py
index fbbd9b5f..b8c4f3ea 100644
--- a/src/napari_lattice/_tests/test_batch_deskew_yaml.py
+++ b/core/tests/test_batch_deskew_yaml.py
@@ -1,28 +1,15 @@
 # Tests for napari_lattice using the config file and saving ouput as h5
 # Thanks to DrLachie for cool function to write the config file
 
-import subprocess
 from skimage.io import imread, imsave
-import os
+import tempfile
 import numpy as np
 from pathlib import Path
-import platform
+from lls_core.cmds.__main__ import main as run_cli
 
-# For testing in Windows
-if platform.system() == "Windows":
-    home_dir = str(Path.home())
-    home_dir = home_dir.replace("\\", "\\\\")
-    img_dir = home_dir + "\\\\raw.tiff"
-    config_location = home_dir + "\\\\config_deskew.yaml"
-else:
-    home_dir = str(Path.home())
-    img_dir = os.path.join(home_dir, "raw.tiff")
-    config_location = os.path.join(home_dir, "config_deskew.yaml")
-
-
-def write_config_file(config_settings, output_file_location):
+def write_config_file(config_settings: dict, output_file_location: Path):
     # Write config file for napari_lattice
-    with open(output_file_location, 'w') as f:
+    with output_file_location.open('w') as f:
         for key, val in config_settings.items():
             if val is not None:
                 if type(val) is str:
@@ -41,38 +28,40 @@ def write_config_file(config_settings, output_file_location):
 
     print("Config found written to %s" % output_file_location)
 
+def create_data(dir: Path) -> Path:
+    input_file = dir / 'raw.tiff'
+    config_location = dir / "config_deskew.yaml"
 
-def create_data():
     # Create a zero array of shape 5x5x5 with a value of 10 at (2,4,2)
     raw = np.zeros((5, 5, 5))
     raw[2, 4, 2] = 10
     # Save image as a tif filw in home directory
-    imsave(img_dir, raw)
-    assert os.path.exists(img_dir)
+    imsave(input_file, raw)
+    assert input_file.exists()
 
-    config = {
-        "input": img_dir,
-        "output": home_dir,
+    config: dict[str, str] = {
+        "input": str(input_file),
+        "output": str(dir),
         "processing": "deskew",
-        "output_file_type": "h5"}
+        "output_file_type": "h5"
+    }
 
     write_config_file(config, config_location)
-    assert os.path.exists(config_location)
+    assert config_location.exists()
+
+    return config_location
 
 
 def test_yaml_deskew():
     """Write image to disk and then execute napari_lattice from terminal
        Checks if an deskewed output file is created for both tif and h5
     """
-    create_data()
-    # Batch deskew and save as h5
-    cmd = f"napari_lattice --config {config_location}"
-    deskew_process_yaml = subprocess.Popen(
-        cmd, stdout=subprocess.PIPE, shell=True)
-
-    # Read data from stdout and stderr. waits for process to terminate, or it will execute next line immmediately
-    _ = deskew_process_yaml.communicate()
+    with tempfile.TemporaryDirectory() as test_dir:
+        test_dir = Path(test_dir)
+        config_location = create_data(test_dir)
+        # Batch deskew and save as h5
+        run_cli(["--config", str(config_location)])
 
-    # checks if h5 files written
-    assert os.path.exists(os.path.join(home_dir, "raw", "raw.h5"))
-    assert os.path.exists(os.path.join(home_dir, "raw", "raw.xml"))
+        # checks if h5 files written
+        assert (test_dir / "raw" / "raw.h5").exists()
+        assert (test_dir / "raw" / "raw.xml").exists()
diff --git a/src/napari_lattice/_tests/test_crop_deskew.py b/core/tests/test_crop_deskew.py
similarity index 95%
rename from src/napari_lattice/_tests/test_crop_deskew.py
rename to core/tests/test_crop_deskew.py
index 8eb8f17a..e873c3f7 100644
--- a/src/napari_lattice/_tests/test_crop_deskew.py
+++ b/core/tests/test_crop_deskew.py
@@ -1,7 +1,7 @@
 import pyclesperanto_prototype as cle 
 import numpy as np 
 
-from napari_lattice.llsz_core import crop_volume_deskew
+from lls_core.llsz_core import crop_volume_deskew
 
 
 def test_crop_deskew():
diff --git a/src/napari_lattice/_tests/test_deconvolution.py b/core/tests/test_deconvolution.py
similarity index 62%
rename from src/napari_lattice/_tests/test_deconvolution.py
rename to core/tests/test_deconvolution.py
index 5f08ac21..44aeb286 100644
--- a/src/napari_lattice/_tests/test_deconvolution.py
+++ b/core/tests/test_deconvolution.py
@@ -4,7 +4,6 @@
 
 import numpy.testing as npt
 from skimage.io import imread
-from napari_lattice.llsz_core import pycuda_decon
 import pyclesperanto_prototype as cle
 
 from os.path import dirname
@@ -18,21 +17,30 @@
 ATOL = 0.015
 RTOL = 0.15
 
-GPU_DEVICES = cle.available_device_names(dev_type="gpu")
+try:
+    gpu_devices = cle.available_device_names(dev_type="gpu")
+except:
+    gpu_devices = []
 
 # if no GPU devices, skip test; currently does not check if its non NVIDIA devices, so it can throw an error if a non-NVIDIA Gpu is used
 
+try:
+    import pycudadecon._libwrap
+    cuda_decon_available = True
+except (FileNotFoundError, ModuleNotFoundError):
+    cuda_decon_available = False
 
-@pytest.mark.skipif(condition=not (len(GPU_DEVICES)), reason="GPU not detected, so deconvolution with pycudadecon skipped.")
+@pytest.mark.skipif(condition=len(gpu_devices) < 1, reason="GPU not detected, so deconvolution with pycudadecon skipped.")
+@pytest.mark.skipif(condition=not cuda_decon_available, reason="cudadecon library is not installed")
 def test_deconvolution_pycudadecon():
+    from lls_core.llsz_core import pycuda_decon
 
     data = imread(test_data_dir+"/raw.tif")
     psf = imread(test_data_dir+"/psf.tif")
     decon_saved = imread(test_data_dir+"/deconvolved.tif")
-
-    deconvolved = pycuda_decon(image=data, psf=psf, num_iter=10)
+    deconvolved = pycuda_decon(image=data, psf=psf, num_iter=10,background="auto")
     npt.assert_allclose(deconvolved, decon_saved, atol=ATOL)  # , verbose=True)
-
+    
 # Test for opencl deconvolution
 # def test_deconvolution_opencl():
 #    pass
diff --git a/src/napari_lattice/_tests/test_deskew.py b/core/tests/test_deskew.py
similarity index 60%
rename from src/napari_lattice/_tests/test_deskew.py
rename to core/tests/test_deskew.py
index bcb8ba11..8cd06c93 100644
--- a/src/napari_lattice/_tests/test_deskew.py
+++ b/core/tests/test_deskew.py
@@ -1,7 +1,7 @@
 #filename and function name should start with "test_" when using pytest
 import pyclesperanto_prototype as cle 
 import numpy as np 
-
+from lls_core.lattice_data import lattice_fom_array
 
 def test_deskew():
 
@@ -14,4 +14,7 @@ def test_deskew():
     assert deskewed.shape == (4,8,5)
     assert deskewed[2,2,0] == 0.5662433505058289
 
-    
+def test_lattice_data_deskew():
+    raw = np.zeros((5, 5, 5))
+    lattice = lattice_fom_array(raw, physical_pixel_sizes = (1, 1, 1), save_name="test")
+    assert lattice.deskew_vol_shape == [2, 9, 5]
diff --git a/src/napari_lattice/_tests/test_workflows.py b/core/tests/test_workflows.py
similarity index 92%
rename from src/napari_lattice/_tests/test_workflows.py
rename to core/tests/test_workflows.py
index 5bd59769..bd285f20 100644
--- a/src/napari_lattice/_tests/test_workflows.py
+++ b/core/tests/test_workflows.py
@@ -1,4 +1,3 @@
-import subprocess
 from skimage.io import imread, imsave
 import os
 import numpy as np
@@ -9,6 +8,8 @@
 from napari_workflows import Workflow
 from napari_workflows._io_yaml_v1 import load_workflow, save_workflow
 
+from lls_core.cmds.__main__ import main as run_cli
+
 # For testing in Windows
 if platform.system() == "Windows":
     home_dir = str(Path.home())
@@ -110,12 +111,7 @@ def test_workflow_lattice():
        This will apply deskewing before processing the workflow
     """
     # Deskew, apply workflow and save as h5
-    cmd = f"napari_lattice --config {config_location}"
-    deskew_process_yaml = subprocess.Popen(
-        cmd, stdout=subprocess.PIPE, shell=True)
-
-    # Read data from stdout and stderr. waits for process to terminate, or it will execute next line immmediately
-    _ = deskew_process_yaml.communicate()
+    run_cli(["--config", config_location])
 
     # checks if h5 file written
     h5_img = os.path.join(home_dir, "raw", "_0_raw.h5")
diff --git a/plugin/tests/test_dock_widget.py b/plugin/tests/test_dock_widget.py
new file mode 100644
index 00000000..58b8b091
--- /dev/null
+++ b/plugin/tests/test_dock_widget.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+from napari_lattice._dock_widget import _napari_lattice_widget_wrapper
+import numpy as np
+from typing import Callable, TYPE_CHECKING
+from magicclass.testing import check_function_gui_buildable, FunctionGuiTester
+from napari.layers import Image
+
+if TYPE_CHECKING:
+    from napari import Viewer
+
+# Test if the widget can be created
+
+# make_napari_viewer is a pytest fixture that returns a napari viewer object
+# Commenting this out as github CI is fixed
+# @pytest.mark.skip(reason="GUI tests currently fail in github CI, unclear why")
+# When testing locally, need pytest-qt
+
+def test_dock_widget(make_napari_viewer: Callable[[], Viewer]):
+    # make viewer and add an image layer using our fixture
+    viewer = make_napari_viewer()
+
+    # Check if an image can be added as a layer
+    viewer.add_image(np.random.random((100, 100)))
+
+    # Test if napari-lattice widget can be created in napari
+    gui = _napari_lattice_widget_wrapper()
+    viewer.window.add_dock_widget(gui)
+
+def test_check_buildable():
+    widget = _napari_lattice_widget_wrapper()
+    check_function_gui_buildable(widget)
+
+def test_plugin_initialize(make_napari_viewer: Callable[[], Viewer]):
+    ui = _napari_lattice_widget_wrapper()
+    viewer = make_napari_viewer()
+    viewer.window.add_dock_widget(ui)
+    image = Image(np.random.random((100, 100, 100, 100)))
+    tester = FunctionGuiTester(ui.LlszMenu.Choose_Image_Layer) 
+    tester.call(img_layer=image, last_dimension_channel="time")
+
diff --git a/src/napari_lattice/_tests/data/deconvolved.tif b/src/napari_lattice/_tests/data/deconvolved.tif
deleted file mode 100644
index a66f716e..00000000
Binary files a/src/napari_lattice/_tests/data/deconvolved.tif and /dev/null differ
diff --git a/src/napari_lattice/_tests/test_batch_deskew_args.py b/src/napari_lattice/_tests/test_batch_deskew_args.py
deleted file mode 100644
index ec28a6c1..00000000
--- a/src/napari_lattice/_tests/test_batch_deskew_args.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# Tests for napari_lattice using arguments and saving output files as h5, as well as tiff
-
-import subprocess
-from skimage.io import imread, imsave
-import os
-import numpy as np
-from pathlib import Path
-
-home_dir = str(Path.home())+os.sep
-img_dir = home_dir+"raw.tiff"
-
-
-def create_image():
-    # Create a zero array of shape 5x5x5 with a value of 10 at (2,4,2)
-    raw = np.zeros((5, 5, 5))
-    raw[2, 4, 2] = 10
-    # Save image as a tif filw in home directory
-    imsave(img_dir, raw)
-    assert os.path.exists(img_dir)
-
-
-def test_batch_deskew_h5():
-    """Write image to disk and then execute napari_lattice from terminal
-       Checks if an deskewed output file is created for both tif and h5
-    """
-    create_image()
-    assert os.path.exists(img_dir)
-    # Batch deskew and save as h5
-    cmd = f"napari_lattice --input '{img_dir}' --output '{home_dir}' --processing deskew --output_file_type h5"
-    deskew_process_h5 = subprocess.Popen(
-        cmd, stdout=subprocess.PIPE, shell=True)
-
-    # Read data from stdout and stderr. waits for process to terminate, or it will execute next line immmediately
-    _ = deskew_process_h5.communicate()
-
-    # checks if h5 files written
-    assert os.path.exists(home_dir+"raw/raw.h5")
-    assert os.path.exists(home_dir+"raw/raw.xml")
-
-
-def test_batch_deskew_tiff():
-    # tiff file deskew
-    cmd = f"napari_lattice --input {img_dir} --output {home_dir} --processing deskew --output_file_type tiff"
-    deskew_process_tiff = subprocess.Popen(
-        cmd, stdout=subprocess.PIPE, shell=True)
-    _ = deskew_process_tiff.communicate()
-
-    # checks if tiff written
-    assert os.path.exists(home_dir+"raw/C0T0_raw.tif")
-
-
-# verify output file by opening and checking if pixel value and coordinate
diff --git a/src/napari_lattice/_tests/test_dock_widget.py b/src/napari_lattice/_tests/test_dock_widget.py
deleted file mode 100644
index dfb3d0cb..00000000
--- a/src/napari_lattice/_tests/test_dock_widget.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from napari_lattice._dock_widget import _napari_lattice_widget_wrapper
-import numpy as np
-import pytest
-
-# Test if the widget can be created
-
-# make_napari_viewer is a pytest fixture that returns a napari viewer object
-# Commenting this out as github CI is fixed
-# @pytest.mark.skip(reason="GUI tests currently fail in github CI, unclear why")
-# When testing locally, need pytest-qt
-
-
-def test_dock_widget(make_napari_viewer):
-    # make viewer and add an image layer using our fixture
-
-    viewer = make_napari_viewer()
-
-    # Check if an image can be added as a layer
-    viewer.add_image(np.random.random((100, 100)))
-
-    # Test if napari-lattice widget can be created in napari
-    viewer.window.add_dock_widget(_napari_lattice_widget_wrapper())
-
-    pass