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

Issue:
Add sample data loader from zenodo



Discussion / hints:

Fix patch:
diff --git a/setup.cfg b/setup.cfg
index 49c40af..30c57ee 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -35,7 +35,8 @@ install_requires =
     magicgui
     qtpy
     scikit-image
-    fractal-tasks-core==0.14.2
+    fractal-tasks-core==0.14.3
+    wget
 
 python_requires = >=3.8
 include_package_data = True
diff --git a/src/napari_ome_zarr_navigator/__init__.py b/src/napari_ome_zarr_navigator/__init__.py
index 5ee1cb1..9f7483f 100644
--- a/src/napari_ome_zarr_navigator/__init__.py
+++ b/src/napari_ome_zarr_navigator/__init__.py
@@ -1,3 +1,5 @@
+from pathlib import Path
+
 try:
     from ._version import version as __version__
 except ImportError:
@@ -10,3 +12,8 @@
 )
 
 __all__ = ("ROILoader", "ImgBrowser")
+FILE = Path(__file__).resolve()
+_PACKAGE_DIR = FILE.parents[2]
+_MODULE_DIR = FILE.parent
+_TEST_DIR = _MODULE_DIR.joinpath("_tests")
+_TEST_DATA_DIR = _PACKAGE_DIR.joinpath("test_data")
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/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]


Test patch:
diff --git a/src/napari_ome_zarr_navigator/_tests/test_sample_data.py b/src/napari_ome_zarr_navigator/_tests/test_sample_data.py
index fe7b7ef..09c0ea9 100644
--- a/src/napari_ome_zarr_navigator/_tests/test_sample_data.py
+++ b/src/napari_ome_zarr_navigator/_tests/test_sample_data.py
@@ -1,7 +1,16 @@
-# from napari_ome_zarr_navigator import make_sample_data
+from pathlib import Path
 
-# add your tests here...
+from napari_ome_zarr_navigator.generate_test_data import (
+    load_ome_zarr_from_zenodo,
+)
 
 
-def test_something():
-    pass
+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
diff --git a/src/napari_ome_zarr_navigator/generate_test_data/__init__.py b/src/napari_ome_zarr_navigator/generate_test_data/__init__.py
new file mode 100644
index 0000000..e69de29