diff --git a/docs/source/_toc.yml b/docs/source/_toc.yml index 4ff590be..bbf2aa35 100644 --- a/docs/source/_toc.yml +++ b/docs/source/_toc.yml @@ -16,6 +16,11 @@ parts: - caption: Advanced chapters: + - file: advanced/labels/index + sections: + - file: advanced/labels/adding_labels + - file: advanced/labels/labels_metadata + - file: advanced/sharding - file: advanced/build_custom_pyramid - file: advanced/write_hcs_plate @@ -30,6 +35,7 @@ parts: chapters: - file: api sections: + - file: api/image - file: api/writer - file: api/reader - file: api/io diff --git a/docs/source/advanced/build_custom_pyramid.ipynb b/docs/source/advanced/build_custom_pyramid.ipynb index 741209ab..77bd1bc1 100644 --- a/docs/source/advanced/build_custom_pyramid.ipynb +++ b/docs/source/advanced/build_custom_pyramid.ipynb @@ -11,7 +11,7 @@ "\n", "Multi-resolution pyramids are an integral part of ome-zarr image data\n", "and enable fast rendering of large images.\n", - "The entrypoints to writing ome-zarr images in ome-zarr-py ({py:func}`ome_zarr.writer.write_image` and {py:func}`ome_zarr.writer.write_labels`)\n", + "The entrypoints to writing ome-zarr images in ome-zarr-py ({py:func}`ome_zarr.classes.image.OMEZarrMultiscale`, {py:func}`ome_zarr.writer.write_image` and {py:func}`ome_zarr.writer.write_labels`)\n", "build these pyramids under the hood as delayed dask arrays based on the settings for the scaling functions and scale factors.\n", "\n", "In this example, the downsampling will be applied in all spatial dimensions *except the z dimension*, which will be left at a scale factor of 1.\n", @@ -20,9 +20,26 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 1, "id": "642955b1", "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from ome_zarr import OMEZarrImage, OMEZarrLabels, OMEZarrMultiscale\n", + "from ome_zarr.writer import write_image\n", + "\n", + "scale_factors = [2, 4, 8]\n", + "rng = np.random.default_rng(0)\n", + "data = rng.poisson(lam=10, size=(64, 64, 64)).astype(np.uint8)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a8f0defa", + "metadata": {}, "outputs": [ { "data": { @@ -30,26 +47,51 @@ "[]" ] }, - "execution_count": 16, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "import numpy as np\n", - "\n", - "from ome_zarr.writer import write_image\n", - "\n", - "scale_factors = [2, 4, 8]\n", - "rng = np.random.default_rng(0)\n", - "data = rng.poisson(lam=10, size=(64, 64, 64)).astype(np.uint8)\n", - "\n", "write_image(\n", " data,\n", " \"test_ngff_image.ome.zarr\",\n", " axes=\"zyx\",\n", " scale_factors=scale_factors,\n", - " )" + ")" + ] + }, + { + "cell_type": "markdown", + "id": "c3f7a5aa", + "metadata": {}, + "source": [ + "Or, if you choose to work with the {py:class}`ome_zarr.classes.image.OMEZarrMultiscale` and {py:class}`ome_zarr.classes.image.OMEZarrImage` classes directly,\n", + "they accept the same arguments:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0315903", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ngff_image = OMEZarrImage(data, axes=\"zyx\")\n", + "ngff_multiscales = OMEZarrMultiscale(ngff_image, scale_factors=scale_factors)\n", + "\n", + "ngff_multiscales.to_ome_zarr(\"test_ngff_image_multiscales.ome.zarr\")" ] }, { @@ -66,7 +108,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 4, "id": "60dcd278", "metadata": {}, "outputs": [ @@ -76,7 +118,7 @@ "[]" ] }, - "execution_count": 17, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -108,7 +150,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 5, "id": "877ab60d", "metadata": {}, "outputs": [ @@ -136,7 +178,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 6, "id": "859ca482", "metadata": {}, "outputs": [ @@ -146,7 +188,7 @@ "[]" ] }, - "execution_count": 19, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -161,6 +203,38 @@ ")\n" ] }, + { + "cell_type": "markdown", + "id": "9b33ad31", + "metadata": {}, + "source": [ + "Again, the {py:class}`ome_zarr.OMEZarrMultiscale` accepts the same argument:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "84c73869", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ngff_image = OMEZarrImage(data, axes=\"zyx\")\n", + "ngff_multiscales = OMEZarrMultiscale(ngff_image, scale_factors=scale_factors, method=\"nearest\")\n", + "\n", + "ngff_multiscales.to_ome_zarr(\"test_ngff_image_multiscales.ome.zarr\")" + ] + }, { "cell_type": "markdown", "id": "893a1b3b", @@ -175,20 +249,32 @@ "you will want to use a method that preserves the label values, such as {py:func}`ome_zarr.scale.Methods.NEAREST`.\n", "\n", "See also section on [writing labels](basic:labels)\n", + "```\n", "\n", - "```" + "If you specifically want to create a labels image, you can use the {py:class}`ome_zarr.classes.image.OMEZarrLabels` class.\n", + "It provides the same functionality as the {py:class}`ome_zarr.classes.image.OMEZarrMultiscale` class, but the downsampling function used under the hood defaults to {py:func}`ome_zarr.scale.Methods.NEAREST`,\n", + "which is typically the most appropriate for labels data.\n", + "\n", + "For more information on writing labels data, see the [labels writing section](advanced/labels/index)." ] }, { - "cell_type": "markdown", - "id": "fbbeecf3", + "cell_type": "code", + "execution_count": null, + "id": "2e5edc2e", "metadata": {}, - "source": [] + "outputs": [], + "source": [ + "ngff_image = OMEZarrImage(data, axes=\"zyx\")\n", + "ngff_multiscales = OMEZarrLabels(ngff_image, scale_factors=scale_factors)\n", + "\n", + "ngff_multiscales.to_ome_zarr(\"test_ngff_image_multiscales_labels.ome.zarr\")" + ] } ], "metadata": { "kernelspec": { - "display_name": "ngff-spec", + "display_name": "ome-zarr (3.12.12)", "language": "python", "name": "python3" }, diff --git a/docs/source/advanced/labels/adding_labels.ipynb b/docs/source/advanced/labels/adding_labels.ipynb new file mode 100644 index 00000000..712627ec --- /dev/null +++ b/docs/source/advanced/labels/adding_labels.ipynb @@ -0,0 +1,289 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f2b8b166", + "metadata": {}, + "source": [ + "# Adding labels to ome-zarrs\n", + "(advanced:labels:adding_labels)=\n", + "\n", + "Unlike demonstrated in the [basic labels example](basic:labels), in many scenarios, it is not desirable to write a complete structure consisting of an ome-zarr image and some labels at once.\n", + "Instead, one might want to *first* write the ome-zarr image (i.e., after image conversion), and *then* add the labels later (i.e., after segmentation).\n", + "\n", + "The ome-zarr-py allows to do this in a lean and top-level way." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "85c4f96d", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from skimage.data import binary_blobs\n", + "\n", + "from ome_zarr import OMEZarrImage, OMEZarrLabels, OMEZarrMultiscale" + ] + }, + { + "cell_type": "markdown", + "id": "23aabf32", + "metadata": {}, + "source": [ + "First, let's create some sample ome-zarr image data and write it to disk:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "65be7a0e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rng = np.random.default_rng(0)\n", + "data = rng.poisson(10, size=(64, 64, 64)).astype(np.uint8)\n", + "\n", + "ngff_image = OMEZarrImage(data, axes=\"zyx\", name=\"image\")\n", + "ngff_multiscales = OMEZarrMultiscale(image=ngff_image)\n", + "\n", + "# write the image data to disk\n", + "ngff_multiscales.to_ome_zarr(\"image_with_labels.zarr\", overwrite=True)" + ] + }, + { + "cell_type": "markdown", + "id": "71d44e6f", + "metadata": {}, + "source": [ + "## Adding labels data\n", + "\n", + "Now, we perform a pseudo-segmentation and create some labels data.\n", + "The created data is then also converted into an instance of {py:class}`ome_zarr.classes.NgffMultiscales`." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a141e669", + "metadata": {}, + "outputs": [], + "source": [ + "labels = OMEZarrImage(\n", + " data=binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype('int8'),\n", + " axes=\"zyx\",\n", + ")\n", + "labels_multiscales = OMEZarrLabels(image=labels)" + ] + }, + { + "cell_type": "markdown", + "id": "aa56d70d", + "metadata": {}, + "source": [ + "If we assume that the image data has been written to disk previously, we need to open the existing ome-zarr file to append our labels data to the `labels` attribute of the parent image.\n", + "Since no label images have yet been added to the group, we find the `labels` attribute to be empty:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "02e92aa7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "None\n" + ] + } + ], + "source": [ + "parent_image = OMEZarrMultiscale.from_ome_zarr(\"image_with_labels.zarr\")\n", + "print(parent_image.labels)" + ] + }, + { + "cell_type": "markdown", + "id": "5103d21e", + "metadata": {}, + "source": [ + " This attribute is going to be a Python dictionary, so new images can be added to it as follows:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "985631a1", + "metadata": {}, + "outputs": [], + "source": [ + "parent_image.labels = {\n", + " \"label_image_1\": labels_multiscales\n", + " }" + ] + }, + { + "cell_type": "markdown", + "id": "663fb08f", + "metadata": {}, + "source": [ + "We can now store the additional labels data to the already existing group under `\"image_with_labels.zarr/labels/label_image_1\"`. The name of the label image group corresponds to the key of the dictionary entry above.\n", + "\n", + "```{note}\n", + "When writing the labels data, we want to **append** the labels data, not write the entire image data again. For this to happen, we need to set the `overwrite` argument of the `to_ome_zarr` method to `False`\n", + "```\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "9187a4fd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "parent_image.to_ome_zarr(\"image_with_labels.zarr\", overwrite=False)" + ] + }, + { + "cell_type": "markdown", + "id": "f2a41825", + "metadata": {}, + "source": [ + "## Appending more labels data\n", + "\n", + "In the above example, we added a single label image to the parent image.\n", + "However, it is possible to distinctively append labels images rather than adding them altogether. If we open the ome-zarr file again, we can find the already added label image under the `labels` attribute of the parent image:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cf9a7bcc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'label_image_1': }" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "parent_image = OMEZarrMultiscale.from_ome_zarr(\"image_with_labels.zarr\")\n", + "parent_image.labels" + ] + }, + { + "cell_type": "markdown", + "id": "4d4b1f15", + "metadata": {}, + "source": [ + "We can now append more label image to the `labels` attribute of the parent image..." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3b6e5dbc", + "metadata": {}, + "outputs": [], + "source": [ + "labels2 = OMEZarrImage(\n", + " data=binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype('int8'),\n", + " axes=\"zyx\",\n", + ")\n", + "labels_multiscales2 = OMEZarrLabels(image=labels2)" + ] + }, + { + "cell_type": "markdown", + "id": "56ed964e", + "metadata": {}, + "source": [ + "...and write the whole lot to disk.\n", + "Again, when `overwrite=False`, the existing image data (and the already written label image `label_image_1`) will not be overwritten, but the new label image `label_image_2` will be added to the `labels` group as a new subgroup. " + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "8d222883", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "C:\\Users\\johan\\Documents\\GitHub\\ome-zarr-py\\ome_zarr\\classes\\image.py:666: UserWarning: Label group label_image_1 already exists in store. Skipping writing this label since overwrite=False.\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "parent_image.labels[\"label_image_2\"] = labels_multiscales2\n", + "parent_image.to_ome_zarr(\"image_with_labels.zarr\", overwrite=False)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ome-zarr (3.12.12)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/advanced/labels/index.md b/docs/source/advanced/labels/index.md new file mode 100644 index 00000000..04e7a7f5 --- /dev/null +++ b/docs/source/advanced/labels/index.md @@ -0,0 +1,4 @@ +# Working with labels +(advanced/labels/index)= + +This section provides more detailed information on how to work with labels data in ome-zarr. diff --git a/docs/source/advanced/labels/labels_metadata.ipynb b/docs/source/advanced/labels/labels_metadata.ipynb new file mode 100644 index 00000000..8a3d931d --- /dev/null +++ b/docs/source/advanced/labels/labels_metadata.ipynb @@ -0,0 +1,267 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e6e4a0ff", + "metadata": {}, + "source": [ + "# Image-labels metadata\n", + "\n", + "To ease the interpretation of label images and make them identifiable as label images,\n", + "it is helpful to add the [`image-label` metadata](https://ngff.openmicroscopy.org/specifications/0.5/index.html#labels-metadata) to the written label image data.\n", + "The {py:class}`ome_zarr.classes.image.OMEZarrLabels` provides an easy entrypoint to pass this metadata when writing label images to disk and to read it back in when loading an image with labels from disk.\n", + "\n", + "Similar to the [previous notebook](advanced:labels:adding_labels), we will start by creating a parent image and a dummy label image, to which we add some metadata." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b19734dd", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from skimage.data import binary_blobs\n", + "\n", + "from ome_zarr import OMEZarrImage, OMEZarrLabels, OMEZarrMultiscale" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ab4e12c0", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(0)\n", + "data = rng.poisson(10, size=(64, 64, 64)).astype(np.uint8)\n", + "\n", + "ngff_image = OMEZarrImage(data, axes=\"zyx\", name=\"image\")\n", + "ngff_multiscales = OMEZarrMultiscale(image=ngff_image)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "36906483", + "metadata": {}, + "outputs": [], + "source": [ + "labels = OMEZarrImage(\n", + " data=binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype('int8'),\n", + " axes=\"zyx\",\n", + ")\n", + "labels_multiscales = OMEZarrLabels(image=labels)" + ] + }, + { + "cell_type": "markdown", + "id": "5370d65f", + "metadata": {}, + "source": [ + "## Composing the image-label metadata\n", + "\n", + "In Python context, the `image-label` metadata is a dictionary composed of the following fields:\n", + "- `colors`: Consists of the actual integer label value and a list of rgba color values.\n", + "- `properties`: Unspecified, per-object key-value pairs. The `label-value` property links the metadata to the actual label value in the label image.\n", + "- `source`: The source of the label image. Automatically written by the writer tool.\n", + "- `version`: The version of the metadata schema. Automatically written by the writer tool.\n", + "\n", + "In our example (a binary label image), we need only provide the metadata for two kinds of objects (i.e., the background and the foreground):" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5b481d88", + "metadata": {}, + "outputs": [], + "source": [ + "colors = [\n", + " {\"label-value\": 0, \"rgba\": [0, 0, 0, 255]},\n", + " {\"label-value\": 1, \"rgba\": [255, 255, 255, 255]},\n", + "]\n", + "\n", + "properties = [\n", + " {\"label-value\": 0, \"class\": \"background\"},\n", + " {\"label-value\": 1, \"class\": \"foreground\"},\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "a5f15839", + "metadata": {}, + "source": [ + "We can now pass this metadata to the respective field of the {py:class}`ome_zarr.classes.image.OMEZarrLabels` object and write it to disk:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e1960a55", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "C:\\Users\\johan\\Documents\\GitHub\\ome-zarr-py\\ome_zarr\\classes\\image.py:666: UserWarning: Label group test_labels already exists in store. Skipping writing this label since overwrite=False.\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "labels_multiscales.image_label = {\n", + " \"image-label\": {\n", + " \"colors\": colors,\n", + " \"properties\": properties\n", + " }\n", + "}\n", + "\n", + "ngff_multiscales.labels = {\"test_labels\": labels_multiscales}\n", + "\n", + "# write to disk\n", + "ngff_multiscales.to_ome_zarr(\"labels_metadata_example.zarr\")" + ] + }, + { + "cell_type": "markdown", + "id": "a046182b", + "metadata": {}, + "source": [ + "## Reading the image-label metadata\n", + "\n", + "When reading an ome-zarr with labels from disk, the `image-label` metadata is automatically parsed and made available as part of the {py:class}`ome_zarr.classes.image.OMEZarrMultiscale` object.\n", + "We can retrieve the labels image from the `labels` attribute of the parent `OMEZarrMultiscale` image:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "1904c078", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "LabelBase(colors=None, properties=None, source=None, version=None, image-label={'colors': [{'label-value': 0, 'rgba': [0, 0, 0, 255]}, {'label-value': 1, 'rgba': [255, 255, 255, 255]}], 'properties': [{'label-value': 0, 'class': 'background'}, {'label-value': 1, 'class': 'foreground'}]})" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "image = OMEZarrMultiscale.from_ome_zarr(\"labels_metadata_example.zarr\")\n", + "image.labels[\"test_labels\"].image_label" + ] + }, + { + "cell_type": "markdown", + "id": "5aad6334", + "metadata": {}, + "source": [ + "This can be converted into a more human-readable format using the `model_dump()` method of the `LabelBase` class:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1d2f6b9d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'colors': None,\n", + " 'properties': None,\n", + " 'source': None,\n", + " 'version': None,\n", + " 'image-label': {'colors': [{'label-value': 0, 'rgba': [0, 0, 0, 255]},\n", + " {'label-value': 1, 'rgba': [255, 255, 255, 255]}],\n", + " 'properties': [{'label-value': 0, 'class': 'background'},\n", + " {'label-value': 1, 'class': 'foreground'}]}}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "image.labels[\"test_labels\"].image_label.model_dump()" + ] + }, + { + "cell_type": "markdown", + "id": "3436ac94", + "metadata": {}, + "source": [ + "Alternatively, it is possible to read only the label image (using the {py:class}`ome_zarr.classes.image.OMEZarrLabels`) without ingesting the parent image and its metadata:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "9f93e403", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'colors': None,\n", + " 'properties': None,\n", + " 'source': None,\n", + " 'version': None,\n", + " 'image-label': {'colors': [{'label-value': 0, 'rgba': [0, 0, 0, 255]},\n", + " {'label-value': 1, 'rgba': [255, 255, 255, 255]}],\n", + " 'properties': [{'label-value': 0, 'class': 'background'},\n", + " {'label-value': 1, 'class': 'foreground'}]}}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "label_image = image = OMEZarrLabels.from_ome_zarr(\"labels_metadata_example.zarr/labels/test_labels\")\n", + "label_image.image_label.model_dump()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ome-zarr (3.12.12)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/api/image.rst b/docs/source/api/image.rst new file mode 100644 index 00000000..e571c79d --- /dev/null +++ b/docs/source/api/image.rst @@ -0,0 +1,11 @@ +OMEZarr classes (``ome_zarr.classes``) +====================================== + +.. autoclass:: ome_zarr.classes.image.OMEZarrImage + :members: + +.. autoclass:: ome_zarr.classes.image.OMEZarrMultiscale + :members: + +.. autoclass:: ome_zarr.classes.image.OMEZarrLabels + :members: diff --git a/docs/source/basic/read_image.ipynb b/docs/source/basic/read_image.ipynb index e4902be4..efedc6f4 100644 --- a/docs/source/basic/read_image.ipynb +++ b/docs/source/basic/read_image.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "9eb6730c", + "id": "23215873", "metadata": {}, "source": [ "# Read OME-ZARR images\n", @@ -10,6 +10,289 @@ "\n", "This sample code reads an image stored on remote s3 server,\n", "but the same code can be used to read data on a local file system.\n", + "In either case, the data is exposed as an instance of the {py:class}`ome_zarr.classes.image.OMEZarrMultiscale` class, which provides access to the multiscale levels and metadata of the OME-ZARR image." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "ad27af87", + "metadata": {}, + "outputs": [], + "source": [ + "from ome_zarr import OMEZarrMultiscale\n", + "\n", + "url = \"https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr\"\n", + "\n", + "ngff_image = OMEZarrMultiscale.from_ome_zarr(url)" + ] + }, + { + "cell_type": "markdown", + "id": "38e3c2ca", + "metadata": {}, + "source": [ + "You can access the multiscale levels by inspecting the `images` attributes of the {py:class}`ome_zarr.classes.image.OMEZarrMultiscale` object,\n", + "which is a list of {py:class}`ome_zarr.classes.image.OMEZarrImage` objects." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "2e91ca92", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[OMEZarrImage(data=dask.array, axes=['c', 'z', 'y', 'x'], scale={'c': 1.0, 'z': 0.5002025531914894, 'y': 0.3603981534640209, 'x': 0.3603981534640209}, axes_units={'z': 'micrometer', 'y': 'micrometer', 'x': 'micrometer'}, name='image', channel_names=None, channel_colors=None, contrast_limits=None),\n", + " OMEZarrImage(data=dask.array, axes=['c', 'z', 'y', 'x'], scale={'c': 1.0, 'z': 0.5002025531914894, 'y': 0.7207963069280418, 'x': 0.7207963069280418}, axes_units={'z': 'micrometer', 'y': 'micrometer', 'x': 'micrometer'}, name='image', channel_names=None, channel_colors=None, contrast_limits=None),\n", + " OMEZarrImage(data=dask.array, axes=['c', 'z', 'y', 'x'], scale={'c': 1.0, 'z': 0.5002025531914894, 'y': 1.4415926138560835, 'x': 1.4415926138560835}, axes_units={'z': 'micrometer', 'y': 'micrometer', 'x': 'micrometer'}, name='image', channel_names=None, channel_colors=None, contrast_limits=None)]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ngff_image.images" + ] + }, + { + "cell_type": "markdown", + "id": "82451db6", + "metadata": {}, + "source": [ + "And of course, retrieve the data as a `dask` array using the `data` attribute of each image:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "d308afa1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Array Chunk
Bytes 67.09 MiB 128.00 kiB
Shape (2, 236, 275, 271) (1, 1, 256, 256)
Dask graph 1888 chunks in 2 graph layers
Data type uint16 numpy.ndarray
\n", + "
\n", + " \n", + "\n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + "\n", + " \n", + " 2\n", + " 1\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + "\n", + " \n", + " \n", + "\n", + " \n", + " 271\n", + " 275\n", + " 236\n", + "\n", + "
" + ], + "text/plain": [ + "dask.array" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ngff_image.images[0].data" + ] + }, + { + "cell_type": "markdown", + "id": "0839c251", + "metadata": {}, + "source": [ + "You can check whether label images were attached to this image by inspecting the `labels` attribute of the {py:class}`ome_zarr.classes.image.OMEZarrMultiscale` object,\n", + "which is a dictionary mapping label image names to {py:class}`ome_zarr.classes.image.OMEZarrLabels` objects." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "fff32b33", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'0': }" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ngff_image.labels" + ] + }, + { + "cell_type": "markdown", + "id": "9eb6730c", + "metadata": {}, + "source": [ + "## Direct read\n", + "\n", + "The code below here demonstrates an alternative, equally functional API for reading OME-ZARR images.\n", + "\n", + "This sample code reads an image stored on remote s3 server,\n", + "but the same code can be used to read data on a local file system.\n", "In either case, the data is exposed as [`dask` arrays](https://docs.dask.org/en/stable/array.html);\n", "\n", "You can obtain a list of \"nodes\" which include all arrays stored in the group:" @@ -17,19 +300,19 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "54d5f59a", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr/ [zgroup],\n", - " https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr/labels/ [zgroup] (hidden),\n", - " https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr/labels/0/ [zgroup] (hidden)]" + "[https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr/ [zgroup],\n", + " https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr/labels/ [zgroup] (hidden),\n", + " https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr/labels/0/ [zgroup] (hidden)]" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -38,8 +321,6 @@ "from ome_zarr.io import parse_url\n", "from ome_zarr.reader import Reader\n", "\n", - "url = \"https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr\"\n", - "\n", "# read the image data\n", "reader = Reader(parse_url(url))\n", "# nodes may include images, labels etc\n", @@ -278,7 +559,7 @@ { "cell_type": "code", "execution_count": null, - "id": "eee17b99", + "id": "6513749e", "metadata": {}, "outputs": [], "source": [] @@ -286,7 +567,7 @@ ], "metadata": { "kernelspec": { - "display_name": "ngff-spec", + "display_name": "ome-zarr (3.12.12)", "language": "python", "name": "python3" }, diff --git a/docs/source/basic/write_image.ipynb b/docs/source/basic/write_image.ipynb index 42d70e1e..2cd9c91f 100644 --- a/docs/source/basic/write_image.ipynb +++ b/docs/source/basic/write_image.ipynb @@ -8,26 +8,135 @@ "# Write OME-ZARR images\n", "(basic:write)=\n", "\n", - "The principle entry-point for writing OME-NGFF images is {py:func}`ome_zarr.writer.write_image`.\n", - "This takes an n-dimensional `numpy` array or `dask` array and writes it to the specified `zarr group` according\n", - "to the OME-NGFF specification.\n", - "By default, a pyramid of resolution levels will be created by down-sampling the data by a factor\n", - "of 2 in the X and Y dimensions.\n", + "Writing ome-zarr images is primarily exposed through the {py:class}`ome_zarr.classes.image.OMEZarrImage` and {py:class}`ome_zarr.classes.image.OMEZarrMultiscales` classes, which provide a high-level API for creating and manipulating OME-ZARR images and pyramids." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "21e12529", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from ome_zarr import OMEZarrImage, OMEZarrMultiscale" + ] + }, + { + "cell_type": "markdown", + "id": "33774809", + "metadata": {}, + "source": [ + "Let's first create some random data to write:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4cdd0f46", + "metadata": {}, + "outputs": [], + "source": [ + "path = \"test_ngff.ome.zarr\"\n", + "\n", + "# create some random data to write\n", + "size_xy = 128\n", + "size_z = 10\n", + "rng = np.random.default_rng(0)\n", + "data = rng.poisson(lam=10, size=(2, size_z, size_xy, size_xy)).astype(np.uint8)" + ] + }, + { + "cell_type": "markdown", + "id": "4fe34e38", + "metadata": {}, + "source": [ + "We then create an {py:class}`OMEZarrImage` from our data,\n", + "where we can specify some basic metadata for the image data, such as the types of axes (`czyx`) and their scales and units.\n", + "The {py:class}`OMEZarrMultiscale` class creation then builds a multiscale pyramid of dask arrays by downsampling as specified by the `scale_factors` parameter.\n", + "You can use this class to pass how viewers should render the image by specifying optional parameters such as `channel_names`, `channel_colors` and `contrast_limits`.\n", + "As a last step, we write the multiscale image to disk using the `to_ome_zarr` method, which will create a valid OME-ZARR file that can be read by any OME-ZARR compatible viewer." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "ce122c48", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "image = OMEZarrImage(\n", + " data=data,\n", + " axes=[\"c\", \"z\", \"y\", \"x\"],\n", + " scale={\"c\": 1.0, \"z\": 0.5, \"y\": 0.1, \"x\": 0.1},\n", + " axes_units={\"z\": \"micrometer\", \"y\": \"micrometer\", \"x\": \"micrometer\"},\n", + ")\n", + "\n", + "multiscales = OMEZarrMultiscale(\n", + " image=image,\n", + " scale_factors=(2, 4, 8),\n", + " method=\"resize\",\n", + " channel_names=[\"DAPI\", \"GFP\"], # optional\n", + " channel_colors=[\"00FFFF\", \"FF00FF\"], # optional\n", + " contrast_limits=[(0, 255), (0, 255)] # optional\n", + " )\n", + "multiscales.to_ome_zarr(path)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8ddc43e2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[OMEZarrImage(data=dask.array, axes=['c', 'z', 'y', 'x'], scale={'c': 1.0, 'z': 0.5, 'y': 0.1, 'x': 0.1}, axes_units={'z': 'micrometer', 'y': 'micrometer', 'x': 'micrometer'}, name='image'),\n", + " OMEZarrImage(data=dask.array, axes=['c', 'z', 'y', 'x'], scale={'c': 1.0, 'z': 0.5, 'y': 0.2, 'x': 0.2}, axes_units={'z': 'micrometer', 'y': 'micrometer', 'x': 'micrometer'}, name='image'),\n", + " OMEZarrImage(data=dask.array, axes=['c', 'z', 'y', 'x'], scale={'c': 1.0, 'z': 0.5, 'y': 0.4, 'x': 0.4}, axes_units={'z': 'micrometer', 'y': 'micrometer', 'x': 'micrometer'}, name='image'),\n", + " OMEZarrImage(data=dask.array, axes=['c', 'z', 'y', 'x'], scale={'c': 1.0, 'z': 0.5, 'y': 0.8, 'x': 0.8}, axes_units={'z': 'micrometer', 'y': 'micrometer', 'x': 'micrometer'}, name='image')]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "multiscales.images" + ] + }, + { + "cell_type": "markdown", + "id": "9015365c", + "metadata": {}, + "source": [ + "## API alternative: Direct write\n", + "\n", + "Besides the above-described class-based approach, another principle entry-point for writing OME-ZARR images is using the {py:func}`ome_zarr.writer.write_image` function.\n", + "This takes an n-dimensional `numpy` array or `dask` array and writes it to the specified zarr group according to the OME-ZARR specification.\n", + "By default, a pyramid of resolution levels will be created by down-sampling the data by a factor of 2 in the X and Y dimensions.\n", "For more custom control over the pyramid, see the more in-depth example on [scaling functions and scale factors](advanced:pyramid)." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "f87d36fa", - "metadata": { - "execution": { - "iopub.execute_input": "2026-04-21T14:22:46.982748Z", - "iopub.status.busy": "2026-04-21T14:22:46.982456Z", - "iopub.status.idle": "2026-04-21T14:22:47.853028Z", - "shell.execute_reply": "2026-04-21T14:22:47.852515Z" - } - }, + "metadata": {}, "outputs": [ { "data": { @@ -35,27 +144,16 @@ "[]" ] }, - "execution_count": 2, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "import numpy as np\n", - "\n", "from ome_zarr.writer import write_image\n", "\n", "path = \"test_ngff_image.ome.zarr\"\n", - "\n", - "size_xy = 128\n", - "size_z = 10\n", - "rng = np.random.default_rng(0)\n", - "data = rng.poisson(lam=10, size=(size_z, size_xy, size_xy)).astype(np.uint8)\n", - "\n", - "write_image(\n", - " data, path, axes=\"zyx\",scale={\"z\": 1.0, \"y\": 0.5, \"x\": 0.5},\n", - " axes_units={\"z\": \"micrometer\", \"y\": \"micrometer\", \"x\": \"micrometer\"},\n", - " )" + "write_image(data, path, axes=\"czyx\")" ] }, { @@ -68,27 +166,31 @@ "\n", "The default version of OME-NGFF is v0.5, which is based on Zarr v3.\n", "A zarr v3 group and store is created by `zarr.open_group()` below.\n", - "To write OME-NGFF v0.4 (Zarr v2), add the `fmt=FormatV04()` argument." + "To write OME-NGFF v0.4 (Zarr v2), pass the `fmt=FormatV04()` argument." ] }, { "cell_type": "code", "execution_count": null, "id": "cfb1d49f", - "metadata": { - "execution": { - "iopub.execute_input": "2026-04-21T14:22:47.854602Z", - "iopub.status.busy": "2026-04-21T14:22:47.854423Z", - "iopub.status.idle": "2026-04-21T14:22:47.887527Z", - "shell.execute_reply": "2026-04-21T14:22:47.886995Z" + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" } - }, - "outputs": [], + ], "source": [ "from ome_zarr.format import FormatV04\n", "\n", "path = \"test_ngff_image_v2.ome.zarr\"\n", - "write_image(data, path, axes=\"zyx\", fmt=FormatV04())" + "write_image(data, path, axes=\"zyx\", fmt=FormatV04()) " ] }, { diff --git a/docs/source/basic/write_labels.ipynb b/docs/source/basic/write_labels.ipynb index bc176086..6ecbfb62 100644 --- a/docs/source/basic/write_labels.ipynb +++ b/docs/source/basic/write_labels.ipynb @@ -14,40 +14,50 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 1, "id": "ab3c7251", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "import os\n", - "\n", "import numpy as np\n", - "import zarr\n", "from skimage.data import binary_blobs\n", "\n", - "from ome_zarr.writer import write_image, write_labels\n", - "\n", - "path = \"test_ngff_image_with_labels.zarr\"\n", - "os.mkdir(path)\n", - "\n", + "from ome_zarr import OMEZarrImage, OMEZarrLabels, OMEZarrMultiscale" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "dba247a7", + "metadata": {}, + "outputs": [], + "source": [ "mean_val = 10\n", "size = 64\n", "rng = np.random.default_rng(0)\n", - "data = rng.poisson(mean_val, size=(size, size, size)).astype(np.uint8)\n", - "\n", - "root = zarr.open_group(path, mode=\"w\")\n", - "write_image(image=data, group=root, axes=\"zyx\")" + "data = rng.poisson(mean_val, size=(size, size, size)).astype(np.uint8)" + ] + }, + { + "cell_type": "markdown", + "id": "826ee469", + "metadata": {}, + "source": [ + "We can directly turn this into an {py:class}`ome_zarr.classes.image.OMEZarrMultiscales` object:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5c412cd5", + "metadata": {}, + "outputs": [], + "source": [ + "ngff_image = OMEZarrImage(\n", + " data=data,\n", + " axes=\"zyx\",\n", + ")\n", + "ngff_multiscales = OMEZarrMultiscale(image=ngff_image)" ] }, { @@ -60,7 +70,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 4, "id": "bd152f2c", "metadata": {}, "outputs": [], @@ -74,31 +84,45 @@ }, { "cell_type": "markdown", - "id": "901d9da3", + "id": "5ae63c13", + "metadata": {}, + "source": [ + "We now turn these two label images into instances of {py:class}`ome_zarr.classes.image.OMEZarrLabels` similar to how we did above for the image data:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e971713c", "metadata": {}, + "outputs": [], "source": [ - "We now need to create a new [zarr group](https://zarr.readthedocs.io/en/latest/user-guide/groups/) inside the hierarchy of the ome-zarr file\n", - "to tore the labels data.\n", - "By doing this, the hierarchy inside the ome-zarr file will look like this:\n", + "label_ngff1 = OMEZarrImage(\n", + " data=blobs,\n", + " axes=\"zyx\",\n", + ")\n", + "label_ngff2 = OMEZarrImage(\n", + " data=blobs2,\n", + " axes=\"zyx\",\n", + ")\n", "\n", - "```\n", - "test_ngff_image_with_labels.zarr\n", - "\u251c\u2500\u2500 zarr.json\n", - "\u251c\u2500\u2500 labels\n", - "\u2502 \u251c\u2500\u2500 zarr.json\n", - "\u2502 \u2514\u2500\u2500 blobs\n", - "| \u251c\u2500\u2500 .zarr.json\n", - "| \u251c\u2500\u2500 s0\n", - "| \u2514\u2500\u2500 s1\n", - "\u251c\u2500\u2500 s0\n", - "\u2514\u2500\u2500 s1\n", - "```" + "# create OMEZarrMultiscales for labels\n", + "labels_multiscales1 = OMEZarrLabels(image=label_ngff1)\n", + "labels_multiscales2 = OMEZarrLabels(image=label_ngff2)" + ] + }, + { + "cell_type": "markdown", + "id": "acd3d43b", + "metadata": {}, + "source": [ + "We can now add the labels as an attribute of the image data and write the whole thing to disk:" ] }, { "cell_type": "code", - "execution_count": 13, - "id": "9f1aea98", + "execution_count": 6, + "id": "1fc52056", "metadata": {}, "outputs": [ { @@ -107,24 +131,49 @@ "[]" ] }, - "execution_count": 13, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# write the labels to /labels\n", - "labels_grp = root.create_group(\"labels\")\n", + "ngff_multiscales.labels = {\n", + " \"labels1\": labels_multiscales1,\n", + " \"labels2\": labels_multiscales2\n", + "}\n", + "\n", + "ngff_multiscales.to_ome_zarr(\"ngff_multiscales_with_labels.zarr\", overwrite=True)" + ] + }, + { + "cell_type": "markdown", + "id": "901d9da3", + "metadata": {}, + "source": [ + "This automatically creates the following file structure on disk:\n", "\n", - "label_name = \"blobs\"\n", - "label_grp = labels_grp.create_group(label_name)\n", - "write_labels(blobs, label_grp, axes=\"zyx\", name=label_name)" + "```\n", + "ngff_multiscales_with_labels.zarr\n", + "\u251c\u2500\u2500 zarr.json\n", + "\u251c\u2500\u2500 labels\n", + "\u2502 \u251c\u2500\u2500 zarr.json\n", + "\u2502 \u251c\u2500\u2500 labels1\n", + "| | \u251c\u2500\u2500 .zarr.json\n", + "| | \u251c\u2500\u2500 s0\n", + "| | \u2514\u2500\u2500 s1\n", + "\u2502 \u2514\u2500\u2500 labels2\n", + "| \u251c\u2500\u2500 .zarr.json\n", + "| \u251c\u2500\u2500 s0\n", + "| \u2514\u2500\u2500 s1\n", + "\u251c\u2500\u2500 s0\n", + "\u2514\u2500\u2500 s1\n", + "```" ] }, { "cell_type": "code", "execution_count": null, - "id": "0edf2e90", + "id": "a907f81b", "metadata": {}, "outputs": [], "source": [] @@ -132,7 +181,7 @@ ], "metadata": { "kernelspec": { - "display_name": "ngff-spec", + "display_name": "ome-zarr (3.12.12)", "language": "python", "name": "python3" }, diff --git a/ome_zarr/__init__.py b/ome_zarr/__init__.py index d7d7ed22..84c34696 100644 --- a/ome_zarr/__init__.py +++ b/ome_zarr/__init__.py @@ -1,6 +1,8 @@ from dask import __version__ as dask_version from packaging.version import Version +from .classes import OMEZarrImage, OMEZarrLabels, OMEZarrMultiscale + # Expose __version__ and fallback when _version.py doesn't exist. try: from ._version import version as __version__ @@ -10,4 +12,4 @@ # If not 2026.3.0 it must be 2025.11.0 or lower. Name indicates kwargs only contain array kwargs in the dask version. USE_DASK_ARRAY_KWARGS = Version(dask_version) >= Version("2026.3.0") -__all__ = ["__version__"] +__all__ = ["OMEZarrImage", "OMEZarrLabels", "OMEZarrMultiscale", "__version__"] diff --git a/ome_zarr/classes/__init__.py b/ome_zarr/classes/__init__.py new file mode 100644 index 00000000..fb26acb1 --- /dev/null +++ b/ome_zarr/classes/__init__.py @@ -0,0 +1,7 @@ +from .image import OMEZarrImage, OMEZarrLabels, OMEZarrMultiscale + +__all__ = [ + "OMEZarrImage", + "OMEZarrLabels", + "OMEZarrMultiscale", +] diff --git a/ome_zarr/classes/image.py b/ome_zarr/classes/image.py new file mode 100644 index 00000000..070b6a58 --- /dev/null +++ b/ome_zarr/classes/image.py @@ -0,0 +1,1050 @@ +from __future__ import annotations + +import warnings +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Literal, cast + +import dask.array as da +import numpy as np +import zarr +from ome_zarr_models.common.image_label_types import LabelBase as Label +from ome_zarr_models.common.omero import Omero +from ome_zarr_models.v05.axes import ( + Axis, +) +from ome_zarr_models.v05.coordinate_transformations import ( + Identity as Identity, +) +from ome_zarr_models.v05.coordinate_transformations import ( + VectorScale as Scale, +) +from ome_zarr_models.v05.coordinate_transformations import ( + VectorTranslation as Translation, +) +from ome_zarr_models.v05.multiscales import ( + Dataset, +) +from ome_zarr_models.v05.multiscales import ( + Multiscale as MultiscaleV05, +) +from pydantic import ValidationError + +from ome_zarr.scale import Methods + +SPATIAL_DIMS = ["z", "y", "x"] +DEFAULT_COLORS = [ + "00FFFF", # cyan + "FF00FF", # magenta + "FFFF00", # yellow + "FF0000", # red + "00FF00", # green + "0000FF", # blue + "FFFFFF", # white + "FFA500", # orange + "800080", # purple + "008000", # dark green +] + + +@dataclass +class OMEZarrImage: + """ + Single-scale image representation with metadata. + + This class serves as the entrypoint to creating ome-zarr + images on disk. The :py:class:`OMEZarrMultiscale` class and + :py:class:`OMEZarrLabels` multi-resolution representations of + ome-zarr images can be created from instances of this class. + + Parameters + ---------- + data : dask.array.Array or numpy.ndarray + The image data array. Can be a NumPy array or a Dask array. + If a NumPy array is provided, it will be converted to a + Dask array internally. + axes : Sequence[str] or str + The axis names corresponding to the data array axes, + i.e. ('c', 'z', 'y', 'x'). + scale : dict[str, float] | None + The physical scale for each axis, with keys as axis names, + e.g. {'x': 0.1, 'y': 0.1, 'z': 0.5}. Missing axes are auto-set to 1.0 + with a warning. Default is None, which sets all scales to 1.0. + axes_units : dict[str, str] | None + Units for each axis, e.g. {'x': 'micrometer', 'y': 'micrometer'}. + Default is None (no units). + name : str + Name of the image. Default is "image". + + Example + ------- + .. code-block:: python + + import numpy as np + data = np.random.poisson(lam=10, size=(2, 10, 128, 128)).astype(np.uint8) + image = OMEZarrImage( + data=data, + axes="czyx", + scale={"c": 1.0, "z": 0.5, "y": 0.1, "x": 0.1}, + axes_units={"c": None, "z": "micrometer", "y": "micrometer", "x": "micrometer"}, + name="my_image", + ) + """ + + data: da.Array | np.ndarray + axes: Sequence[str] | str + scale: dict[str, float] | None = None + axes_units: dict[str, str] | None = None + name: str = "image" + + def __post_init__(self): + # coerce axes to list + if isinstance(self.axes, str): + self.axes = list(self.axes) + + # validate dimensions match data shape + if len(self.axes) != len(self.data.shape): + raise ValueError( + f"Number of dimensions in data ({len(self.data.shape)}) " + f"does not match number of dims ({len(self.axes)})" + ) + + # set default scale if unset + if self.scale is None: + self.scale = dict.fromkeys(self.axes, 1.0) + + # validate and normalize scale dict + if (scale_set := set(self.scale)) != (axes_set := set(self.axes)): + if diff := scale_set.difference(axes_set): + raise ValueError( + f"Scale contains invalid ax(i)(e)s: {diff}. Valid axes are: {axes_set}" + ) + + warnings.warn( + f"Scale value not provided for ax(i)(e)s '{axes_set.difference(scale_set)}'. " + f"Using default scale of 1.0.", + stacklevel=2, + ) + + # rebuild scale dict with defaults for missing axes + self.scale = {d: self.scale.get(d, 1.0) for d in self.axes} + + # coerce data to dask array + if not isinstance(self.data, da.Array): + self.data = da.from_array(self.data) + + +class OMEZarrMultiscaleBase: + + name: str + + def __init__( + self, + image: OMEZarrImage, + scale_factors: list[int] | tuple[int, ...] | list[dict[str, int]] | None = None, + method: str | Methods | None = Methods.RESIZE, + coordinateTransformations: list[Scale | Translation | Identity] | None = None, + ): + from ome_zarr.scale import _build_pyramid + + if scale_factors is None: + scale_factors = (2, 4, 8, 16) + + self.name = image.name + + if isinstance(method, Methods): + method = str(method.value) + elif method is None: + method = str(Methods.RESIZE.value) + + # Build the pyramid data + pyramid = _build_pyramid( + image=image.data, + dims=image.axes, + scale_factors=scale_factors, + method=method, + ) + + # build scales for each level based on the original image shape + # and the pyramid level shapes + scales = [] + # image.scale is guaranteed to be a dict after NgffImage.__post_init__ + image_scale = image.scale + if not isinstance(image_scale, dict): + raise ValueError("Expected image.scale to be a dict after initialization") + + for shape in [d.shape for d in pyramid]: + scale = [full / level for full, level in zip(image.data.shape, shape)] + scales.append( + { + d: s * image_scale[d] if d in image_scale else 1.0 + for d, s in zip(image.axes, scale) + } + ) + + # Create Image instances for each pyramid level + images = [] + datasets = [] + for idx, (level_data, level_scale) in enumerate(zip(pyramid, scales)): + + images.append( + OMEZarrImage( + data=level_data, + axes=image.axes, + scale=level_scale, + axes_units=image.axes_units, + name=image.name, + ) + ) + datasets.append( + Dataset( + path=f"s{idx}", + coordinateTransformations=( + Scale( + type="scale", + scale=list(level_scale.values()), + ), + ), + ) + ) + + self._images = images + + # Build axes metadata + if image.axes_units is None: + image.axes_units = {} + + axes = [] + for d in image.axes: + if d in SPATIAL_DIMS: + axes.append(Axis(name=d, type="space", unit=image.axes_units.get(d))) + elif d == "t": + axes.append(Axis(name=d, type="time", unit=image.axes_units.get(d))) + elif d == "c": + axes.append(Axis(name=d, type="channel", unit=image.axes_units.get(d))) + else: + axes.append(Axis(name=d, type="custom", unit=image.axes_units.get(d))) + + self.metadata = MultiscaleV05( + axes=tuple(axes), + datasets=tuple(datasets), + name=image.name, + coordinateTransformations=coordinateTransformations, + ) + + def to_ome_zarr( + self, + group: zarr.Group | str, + storage_options: list[dict[str, Any]] | dict[str, Any] | None = None, + version: Literal["0.5", "0.4"] = "0.5", + compute: bool = True, + overwrite: bool = False, + ) -> list: + + import os + import shutil + + from ome_zarr.format import Format, FormatV04, FormatV05 + from ome_zarr.utils import _recursive_pop_nones + from ome_zarr.writer import _write_pyramid_to_zarr, check_group_fmt + + delayed = [] + + # Determine if store already exists + if isinstance(group, str): + store_exists = os.path.exists(group) + else: + store_exists = True # zarr.Group was passed in, so it exists + + # Decide whether to write main image data + write_image_data = not store_exists or overwrite + + if write_image_data: + # Delete existing store if overwriting + if overwrite and isinstance(group, str) and os.path.exists(group): + shutil.rmtree(group) + + fmt: Format | None = None + if version == "0.5": + fmt = FormatV05() + elif version == "0.4": + fmt = FormatV04() + else: + raise ValueError(f"Unsupported OME-Zarr version: {version}") + + group, fmt = check_group_fmt(group, fmt) + + # Coerce data to dask arrays for writing + pyramid = [ + img.data if isinstance(img.data, da.Array) else da.from_array(img.data) + for img in self.images + ] + + # write the actual image to disk + delayed = _write_pyramid_to_zarr( + pyramid=pyramid, + group=group, + storage_options=storage_options, + fmt=fmt, + scale=cast(dict[str, float], self.images[0].scale), + axes=[dict(ax) for ax in self.metadata.axes], + compute=compute, + name=self.name, + ) + + # write the metadata to disk + if isinstance(group, str): + group = zarr.open(group, mode="r+") + + # Only write full metadata if we wrote image data, otherwise just update labels + if write_image_data: + # Create a copy of metadata with normalized paths (s0, s1, etc.) + # to match the paths used by _write_pyramid_to_zarr + write_datasets = tuple( + ds.model_copy(update={"path": f"s{idx}"}) + for idx, ds in enumerate(self.metadata.datasets) + ) + write_metadata = self.metadata.model_copy( + update={"datasets": write_datasets} + ) + + if version == "0.4": + # in v0.4, metadata is stored under "multiscales" attribute + metadata_dict = write_metadata.to_version("0.4").model_dump( + by_alias=True + ) + metadata_dict = _recursive_pop_nones(metadata_dict) + metadata_dict["version"] = version + group.attrs["multiscales"] = [metadata_dict] + + elif version == "0.5": + metadata_dict = { + "version": version, + "multiscales": [ + _recursive_pop_nones(write_metadata.model_dump(by_alias=True)) + ], + } + + group.attrs["ome"] = metadata_dict + + delayed += self._write_additional_meta_data( + group=group, + version=version, + storage_options=storage_options, + compute=compute, + overwrite=overwrite, + ) + + return delayed + + @classmethod + def from_ome_zarr( + cls, + group: zarr.Group | str, + ) -> OMEZarrMultiscale | OMEZarrLabels: + """ + Load a multiscale pyramid from an OME-Zarr group. + + Creates an instance with base attributes set, then calls + `_read_additional_metadata` to handle class-specific metadata + (e.g., omero for images, image-label for labels). + + Parameters + ---------- + group : zarr.Group or str + The Zarr group or path containing the OME-Zarr data. + + Returns + ------- + OMEZarrMultiscale | OMEZarrLabels + A container with the loaded images and metadata. + """ + from ome_zarr.utils import _get_version + + if isinstance(group, str): + opened = zarr.open(group, mode="r") + if not isinstance(opened, zarr.Group): + raise ValueError(f"Expected a zarr.Group but got {type(opened)}") + group = opened + + version = _get_version(group) + + is_label = False + + # Handle loading based on version + if version in ("0.1", "0.2", "0.3"): + metadata = cls._read_legacy_metadata(group, version) + if "image-label" in group.attrs: + is_label = True + + elif version == "0.4": + from ome_zarr_models.v04.multiscales import Multiscale as Multiscalev04 + + metadata_json = cast(dict, group.attrs.get("multiscales", [None])[0]) + + if metadata_json is None: + raise ValueError( + "Multiscales metadata not found in group attributes. " + "Opening groups other than multiscales (i.e., HCS, Plates, Wells) " + "is currently not supported." + ) + + metadata = Multiscalev04.model_validate(metadata_json).to_version("0.5") + + if "image-label" in group.attrs: + is_label = True + + elif version == "0.5": + from ome_zarr_models.v05.multiscales import Multiscale as Multiscalev05 + + ome_attrs = cast(dict[str, Any], group.attrs.get("ome", {})) + metadata_json = ome_attrs.get("multiscales", [None])[0] + + if metadata_json is None: + raise ValueError( + "Multiscales metadata not found in group attributes. " + "Opening groups other than multiscales (i.e., HCS, Plates, Wells) " + "is currently not supported." + ) + + metadata = Multiscalev05.model_validate(metadata_json) + + if "image-label" in ome_attrs: + is_label = True + + else: + raise ValueError(f"Unsupported OME-Zarr version: {version}") + + # Create OMEZarrImage instances for each dataset + images: list[OMEZarrImage] = [] + for dataset in metadata.datasets: + path = dataset.path + data = da.from_zarr(group[path]) + coord_transform = dataset.coordinateTransformations[0] + scale = cast(list[float], coord_transform.scale) + # Filter out axes with no unit, and set to None if empty + axes_units: dict[str, str] | None = { + str(ax.name): str(ax.unit) + for ax in metadata.axes + if ax.unit is not None and ax.name is not None + } + if not axes_units: + axes_units = None + axes_names = [str(ax.name) for ax in metadata.axes if ax.name is not None] + images.append( + OMEZarrImage( + data=data, + axes=axes_names, + scale={ + str(ax.name): s + for ax, s in zip(metadata.axes, scale) + if ax.name is not None + }, + axes_units=axes_units, + name=str(metadata.name) if metadata.name else "image", + ) + ) + + return_cls: type[OMEZarrLabels | OMEZarrMultiscale] + if is_label: + return_cls = OMEZarrLabels + else: + return_cls = OMEZarrMultiscale + + # Create instance without calling __init__ + instance = return_cls.__new__(return_cls) + instance._images = images + instance.metadata = metadata + instance.name = str(metadata.name) if metadata.name else "image" + + # Let derived classes read their specific metadata + instance._read_additional_metadata(group, version) + + return instance + + @property + def images(self) -> list[OMEZarrImage]: + """ + List of images at each pyramid level. + """ + return self._images + + def _write_additional_meta_data( + self, + group: zarr.Group, + version: Literal["0.5", "0.4"] = "0.5", + storage_options: list[dict[str, Any]] | dict[str, Any] | None = None, + compute: bool = True, + overwrite: bool = False, + ) -> list: + """ + Hook for derived classes to write additional metadata fields + (e.g. labels, omero, image-label) to the OME-Zarr attributes after writing the main image data. + + Returns + ------- + list + List of delayed objects if compute=False, otherwise empty list. + """ + return [] + + @staticmethod + def _read_legacy_metadata(group, version: str) -> MultiscaleV05: + """Read metadata from legacy OME-Zarr versions (0.1, 0.2, 0.3).""" + from ome_zarr_models.v05.axes import Axis as AxisV05 + from ome_zarr_models.v05.coordinate_transformations import ( + VectorScale, + VectorTranslation, + ) + + metadata_json = cast(dict[str, Any], group.attrs.get("multiscales", [None])[0]) + + axes_map = { + "t": AxisV05(name="t", type="time"), + "c": AxisV05(name="c", type="channel"), + "z": AxisV05(name="z", type="space"), + "y": AxisV05(name="y", type="space"), + "x": AxisV05(name="x", type="space"), + } + + axes_order: list[str] = ["t", "c", "z", "y", "x"] + if version == "0.3": + axes_order_value = metadata_json.get("axes") + if axes_order_value is None: + raise ValueError( + "Metadata version 0.3 requires 'axes' field in metadata" + ) + axes_order = cast(list[str], axes_order_value) + + axes = [axes_map[ax] for ax in axes_order] + + datasets = [] + for idx, ds in enumerate(metadata_json.get("datasets", [])): + scale_level = [ + 2.0 ** idx if s.name in ("z", "y", "x") else 1.0 for s in axes + ] + + if idx == 0: + transforms: tuple[VectorScale | VectorTranslation, ...] = ( + VectorScale(type="scale", scale=scale_level), + ) + else: + translate = [ + 2.0 ** (idx - 1) - 0.5 if s.name in ("z", "y", "x") else 0.0 + for s in axes + ] + transforms = ( + VectorScale(type="scale", scale=scale_level), + VectorTranslation(type="translation", translation=translate), + ) + + datasets.append( + Dataset( + path=ds.get("path", f"s{idx}"), + coordinateTransformations=transforms, + ) + ) + + metadata = MultiscaleV05( + axes=axes, + datasets=tuple(datasets), + type=metadata_json.get("type", None), + metadata=metadata_json.get("metadata", None), + coordinateTransformations=None, + name=metadata_json.get("name", "image"), + ) + + return metadata + + def _read_additional_metadata( + self, + group: zarr.Group, + version: str, + ) -> None: + """ + Hook for derived classes to read additional metadata fields + (e.g., omero, image-label) from the OME-Zarr attributes after loading. + + Called by `from_ome_zarr` after basic loading is complete. + """ + + +class OMEZarrMultiscale(OMEZarrMultiscaleBase): + """ + Container for multiscale image pyramid with OME-Zarr metadata. + + If built from an instance of :py:class:`OMEZarrImage`, the instantiation + of this class handles the construction of the ome-zarr multi-resolution scheme + as delayed dask arrays. + It can be used to write such arrays and associated metadata to disk and read + from local and remote storages. + + This class implements convenient handling of additional subgroups + (i.e., labels) or metadata fields (i.e., the omero metadata field + for display settings). + + Parameters + ---------- + image : OMEZarrImage + The OMEZarrImage instance from which to build the multi-resolution levels. + scale_factors : list[int] | tuple[int, ...] | list[dict[str, int]] | None + Scale factors for each pyramid level. If a list of ints or tuple is provided, + it is applied uniformly across all spatial axes. If a list of dicts is provided, + each dict should specify scale factors for each axis, e.g. {'x': 2, 'y': 2, 'z': 1}. + Default is (2, 4, 8, 16). + method : ome_zarr.scale.Methods | str | None + Rescaling method to use when generating pyramid levels. Default is Methods.RESIZE. + coordinateTransformations : + Additional coordinate transformations to include in the metadata for each level. + labels : OMEZarrLabels | list[OMEZarrLabels] | dict[str, OMEZarrLabels] | None + Labels associated with the image. Can be a single OMEZarrLabels instance, a list of them, + or a dict mapping label names to OMEZarrLabels instances. Default is None (no labels). + channel_names : list[str] | None + List of channel names corresponding to the 'c' axis, e.g. ['DAPI', 'GFP', 'RFP']. + Default is None (no channel names). + channel_colors : list[list[int]] | list[str] | None + List of colors for each channel corresponding to the 'c' axis. + Can be passed as a list of RGB values (i.e., [[255, 0, 0], [0, 255, 0], ...]) + or as hex strings (i.e., ['FF0000', '00FF00', '0000FF']). + Default is None (no channel colors). + contrast_limits : list[tuple[float, float]] | None + List of contrast limits for each channel corresponding to the 'c' axis, + e.g. [(0, 255), (0, 1000), ...]. + Default is None (no contrast limits). + + Attributes + ---------- + images : list[OMEZarrImage] + List of images at each pyramid level. + labels : dict[str, OMEZarrLabels] | None + Dictionary mapping label names to OMEZarrLabels instances, or None if no labels are associated. + metadata : ome_zarr_models.v05.multiscales.Multiscale + The OME-Zarr metadata associated with this multiscale image, + stored as a Pydantic model instance. + Automatically created upon instantiation of the class. + + Methods + ------- + to_ome_zarr(group, storage_options, version, compute, overwrite) + Write the multiscale image pyramid and metadata to an OME-Zarr group. + from_ome_zarr(group) + Load a multiscale image pyramid and metadata from an OME-Zarr group. + + Examples + -------- + .. code-block:: python + + import numpy as np + from ome_zarr import OMEZarrImage, OMEZarrMultiscale + data = np.random.poisson(lam=10, size=(2, 10, 128, 128)).astype(np.uint8) + image = OMEZarrImage( + data=data, + axes="czyx", + ) + multiscale = OMEZarrMultiscale( + image=image, + scale_factors=[2, 4, 8, 16], + channel_names=["DAPI", "GFP"] + ) + """ + + def __init__( + self, + image: OMEZarrImage, + scale_factors: list[int] | tuple[int, ...] | list[dict[str, int]] | None = None, + method: str | Methods | None = Methods.RESIZE, + coordinateTransformations: list[Scale | Translation | Identity] | None = None, + labels: ( + OMEZarrLabels | list[OMEZarrLabels] | dict[str, OMEZarrLabels] | None + ) = None, + channel_names: list[str] | None = None, + channel_colors: list[list[int]] | list[str] | None = None, + contrast_limits: list[tuple[float, float]] | None = None, + ): + super().__init__( + image=image, + scale_factors=scale_factors, + method=method, + coordinateTransformations=coordinateTransformations, + ) + + # Normalize labels to dict format + self._labels = self._parse_labels(labels) + + # Parse omero metadata from channel parameters + self._omero = None + self._parse_omero_metadata(channel_names, channel_colors, contrast_limits) + + def _write_additional_meta_data( + self, + group: zarr.Group, + version: Literal["0.5", "0.4"] = "0.5", + storage_options: list[dict[str, Any]] | dict[str, Any] | None = None, + compute: bool = True, + overwrite: bool = False, + ) -> list: + from ome_zarr.utils import _recursive_pop_nones + + delayed: list = [] + + # Write omero metadata + if self._omero and isinstance(self._omero, Omero): + omero_dict = _recursive_pop_nones(self._omero.model_dump(by_alias=True)) + + if version == "0.4": + group.attrs["omero"] = omero_dict + elif version == "0.5": + if "ome" not in group.attrs: + raise ValueError("OME-Zarr attributes not found in group") + ome = cast(dict, group.attrs["ome"]) + omero_dict["version"] = version + ome["omero"] = omero_dict + group.attrs["ome"] = ome + + # Write labels if present + if self._labels is not None: + label_group = group.require_group("labels") + list_of_labels: list[str] = [] + + for label_name, ms_labels in self._labels.items(): + # Coerce image name to match label name in dict + ms_labels.name = label_name + list_of_labels.append(label_name) + + # Skip if label already exists and overwrite=False + if label_name in label_group and not overwrite: + warnings.warn( + f"Label group {label_name} already exists in store. " + f"Skipping writing this label since overwrite=False." + ) + continue + + label_subgroup = label_group.require_group(label_name) + + # Write this label's pyramid and metadata + # Always overwrite=True here since we've already decided + # whether to skip based on the parent's flag + delayed += ms_labels.to_ome_zarr( + group=label_subgroup, + storage_options=storage_options, + version=version, + compute=compute, + overwrite=True, + ) + + # Update labels list in metadata + if version == "0.4": + label_group.attrs["labels"] = list_of_labels + elif version == "0.5": + label_group.attrs["ome"] = { + "version": version, + "labels": list_of_labels, + } + + return delayed + + def _parse_omero_metadata( + self, + channel_names: list[str] | None, + channel_colors: list[list[int]] | list[str] | None, + contrast_limits: list[tuple[float, float]] | None, + ) -> None: + """ + Build omero metadata from channel parameters. + """ + if "c" not in self._images[0].axes: + n_channels = 1 + else: + # Make default values and then replace with provided values + channel_axis = self._images[0].axes.index("c") + n_channels = self._images[0].data.shape[channel_axis] + + # Make sure that all channel descriptors line up with the data dimensions + for param in [channel_names, channel_colors, contrast_limits]: + if param is not None and len(param) != n_channels: + raise ValueError( + f"Length of {param} ({len(param)}) does not match " + f"number of channels ({n_channels})" + ) + + channel_metadata = [] + for i in range(n_channels): + if channel_names is not None: + name = channel_names[i] + else: + name = f"Channel {i}" + + if channel_colors is not None: + color = channel_colors[i] + # Coerce RGBA/RGB list values to hex strings + if isinstance(color, (list, tuple)): + # Convert RGB/RGBA to hex, taking first + # 3 values and ignoring alpha + color = f"{color[0]:02x}{color[1]:02x}{color[2]:02x}" + else: + color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)] + + color = color.lstrip("#") # Remove # if present + + dtype_max = self._images[0].data.dtype.itemsize * 255 + if contrast_limits is not None: + channel_contrast = contrast_limits[i] + else: + channel_contrast = ( + 0, + dtype_max, + ) # TODO: best way to get max value from dtype? + + channel_metadata.append( + { + "label": name, + "active": True, + "color": color, + "window": { + "min": 0, + "start": channel_contrast[0], + "max": dtype_max, + "end": channel_contrast[1], + }, + } + ) + + try: + self._omero = Omero.model_validate({"channels": channel_metadata}) + except ValidationError as e: + warnings.warn(f"Failed to validate Omero metadata: {e}") + + @property + def labels(self) -> dict[str, OMEZarrLabels] | None: + return self._labels + + @labels.setter + def labels( + self, + value: OMEZarrLabels | list[OMEZarrLabels] | dict[str, OMEZarrLabels] | None, + ): + self._labels = self._parse_labels(value) + + @property + def omero(self) -> Omero | None: + return self._omero + + @omero.setter + def omero(self, value: Omero | dict[str, Any] | None): + if isinstance(value, dict): + self._omero = Omero.model_validate(value) + else: + self._omero = value + + @staticmethod + def _parse_labels( + labels: OMEZarrLabels | list[OMEZarrLabels] | dict[str, OMEZarrLabels] | None, + ) -> dict[str, OMEZarrLabels] | None: + if labels is None: + return None + elif isinstance(labels, OMEZarrLabels): + return {str(labels.name): labels} + elif isinstance(labels, list): + return {str(label.name): label for label in labels} + elif isinstance(labels, dict): + return labels + else: + raise ValueError( + "Invalid type for labels. Expected OMEZarrLabels, " + "list of OMEZarrLabels, or dict of OMEZarrLabels." + ) + + def _read_additional_metadata( + self, + group: zarr.Group, + version: str, + ) -> None: + """Read omero metadata and load labels.""" + # Initialize class-specific attributes + self._labels = None + self._omero = None + + # Read omero metadata + omero_dict: dict[str, Any] | None = None + + if version in ("0.1", "0.2", "0.3", "0.4") and "omero" in group.attrs: + omero_dict = cast(dict[str, Any] | None, group.attrs.get("omero", None)) + elif version == "0.5": + ome_attrs = cast(dict[str, Any], group.attrs.get("ome", {})) + if "omero" in ome_attrs: + omero_dict = cast(dict[str, Any] | None, ome_attrs.get("omero", None)) + + if omero_dict is not None: + try: + self._omero = Omero.model_validate(omero_dict) + except ValidationError as e: + warnings.warn(f"Invalid Omero metadata: {e}") + + # Read labels list + list_of_labels: list[str] = [] + + if version in ("0.1", "0.2", "0.3", "0.4") and "labels" in group: + labels_json = group["labels"].attrs.get("labels", []) + list_of_labels = ( + cast(list[str], labels_json) if isinstance(labels_json, list) else [] + ) + elif version == "0.5" and "labels" in group: + labels_ome_attrs = cast( + dict[str, Any], group["labels"].attrs.get("ome", {}) + ) + list_of_labels = cast(list[str], labels_ome_attrs.get("labels", [])) + + # Load labels if they exist + if list_of_labels: + loaded_labels: dict[str, OMEZarrLabels] = {} + for label_name in list_of_labels: + label_subgroup = group[f"labels/{label_name}"] + if not isinstance(label_subgroup, zarr.Group): + warnings.warn(f"Label {label_name} is not a zarr.Group, skipping") + continue + label_multiscale = cast( + OMEZarrLabels, OMEZarrLabels.from_ome_zarr(label_subgroup) + ) + loaded_labels[label_name] = label_multiscale + self._labels = loaded_labels + + +class OMEZarrLabels(OMEZarrMultiscaleBase): + """ + Container for label images with OME-Zarr metadata. + + This class extends OMEZarrMultiscaleBase and implements + handling of additional metadata fields specific to label images, + such as image-label metadata. + + Parameters + ---------- + image : OMEZarrImage + scale_factors : list[int] | tuple[int, ...] | list[dict[str, int]] | None, optional + Scale factors for each pyramid level. If a list of ints or tuple is provided, + it is applied uniformly across all spatial axes. If a list of dicts is provided, + each dict should specify scale factors for each axis, e.g. {'x': 2, 'y': 2, 'z': 1}. + Default is (2, 4, 8, 16). + method : str | ome_zarr.scale.Methods, optional + Rescaling method to use when generating pyramid levels. Default is Methods.NEAREST, + since these are labels. + auto_parse_labels : bool, optional + Whether to automatically inspect the data for present label values and write these + to the metadata. This can be time consuming for large datasets, so it is optional. + Default is True. + + Attributes + ---------- + images : list[OMEZarrImage] + List of label images at each pyramid level. + image_label : Label | None + Optional image-label metadata for rendering label images, or None if not provided. + metadata : ome_zarr_models.v05.multiscales.Multiscale + The OME-Zarr metadata associated with this multiscale image, + stored as a Pydantic model instance. + Automatically created upon instantiation of the class. + """ + + _image_label: Label | None + + def __init__( + self, + image: OMEZarrImage, + scale_factors: list[int] | tuple[int, ...] | list[dict[str, int]] | None = None, + method: str | Methods | None = Methods.NEAREST, + auto_parse_labels: bool = True, + ): + super().__init__( + image=image, + scale_factors=scale_factors, + method=method, + coordinateTransformations=None, + ) + + # Build image-label metadata if auto_parse_labels is enabled + self._image_label = None + if auto_parse_labels: + self._parse_image_label_metadata() + + def _parse_image_label_metadata(self) -> None: + """Build image-label metadata by inspecting unique label values.""" + label_values = da.unique(self._images[0].data).compute().tolist() + colors = [ + { + "label-value": label, + "rgba": [np.random.randint(0, 255) for _ in range(3)] + [255], + } + for label in label_values + ] + + self._image_label = Label.model_validate( + { + "colors": colors, + "source": {"image": "../.."}, + "properties": [{"label-value": i} for i in label_values], + } + ) + + @property + def image_label(self) -> Label | None: + return self._image_label + + @image_label.setter + def image_label(self, value: Label | dict[str, Any] | None): + if isinstance(value, dict): + self._image_label = Label.model_validate(value) + else: + self._image_label = value + + def _write_additional_meta_data( + self, + group: zarr.Group, + version: Literal["0.5", "0.4"] = "0.5", + storage_options: list[dict[str, Any]] | dict[str, Any] | None = None, + compute: bool = True, + overwrite: bool = False, + ) -> list: + from ome_zarr.utils import _recursive_pop_nones + + if self._image_label is not None and isinstance(self._image_label, Label): + if version == "0.4": + group.attrs["image-label"] = _recursive_pop_nones( + self._image_label.model_dump(by_alias=True) + ) + elif version == "0.5": + ome = cast(dict, group.attrs.get("ome", {})) + ome["image-label"] = _recursive_pop_nones( + self._image_label.model_dump(by_alias=True) + ) + group.attrs["ome"] = ome + + return [] + + def _read_additional_metadata( + self, + group: zarr.Group, + version: str, + ) -> None: + """Read image-label metadata.""" + # Initialize class-specific attributes + self._image_label = None + + image_label_dict: dict[str, Any] | None = None + + if version in ("0.1", "0.2", "0.3", "0.4"): + if "image-label" in group.attrs: + image_label_dict = cast( + dict[str, Any] | None, group.attrs.get("image-label", None) + ) + elif version == "0.5": + ome_attrs = cast(dict[str, Any], group.attrs.get("ome", {})) + if "image-label" in ome_attrs: + image_label_dict = cast( + dict[str, Any] | None, ome_attrs.get("image-label", None) + ) + + if image_label_dict is not None: + try: + self._image_label = Label.model_validate(image_label_dict) + except ValidationError as e: + warnings.warn(f"Invalid image-label metadata: {e}") diff --git a/ome_zarr/utils.py b/ome_zarr/utils.py index 8c5855ec..677e70af 100644 --- a/ome_zarr/utils.py +++ b/ome_zarr/utils.py @@ -429,3 +429,66 @@ def strip_common_prefix(parts: list[list[str]]) -> str: parts[idx] = parts[idx][first_mismatch - 1 :] return common + + +def _get_version(group: zarr.Group) -> str: + """ + Safely extract version from OME-Zarr group attributes. + + Checks for version in known locations: + - group.attrs["ome"]["version"] for v0.5+ + - group.attrs["multiscales"][0]["version"] for v0.4 or lower + + Returns + ------- + str + The OME-Zarr format version. + + Raises + ------ + ValueError + If version cannot be found in expected locations. + """ + # Try v0.5+ format first + ome_attrs = group.attrs.get("ome") + if isinstance(ome_attrs, dict) and "version" in ome_attrs: + return ome_attrs["version"] + + # Try v0.4 or lower format + multiscales = group.attrs.get("multiscales") + if isinstance(multiscales, list) and len(multiscales) > 0: + multiscale = multiscales[0] + if isinstance(multiscale, dict) and "version" in multiscale: + return multiscale["version"] + + raise ValueError( + "Could not find 'version' in group attributes. " + "Expected location: group.attrs['ome']['version'] (v0.5+) " + "or group.attrs['multiscales'][0]['version'] (v0.4 or lower)" + ) + + +def _recursive_pop_nones(data: dict) -> dict: + """ + Recursively remove None values from a nested dictionary. + """ + output: dict = {} + for key, value in data.items(): + if isinstance(value, dict): + nested = _recursive_pop_nones(value) + if nested: + output[key] = nested + elif isinstance(value, (list, tuple)): + nested_list = [] + for item in value: + if isinstance(item, dict): + nested_item = _recursive_pop_nones(item) + if nested_item: + nested_list.append(nested_item) + elif item is not None: + nested_list.append(item) + if nested_list: + output[key] = nested_list + elif value is not None: + output[key] = value + return output diff --git a/ome_zarr/writer.py b/ome_zarr/writer.py index 7ce813da..4f94575d 100644 --- a/ome_zarr/writer.py +++ b/ome_zarr/writer.py @@ -269,7 +269,7 @@ def write_multiscale( coordinate_transformations: list[list[dict[str, Any]]] | None = None, storage_options: JSONDict | list[JSONDict] | None = None, name: str | None = None, - compute: bool | None = True, + compute: bool = True, scale: dict[str, float] | None = None, axes_units: dict[str, str] | None = None, **metadata: str | JSONDict | list[JSONDict], @@ -283,6 +283,11 @@ def write_multiscale( 5-dimensional with dimensions ordered (t, c, z, y, x) :type group: :class:`zarr.Group` :param group: The group within the zarr store to store the data in + :type scale: dict of str to float, optional + :param scale: + The physical pixel size for each dimension, e.g. {"z": 0.1, "y": 0.1, "x": 0.5}. + The pixel sizes for every resolution level are calculated directly from the defined `scale` and + `scale_factors` for each level. :type chunks: int or tuple of ints, optional :param chunks: The size of the saved chunks to store the image. @@ -298,6 +303,9 @@ def write_multiscale( :param axes: List of axes dicts, or names. Not needed for v0.1 or v0.2 or if 2D. Otherwise this must be provided + :param axes_units: + The physical units for each dimension, e.g. {"t": "millisecond", "z": "micrometer", "y": "micrometer", "x": "micrometer"}. + For a list of recommended units, see [ngff specification](https://ngff.openmicroscopy.org/specifications/0.5/index.html#axes-metadata). :type coordinate_transformations: 2Dlist of dict, optional :param coordinate_transformations: List of transformations for each path. @@ -556,17 +564,23 @@ def write_well_metadata( def write_image( image: ArrayLike, group: zarr.Group | str, - scale_factors: list[int] | tuple[int, ...] | list[dict[str, int]] = (2, 4, 8, 16), + scale_factors: list[int] | tuple[int, ...] | list[dict[str, int]] | None = ( + 2, + 4, + 8, + 16, + ), + name: str = "image", method: Methods | None = Methods.RESIZE, scaler: Scaler | None = None, fmt: Format | None = None, axes: AxesType = None, coordinate_transformations: list[list[dict[str, Any]]] | None = None, storage_options: JSONDict | list[JSONDict] | None = None, - compute: bool | None = True, + compute: bool = True, scale: dict[str, float] | None = None, axes_units: dict[str, str] | None = None, - **metadata: str | JSONDict | list[JSONDict], + **metadata: JSONDict, ) -> list: """ Write an image to the zarr store according to the OME-Zarr specification, supporting multiscale pyramids. @@ -579,14 +593,23 @@ def write_image( dimensions ordered (t, c, z, y, x). Can be a NumPy or Dask array. group : zarr.Group or str The zarr group to write the metadata, or a path to create - scale_factors : list of int or list of dict, optional - The downsampling factors for each pyramid level. Default: [2, 4, 8, 16]. + scale: dict of str to float, optional + The physical pixel size for each spatial dimension, e.g. {"z": 0.5, "y": 0.1, "x": 0.1}. + If unset, the used pixel sizes default to 1.0 for all dimensions. + scale_factors : Sequence[int] | list[dict[str, int]], optional + The downsampling factors for each pyramid level. Default: (2, 4, 8, 16). Passing a list of integers (i.e., [2, 4, 8]) will apply the downsampling in all spatial dimensions *except the z dimension*, which will be left at a scale factor of 1. To apply downsampling to the z-dimension, pass the scale factors as a list of dicts, e.g. `[{"z": 2, "y": 2, "x": 2}, {"z": 4, "y": 4, "x": 4}, {"z": 8, "y": 8, "x": 8}]`. If dimensions are omitted in this dictionary, the downsampling factor for that dimension will default to 1. + name: str, optional + The name of the image, to be included in the metadata. Defaults to "image". + axes_units : dict of str to str, optional + The physical units for each dimension, + e.g. {"t": "millisecond", "z": "micrometer", "y": "micrometer", "x": "micrometer"}. + For a list of recommended units, see [ngff specification](https://ngff.openmicroscopy.org/specifications/0.5/index.html#axes-metadata). method : ome_zarr.scale.Methods, optional Downsampling method to use. Available methods are: @@ -631,7 +654,7 @@ def write_image( e.g. {"t": "millisecond", "z": "micrometer", "y": "micrometer", "x": "micrometer"}. For a list of recommended units, see [ngff specification](https://ngff.openmicroscopy.org/specifications/0.5/index.html#axes-metadata). `**metadata` : dict - Additional metadata to store. + Additional metadata to store, i.e. {"omero": {...}}. This is passed through to the multiscales metadata. Returns ------- @@ -644,7 +667,7 @@ def write_image( The `scaler` argument is deprecated and will be removed in a future version. Use `scale_factors` and `method` for all new code. """ - from .scale import _build_pyramid + from .classes import OMEZarrImage, OMEZarrMultiscale if method is None: method = Methods.RESIZE @@ -670,11 +693,6 @@ def write_image( "The 'coordinate_transformations' argument is deprecated and will " "be removed in a future version. Please use the `scale` argument " "to specify the physical pixel size for each dimension instead. " - "When `coordinate_transformations` is provided, it takes " - "precedence over `scale`, so `scale` is not applied. When " - "`coordinate_transformations` is not provided, the pixel sizes " - "for every resolution level are calculated from `scale` and " - "`scale_factors`." ) warnings.warn(msg, DeprecationWarning) @@ -711,32 +729,24 @@ def write_image( else: method = Methods.RESIZE - if method is None: - method = Methods.RESIZE + omero = metadata.get("omero") - # Create the pyramid - pyramid = _build_pyramid( - image, - scale_factors, - dims=dims, + singlescale = OMEZarrImage( + data=image, scale=scale, axes=dims, name=name, axes_units=axes_units + ) + multiscale = OMEZarrMultiscale( + image=singlescale, + scale_factors=scale_factors, method=method, ) + multiscale.omero = omero - name = metadata.pop("name", None) - name = str(name) if name is not None else None - - dask_delayed_jobs = _write_pyramid_to_zarr( - pyramid, - group, - fmt=fmt, - scale=scale, - axes_units=axes_units, - axes=axes, - coordinate_transformations=coordinate_transformations, + dask_delayed_jobs = multiscale.to_ome_zarr( + group=group, storage_options=storage_options, - name=name, + version=fmt.version, # type: ignore[arg-type] compute=compute, - **metadata, + overwrite=True, ) return dask_delayed_jobs @@ -765,7 +775,7 @@ def _write_pyramid_to_zarr( coordinate_transformations: list[list[dict[str, Any]]] | None = None, storage_options: JSONDict | list[JSONDict] | None = None, name: str | None = None, - compute: bool | None = True, + compute: bool = True, **metadata: str | JSONDict | list[JSONDict], ) -> list: @@ -1029,7 +1039,7 @@ def write_multiscale_labels( label_metadata: JSONDict | None = None, scale: dict[str, float] | None = None, axes_units: dict[str, str] | None = None, - compute: bool | None = True, + compute: bool = True, **metadata: JSONDict, ) -> list: """ @@ -1047,6 +1057,11 @@ def write_multiscale_labels( :param group: The zarr group or path to write the metadata in. :type name: str, optional :param name: The name of this labels data. + :type scale: dict of str to float, optional + :param scale: + The physical pixel size for each dimension, e.g. {"z": 0.1, "y": 0.1, "x": 0.5}. + The pixel sizes for every resolution level are calculated directly from the defined `scale` and + `scale_factors` for each level. :type chunks: int or tuple of ints, optional :type fmt: :class:`ome_zarr.format.Format`, optional :param fmt: @@ -1056,6 +1071,10 @@ def write_multiscale_labels( :param axes: The names of the axes. e.g. ["t", "c", "z", "y", "x"]. Ignored for versions 0.1 and 0.2. Required for version 0.3 or greater. + :type axes_units: dict of str to str, optional + :param axes_units: + The physical units for each dimension, e.g. {"t": "millisecond", "z": "micrometer", "y": "micrometer", "x": "micrometer"}. + For a list of recommended units, see [ngff specification](https://ngff.openmicroscopy.org/specifications/0.5/index.html#axes-metadata). :type coordinate_transformations: list of dict :param coordinate_transformations: For each resolution, we have a List of transformation Dicts (not validated). @@ -1152,7 +1171,7 @@ def write_multiscale_labels( def write_labels( labels: np.ndarray | da.Array, group: zarr.Group | str, - name: str, + name: str = "labels", scaler: Scaler | None = Scaler(order=0), scale_factors: list[int] | tuple[int, ...] | list[dict[str, int]] = (2, 4, 8, 16), method: Methods = Methods.NEAREST, @@ -1163,7 +1182,7 @@ def write_labels( label_metadata: JSONDict | None = None, scale: dict[str, float] | None = None, axes_units: dict[str, str] | None = None, - compute: bool | None = True, + compute: bool = True, **metadata: JSONDict, ) -> list: """ @@ -1184,11 +1203,15 @@ def write_labels( `scale_factors` for each level. name : str The name of this labels data. + scale: dict of str to float, optional + The physical pixel size for each dimension, e.g. {"z": 0.1, "y": 0.1, "x": 0.5}. + The pixel sizes for every resolution level are calculated directly from the defined `scale` and + `scale_factors` for each level. scaler : ome_zarr.scale.Scaler, optional [DEPRECATED] Scaler implementation for downsampling the label data. Passing this argument will raise a warning and is no longer supported. Use `scale_factors` and `method` instead. - scale_factors : tuple of int, optional + scale_factors : Sequence[int] | tuple[int, ...] | list[dict[str, int]], optional The downsampling factors for each pyramid level. Default: (2, 4, 8, 16). Passing a list of integers (i.e., [2, 4, 8]) will apply the downsampling in all spatial dimensions *except the z dimension*, which will be left at a scale factor of 1. @@ -1238,7 +1261,7 @@ def write_labels( e.g. {"t": "millisecond", "z": "micrometer", "y": "micrometer", "x": "micrometer"}. For a list of recommended units, see [ngff specification](https://ngff.openmicroscopy.org/specifications/0.5/index.html#axes-metadata). `**metadata` : dict - Additional metadata to store. + Additional metadata to store, i.e. {"image-label": {...}}. This is passed through to the image-label metadata. Returns ------- @@ -1252,7 +1275,7 @@ def write_labels( `scale_factors` and `method` for all new code. Labels downsampling should avoid interpolation; nearest-neighbor is recommended. """ - from .scale import _build_pyramid + from .classes import OMEZarrImage, OMEZarrLabels group, fmt = check_group_fmt(group, fmt) sub_group = group.require_group(f"labels/{name}") @@ -1268,6 +1291,11 @@ def write_labels( if scale is None: scale = dict.fromkeys(dims, 1.0) + if method is None: + method = Methods.NEAREST + + image_label = metadata.get("image-label") + if scaler is not None: msg = """ The 'scaler' argument is deprecated and will be removed in version 0.13.0. @@ -1284,41 +1312,24 @@ def write_labels( "The 'coordinate_transformations' argument is deprecated and will " "be removed in a future version. Please use the `scale` argument " "to specify the physical pixel size for each dimension instead. " - "When `coordinate_transformations` is provided, it takes " - "precedence over `scale`, so `scale` is not applied. When " - "`coordinate_transformations` is not provided, the pixel sizes " - "for every resolution level are calculated from `scale` and " - "`scale_factors`." ) warnings.warn(msg, DeprecationWarning) - if method is None: - method = Methods.NEAREST - - if not isinstance(labels, da.Array): - labels = da.from_array(labels) - - pyramid = _build_pyramid( - labels, - scale_factors, - dims=dims, + singlescale = OMEZarrImage( + data=labels, axes=dims, name=name, scale=scale, axes_units=axes_units + ) + multiscales = OMEZarrLabels( + image=singlescale, + scale_factors=scale_factors, method=method, ) - - dask_delayed_jobs = [] - - dask_delayed_jobs = _write_pyramid_to_zarr( - pyramid, - sub_group, - fmt=fmt, - scale=scale, - axes_units=axes_units, - axes=axes, - coordinate_transformations=coordinate_transformations, + multiscales.image_label = image_label + dask_delayed_jobs = multiscales.to_ome_zarr( + group=sub_group, storage_options=storage_options, - name=name, + version=fmt.version, # type: ignore[arg-type] compute=compute, - **metadata, + overwrite=True, ) write_label_metadata( diff --git a/pyproject.toml b/pyproject.toml index ec85cef8..2697ba46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "scikit-image>=0.19.0", "toolz", "rangehttpserver", + "ome-zarr-models >= 1.6", "Deprecated", ] classifiers = [ @@ -48,7 +49,6 @@ ome_zarr = "ome_zarr.cli:main" [dependency-groups] tests = [ "pytest", - "ome-zarr-models" ] [project.urls] diff --git a/tests/test_reader.py b/tests/test_reader.py index 79c11dd0..509d4343 100644 --- a/tests/test_reader.py +++ b/tests/test_reader.py @@ -4,6 +4,7 @@ import zarr from numpy import ones, zeros +from ome_zarr import OMEZarrLabels, OMEZarrMultiscale from ome_zarr.data import create_zarr from ome_zarr.format import FormatV04 from ome_zarr.io import parse_url @@ -17,6 +18,61 @@ ) +@pytest.mark.parametrize( + ["url", "has_omero", "has_labels"], + [ + ( + {"0.1": "https://livingobjects.ebi.ac.uk/idr/zarr/v0.1/6001237.zarr"}, + True, + True, + ), + ( + {"0.2": "https://livingobjects.ebi.ac.uk/idr/zarr/v0.2/6001240.zarr"}, + True, + True, + ), + ( + { + "0.3": "https://livingobjects.ebi.ac.uk/idr/zarr/v0.3/idr0052A/5514375.zarr" + }, + True, + True, + ), + ( # one for reading 0.3 metadata with only 3 axes (t, y, x) + { + "0.3": "https://livingobjects.ebi.ac.uk/idr/zarr/v0.3/idr0109A/12922361.zarr" + }, + True, + False, + ), + ], +) +def test_class_reader_legacy(url, has_omero, has_labels): + image = OMEZarrMultiscale.from_ome_zarr(next(iter(url.values()))) + + if has_omero: + assert image._omero is not None + assert hasattr(image._omero, "channels") + + if has_labels: + assert image.labels != [] + assert image.labels is not None + # image.labels must be one of: + # - OMEZarrLabels + # - list[OMEZarrLabels] + # - dict(str, OMEZarrLabels) + if isinstance(image.labels, dict): + for label in image.labels.values(): + assert isinstance(label, OMEZarrLabels) + + elif isinstance(image.labels, list): + for label in image.labels: + assert isinstance(label, OMEZarrLabels) + + else: + assert isinstance(image.labels, OMEZarrLabels) + + class TestReader: @pytest.fixture(autouse=True) def initdir(self, tmpdir): @@ -62,6 +118,11 @@ def test_read_v05(self): "version": "0.5", "multiscales": [ { + "axes": [ + {"name": "z", "type": "space"}, + {"name": "y", "type": "space"}, + {"name": "x", "type": "space"}, + ], "datasets": [ { "path": "s0", @@ -72,7 +133,7 @@ def test_read_v05(self): } ], } - ] + ], } ], } @@ -82,6 +143,11 @@ def test_read_v05(self): image_node = nodes[0] assert np.allclose(data, image_node.data[0]) + # now the same with the class-based API for v0.5 + ms = OMEZarrMultiscale.from_ome_zarr(img_path) + + assert len(ms.images) == 1 + class TestInvalid: @pytest.fixture(autouse=True) @@ -177,3 +243,23 @@ def test_multiwells_plate(self, field_paths): result = pyramid[0].compute() assert isinstance(result, np.ndarray) assert result.max() > 0, "Expected non-zero values in the array" + + +def test_class_reader(): + from ome_zarr_models.common.omero import Omero + + url = "https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr" + image = OMEZarrMultiscale.from_ome_zarr(url) + + # image is known to have "omero" metadata with "channels" key + assert image.omero is not None + assert isinstance(image.omero, Omero) + assert hasattr(image.omero, "channels") + + # image is known to have one labels image of name "0" + assert len(image.labels) == 1 + assert "0" in image.labels + + label_image = image.labels["0"] + assert isinstance(label_image, OMEZarrLabels) + assert label_image.image_label is not None diff --git a/tests/test_writer.py b/tests/test_writer.py index d3c3ac80..e6e28dee 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1,4 +1,3 @@ -import filecmp import json import pathlib import re @@ -21,7 +20,12 @@ from zarr.abc.codec import BytesBytesCodec from zarr.codecs import BloscCodec -from ome_zarr import USE_DASK_ARRAY_KWARGS +from ome_zarr import ( + USE_DASK_ARRAY_KWARGS, + OMEZarrImage, + OMEZarrLabels, + OMEZarrMultiscale, +) from ome_zarr.format import ( CurrentFormat, FormatV03, @@ -45,10 +49,10 @@ TRANSFORMATIONS = [ [{"scale": [1, 1, 0.5, 0.18, 0.18], "type": "scale"}], - [{"scale": [1, 1, 1.0, 0.36, 0.36], "type": "scale"}], - [{"scale": [1, 1, 2.0, 0.72, 0.72], "type": "scale"}], - [{"scale": [1, 1, 4.0, 1.44, 1.44], "type": "scale"}], - [{"scale": [1, 1, 8.0, 2.88, 2.88], "type": "scale"}], + [{"scale": [1, 1, 0.5, 0.36, 0.36], "type": "scale"}], + [{"scale": [1, 1, 0.5, 0.72, 0.72], "type": "scale"}], + [{"scale": [1, 1, 0.5, 1.44, 1.44], "type": "scale"}], + [{"scale": [1, 1, 0.5, 2.88, 2.88], "type": "scale"}], ] FORMAT_VERSIONS = [ @@ -110,14 +114,226 @@ def create_data(self, shape, dtype=np.uint8, mean_val=10): @pytest.fixture( params=( (1, 2, 1, 256, 256), - (3, 512, 512), - (300, 500), # test edge chunks of different shapes + (32, 256, 256), + (256, 256), # test edge chunks of different shapes ), ids=["5D", "3D", "2D"], ) def shape(self, request): return request.param + def test_image_class_bad_args(self): + data = self.create_data((2, 128, 128)) + + with pytest.raises(ValueError): + OMEZarrImage(data=data, axes="czyx") # more axes than data dims + + with pytest.raises(ValueError): + # more scale values than data dims + OMEZarrImage( + data=data, + axes="zyx", + scale={"c": 1.0, "z": 0.5, "y": 0.5, "x": 0.5}, + ) + + # unset axes must default to 1.0 + image = OMEZarrImage(data=data, axes="cyx", scale={"y": 0.5, "x": 0.5}) + assert image.scale["c"] == 1.0 + + # less channels then dims in channel axis + with pytest.raises(ValueError): + multiscales = OMEZarrMultiscale( + image=image, + channel_names=["Channel 0"], + ) + + # less channel_names than channel_colors + with pytest.raises(ValueError): + multiscales = OMEZarrMultiscale( + image=image, + channel_names=["Channel 0", "Channel 1"], + channel_colors=["#ff0000"], + ) + + # less channel_names than contrast limits + with pytest.raises(ValueError): + multiscales = OMEZarrMultiscale( + image=image, + channel_names=["Channel 0", "Channel 1"], + contrast_limits=[(0, 255)], + ) + + multiscales = OMEZarrMultiscale( + image=image, + scale_factors=None, + method=None, + channel_names=["Channel 0", "Channel 1"], + channel_colors=[[255, 0, 0], [0, 255, 0]], + contrast_limits=[(0, 255), (0, 255)], + ) + assert len(multiscales.images) == 5 + + multiscales.to_ome_zarr(self.path / "test_bad_args.zarr", version="0.5.5") + + @pytest.mark.parametrize("storage_options_list", [True, False]) + def test_image_class_writer( + self, shape, format_version_all, array_constructor, storage_options_list + ): + version = format_version_all() + + if version.version == "0.5": + grp_path = self.path_v3 / "test" + else: + grp_path = self.path / "test" + + data = self.create_data(shape) + data_labels = (data > data.mean()).astype( + np.uint8 + ) # just some binary data for testing + data = array_constructor(data) + axes = "tczyx"[-len(shape) :] + + chunks = [(128, 128), (50, 50), (25, 25), (25, 25), (25, 25), (25, 25)] + storage_options = {"chunks": chunks[0]} + if storage_options_list: + storage_options = [{"chunks": chunk} for chunk in chunks] + scale_factors = [ + {str(d): 2 ** i if d in ("x", "y") else 1.0 for d in axes} + for i in range(1, len(TRANSFORMATIONS)) + ] + + # make sure default is set correctly when not providing scale + image = OMEZarrImage( + data=data, + axes=axes, + ) + assert all(image.scale[d] == 1.0 for d in axes) + + if "c" in axes: + channel_names = [f"Channel {i}" for i in range(shape[axes.index("c")])] + else: + channel_names = None + + # convert to image classes + labels_name = "test_labels" + image = OMEZarrImage( + data=data, + axes=axes, + scale=dict(zip(axes, TRANSFORMATIONS[0][0]["scale"])), + ) + labels = OMEZarrImage( + data=data_labels, + axes=axes, + scale=dict(zip(axes, TRANSFORMATIONS[0][0]["scale"])), + name=labels_name, + ) + + labels_multiscales = OMEZarrLabels(image=labels, scale_factors=scale_factors) + + image_multiscales = OMEZarrMultiscale( + image=image, + scale_factors=scale_factors, + labels=labels_multiscales, + channel_names=channel_names, + ) + + # write image and labels to disk + image_multiscales.to_ome_zarr( + group=str(grp_path), + version=version.version, + storage_options=storage_options, + overwrite=True, + ) + + # Verify image data + out = zarr.open_group(grp_path) + node_metadata = out.attrs + if "ome" in node_metadata: + node_metadata = node_metadata["ome"] + assert "multiscales" in node_metadata + paths = [d["path"] for d in node_metadata["multiscales"][0]["datasets"]] + node_data = [da.from_zarr(grp_path / path) for path in paths] + if version.version in ("0.1", "0.2"): + # v0.1 and v0.2 MUST be 5D + assert node_data[0].ndim == 5 + else: + assert node_data[0].shape == shape + print("node.metadata", node_metadata) + + # check written coordinatetransormations match relative factors between array sizes + for level, nd_array in enumerate(node_data): + if level == 0: + # check first written scale values explicitly match those in TRANSFORMATIONS + for d in axes: + assert ( + node_metadata["multiscales"][0]["datasets"][level][ + "coordinateTransformations" + ][0]["scale"][axes.index(d)] + == TRANSFORMATIONS[0][0]["scale"][axes.index(d)] + ) + continue + + # first calculate relative factors between this and previous level + relative_factors = { + d: node_data[0].shape[axes.index(d)] / nd_array.shape[axes.index(d)] + for d in axes + } + + # then convert into corresponding scale values + expected_scale = { + d: TRANSFORMATIONS[0][0]["scale"][axes.index(d)] * relative_factors[d] + for d in axes + } + + # make sure we are doing this correctly for dimensions that + # are not supposed to be downsampled + if "t" in axes: + assert expected_scale["t"] == 1.0 + if "c" in axes: + assert expected_scale["c"] == 1.0 + if "z" in axes: + assert relative_factors["z"] == 1.0 + + # retrieve written scale factors from metadata and check they match expected + cts = node_metadata["multiscales"][0]["datasets"][level][ + "coordinateTransformations" + ] + assert len(cts) == 1 + transf = cts[0] + assert transf["type"] == "scale" + for d in axes: + assert transf["scale"][axes.index(d)] == expected_scale[d] + # check chunks for first 2 resolutions (before shape gets smaller than chunk) + for level, nd_array in enumerate(node_data[:2]): + expected = chunks[level] if storage_options_list else chunks[0] + first_chunk = [c[0] for c in nd_array.chunks] + assert tuple(first_chunk) == _retuple(expected, nd_array.shape) + assert np.allclose(data, node_data[0][...].compute()) + + # Verify labels data + label_group = zarr.open(f"{grp_path}/labels", mode="r") + label_group_attrs = label_group.attrs + if version.version == "0.5": + label_group_attrs = label_group_attrs["ome"] + assert "labels" in label_group_attrs + assert labels_name in label_group_attrs["labels"] + + # read data back in + image = OMEZarrMultiscale.from_ome_zarr(str(grp_path)) + + assert labels_name in list(image.labels.keys()) + + if version.version == "0.4": + # Validate with ome-zarr-models-py: only supports v0.4 + Models04Image.from_zarr(out) + elif version.version == "0.5": + Models05Image.from_zarr(out) + + # verify omero and image-labels metadata + if "c" in axes: + assert image.omero is not None + assert image.labels["test_labels"].image_label is not None + @pytest.mark.parametrize("storage_options_list", [True, False]) def test_writer( self, shape, format_version_all, array_constructor, storage_options_list @@ -170,6 +386,7 @@ def test_writer( fmt=fmt, axes=axes, axes_units=axes_units, + scale=dict(zip(axes, TRANSFORMATIONS[0][0]["scale"][-len(shape) :])), coordinate_transformations=transformations, storage_options=storage_options, ) @@ -380,13 +597,11 @@ def test_write_image_dask(self, read_from_zarr, compute, zarr_format): grp_path = self.path / "test" fmt = FormatV04() zarr_attrs = ".zattrs" - zarr_array = ".zarray" group = self.group else: grp_path = self.path_v3 / "test" fmt = CurrentFormat() zarr_attrs = "zarr.json" - zarr_array = "zarr.json" group = self.group_v3 # Size 100 tests resize shapes: https://github.com/ome/ome-zarr-py/issues/219 @@ -395,6 +610,7 @@ def test_write_image_dask(self, read_from_zarr, compute, zarr_format): data_delayed = da.from_array(data) chunks = (32, 32) axes = "zyx" + scale = dict(zip(axes, TRANSFORMATIONS[0][0]["scale"][-len(shape) :])) # same NAME needed for exact zarr_attrs match below # (otherwise group.name is used) NAME = "test_write_image_dask" @@ -410,6 +626,7 @@ def test_write_image_dask(self, read_from_zarr, compute, zarr_format): data_delayed, temp_group, axes=axes, + scale=scale, storage_options=opts, name=NAME, ) @@ -429,6 +646,7 @@ def test_write_image_dask(self, read_from_zarr, compute, zarr_format): group, axes=axes, storage_options={"chunks": chunks}, + scale=scale, compute=compute, name=NAME, ) @@ -464,29 +682,38 @@ def test_write_image_dask(self, read_from_zarr, compute, zarr_format): axis_name = axes[idx] if axis_name == "z": # z-axis is not downsampled by default - assert value == 1.0 + assert value == scale[axis_name] elif axis_name in ("x", "y"): # spatial dimensions are downsampled - assert value == shape[idx] / (shape[idx] // (2**level)) + assert value == scale[axis_name] * shape[idx] / ( + shape[idx] // (2**level) + ) else: # non-spatial dimensions (t, c) are not downsampled assert value == 1.0 if read_from_zarr and level < 3: # if shape smaller than chunk, dask writer uses chunk == shape # so we only compare larger resolutions - assert filecmp.cmp( - f"{grp_path}/temp/to_dask/s{level}/{zarr_array}", - f"{grp_path}/s{level}/{zarr_array}", - shallow=False, - ) + import json + + with open(f"{grp_path}/temp/to_dask/{zarr_attrs}") as f: + temp_meta = json.load(f) + + with open(f"{grp_path}/{zarr_attrs}") as f: + final_meta = json.load(f) + + assert temp_meta == final_meta if read_from_zarr: - # exact match, including NAME - assert filecmp.cmp( - f"{grp_path}/temp/to_dask/{zarr_attrs}", - f"{grp_path}/{zarr_attrs}", - shallow=False, - ) + import json + + with open(f"{grp_path}/temp/to_dask/{zarr_attrs}") as f: + temp_meta = json.load(f) + + with open(f"{grp_path}/{zarr_attrs}") as f: + final_meta = json.load(f) + + assert temp_meta == final_meta # Validate with ome-zarr-models-py if fmt.version == "0.4": @@ -1488,7 +1715,7 @@ def create_image_data(self, group, shape, fmt, axes, transformations): @pytest.fixture( params=( (1, 2, 1, 256, 256), - (3, 512, 512), + (32, 256, 256), (256, 256), ), ids=["5D", "3D", "2D"], @@ -1497,7 +1724,7 @@ def shape(self, request): return request.param def verify_label_data( - self, img_path, label_name, label_data, fmt, shape, transformations + self, img_path, label_name, label_data, fmt, shape, transformations, scale ): # Verify image data out = zarr.open_group(f"{img_path}/labels/{label_name}") @@ -1513,13 +1740,32 @@ def verify_label_data( else: assert node_data[0].shape == shape - if fmt.version not in ("0.1", "0.2", "0.3"): - cts = [ - d["coordinateTransformations"] - for d in node_metadata["multiscales"][0]["datasets"] - ] - for transf, expected in zip(cts, transformations): - assert transf == expected + cts = [ + d["coordinateTransformations"] + for d in node_metadata["multiscales"][0]["datasets"] + ] + + axes = list(scale.keys()) + for level, transfs in enumerate(cts): + assert len(transfs) == 1 + assert transfs[0]["type"] == "scale" + assert len(transfs[0]["scale"]) == len(shape) + + # default downsamples by factor 2 each level, except z-axis + for idx, value in enumerate(transfs[0]["scale"]): + axis_name = axes[idx] + if axis_name == "z": + # z-axis is not downsampled by default + assert value == scale[axis_name] + elif axis_name in ("x", "y"): + # spatial dimensions are downsampled + assert value == scale[axis_name] * shape[idx] / ( + shape[idx] // (2**level) + ) + else: + # non-spatial dimensions (t, c) are not downsampled + assert value == 1.0 + assert np.allclose(label_data, node_data[0][...].compute()) # Verify label metadata @@ -1592,6 +1838,7 @@ def test_write_labels( assert label_data.ndim == 5 label_name = "my-labels" label_data = array_constructor(label_data) + scale = dict(zip(axes, TRANSFORMATIONS[0][0]["scale"][-len(shape) :])) # create the root level image data self.create_image_data(group, shape, fmt, axes, transformations) @@ -1602,10 +1849,10 @@ def test_write_labels( name=label_name, fmt=fmt, axes=axes, - coordinate_transformations=transformations, + scale=scale, ) label_data = self.verify_label_data( - img_path, label_name, label_data, fmt, shape, transformations + img_path, label_name, label_data, fmt, shape, transformations, scale ) for level in label_data: @@ -1773,8 +2020,9 @@ def test_write_multiscale_labels( axes=axes, coordinate_transformations=transformations, ) + scale = dict(zip(axes, transformations[0][0]["scale"][-len(shape) :])) self.verify_label_data( - img_path, label_name, label_data, fmt, shape, transformations + img_path, label_name, label_data, fmt, shape, transformations, scale ) def test_write_multiscale_labels_storage_options( @@ -1897,8 +2145,9 @@ def test_write_multiscale_labels_storage_options( if fmt.version == "0.4": Models04Labels.from_zarr(group["labels"]) + scale = dict(zip(axes, transformations[0][0]["scale"][-len(shape) :])) self.verify_label_data( - img_path, label_name, label_data, fmt, shape, transformations + img_path, label_name, label_data, fmt, shape, transformations, scale ) @pytest.mark.parametrize( @@ -1951,8 +2200,9 @@ def test_two_label_images(self, array_constructor, fmt): axes=axes, coordinate_transformations=transformations, ) + scale = dict(zip(axes, transformations[0][0]["scale"][-len(shape) :])) self.verify_label_data( - img_path, label_name, label_data, fmt, shape, transformations + img_path, label_name, label_data, fmt, shape, transformations, scale ) # Verify label metadata @@ -1963,3 +2213,94 @@ def test_two_label_images(self, array_constructor, fmt): assert "labels" in attrs assert len(attrs["labels"]) == len(label_names) assert all(label_name in attrs["labels"] for label_name in label_names) + + @pytest.mark.parametrize( + "fmt", + (pytest.param(FormatV04(), id="V04"), pytest.param(FormatV05(), id="V05")), + ) + def write_labels_class_API(self, fmt): + from ome_zarr import OMEZarrImage, OMEZarrMultiscale + + if fmt.version == "0.5": + img_path = self.path_v3 + group = self.root_v3 + else: + img_path = self.path + group = self.root + + # create dummy data + image_data = np.random.randint(0, 1000, size=(1, 2, 1, 128, 128)) + label_data1 = np.random.randint(0, 1000, size=(1, 2, 1, 128, 128)) + label_data2 = np.random.randint(0, 1000, size=(1, 2, 1, 128, 128)) + label_data3 = np.random.randint(0, 1000, size=(1, 2, 1, 128, 128)) + + # create single-scale objects + singlescale = OMEZarrImage(data=image_data, axes="tczyx") + singlescale_labels = OMEZarrImage(data=label_data1, axes="tczyx") + singlescale_labels2 = OMEZarrImage(data=label_data2, axes="tczyx") + singlescale_labels3 = OMEZarrImage(data=label_data3, axes="tczyx") + + # create multiscale objects + ms_labels = OMEZarrMultiscale(image=singlescale_labels, method="nearest") + ms_labels2 = OMEZarrMultiscale(image=singlescale_labels2, method="nearest") + ms_labels3 = OMEZarrMultiscale(image=singlescale_labels3, method="nearest") + ms = OMEZarrMultiscale(image=singlescale, labels={"first_labels": ms_labels}) + + # write to zarr + ms.to_ome_zarr(group, version=fmt.version, overwrite=True) + + # now check that the respective groups and metadata exist and is correct + label_group = zarr.open(f"{img_path}/labels", mode="r") + if fmt.version == "0.5": + label_attrs = label_group.attrs["ome"] + elif fmt.version == "0.4": + label_attrs = label_group.attrs + + assert "labels" in label_attrs + assert "first_labels" in label_attrs["labels"] + + # read from group and make sure the written labels are in the .labels attribute + ms_test = OMEZarrMultiscale.from_ome_zarr(group) + assert "first_labels" in ms_test.labels + + # now we add the other labels to that attribute + ms_test.labels["second_labels"] = ms_labels2 + ms_test.to_ome_zarr(group, version=fmt.version, overwrite=False) + + # now check that we still have both labels in the metadata + label_group = zarr.open(f"{img_path}/labels", mode="r") + if fmt.version == "0.5": + label_attrs = label_group.attrs["ome"] + elif fmt.version == "0.4": + label_attrs = label_group.attrs + + assert "labels" in label_attrs + assert "first_labels" in label_attrs["labels"] + assert "second_labels" in label_attrs["labels"] + + # Lastly, we check that the overwrite for labels works as intended: + ms.labels = {"third_labels": ms_labels3} + ms.to_ome_zarr(group, version=fmt.version, overwrite=True) + + # Now, only the third label should be present in the metadata and as a zarr group, + # but the first and second labels should be gone + label_group = zarr.open(f"{img_path}/labels", mode="r") + if fmt.version == "0.5": + label_attrs = label_group.attrs["ome"] + elif fmt.version == "0.4": + label_attrs = label_group.attrs + + assert "labels" in label_attrs + assert "first_labels" not in label_attrs["labels"] + assert "second_labels" not in label_attrs["labels"] + assert "third_labels" in label_attrs["labels"] + assert "first_labels" not in label_group + assert "second_labels" not in label_group + assert "third_labels" in label_group + + ms_test = OMEZarrMultiscale.from_ome_zarr(group) + assert "third_labels" in ms_test.labels + + +if __name__ == "__main__": + pytest.main([__file__])