Repository: fractal-napari-plugins-collection/napari-ome-zarr-navigator (language: python)

Issue:
Merge build-img-browser into roi_loader branch
@fdsteffen I'll start to work a bit more on the testing side as well and will base it on the work you've already done on downloading the test data from Zenodo.

My goal is to switch the API to use pooch (similar to how we do it here: https://github.com/fractal-analytics-platform/fractal-tasks-core/blob/6df32280382c41175245e0ae7cd500a718c3a339/tests/tasks/conftest.py#L73) so that we can use caching in the Github CI.



Discussion / hints:

Fix patch:
diff --git a/pyproject.toml b/pyproject.toml
index 03adbff..3a46347 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,7 @@ ignore = [
     "E501", # line too long. let black handle this
     "UP006", "UP007", # type annotation. As using magicgui require runtime type annotation then we disable this.
     "SIM117", # flake8-simplify - some of merged with statements are not looking great with black, reanble after drop python 3.9
+    "G004", # Logging statement uses f-string
 ]
 
 exclude = [
diff --git a/setup.cfg b/setup.cfg
index abb2f7e..eba7e90 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -35,7 +35,8 @@ install_requires =
     magicgui
     qtpy
     scikit-image
-    fractal-tasks-core==0.14.0
+    fractal-tasks-core==0.14.3
+    ome-zarr
     wget
 
 python_requires = >=3.8
diff --git a/src/napari_ome_zarr_navigator/_sample_data.py b/src/napari_ome_zarr_navigator/_sample_data.py
index 453db51..cac7f54 100644
--- a/src/napari_ome_zarr_navigator/_sample_data.py
+++ b/src/napari_ome_zarr_navigator/_sample_data.py
@@ -1,21 +1,111 @@
-"""
-This module is an example of a barebones sample data provider for napari.
+from __future__ import annotations
 
-It implements the "sample data" specification.
-see: https://napari.org/stable/plugins/guides.html?#sample-data
+from pathlib import Path
+from typing import Union
+import re
+import requests
+import urllib
+import hashlib
+import wget
+import shutil
 
-Replace code below according to your needs.
-"""
-from __future__ import annotations
+from napari.types import LayerDataTuple
+
+from ome_zarr.io import parse_url
+from ome_zarr.reader import Reader, Node
+
+from napari_ome_zarr_navigator import _TEST_DATA_DIR
+
+
+def load_ome_zarr_from_zenodo(doi: str, zarr_url: Union[str, Path]):
+    doi_path = Path(_TEST_DATA_DIR).joinpath(doi.replace("/", "_"))
+    zarr_path = doi_path.joinpath(zarr_url)
+    if not doi_path.is_dir():
+        download_from_zenodo(doi, directory=doi_path)
+        shutil.unpack_archive(zarr_path.with_suffix(".zarr.zip"), doi_path)
+    reader = Reader(parse_url(zarr_path))
+    zarr_group = list(reader())[0]
+    return zarr_group
+
+
+def download_from_zenodo(
+    doi: str,
+    overwrite: bool = False,
+    directory: Union[str, Path] = Path(),
+    access_token: str = None,
+):
+    record_id = re.match(r".*/zenodo.(\w+)", doi).group(1)
+    url = "https://zenodo.org/api/records/" + record_id
+    js = requests.get(url).json()
+    doi = js["metadata"]["doi"]
+    print("Title: " + js["metadata"]["title"])
+    print("Publication date: " + js["metadata"]["publication_date"])
+    print("DOI: " + js["metadata"]["doi"])
+    print(
+        "Total file size: {:.1f} MB".format(
+            sum(f["size"] / 10**6 for f in js["files"])
+        )
+    )
+    doi_path = Path(directory)
+    try:
+        doi_path.mkdir(exist_ok=overwrite, parents=True)
+    except FileExistsError:
+        print(f"{doi_path} exists. Don't overwrite.")
+        return
+    for file in js["files"]:
+        file_path = Path(doi_path).joinpath(file["key"])
+        algorithm, checksum = file["checksum"].split(":")
+        try:
+            link = urllib.parse.unquote(file["links"]["self"])
+            wget.download(
+                f"{link}?access_token={access_token}", str(directory)
+            )
+            check_passed, returned_checksum = verify_checksum(
+                file_path, algorithm, checksum
+            )
+            if check_passed:
+                print(f"\nChecksum is correct. ({checksum})")
+            else:
+                print(
+                    f"\nChecksum is incorrect! ({checksum} got: {returned_checksum})"
+                )
+        except urllib.error.HTTPError:
+            pass
+
+
+def verify_checksum(filename: Union[str, Path], algorithm, original_checksum):
+    h = hashlib.new(algorithm)
+    with open(filename, "rb") as f:
+        h.update(f.read())
+        returned_checksum = h.hexdigest()
+    if returned_checksum == original_checksum:
+        return True, returned_checksum
+    else:
+        return False, returned_checksum
+
+
+def hiPSC_zarr() -> list[LayerDataTuple]:
+    doi = "10.5281_zenodo.10424292"
+    zarr_url = "20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr"
+    return load_zarr(doi, zarr_url)
 
-import numpy
 
+def load_zarr(doi: str, zarr_url: Union[str, Path]) -> list[LayerDataTuple]:
+    ome_zarr = load_ome_zarr_from_zenodo(doi, zarr_url)
+    if ome_zarr:
 
-def make_sample_data():
-    """Generates an image"""
-    # Return list of tuples
-    # [(data1, add_image_kwargs1), (data2, add_image_kwargs2)]
-    # Check the documentation for more information about the
-    # add_image_kwargs
-    # https://napari.org/stable/api/napari.Viewer.html#napari.Viewer.add_image
-    return [(numpy.random.rand(512, 512), {})]
+        return [
+            (
+                ome_zarr.data,
+                {
+                    "name": ome_zarr.metadata["name"],
+                    "channel_axis": 0,
+                    "contrast_limits": ome_zarr.metadata["contrast_limits"],
+                    "colormap": ome_zarr.metadata["colormap"],
+                    "metadata": {"sample_path": ome_zarr.zarr.path},
+                },
+                "image",
+            )
+        ]
+    else:
+        return [(None,)]
diff --git a/src/napari_ome_zarr_navigator/img_browser.py b/src/napari_ome_zarr_navigator/img_browser.py
index d885564..04ff1bf 100644
--- a/src/napari_ome_zarr_navigator/img_browser.py
+++ b/src/napari_ome_zarr_navigator/img_browser.py
@@ -1,86 +1,411 @@
-"""
-This module contains four napari widgets declared in
-different ways:
-
-- a pure Python function flagged with `autogenerate: true`
-    in the plugin manifest. Type annotations are used by
-    magicgui to generate widgets for each parameter. Best
-    suited for simple processing tasks - usually taking
-    in and/or returning a layer.
-- a `magic_factory` decorated function. The `magic_factory`
-    decorator allows us to customize aspects of the resulting
-    GUI, including the widgets associated with each parameter.
-    Best used when you have a very simple processing task,
-    but want some control over the autogenerated widgets. If you
-    find yourself needing to define lots of nested functions to achieve
-    your functionality, maybe look at the `Container` widget!
-- a `magicgui.widgets.Container` subclass. This provides lots
-    of flexibility and customization options while still supporting
-    `magicgui` widgets and convenience methods for creating widgets
-    from type annotations. If you want to customize your widgets and
-    connect callbacks, this is the best widget option for you.
-- a `QWidget` subclass. This provides maximal flexibility but requires
-    full specification of widget layouts, callbacks, events, etc.
-
-References:
-- Widget specification: https://napari.org/stable/plugins/guides.html?#widgets
-- magicgui docs: https://pyapp-kit.github.io/magicgui/
-
-Replace code below according to your needs.
-"""
 from typing import TYPE_CHECKING
 
-from magicgui.widgets import CheckBox, Container, create_widget
-from skimage.util import img_as_float
+import pandas as pd
+from pathlib import Path
+from typing import Union
+from zarr.errors import PathNotFoundError
+import anndata as ad
+import zarr
+import re
+
+
+from napari_ome_zarr_navigator.util import alpha_to_numeric, numeric_to_alpha
+
+
+from magicgui.widgets import (
+    CheckBox,
+    ComboBox,
+    Container,
+    FileEdit,
+    PushButton,
+    RadioButtons,
+    Select,
+    SpinBox,
+)
 
 if TYPE_CHECKING:
     import napari
 
 
-# if we want even more control over our widget, we can use
-# magicgui `Container`
 class ImgBrowser(Container):
+    # TODO: if no condition table is available in the OME-zarr only load the col/rows into the Select widget, otherwise load the filters
     def __init__(self, viewer: "napari.viewer.Viewer"):
-        super().__init__()
-        self._viewer = viewer
-        # use create_widget to generate widgets from type annotations
-        self._image_layer_combo = create_widget(
-            label="Image", annotation="napari.layers.Image"
+        self.viewer = viewer
+        self.zarr_dir = FileEdit(
+            label="OME-ZARR URL", mode="d", filter="*.zarr"
         )
-        self._threshold_slider = create_widget(
-            label="Threshold", annotation=float, widget_type="FloatSlider"
+        self.selection_mode = RadioButtons(
+            choices=["Drug/Conc", "Row/Col"],
+            label="Mode",
+            value="Drug/Conc",
         )
-        self._threshold_slider.min = 0
-        self._threshold_slider.max = 1
-        # use magicgui widgets directly
-        self._invert_checkbox = CheckBox(text="Keep pixels below threshold")
-
-        # connect your own callbacks
-        self._threshold_slider.changed.connect(self._threshold_im)
-        self._invert_checkbox.changed.connect(self._threshold_im)
-
-        # append into/extend the container with your widgets
-        self.extend(
-            [
-                self._image_layer_combo,
-                self._threshold_slider,
-                self._invert_checkbox,
-            ]
+
+        self.drug = ComboBox(
+            label="Drug",
+        )
+        self.concentration = ComboBox(
+            label="Conc. (nM)",
+        )
+        self.row_alpha = ComboBox(
+            label="Row",
+        )
+        self.col = ComboBox(label="Column")
+        self.well = ComboBox(
+            label="Well",
+        )
+        self.display_drugs = Select(
+            label="content", enabled=True, allow_multiple=False
+        )
+        self.select_well = PushButton(text="Select well", enabled=False)
+        self.new_layers = CheckBox(label="Add Image/Labels as new layer(s)")
+        self.drug_layout = pd.DataFrame(
+            {"row": [], "col": [], "drug": [], "concentration": [], "unit": []}
         )
 
-    def _threshold_im(self):
-        image_layer = self._image_layer_combo.value
-        if image_layer is None:
-            return
+        self.well = Select(label="content", enabled=True, allow_multiple=False)
 
-        image = img_as_float(image_layer.data)
-        name = image_layer.name + "_thresholded"
-        threshold = self._threshold_slider.value
-        if self._invert_checkbox.value:
-            thresholded = image < threshold
+        # super().__init__(
+        #     widgets=[
+        #         self.zarr_dir,
+        #         self.drug,
+        #         self.concentration,
+        #         self.row_alpha,
+        #         self.col,
+        #         self.well,
+        #         self.display_drugs,
+        #         self.new_layers,
+        #         self.select_well,
+        #     ]
+        # )
+
+        super().__init__(
+            widgets=[self.zarr_dir, self.row_alpha, self.col, self.well],
+        )
+        self.drug.changed.connect(self.set_doses)
+        # self.select_well.clicked.connect(self.load_zarr)
+        self.zarr_dir.changed.connect(self.select_layer)
+        self.row_alpha.changed.connect(self.set_drugs)
+        self.col.changed.connect(self.set_drugs)
+        self.drug.changed.connect(self.set_row_col)
+        self.concentration.changed.connect(self.set_row_col)
+        self.viewer.layers.events.removed.connect(self.check_empty_layerlist)
+        self.viewer.layers.selection.events.changed.connect(self.get_zarr_url)
+
+        self.zarr_dir.changed.connect(self.initialize_filters)
+
+    def initialize_filters(self):
+        zarr_dict = parse_zarr_url(self.zarr_dir.value)
+        self.zarr_root = zarr_dict["root"]
+        try:
+            self.df = (
+                load_table(
+                    self.zarr_root,
+                    "condition",
+                    zarr_dict["well"],
+                )
+                .to_df()
+                .astype({"col": "int"})
+            )
+        except PathNotFoundError:
+            napari.utils.notifications.show_warning(
+                f"Some wells don't have a conditions table associated."
+            )
         else:
-            thresholded = image > threshold
-        if name in self._viewer.layers:
-            self._viewer.layers[name].data = thresholded
+            filter_names = self.df.columns.drop(["row", "col"])
+            self.filters = Container(
+                widgets=[
+                    Container(
+                        widgets=[
+                            ComboBox(
+                                choices=self.df[filter].unique(), enabled=False
+                            ),
+                            CheckBox(label=filter, value=False),
+                        ],
+                        layout="horizontal",
+                    )
+                    for filter in filter_names
+                ],
+                layout="vertical",
+            )
+            # print(self.filters[0][0])
+            self.viewer.window.add_dock_widget(
+                widget=self.filters, name="Filters"
+            )
+
+        for i in range(self.df.shape[1]):
+            self.filters[i][1].changed.connect(self.toggle_filter(i))
+
+    def toggle_filter(self, i):
+        def toggle_on_change():
+            self.filters[i][0].enabled = not self.filters[i][0].enabled
+
+        return toggle_on_change
+
+    def check_empty_layerlist(self):
+        if len(self.viewer.layers) == 0:
+            self.zarr_dir.value = ""
+
+    def get_zarr_url(self):
+        active = self.viewer.layers.selection.active
+        if active and active.as_layer_data_tuple()[-1] == "image":
+            path = self.viewer.layers.selection.active.source.path
+            if path:
+                self.zarr_dir.value = Path(path)
+            if "sample_path" in self.viewer.layers.selection.active.metadata:
+                self.zarr_dir.value = Path(
+                    self.viewer.layers.selection.active.metadata["sample_path"]
+                )
+
+    def select_layer(self):
+        zarr_dict = parse_zarr_url(self.zarr_dir.value)
+        self.zarr_root = zarr_dict["root"]
+        if zarr_dict["root"]:
+            try:
+                self.drug_layout = (
+                    load_table(
+                        self.zarr_root,
+                        "condition",
+                        zarr_dict["well"],
+                    )
+                    .to_df()
+                    .astype({"col": "int"})
+                )
+            except PathNotFoundError:
+                napari.utils.notifications.show_warning(
+                    f"Some wells don't have a conditions table associated."
+                )
+            else:
+                self.row_alpha.choices = self.drug_layout["row"].unique()
+                self.row_alpha._default_choices = self.drug_layout[
+                    "row"
+                ].unique()
+                self.col.choices = self.drug_layout["col"].unique()
+                self.col._default_choices = self.drug_layout["col"].unique()
+                self.drug.choices = self.drug_layout["drug"].unique()
+                self.drug._default_choices = self.drug_layout["drug"].unique()
+                if self.zarr_root == self.zarr_dir.value:
+                    self.select_well.enabled = True
+                    self.new_layers.enabled = True
+                else:
+                    self.new_layers.enabled = False
+                    self.select_well.enabled = False
         else:
-            self._viewer.add_labels(thresholded, name=name)
+            self.row_alpha.choices = []
+            self.row_alpha._default_choices = []
+            self.col.choices = []
+            self.col._default_choices = []
+            self.drug.choices = []
+            self.drug._default_choices = []
+            self.set_doses()
+            self.set_row_col()
+            self.set_drugs()
+            self.select_well.enabled = False
+
+    def set_doses(self):
+        if not self.drug_layout.empty:
+            concentrations = self.drug_layout.query(
+                "drug == @self.drug.value"
+            )["concentration"].unique()
+            self.concentration.choices = concentrations
+            self.concentration._default_choices = concentrations
+
+    def set_row_col(self):
+        if not self.drug_layout.empty:
+            tbl = self.drug_layout.query(
+                "(drug == @self.drug.value) & (concentration == @self.concentration.value)"
+            )
+            well = tbl["row"] + tbl["col"].astype(str)
+            self.well.choices = well
+            self.well._default_choices = well
+
+    def set_drugs(self):
+        if not self.drug_layout.empty:
+            tbl = self.drug_layout.query(
+                "(row == @self.row_alpha.value) & (col == @self.col.value)"
+            )
+            drug_concentrations = (
+                tbl["drug"] + " (" + tbl["concentration"].astype(str) + " nM)"
+            )
+            self.display_drugs.choices = drug_concentrations
+            self.display_drugs._default_choices = drug_concentrations
+
+    def toggle_selection_mode(self):
+        if self.selection_mode.value == "Row/Col":
+            self.row_alpha.visible = True
+            self.col.visible = True
+            self.drug.visible = False
+            self.concentration.visible = False
+            self.well.visible = False
+            self.display_drugs.visible = True
+        else:
+            self.row_alpha.visible = False
+            self.col.visible = False
+            self.drug.visible = True
+            self.concentration.visible = True
+            self.display_drugs.visible = False
+            self.well.visible = True
+
+    # def load_zarr(self):
+    #     if self.selection_mode.value == "Row/Col":
+    #         well = f"{self.row_alpha.value}{self.col.value}"
+    #         row_alpha = self.row_alpha.value
+    #         row = operio.util.alpha_to_numeric(row_alpha)
+    #         col = self.col.value
+    #     else:
+    #         well = self.well.value
+    #         m = re.match(r"(\w+)(\d+)", well)
+    #         row_alpha = m.group(1)
+    #         row = operio.util.alpha_to_numeric(row_alpha)
+    #         col = int(m.group(2))
+    #     roi_url, roi_idx = operio.io.get_roi(
+    #         self.zarr_root,
+    #         row_alpha,
+    #         row,
+    #         "well_ROI_table",
+    #         level=0,
+    #     )
+    #     if self.new_layers.value:
+    #         img = operio.io.load_intensity_roi(roi_url, roi_idx)
+    #         labels = operio.io.load_label_roi(roi_url, roi_idx)
+    #         self.viewer.add_image(img, name=f"{row_alpha}{col}")
+    #         label_layer = self.viewer.add_labels(
+    #             labels, name=f"{row_alpha}{col}_label"
+    #         )
+    #         operio.io.napari.add_features_to_labels(
+    #             label_layer, self.zarr_root, row_alpha, col
+    #         )
+    #     else:
+    #         self.draw_well(roi_idx, row, col, well)
+
+    # def draw_well(self, roi_idx: pd.DataFrame, row: int, col: int, well: str):
+    #     r = roi_idx.iloc[0]
+    #     row_min = operio.util.alpha_to_numeric(self.row_alpha.choices[0])
+    #     col_min = self.col.min
+
+    #     rec = np.array(
+    #         [
+    #             [
+    #                 (row - row_min) * (r.e_x - r.s_x) + r.s_x,
+    #                 (col - col_min) * (r.e_y - r.s_y) + r.s_y,
+    #             ],
+    #             [
+    #                 (row - row_min) * (r.e_x - r.s_x) + r.e_x,
+    #                 (col - col_min) * (r.e_y - r.s_y) + r.e_y,
+    #             ],
+    #         ]
+    #     )
+
+    #     for layer in self.viewer.layers:
+    #         if type(layer) == napari.layers.Shapes:
+    #             self.viewer.layers.remove(layer)
+    #     self.viewer.add_shapes(
+    #         rec,
+    #         shape_type="rectangle",
+    #         edge_width=5,
+    #         edge_color="white",
+    #         face_color="transparent",
+    #         name=well,
+    #     )
+    #     self.viewer.camera.center = rec.mean(axis=0)
+    #     self.viewer.camera.zoom = 0.25
+
+
+def parse_zarr_url(zarr_url: Union[str, Path]) -> dict:
+    """Parse the OME-ZARR URL into a dictionary with the root URL, row, column and dataset
+
+    Args:
+        zarr_url: Path to the OME-ZARR
+
+    Returns:
+        Dictionary with root URL, row, column and dataset
+    """
+    zarr_dict = {
+        "root": None,
+        "row_alpha": None,
+        "row": None,
+        "col": None,
+        "well": None,
+        "dataset": None,
+    }
+    if zarr_url:
+        parts = [
+            p.replace("\\", "") for p in Path(zarr_url).parts
+        ]  # remove backslash escape character
+        root_idx = None
+        for i, p in enumerate(parts):
+            if p.endswith(".zarr"):
+                root_idx = i
+                zarr_dict["root"] = Path(*parts[0 : i + 1])
+            if root_idx and i == root_idx + 1:
+                zarr_dict["row_alpha"] = p
+                zarr_dict["row"] = alpha_to_numeric(p)
+            if root_idx and i == root_idx + 2:
+                zarr_dict["col"] = int(p)
+                zarr_dict["well"] = zarr_dict["row_alpha"] + p
+            if root_idx and i == root_idx + 3:
+                zarr_dict["dataset"] = int(p)
+    return zarr_dict
+
+
+def load_table(
+    zarr_url: Union[str, Path],
+    name: str,
+    wells: Union[str, list[str]] = None,
+    dataset: int = 0,
+) -> ad.AnnData:
+    """Load an Anndata table from a zarr url
+
+    Args:
+        zarr_url: Path to the OME-ZARR
+        name: Name of the table
+        wells: A single well or a list of wells on a plate
+        dataset: Index of the dataset
+
+    Returns:
+        An Anndata table with columns x/y/z_micrometer, len_x/y/z_micrometer and x/y_micrometer_original
+    """
+    wells = _validate_wells(wells, zarr_url)
+    wells_str = [f"{w[0]}{w[1]}" for w in wells]
+    tbl = list()
+    while wells:
+        row_alpha, col = wells.pop()
+        try:
+            tbl.append(
+                ad.read_zarr(
+                    f"{zarr_url}/{row_alpha}/{col}/{dataset}/tables/{name}"
+                )
+            )
+        except PathNotFoundError:
+            print(f"No condition table was found for well {row_alpha}{col}")
+    if len(wells_str) > 1:
+        return ad.concat(tbl, keys=wells_str, index_unique="-")
+    else:
+        return ad.concat(tbl)
+
+
+def _validate_wells(
+    wells: Union[str, list[str]], zarr_url: Union[str, Path]
+) -> set[tuple[str, int]]:
+    """Check that wells are formatted correctly
+
+    Args:
+        wells: A single well or a list of wells on a plate
+        zarr_url: Path to the OME-ZARR
+
+    Returns:
+        A unique set of alphanumeric tuples describing the wells
+    """
+    if wells is not None:
+        wells = [wells] if isinstance(wells, str) else wells
+        matches = [re.match(r"([A-Z]+)(\d+)", well) for well in wells]
+        # wells = set([(m.group(1), int(m.group(2))) for m in matches])
+        wells = set([(m.group(1), m.group(2)) for m in matches])
+    else:
+        with zarr.open(zarr_url) as metadata:
+            matches = [
+                re.match(r"([A-Z]+)/(\d+)", well["path"])
+                for well in metadata.attrs["plate"]["wells"]
+            ]
+            # wells = set([(m.group(1), int(m.group(2))) for m in matches])
+            wells = set([(m.group(1), m.group(2)) for m in matches])
+    return wells
diff --git a/src/napari_ome_zarr_navigator/napari.yaml b/src/napari_ome_zarr_navigator/napari.yaml
index 89564f5..768a9ff 100644
--- a/src/napari_ome_zarr_navigator/napari.yaml
+++ b/src/napari_ome_zarr_navigator/napari.yaml
@@ -12,9 +12,12 @@ contributions:
     - id: napari-ome-zarr-navigator.make_roi_loader
       python_name: napari_ome_zarr_navigator:ROILoader
       title: ROI Loader
+    - id: napari-ome-zarr-navigator.hiPSC_zarr
+      python_name: napari_ome_zarr_navigator._sample_data:hiPSC_zarr
+      title: Load sample data from Zenodo
   sample_data:
-    - command: napari-ome-zarr-navigator.make_sample_data
-      display_name: napari OME-Zarr Navigator
+    - command: napari-ome-zarr-navigator.hiPSC_zarr
+      display_name: hiPSC
       key: unique_id.1
   widgets:
     - command: napari-ome-zarr-navigator.make_img_browser
diff --git a/src/napari_ome_zarr_navigator/ome_zarr_image.py b/src/napari_ome_zarr_navigator/ome_zarr_image.py
new file mode 100644
index 0000000..54f0126
--- /dev/null
+++ b/src/napari_ome_zarr_navigator/ome_zarr_image.py
@@ -0,0 +1,391 @@
+import logging
+from functools import lru_cache
+from pathlib import Path
+from typing import Optional
+
+import anndata as ad
+import dask.array as da
+import numpy as np
+import zarr
+from fractal_tasks_core.ngff.specs import Multiscale
+from fractal_tasks_core.ngff.zarr_utils import load_NgffImageMeta
+from fractal_tasks_core.roi import convert_ROI_table_to_indices
+from pydantic import ValidationError
+
+logger = logging.getLogger(__name__)
+
+
+class OMEZarrImage:
+    """
+    Class that represents a given OME-Zarr image.
+
+    Helper class provides read access & info about the Zarr image. One of the
+    complexities to be aware of: Some information, like the ROI table, is
+    specific to the whole Zarr image. But the image metadata and the label
+    metadata don't need to match (will have different multiscales).
+
+    """
+
+    def __init__(self, zarr_url) -> None:
+        self.zarr_url = zarr_url
+        if not self.is_zarr_dataset(zarr_url):
+            raise ValueError
+        try:
+            self.image_meta = load_NgffImageMeta(zarr_url)
+        except ValidationError as e:
+            raise ValueError(
+                "The provided Zarr is not a valid OME-Zarr image. Loading its"
+                "metadata triggered the following error ValidationError: "
+                f"{e.json()}"
+            ) from e
+        with zarr.open(zarr_url, mode="r") as zarr_group:
+            self.zarr_subgroups = list(zarr_group.group_keys())
+
+    def get_channel_list(self):
+        channel_names = []
+        for channel in self.image_meta.omero.channels:
+            channel_names.append(channel.label)
+        return channel_names
+
+    def get_pyramid_levels(self):
+        pyramid_levels = []
+        for ds in self.image_meta.multiscale.datasets:
+            pyramid_levels.append(ds.path)
+        return pyramid_levels
+
+    def get_labels_list(self):
+        if "labels" not in self.zarr_subgroups:
+            return [""]
+        with zarr.open(self.zarr_url, mode="r") as zarr_group:
+            return list(zarr_group.labels.group_keys())
+
+    def get_tables_list(self, table_type: str = None, strict: bool = False):
+        """
+        Find the list of tables in the Zarr file
+
+        Optionally match a table type and only return the names of those tables.
+
+        Params:
+            table_type (str): The type of table to look for. Special handling for
+                "ROIs" => matches both "roi_table" & "masking_roi_table".
+            strict (bool): If True, only return tables that have a type attribute.
+                If False, also include tables without a type attribute.
+        """
+        if "tables" not in self.zarr_subgroups:
+            return [""]
+        with zarr.open(self.zarr_url, mode="r") as zarr_group:
+            all_tables = list(zarr_group.tables.group_keys())
+
+        if not table_type:
+            return all_tables
+        else:
+            return self.filter_tables_by_type(all_tables, table_type, strict)
+
+    def filter_tables_by_type(
+        self, all_tables, table_type: str = None, strict: bool = False
+    ):
+        tables_list = []
+        for table_name in all_tables:
+            with zarr.open(self.zarr_url, mode="r").tables[
+                table_name
+            ] as table:
+                table_attrs = table.attrs.asdict()
+                if "type" in table_attrs:
+                    if table_type == "ROIs":
+                        roi_table_types = ["roi_table", "masking_roi_table"]
+                        if table_attrs["type"] in roi_table_types:
+                            tables_list.append(table_name)
+                    elif table_attrs["type"] == table_type:
+                        tables_list.append(table_name)
+                else:
+                    # If there are tables without types, let the users choose
+                    # from all tables
+                    logger.warning(
+                        f"Table {table_name} had no type attribute."
+                    )
+                    if not strict:
+                        tables_list.append(table_name)
+        return tables_list
+
+    @staticmethod
+    def is_zarr_dataset(path):
+        """
+        Check if the given path contains a Zarr dataset.
+
+        Parameters:
+        - path: str, the path to the dataset.
+
+        Returns:
+        - bool, True if the path contains a Zarr dataset, False otherwise.
+        """
+        if path == Path("."):
+            # Handle the scenario where no path was set
+            return False
+
+        try:
+            # Attempt to open the path as a Zarr dataset
+            zarr.open(path, mode="r")
+            return True
+        except ValueError:
+            # ValueError indicates that it is not a Zarr dataset
+            print(f"No Zarr found at {path}")
+            return False
+        except PermissionError:
+            # Handle the case where the path exists but there's no read permission
+            print(f"Permission denied when accessing the path: {path}")
+            return False
+        except FileNotFoundError:
+            # Handle the case where the path does not exist
+            print(f"Path does not exist: {path}")
+            return False
+
+    @staticmethod
+    @lru_cache(maxsize=16)
+    def read_table(zarr_url, roi_table: str):
+        with zarr.open(zarr_url, mode="r").tables[roi_table] as table:
+            return ad.read_zarr(table)
+
+    def _get_image_scale_zyx_and_index(self, level, multiscale):
+        # TODO: Get this back into the NgffImageMeta model:
+        # Access level by name
+        for i, dataset in enumerate(multiscale.datasets):
+            if dataset.path == level:
+                # Only handles datasets with a single scale
+                scale = self._sanitize_scale(
+                    dataset.coordinateTransformations[0].scale, multiscale
+                )
+                return scale, i
+        raise ValueError(
+            f"Level {level} not available in {multiscale.datasets}."
+        )
+
+    def _get_full_res_scale_zyx(self, multiscale: Multiscale):
+        dataset = multiscale.datasets[0]
+        if len(dataset.coordinateTransformations) > 1:
+            raise NotImplementedError(
+                "OMEZarrImage has only been implemented for Zarrs "
+                "with a single coordinate transformation"
+            )
+        # Only handles datasets with a single scale
+        scale = self._sanitize_scale(
+            dataset.coordinateTransformations[0].scale, multiscale
+        )
+        return scale
+
+    @staticmethod
+    def _sanitize_scale(scale, multiscale):
+        if len(scale) < 3:
+            raise NotImplementedError(
+                "OMEZarrImage has only been implemented for OME-Zarrs that "
+                "contain the zyx dimensions last. This Zarr had: "
+                f"{multiscale.axes}"
+            )
+
+        if (
+            multiscale.axes[-3].name == "z"
+            and multiscale.axes[-2].name == "y"
+            and multiscale.axes[-1].name == "x"
+        ):
+            return scale[-3:]
+        elif (
+            multiscale.axes[-3].name == "c"
+            and multiscale.axes[-2].name == "y"
+            and multiscale.axes[-1].name == "x"
+        ):
+            logger.warning(
+                "Processing a cyx image. This has not been tested well."
+            )
+            return scale[-3:]
+        else:
+            raise NotImplementedError(
+                "OMEZarrImage has only been implemented for OME-Zarrs that "
+                "contain the zyx dimensions last. This Zarr had: "
+                f"{multiscale.axes}"
+            )
+
+    def _get_available_scales_dict(self, multiscale):
+        """
+        Returns a dict of available scales.
+
+        Keys: Name of dataset (= the pyramid level)
+        Values: Their scales in zyx
+        """
+        scales_dict = {}
+        for dataset in multiscale.datasets:
+            scales_dict[dataset.path] = self._sanitize_scale(
+                dataset.coordinateTransformations[0].scale, multiscale
+            )
+
+        return scales_dict
+
+    def _get_closest_level_path(
+        self, level_path: str, label_multiscale, img_multiscale
+    ):
+        target_scale, _ = self._get_image_scale_zyx_and_index(
+            level_path, img_multiscale
+        )
+        label_scales = self._get_available_scales_dict(label_multiscale)
+        closest_scale = list(label_scales.keys())[0]
+        for key, val in label_scales.items():
+            if np.linalg.norm(
+                np.array(val) - np.array(target_scale)
+            ) < np.linalg.norm(
+                np.array(label_scales[closest_scale]) - np.array(target_scale)
+            ):
+                closest_scale = key
+
+        return closest_scale
+
+    def get_roi_indices(
+        self,
+        roi_table: str,
+        level_index: int,
+        full_res_pxl_sizes_zyx: tuple,
+    ):
+        """
+        Get the indices of a specific ROI from a ROI table.
+
+        Args:
+        - roi_table: Name of the ROI table to load
+        - level_index: Index of the resolution level to load
+        - pixel_size_zyx: pixel size as a tuple z, y, x
+
+        """
+        # Get the ROI table
+        roi_an = self.read_table(self.zarr_url, roi_table)
+
+        # TODO: Switch to a more robust way of loading indices when the
+        # dimensionality of the image can vary. This only works for 3D images
+        # (all Yokogawa images are saved as 3D images) and
+        # by accident for 2D MD images (if they are multichannel)
+        # See issue 420 on fractal-tasks-core
+        indices_list = convert_ROI_table_to_indices(
+            roi_an,
+            full_res_pxl_sizes_zyx=full_res_pxl_sizes_zyx,
+            level=level_index,
+        )
+
+        return indices_list
+
+    def load_zarr_array_index_based(
+        self,
+        zarr_url: str,
+        roi_table: str,
+        roi_index: int,
+        multiscale: Multiscale,
+        subset: Optional[int] = None,
+        level_path: str = "0",
+        as_np: bool = False,
+    ):
+        """
+        Load an intensity ROI based on indices
+
+        Args:
+        - zarr_url: zarr_url of the array to load (either zarr url of the
+            image or zarr url of the label image).
+        - roi_table: Name of the ROI table to load
+        - roi_index: Index
+        - subset: Used to only load a given subset of a Zarr array, for
+            example just one channel based on its index
+        - multiscale: Multiscale object
+        - level_path: Name of the resolution level to load
+        - as_np: Whether to return the array as a lazy dask array or a numpy
+            array
+
+        """
+        # TODO: Get multiscale from the zarr_url instead of needing it as
+        # input here? How robustly can we get them for intensity image vs.
+        # label image?
+        img_scale, level_index = self._get_image_scale_zyx_and_index(
+            level_path,
+            multiscale=multiscale,
+        )
+        full_res_pxl_sizes_zyx = self._get_full_res_scale_zyx(
+            multiscale=multiscale
+        )
+        s_z, e_z, s_y, e_y, s_x, e_x = self.get_roi_indices(
+            roi_table,
+            level_index,
+            full_res_pxl_sizes_zyx=full_res_pxl_sizes_zyx,
+        )[roi_index][:]
+
+        # Load data
+        # TODO: make this more axes-robust (e.g. able to handle 5D axes)
+        img_data_zyx = da.from_zarr(f"{zarr_url}/{level_path}")
+        if subset is not None:
+            img_data_zyx = img_data_zyx[subset]
+
+        if len(img_data_zyx.shape) == 2:
+            img_roi = img_data_zyx[s_y:e_y, s_x:e_x]
+            img_scale = img_scale[1:]
+        else:
+            img_roi = img_data_zyx[s_z:e_z, s_y:e_y, s_x:e_x]
+
+        if as_np:
+            return np.array(img_roi), img_scale
+        else:
+            return img_roi, img_scale
+
+    def load_intensity_roi(
+        self,
+        roi_table: str,
+        roi_name: str,
+        channel: str,
+        level_path: str = "0",
+    ):
+        roi_an = self.read_table(self.zarr_url, roi_table)
+        roi_index = roi_an.obs.index.get_loc(roi_name)
+
+        # This assumes the order of the omero channels will match the order
+        # of the channels in the Zarr array.
+        channels = self.get_channel_list()
+        # This triggers a ValueError if the channel is not found in the list
+        channel_index = channels.index(channel)
+
+        return self.load_zarr_array_index_based(
+            zarr_url=self.zarr_url,
+            roi_table=roi_table,
+            roi_index=roi_index,
+            subset=channel_index,
+            multiscale=self.image_meta.multiscale,
+            level_path=level_path,
+        )
+
+    def load_label_roi(
+        self,
+        roi_table: str,
+        roi_name: str,
+        label: str,
+        level_path_img: str = "0",
+        level_path_label: Optional[str] = None,
+    ):
+        """
+        Load a label image ROI.
+
+        Can either load the label based on a given level_path_label or pick the
+        pyramid level of the label layer that's closest in resolution to a
+        given pyramid level on the image level.
+        """
+        zarr_url_label = f"{self.zarr_url}/labels/{label}"
+
+        roi_an = self.read_table(self.zarr_url, roi_table)
+        roi_index = roi_an.obs.index.get_loc(roi_name)
+
+        label_multiscale = load_NgffImageMeta(zarr_url_label).multiscale
+
+        if level_path_label is not None:
+            level_path = level_path_label
+        else:
+            level_path = self._get_closest_level_path(
+                level_path=level_path_img,
+                label_multiscale=label_multiscale,
+                img_multiscale=self.image_meta.multiscale,
+            )
+
+        return self.load_zarr_array_index_based(
+            zarr_url=zarr_url_label,
+            roi_table=roi_table,
+            roi_index=roi_index,
+            multiscale=label_multiscale,
+            level_path=level_path,
+        )
diff --git a/src/napari_ome_zarr_navigator/roi_loader.py b/src/napari_ome_zarr_navigator/roi_loader.py
index d0ab776..1da0bd2 100644
--- a/src/napari_ome_zarr_navigator/roi_loader.py
+++ b/src/napari_ome_zarr_navigator/roi_loader.py
@@ -1,86 +1,420 @@
-"""
-This module contains four napari widgets declared in
-different ways:
-
-- a pure Python function flagged with `autogenerate: true`
-    in the plugin manifest. Type annotations are used by
-    magicgui to generate widgets for each parameter. Best
-    suited for simple processing tasks - usually taking
-    in and/or returning a layer.
-- a `magic_factory` decorated function. The `magic_factory`
-    decorator allows us to customize aspects of the resulting
-    GUI, including the widgets associated with each parameter.
-    Best used when you have a very simple processing task,
-    but want some control over the autogenerated widgets. If you
-    find yourself needing to define lots of nested functions to achieve
-    your functionality, maybe look at the `Container` widget!
-- a `magicgui.widgets.Container` subclass. This provides lots
-    of flexibility and customization options while still supporting
-    `magicgui` widgets and convenience methods for creating widgets
-    from type annotations. If you want to customize your widgets and
-    connect callbacks, this is the best widget option for you.
-- a `QWidget` subclass. This provides maximal flexibility but requires
-    full specification of widget layouts, callbacks, events, etc.
-
-References:
-- Widget specification: https://napari.org/stable/plugins/guides.html?#widgets
-- magicgui docs: https://pyapp-kit.github.io/magicgui/
-
-Replace code below according to your needs.
-"""
-from typing import TYPE_CHECKING
-
-from magicgui.widgets import CheckBox, Container, create_widget
-from skimage.util import img_as_float
-
-if TYPE_CHECKING:
-    import napari
-
-
-# if we want even more control over our widget, we can use
-# magicgui `Container`
+import logging
+
+import napari
+import zarr
+from magicgui.widgets import (
+    ComboBox,
+    Container,
+    FileEdit,
+    PushButton,
+    Select,
+)
+from napari.qt.threading import thread_worker
+
+from napari_ome_zarr_navigator.ome_zarr_image import OMEZarrImage
+from napari_ome_zarr_navigator.utils_roi_loader import (
+    read_table,
+)
+
+logger = logging.getLogger(__name__)
+
+
 class ROILoader(Container):
-    def __init__(self, viewer: "napari.viewer.Viewer"):
-        super().__init__()
+    def __init__(self, viewer: napari.viewer.Viewer, zarr_url=None):
         self._viewer = viewer
-        # use create_widget to generate widgets from type annotations
-        self._image_layer_combo = create_widget(
-            label="Image", annotation="napari.layers.Image"
+        self.channel_dict = {}
+        self.channel_names_dict = {}
+        self.labels_dict = {}
+        self._zarr_url_picker = FileEdit(label="Zarr URL", mode="d")
+        self._roi_table_picker = ComboBox(label="ROI Table")
+        self._roi_picker = ComboBox(label="ROI")
+        self._channel_picker = Select(
+            label="Channels",
+        )
+        self._level_picker = ComboBox(label="Image Level")
+        self._label_picker = Select(
+            label="Labels",
         )
-        self._threshold_slider = create_widget(
-            label="Threshold", annotation=float, widget_type="FloatSlider"
+        self._feature_picker = Select(
+            label="Features",
         )
-        self._threshold_slider.min = 0
-        self._threshold_slider.max = 1
-        # use magicgui widgets directly
-        self._invert_checkbox = CheckBox(text="Keep pixels below threshold")
-
-        # connect your own callbacks
-        self._threshold_slider.changed.connect(self._threshold_im)
-        self._invert_checkbox.changed.connect(self._threshold_im)
-
-        # append into/extend the container with your widgets
-        self.extend(
-            [
-                self._image_layer_combo,
-                self._threshold_slider,
-                self._invert_checkbox,
+        self._run_button = PushButton(value=False, text="Load ROI")
+
+        self._ome_zarr_image: OMEZarrImage = None
+        self.image_changed = ImageEvent()
+        # Initialize possible choices
+
+        # Update selections & bind buttons
+        self._zarr_url_picker.changed.connect(self.update_image_selection)
+        self.image_changed.connect(self.update_roi_table_choices)
+        self.image_changed.connect(self.update_available_image_attrs)
+        self._roi_table_picker.changed.connect(self.update_roi_selection)
+        self._run_button.clicked.connect(self.run)
+
+        if zarr_url:
+            self.ome_zarr_image = OMEZarrImage(zarr_url)
+
+        super().__init__(
+            widgets=[
+                self._zarr_url_picker,
+                self._roi_table_picker,
+                self._roi_picker,
+                self._channel_picker,
+                self._level_picker,
+                self._label_picker,
+                self._feature_picker,
+                self._run_button,
             ]
         )
 
-    def _threshold_im(self):
-        image_layer = self._image_layer_combo.value
-        if image_layer is None:
-            return
+    @property
+    def ome_zarr_image(self):
+        return self._ome_zarr_image
+
+    @ome_zarr_image.setter
+    def ome_zarr_image(self, value) -> OMEZarrImage:
+        if self._ome_zarr_image != value:
+            self._ome_zarr_image = value
+            self.image_changed.emit(self._ome_zarr_image)
 
-        image = img_as_float(image_layer.data)
-        name = image_layer.name + "_thresholded"
-        threshold = self._threshold_slider.value
-        if self._invert_checkbox.value:
-            thresholded = image < threshold
+    def update_roi_selection(self):
+        @thread_worker
+        def get_roi_choices():
+            try:
+                roi_table = read_table(
+                    self._zarr_url_picker.value, self._roi_table_picker.value
+                )
+                new_choices = list(roi_table.obs_names)
+                return new_choices
+            except zarr.errors.PathNotFoundError:
+                return [""]
+
+        if self.ome_zarr_image:
+            worker = get_roi_choices()
+            worker.returned.connect(self._apply_roi_choices_update)
+            worker.start()
+        else:
+            self._apply_roi_choices_update([""])
+
+    def _apply_roi_choices_update(self, roi_list):
+        """
+        Update the list of available ROIs in the dropdown
+        """
+        self._roi_picker.choices = roi_list
+        self._roi_picker._default_choices = roi_list
+
+    def update_roi_table_choices(self, event):
+        @thread_worker
+        def threaded_get_table_list(table_type: str = None, strict=False):
+            return self.ome_zarr_image.get_tables_list(
+                table_type=table_type,
+                strict=strict,
+            )
+
+        if self.ome_zarr_image:
+            worker = threaded_get_table_list(
+                table_type="ROIs",
+                strict=False,
+            )
+            worker.returned.connect(self._apply_roi_table_choices_update)
+            worker.start()
         else:
-            thresholded = image > threshold
-        if name in self._viewer.layers:
-            self._viewer.layers[name].data = thresholded
+            self._apply_roi_table_choices_update([""])
+
+    def _apply_roi_table_choices_update(self, table_list):
+        """
+        Update the list of available ROI tables in the dropdown menu
+        """
+        self._roi_table_picker.choices = table_list
+        self._roi_table_picker._default_choices = table_list
+
+    def update_image_selection(self):
+        zarr_url = self._zarr_url_picker.value
+        try:
+            self.ome_zarr_image = OMEZarrImage(zarr_url)
+        except ValueError:
+            self.ome_zarr_image = None
+
+    def update_available_image_attrs(self, new_zarr_img):
+        if new_zarr_img:
+            channels = self.ome_zarr_image.get_channel_list()
+            levels = self.ome_zarr_image.get_pyramid_levels()
+            labels = self.ome_zarr_image.get_labels_list()
+            features = self.ome_zarr_image.get_tables_list(
+                table_type="feature_table"
+            )
+            self.set_available_image_attrs(channels, levels, labels, features)
         else:
-            self._viewer.add_labels(thresholded, name=name)
+            # If zarr image was set to None, reset all selectors
+            self.set_available_image_attrs([""], [""], [""], [""])
+
+    def set_available_image_attrs(self, channels, levels, labels, features):
+        self._channel_picker.choices = channels
+        self._channel_picker._default_choices = channels
+        # Set pyramid levels
+        self._level_picker.choices = levels
+        self._level_picker._default_choices = levels
+
+        # Initialize available label images
+        self._label_picker.choices = labels
+        self._label_picker._default_choices = labels
+
+        # Initialize available features
+        self._feature_picker.choices = features
+        self._feature_picker._default_choices = features
+
+    def run(self):
+        # roi_table = self._roi_table_picker.value
+        # roi_name = self._roi_picker.value
+        # level = self._level_picker.value
+        # channels = self._channel_picker.value
+        # labels = self._label_picker.value
+        # if len(channels) < 1 and len(labels) < 1:
+        #     logger.info(
+        #         "No channel or labels selected. "
+        #         "Select the channels/labels you want to load"
+        #     )
+        #     return
+        # blending = None
+        # scale_img = None
+        print("run")
+
+        # # Load intensity images
+        # for channel in channels:
+        #     img_roi, scale_img = load_intensity_roi(
+        #         zarr_url=self._zarr_url_picker.value,
+        #         roi_of_interest=roi_name,
+        #         channel_index=self.channel_names_dict[channel],
+        #         level=level,
+        #         roi_table=roi_table,
+        #     )
+        #     if not np.any(img_roi):
+        #         return
+        #     channel_meta = self.channel_dict[self.channel_names_dict[channel]]
+        #     colormap = Colormap(
+        #         ["#000000", f"#{channel_meta['color']}"],
+        #         name=channel_meta["color"],
+        #     )
+        #     try:
+        #         rescaling = (
+        #             channel_meta["window"]["start"],
+        #             channel_meta["window"]["end"],
+        #         )
+        #     except KeyError:
+        #         rescaling = None
+
+        #     self._viewer.add_image(
+        #         img_roi,
+        #         scale=scale_img,
+        #         blending=blending,
+        #         contrast_limits=rescaling,
+        #         colormap=colormap,
+        #         name=channel,
+        #     )
+        #     blending = "additive"
+
+
+class ImageEvent:
+    def __init__(self):
+        self.handlers = []
+
+    def connect(self, handler):
+        self.handlers.append(handler)
+
+    def emit(self, *args, **kwargs):
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+    # def run(self):
+    #     roi_table = self._roi_table_picker.value
+    #     roi_name = self._roi_picker.value
+    #     level = self._level_picker.value
+    #     channels = self._channel_picker.value
+    #     labels = self._label_picker.value
+    #     if len(channels) < 1 and len(labels) < 1:
+    #         show_info(
+    #             "No channel or labels selected. "
+    #             "Select the channels/labels you want to load"
+    #         )
+    #         return
+    #     blending = None
+    #     scale_img = None
+
+    #     # Load intensity images
+    #     for channel in channels:
+    #         img_roi, scale_img = load_intensity_roi(
+    #             zarr_url=self._zarr_url_picker.value,
+    #             roi_of_interest=roi_name,
+    #             channel_index=self.channel_names_dict[channel],
+    #             level=level,
+    #             roi_table=roi_table,    #         )
+    #         if not np.any(img_roi):
+    #             show_info(
+    #                 "Could not load this ROI. Did you correctly set the "
+    #                 "`Reset ROI Origin`?"
+    #             )
+    #             return
+
+    #         channel_meta = self.channel_dict[self.channel_names_dict[channel]]
+    #         colormap = Colormap(
+    #             ["#000000", f"#{channel_meta['color']}"],
+    #             name=channel_meta["color"],
+    #         )
+    #         try:
+    #             rescaling = (
+    #                 channel_meta["window"]["start"],
+    #                 channel_meta["window"]["end"],
+    #             )
+    #         except KeyError:
+    #             rescaling = None
+
+    #         self._viewer.add_image(
+    #             img_roi,
+    #             scale=scale_img,
+    #             blending=blending,
+    #             contrast_limits=rescaling,
+    #             colormap=colormap,
+    #             name=channel,
+    #         )
+    #         blending = "additive"
+
+    #     # Load labels
+    #     label_layers = []
+    #     for label in labels:
+    #         label_roi, scale_label = load_label_roi(
+    #             zarr_url=self._zarr_url_picker.value,
+    #             roi_of_interest=roi_name,
+    #             label_name=label,
+    #             target_scale=scale_img,
+    #             roi_table=roi_table,
+    #         )
+    #         if not np.any(label_roi):
+    #             show_info(
+    #                 "Could not load this ROI. Did you correctly set the "
+    #                 "`Reset ROI Origin`?"
+    #             )
+    #             return
+    #         label_layers.append(
+    #             self._viewer.add_labels(
+    #                 label_roi, scale=scale_label, name=label
+    #             )
+    #         )
+
+    #     # Load features
+    #     # Initially a bearbones implementation that only works when a single
+    #     # label image is also loaded at that moment
+    #     features = self._feature_picker.value
+    #     if len(features) > 0:
+    #         if len(labels) != 1:
+    #             show_info(
+    #                 "Not implemented yet: Please select exactly one label "
+    #                 "image to load features for"
+    #             )
+    #             return
+    #         else:
+    #             # TODO: Implement loading multiple features at once
+    #             # (and mapping them to the correct labels)
+    #             if len(features) > 1:
+    #                 show_info(
+    #                     "Not implemented yet: Please select exactly one "
+    #                     "feature to load"
+    #                 )
+    #                 return
+    #             else:
+    #                 # Actual feature loading
+    #                 feature_table = features[0]
+    #                 label_layer = label_layers[0]
+    #                 self.add_feature_table_to_layer(
+    #                     feature_table,
+    #                     label_layer,
+    #                     roi_name,
+    #                 )
+
+    # def add_feature_table_to_layer(self, feature_table, label_layer, roi_name):
+    #     feature_ad = load_features(
+    #         zarr_url=self._zarr_url_picker.value,
+    #         feature_table=feature_table,
+    #     )
+    #     if "label" in feature_ad.obs:
+    #         # TODO: Only load the feature for the ROI,
+    #         # not the whole table
+    #         labels_current_layer = np.unique(label_layer.data)[1:]
+    #         shared_labels = list(
+    #             set(feature_ad.obs["label"].astype(int))
+    #             & set(labels_current_layer)
+    #         )
+    #         features_roi = feature_ad[
+    #             feature_ad.obs["label"].astype(int).isin(shared_labels)
+    #         ]
+    #         features_df = features_roi.to_df()
+    #         # Drop duplicate columns
+    #         features_df = features_df.loc[
+    #             :, ~features_df.columns.duplicated()
+    #         ].copy()
+    #         features_df["label"] = feature_ad.obs["label"].astype(int)
+    #         features_df[
+    #             "roi_id"
+    #         ] = f"{self._zarr_url_picker.value}:ROI_{roi_name}"
+    #         features_df.set_index("label", inplace=True, drop=False)
+    #         # To display correct
+    #         features_df["index"] = features_df["label"]
+    #         label_layer.features = features_df
+    #     else:
+    #         show_info(
+    #             f"Table {feature_table} does not have a label obs "
+    #             "column, can't be loaded as features for the "
+    #             f"layer {label_layer}"
+    #         )
+
+    # def update_roi_tables(self):
+    #     """
+    #     Handles updating the list of available ROI tables
+    #     """
+    #     # Uses the `_default_choices` to avoid having choices reset.
+    #     # See https://github.com/pyapp-kit/magicgui/issues/306
+    #     # roi_table = self._get_roi_table_choices()
+    #     roi_tables = self._get_table_choices(type="ROIs")
+    #     self._roi_table_picker.choices = roi_tables
+    #     self._roi_table_picker._default_choices = roi_tables
+    #     self.update_roi_selection()
+
+    # def update_roi_selection(self):
+    #     """
+    #     Updates all selections that depend on which ROI table was selected
+    #     """
+    #     # Uses the `_default_choices` to avoid having choices reset.
+    #     # See https://github.com/pyapp-kit/magicgui/issues/306
+    #     new_rois = self._get_roi_choices()
+    #     self._roi_picker.choices = new_rois
+    #     self._roi_picker._default_choices = new_rois
+    #     channels = self._get_channel_choices()
+    #     self._channel_picker.choices = channels
+    #     self._channel_picker._default_choices = channels
+    #     levels = self._get_level_choices()
+    #     self._level_picker.choices = levels
+    #     self._level_picker._default_choices = levels
+
+    #     # Initialize available label images
+    #     labels = self._get_label_choices()
+    #     self._label_picker.choices = labels
+    #     self._label_picker._default_choices = labels
+
+    #     # Initialize available features
+    #     features = self._get_table_choices(type="features")
+    #     self._feature_picker.choices = features
+    #     self._feature_picker._default_choices = features
+
+    # def _get_roi_choices(self):
+    #     if not self._roi_table_picker.value:
+    #         # When no roi table is provided.
+    #         # E.g. during bug with self._roi_table_picker reset
+    #         return [""]
+    #     try:
+    #         roi_table = read_table(
+    #             self._zarr_url_picker.value, self._roi_table_picker.value
+    #         )
+    #         new_choices = list(roi_table.obs_names)
+    #         return new_choices
+    #     except zarr.errors.PathNotFoundError:
+    #         new_choices = [""]
+    #         return new_choices
diff --git a/src/napari_ome_zarr_navigator/roi_run_script.py b/src/napari_ome_zarr_navigator/roi_run_script.py
new file mode 100644
index 0000000..83df15c
--- /dev/null
+++ b/src/napari_ome_zarr_navigator/roi_run_script.py
@@ -0,0 +1,20 @@
+import napari
+
+from napari_ome_zarr_navigator.roi_loader import ROILoader
+
+
+def main():
+    # Create a viewer
+    viewer = napari.Viewer()
+
+    zarr_url = "/Users/joel/Library/CloudStorage/Dropbox/Joel/BioVisionCenter/Code/napari-ome-zarr-navigator/test_data/20231222_navigator_test_data/20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr/B/03/0"
+    # zarr_url = None
+    roi_loader_widget = ROILoader(viewer, zarr_url=zarr_url)
+
+    viewer.window.add_dock_widget(roi_loader_widget)
+
+    viewer.show(block=True)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/napari_ome_zarr_navigator/run_img_browser.py b/src/napari_ome_zarr_navigator/run_img_browser.py
new file mode 100755
index 0000000..6d4bf6c
--- /dev/null
+++ b/src/napari_ome_zarr_navigator/run_img_browser.py
@@ -0,0 +1,22 @@
+import napari
+
+from napari_ome_zarr_navigator.img_browser import ImgBrowser
+
+from napari_ome_zarr_navigator import _TEST_DATA_DIR
+
+
+def main():
+    zarr_url = "20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr/B/03/0"
+    viewer = napari.Viewer()
+    zarr_url = "20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr/B/03/0"
+    roi_loader_widget = ImgBrowser(
+        viewer,
+        # zarr_url=_TEST_DATA_DIR.joinpath("10.5281_zenodo.10424292", zarr_url),
+    )
+
+    viewer.window.add_dock_widget(roi_loader_widget, name="OME-ZARR navigator")
+    viewer.show(block=True)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/napari_ome_zarr_navigator/util.py b/src/napari_ome_zarr_navigator/util.py
new file mode 100644
index 0000000..cad2f51
--- /dev/null
+++ b/src/napari_ome_zarr_navigator/util.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+import string
+
+
+def alpha_to_numeric(alpha: str) -> int:
+    """Return the position of a single character in the alphabet
+
+    Args:
+        alpha: Single alphabet character
+
+    Returns:
+        Integer position in the alphabet
+    """
+    return ord(alpha.upper()) - 64
+
+
+def numeric_to_alpha(numeric: int, upper: bool = True) -> str:
+    """Return the upper or lowercase character for a given position in the alphabet
+
+    Args:
+        numeric: Integer position in the alphabet
+
+    Returns:
+        Single alphabet character
+    """
+    if upper:
+        string.ascii_uppercase[numeric - 1]
+    else:
+        string.ascii_lowercase[numeric - 1]
diff --git a/src/napari_ome_zarr_navigator/utils_roi_loader.py b/src/napari_ome_zarr_navigator/utils_roi_loader.py
new file mode 100644
index 0000000..cd29bc0
--- /dev/null
+++ b/src/napari_ome_zarr_navigator/utils_roi_loader.py
@@ -0,0 +1,68 @@
+import logging
+from functools import lru_cache
+from pathlib import Path
+
+import anndata as ad
+import zarr
+from napari.qt.threading import thread_worker
+
+
+@lru_cache(maxsize=16)
+def get_zattrs(zarr_url):
+    with zarr.open(zarr_url) as zarr_attrs:
+        return dict(zarr_attrs.attrs)
+
+
+@lru_cache(maxsize=16)
+def read_table(zarr_url: str, roi_table: str):
+    with zarr.open(zarr_url, mode="r").tables[roi_table] as table:
+        return ad.read_zarr(table)
+
+
+def get_table_list(zarr_url, table_type: str = None, strict: bool = False):
+    """
+    Find the list of tables in the Zarr file
+
+    Optionally match a table type and only return the names of those tables.
+
+    Params:
+        zarr_url (str): The path to the zarr file (e.g. "path/to/file.zarr/B/03/0")
+        table_type (str): The type of table to look for. Special handling for
+            "ROIs" => matches both "roi_table" & "masking_roi_table".
+        strict (bool): If True, only return tables that have a type attribute.
+            If False, also include tables without a type attribute.
+    """
+    table_folder = Path(zarr_url) / "tables"
+    if not table_folder.exists():
+        return []
+    table_meta_dict = get_zattrs(table_folder)
+    table_list = []
+
+    if not table_type:
+        return table_meta_dict["tables"]
+
+    for table_name in table_meta_dict["tables"]:
+        table_attrs = get_zattrs(table_folder / table_name)
+        if "type" in table_attrs:
+            if table_type == "ROIs":
+                roi_table_types = ["roi_table", "masking_roi_table"]
+                if table_attrs["type"] in roi_table_types:
+                    table_list.append(table_name)
+            elif table_attrs["type"] == table_type:
+                table_list.append(table_name)
+        else:
+            # If there are tables without types, let the users choose from all
+            # tables
+            logging.warning(f"Table {table_name} had no type attribute.")
+            if not strict:
+                table_list.append(table_name)
+    return table_list
+
+
+@thread_worker
+def threaded_get_table_list(zarr_url, table_type: str = None, strict=False):
+    return get_table_list(
+        zarr_url=zarr_url,
+        table_type=table_type,
+        strict=strict,
+    )


Test patch:
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index cf68d50..9aed367 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -63,6 +63,13 @@ jobs:
       - name: Coverage
         uses: codecov/codecov-action@v3
 
+      - name: Cache Pooch folder
+        id: cache-pooch-folder
+        uses: actions/cache@v3
+        with:
+          path: ~/.cache/pooch
+          key: pooch-cache
+
   deploy:
     # this will run when you have tagged a commit, starting with "v*"
     # and requires that you have put your twine API key in your
diff --git a/src/napari_ome_zarr_navigator/_tests/conftest.py b/src/napari_ome_zarr_navigator/_tests/conftest.py
new file mode 100644
index 0000000..353e892
--- /dev/null
+++ b/src/napari_ome_zarr_navigator/_tests/conftest.py
@@ -0,0 +1,63 @@
+import os
+import shutil
+from pathlib import Path
+
+import pooch
+import pytest
+
+
+@pytest.fixture(scope="session")
+def testdata_path() -> Path:
+    TEST_DIR = Path(__file__).parent.parent.parent.parent
+    return TEST_DIR / "test_data/"
+
+
+@pytest.fixture(scope="session")
+def zenodo_zarr(testdata_path: Path) -> list[str]:
+    """
+    This takes care of multiple steps:
+
+    1. Download/unzip two Zarr containers (3D and MIP) from Zenodo, via pooch
+    2. Copy the two Zarr containers into tests/data
+    3. Modify the Zarrs in tests/data, to add whatever is not in Zenodo
+    """
+
+    # 1 Download Zarrs from Zenodo
+    DOI = "10.5281/zenodo.10424292"
+    DOI_slug = DOI.replace("/", "_").replace(".", "_")
+    rootfolder = testdata_path / DOI_slug
+
+    registry = {
+        "20200812-CardiomyocyteDifferentiation14-Cycle1.zarr.zip": None,
+        "20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr.zip": None,
+    }
+    folders = [rootfolder / plate[:-4] for plate in registry]
+
+    base_url = f"doi:{DOI}"
+    POOCH = pooch.create(
+        pooch.os_cache("pooch") / DOI_slug,
+        base_url,
+        registry=registry,
+        retry_if_failed=10,
+        allow_updates=False,
+    )
+
+    for ind, file_name in enumerate(
+        [
+            "20200812-CardiomyocyteDifferentiation14-Cycle1.zarr",
+            "20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr",
+        ]
+    ):
+        # 1) Download/unzip a single Zarr from Zenodo
+        file_paths = POOCH.fetch(
+            f"{file_name}.zip", processor=pooch.Unzip(extract_dir=file_name)
+        )
+        zarr_full_path = file_paths[0].split(file_name)[0] + file_name
+        # print(zarr_full_path)
+        folder = folders[ind]
+
+        # 2) Copy the downloaded Zarr into tests/data
+        if os.path.isdir(str(folder)):
+            shutil.rmtree(str(folder))
+        shutil.copytree(Path(zarr_full_path) / file_name, folder)
+    return [str(f) for f in folders]
diff --git a/src/napari_ome_zarr_navigator/_tests/test_sample_data.py b/src/napari_ome_zarr_navigator/_tests/no_run_test_sample_data.py
similarity index 79%
rename from src/napari_ome_zarr_navigator/_tests/test_sample_data.py
rename to src/napari_ome_zarr_navigator/_tests/no_run_test_sample_data.py
index 09c0ea9..a820795 100644
--- a/src/napari_ome_zarr_navigator/_tests/test_sample_data.py
+++ b/src/napari_ome_zarr_navigator/_tests/no_run_test_sample_data.py
@@ -1,16 +1,14 @@
 from pathlib import Path
 
-from napari_ome_zarr_navigator.generate_test_data import (
+from napari_ome_zarr_navigator import _TEST_DATA_DIR
+from napari_ome_zarr_navigator._sample_data import (
     load_ome_zarr_from_zenodo,
 )
 
 
-from napari_ome_zarr_navigator import _TEST_DATA_DIR
-
-
 def test_load_zenodo_data():
     doi = "10.5281/zenodo.10424292"
     zarr_url = "20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr"
     doi_path = doi_path = Path(_TEST_DATA_DIR).joinpath(doi.replace("/", "_"))
     load_ome_zarr_from_zenodo(doi, zarr_url)
-    assert doi_path.is_dir() == True
+    assert doi_path.is_dir()
diff --git a/src/napari_ome_zarr_navigator/_tests/test_OMEZarrImage.py b/src/napari_ome_zarr_navigator/_tests/test_OMEZarrImage.py
new file mode 100644
index 0000000..9380706
--- /dev/null
+++ b/src/napari_ome_zarr_navigator/_tests/test_OMEZarrImage.py
@@ -0,0 +1,187 @@
+import pytest
+
+from napari_ome_zarr_navigator.ome_zarr_image import OMEZarrImage
+
+
+@pytest.fixture(scope="function")
+def ome_zarr_image_2d(zenodo_zarr) -> OMEZarrImage:
+    plate_zarr_url_2D = zenodo_zarr[1]
+    zarr_url_2D = f"{plate_zarr_url_2D}/B/03/0"
+    zarr_img_2D = OMEZarrImage(zarr_url_2D)
+    return zarr_img_2D
+
+
+@pytest.fixture(scope="function")
+def ome_zarr_image_3d(zenodo_zarr) -> OMEZarrImage:
+    plate_zarr_url_3D = zenodo_zarr[0]
+    zarr_url_3D = f"{plate_zarr_url_3D}/B/03/0"
+    zarr_img_3D = OMEZarrImage(zarr_url_3D)
+    return zarr_img_3D
+
+
+def test_OMEZarrImage_init(ome_zarr_image_2d, ome_zarr_image_3d):
+    assert len(ome_zarr_image_2d.image_meta.multiscales) == 1
+    assert ome_zarr_image_2d.zarr_subgroups == ["labels", "tables"]
+    assert len(ome_zarr_image_3d.image_meta.multiscales) == 1
+    assert ome_zarr_image_3d.zarr_subgroups == ["labels", "tables"]
+
+
+def test_channel_list(ome_zarr_image_2d, ome_zarr_image_3d):
+    assert ome_zarr_image_2d.get_channel_list() == [
+        "DAPI",
+        "nanog",
+        "Lamin B1",
+    ]
+    assert ome_zarr_image_3d.get_channel_list() == [
+        "DAPI",
+        "nanog",
+        "Lamin B1",
+    ]
+
+
+def test_pyramid_levels(ome_zarr_image_2d, ome_zarr_image_3d):
+    assert ome_zarr_image_2d.get_pyramid_levels() == ["0", "1", "2", "3", "4"]
+    assert ome_zarr_image_3d.get_pyramid_levels() == ["0", "1", "2", "3", "4"]
+
+
+def test_labels_list(ome_zarr_image_2d, ome_zarr_image_3d):
+    # TODO: Add a test case without labels => new test data?
+    assert ome_zarr_image_2d.get_labels_list() == ["nuclei"]
+    assert ome_zarr_image_3d.get_labels_list() == ["nuclei"]
+
+
+def test_tables_list(ome_zarr_image_2d):
+    assert ome_zarr_image_2d.get_tables_list() == [
+        "FOV_ROI_table",
+        "condition",
+        "measurements",
+        "well_ROI_table",
+    ]
+    # Only ROI tables
+    assert ome_zarr_image_2d.get_tables_list(table_type="ROIs") == [
+        "FOV_ROI_table",
+        "well_ROI_table",
+    ]
+    assert ome_zarr_image_2d.get_tables_list(table_type="roi_table") == [
+        "FOV_ROI_table",
+        "well_ROI_table",
+    ]
+
+    # Feature tables
+    assert ome_zarr_image_2d.get_tables_list(table_type="feature_table") == [
+        "measurements"
+    ]
+
+    # Condition table
+    assert ome_zarr_image_2d.get_tables_list(table_type="condition") == [
+        "condition"
+    ]
+
+    # Check for other table types
+    assert (
+        len(ome_zarr_image_2d.get_tables_list(table_type="masking_roi_table"))
+        == 0
+    )
+    assert len(ome_zarr_image_2d.get_tables_list(table_type="abcd")) == 0
+
+    # TODO: Add test for table without a type & strict parsing
+    # print(ome_zarr_image_2d.get_tables_list(strict=False))
+
+
+def test_wrong_zarr_urls(zenodo_zarr):
+    non_existing_path = "/path/does/not/exist"
+    with pytest.raises(ValueError):
+        OMEZarrImage(non_existing_path)
+
+    zarr_but_not_image = f"{zenodo_zarr[1]}/B/03/"
+    expected_error = (
+        "The provided Zarr is not a valid OME-Zarr image. Loading its"
+        "metadata triggered the following error ValidationError: "
+    )
+    with pytest.raises(ValueError) as exc_info:
+        OMEZarrImage(zarr_but_not_image)
+    assert expected_error in str(exc_info.value)
+    assert "multiscales" in str(exc_info.value)
+    assert "field required" in str(exc_info.value)
+
+
+def test_roi_loading_from_indices(ome_zarr_image_2d):
+    img, scale = ome_zarr_image_2d.load_zarr_array_index_based(
+        zarr_url=ome_zarr_image_2d.zarr_url,
+        roi_table="FOV_ROI_table",
+        roi_index=0,
+        subset=0,
+        multiscale=ome_zarr_image_2d.image_meta.multiscale,
+        level_path="2",
+    )
+    assert scale == [1.0, 0.65, 0.65]
+    assert img.shape == (1, 540, 640)
+
+
+def test_roi_loading(ome_zarr_image_2d):
+    img, scale = ome_zarr_image_2d.load_intensity_roi(
+        roi_table="FOV_ROI_table",
+        roi_name="FOV_1",
+        channel="DAPI",
+        level_path="2",
+    )
+    assert scale == [1.0, 0.65, 0.65]
+    assert img.shape == (1, 540, 640)
+
+
+def test_label_roi_loading_direct_level(ome_zarr_image_2d):
+    img, scale = ome_zarr_image_2d.load_label_roi(
+        roi_table="FOV_ROI_table",
+        roi_name="FOV_1",
+        label="nuclei",
+        level_path_label="0",
+    )
+    assert scale == [1.0, 0.65, 0.65]
+    assert img.shape == (1, 540, 640)
+
+    img, scale = ome_zarr_image_2d.load_label_roi(
+        roi_table="FOV_ROI_table",
+        roi_name="FOV_1",
+        label="nuclei",
+        level_path_label="1",
+    )
+    assert scale == [1.0, 1.3, 1.3]
+    assert img.shape == (1, 270, 320)
+
+
+def test_label_roi_loading_relative_level(ome_zarr_image_2d):
+    img, scale = ome_zarr_image_2d.load_label_roi(
+        roi_table="FOV_ROI_table",
+        roi_name="FOV_1",
+        label="nuclei",
+        level_path_img="0",
+    )
+    assert scale == [1.0, 0.65, 0.65]
+    assert img.shape == (1, 540, 640)
+
+    img, scale = ome_zarr_image_2d.load_label_roi(
+        roi_table="FOV_ROI_table",
+        roi_name="FOV_1",
+        label="nuclei",
+        level_path_img="1",
+    )
+    assert scale == [1.0, 0.65, 0.65]
+    assert img.shape == (1, 540, 640)
+
+    img, scale = ome_zarr_image_2d.load_label_roi(
+        roi_table="FOV_ROI_table",
+        roi_name="FOV_1",
+        label="nuclei",
+        level_path_img="2",
+    )
+    assert scale == [1.0, 0.65, 0.65]
+    assert img.shape == (1, 540, 640)
+
+    img, scale = ome_zarr_image_2d.load_label_roi(
+        roi_table="FOV_ROI_table",
+        roi_name="FOV_1",
+        label="nuclei",
+        level_path_img="3",
+    )
+    assert scale == [1.0, 1.3, 1.3]
+    assert img.shape == (1, 270, 320)
diff --git a/src/napari_ome_zarr_navigator/_tests/test_img_browser.py b/src/napari_ome_zarr_navigator/_tests/test_img_browser.py
index 9669712..2786547 100644
--- a/src/napari_ome_zarr_navigator/_tests/test_img_browser.py
+++ b/src/napari_ome_zarr_navigator/_tests/test_img_browser.py
@@ -1,11 +1,11 @@
-import numpy as np
+# import numpy as np
 
-from napari_ome_zarr_navigator.img_browser import (
-    ImgBrowser,
-)
+# from napari_ome_zarr_navigator.img_browser import (
+#     ImgBrowser,
+# )
 
 
-def test_image_threshold_widget(make_napari_viewer):
-    viewer = make_napari_viewer()
-    layer = viewer.add_image(np.random.random((100, 100)))
-    my_widget = ImgBrowser(viewer)
+# def test_image_threshold_widget(make_napari_viewer):
+#     viewer = make_napari_viewer()
+#     layer = viewer.add_image(np.random.random((100, 100)))
+#     my_widget = ImgBrowser(viewer)
diff --git a/src/napari_ome_zarr_navigator/_tests/test_roi_loader.py b/src/napari_ome_zarr_navigator/_tests/test_roi_loader.py
index 24be3b4..00628df 100644
--- a/src/napari_ome_zarr_navigator/_tests/test_roi_loader.py
+++ b/src/napari_ome_zarr_navigator/_tests/test_roi_loader.py
@@ -5,7 +5,7 @@
 )
 
 
-def test_image_threshold_widget(make_napari_viewer):
+def test_roi_loader_init(make_napari_viewer):
     viewer = make_napari_viewer()
     _ = viewer.add_image(np.random.random((100, 100)))
     _ = ROILoader(viewer)
diff --git a/src/napari_ome_zarr_navigator/generate_test_data/__init__.py b/src/napari_ome_zarr_navigator/generate_test_data/__init__.py
index 85949ae..e69de29 100644
--- a/src/napari_ome_zarr_navigator/generate_test_data/__init__.py
+++ b/src/napari_ome_zarr_navigator/generate_test_data/__init__.py
@@ -1,76 +0,0 @@
-#!/usr/bin/env python3
-from pathlib import Path
-from typing import Union
-import re
-import requests
-import urllib
-import hashlib
-import wget
-import shutil
-
-
-from napari_ome_zarr_navigator import _TEST_DATA_DIR
-
-
-def load_ome_zarr_from_zenodo(doi: str, zarr_url: Union[str, Path]):
-    doi_path = Path(_TEST_DATA_DIR).joinpath(doi.replace("/", "_"))
-    zarr_path = doi_path.joinpath(zarr_url)
-    if not doi_path.is_dir():
-        download_from_zenodo(doi, directory=doi_path)
-        shutil.unpack_archive(zarr_path.with_suffix(".zarr.zip"), doi_path)
-
-
-def download_from_zenodo(
-    doi: str,
-    overwrite: bool = False,
-    directory: Union[str, Path] = Path(),
-    access_token: str = None,
-):
-    record_id = re.match(r".*/zenodo.(\w+)", doi).group(1)
-    url = "https://zenodo.org/api/records/" + record_id
-    js = requests.get(url).json()
-    doi = js["metadata"]["doi"]
-    print("Title: " + js["metadata"]["title"])
-    print("Publication date: " + js["metadata"]["publication_date"])
-    print("DOI: " + js["metadata"]["doi"])
-    print(
-        "Total file size: {:.1f} MB".format(
-            sum(f["size"] / 10**6 for f in js["files"])
-        )
-    )
-    doi_path = Path(directory)
-    try:
-        doi_path.mkdir(exist_ok=overwrite, parents=True)
-    except FileExistsError:
-        print(f"{doi_path} exists. Don't overwrite.")
-        return
-    for file in js["files"]:
-        file_path = Path(doi_path).joinpath(file["key"])
-        algorithm, checksum = file["checksum"].split(":")
-        try:
-            link = urllib.parse.unquote(file["links"]["self"])
-            wget.download(
-                f"{link}?access_token={access_token}", str(directory)
-            )
-            check_passed, returned_checksum = verify_checksum(
-                file_path, algorithm, checksum
-            )
-            if check_passed:
-                print(f"\nChecksum is correct. ({checksum})")
-            else:
-                print(
-                    f"\nChecksum is incorrect! ({checksum} got: {returned_checksum})"
-                )
-        except urllib.error.HTTPError:
-            pass
-
-
-def verify_checksum(filename: Union[str, Path], algorithm, original_checksum):
-    h = hashlib.new(algorithm)
-    with open(filename, "rb") as f:
-        h.update(f.read())
-        returned_checksum = h.hexdigest()
-    if returned_checksum == original_checksum:
-        return True, returned_checksum
-    else:
-        return False, returned_checksum