2. Images and labels¶
Read and write the pixel data.
An Image gives you the data of one resolution level: as numpy or dask, sliced
by axis or by a region of interest in world coordinates. A Label is a
segmentation stored the same way, and behaves the same way.
Images¶
To start working with the image data, instantiate an Image object.
ngio provides a high-level API to access the image data at different resolution levels and pixel sizes.
Getting an image¶
By default, the get_image method returns the highest resolution image:
To get a specific pyramid level, you can use the path parameter:
If you want to get an image with a specific pixel size, you can use the pixel_size parameter:
from ngio import PixelSize
pixel_size = PixelSize(x=0.65, y=0.65, z=1.0)
image = ome_zarr_container.get_image(pixel_size=pixel_size)
print(image)
ngio returns the level whose pixel size is nearest to the one you ask for. That is the default, strict=False, spelled out here:
from ngio import PixelSize
pixel_size = PixelSize(x=0.60, y=0.60, z=1.0)
image = ome_zarr_container.get_image(pixel_size=pixel_size, strict=False)
print(image)
strict=True instead to require an exact match, and raise NgioValueError when no level has that pixel size. The module-level open_image and open_label functions default the other way, to strict=True.
Similarly to the OME-Zarr container, the Image object provides a high-level API to access the image metadata.
dimensions attribute returns an object with the image dimensions for each axis.
pixel_size attribute returns the pixel size for each axis.
Working with image data¶
Once you have the Image object, you can access the image data as a:
get_array is the generic form of the two above: one entry point that picks the backend from a mode argument. Reach for it when the backend is decided at runtime; otherwise prefer the explicit get_as_numpy / get_as_dask.
# One entry point for both, selected with mode="numpy" or mode="dask"
data = image.get_array(mode="numpy")
print(data.shape, data.dtype)
The get_as_* methods can also slice the image data, and return the axes in an order you choose:
# Get a specific channel and axes order
image_slice = image.get_as_numpy(
channel_selection="DAPI",
x=slice(0, 128),
axes_order=["t", "z", "y", "x", "c"],
)
print(image_slice.shape)
To write pixel data back, use the set_array method:
It accepts a numpy array or a dask array, and takes the same slicing and axes_order
arguments as the getters, so you can write back exactly the region you read. Writes
can also combine with what is on disk instead of replacing it — see
Merging instead of overwriting and
Keeping label ids unique across regions
below.
A minimal read-modify-write example:
import numpy as np
def process(patch: np.ndarray) -> np.ndarray:
"""Placeholder for your own processing step.
Replace the body with the operation you want to apply to the patch.
"""
return patch
# Get the image data as a numpy array
data = image.get_as_numpy(
channel_selection="DAPI",
x=slice(0, 128),
y=slice(0, 128),
axes_order=["z", "y", "x", "c"],
)
# Modify the image data
data = process(data)
# Set the modified image data
image.set_array(
data,
channel_selection="DAPI",
x=slice(0, 128),
y=slice(0, 128),
axes_order=["z", "y", "x", "c"],
)
# Consolidate the changes to all resolution levels, see below for more details
image.consolidate(mode="auto")
Important
set_array writes to one resolution level only. Once you have finished editing, consolidate the changes so the rest of the pyramid is rebuilt from it:
track_writes and consolidate selectively — only the pyramid regions that derive from the writes are rebuilt, with results identical to a full rebuild:
with image.track_writes() as regions:
image.set_roi(roi, patch)
image.set_array(other, y=slice(0, 64), x=slice(0, 64))
image.consolidate(regions=regions)
set_* calls made through this image handle are recorded — writes through other handles, custom setter pipes, or worker processes are not seen.
World coordinates slicing¶
To read or write a specific region of the image defined in world coordinates, you can use the Roi object.
from ngio import Roi
# Define a ROI in world coordinates
roi = Roi.from_values(slices={"x": (34.1, 321.6), "y": (10, 330)}, name=None)
# Get the image data in the ROI as a numpy array
print(image.get_roi_as_numpy(roi).shape)
The ROI is defined in micrometres, so it names the same region whatever pyramid level you read it from — on the left it is outlined on the whole image, on the right it is the region that came back:
Axis keywords compose with a roi as a hierarchy: get_roi(roi, z=2) reads the roi's x/y extent at one absolute z plane. An explicit keyword on an axis the roi already pins replaces the roi-derived selection — coordinates are absolute array indices, not relative to the roi — and drops the pipe's roi, so anything needing the region as a Roi (a mask transform, world anchoring) refuses rather than misplacing pixels.
Labels¶
A label is a segmentation mask that identifies objects in the image. In ngio a Label
behaves like an Image, and is accessed and manipulated the same way.
Getting a label¶
See which labels are available in the image:
Here is how to reach one of them:
By default, the get_label method returns the highest resolution label:
To get a specific pyramid level, you can use the path parameter:
If you want to get a label with a specific pixel size, you can use the pixel_size parameter:
from ngio import PixelSize
pixel_size = PixelSize(x=0.65, y=0.65, z=1.0)
label_nuclei = ome_zarr_container.get_label("nuclei", pixel_size=pixel_size)
print(label_nuclei)
As with images, the nearest level wins unless you ask for an exact match with strict=True:
from ngio import PixelSize
pixel_size = PixelSize(x=0.60, y=0.60, z=1.0)
label_nuclei = ome_zarr_container.get_label(
"nuclei", pixel_size=pixel_size, strict=False
)
print(label_nuclei)
Each object in a label carries its own id, drawn here in its own colour over the channel it was segmented from:
Working with label data¶
Reading and writing label data works exactly as it does for images: get_as_numpy, get_as_dask, get_roi_as_numpy and set_array are all available on a Label.
Deriving a label¶
Often, you might want to create a new label based on an existing image. You can do this using the derive_label method:
# Derive a new label
new_label = ome_zarr_container.derive_label("new_label", overwrite=True)
print(new_label)
This will create a new label with the same dimensions as the original image (without channels) and compatible metadata. If you want to create a new label with slightly different metadata see the images API reference.
Merging instead of overwriting¶
By default a write replaces what is on disk. merge= combines with it instead:
"max", "min" and "sum" are commutative and associative, so overlapping regions give the same answer whatever order they are written in. "keep_nonzero" ("the last nonzero write wins") and a custom (existing, patch, ctx) -> array rule do depend on the order.
The merge is a separate argument rather than an entry in transforms=: a transform is a function of the patch alone, while a merge also depends on what is already there — so it runs once, after the chain, with both sides in the array's own space. That is what makes the comparison meaningful and keeps untouched pixels byte-identical.
Masking follows the same split. On a read it fills outside the mask, which is a transform; on a write it protects outside the mask, which is a merge:
from ngio.transforms import MaskMerge, MaskTransform
patch = image.get_roi(roi, transforms=[MaskTransform(label=nuclei, target_image=image)])
image.set_roi(roi, patch, merge=MaskMerge(label=nuclei, target_image=image))
get_roi_masked and set_roi_masked do this for you; reach for the objects directly when you want to combine masking with other transforms.
Keeping label ids unique across regions¶
Segmenting region by region gives each region its own 1, 2, 3, …, so writing them into one array collides. UniqueLabelsTransform gives each region a disjoint slice of the id space:
from ngio.transforms import UniqueLabelsTransform
# Region 4's labels 1, 2, 3 are written as 4001, 4002, 4003.
label.set_roi(roi, patch, transforms=[UniqueLabelsTransform(1000, block_index=4)])
block_size has to exceed the largest label any one region can produce, or ids spill into the next region's block. Inside a masked iterator you can leave block_index out — the ROI's own label supplies it.
The offset is derived from the block index rather than counted up as regions are processed, so it is parallel-safe (no shared counter to synchronize), survives ProcessMapper, and is idempotent — a re-run region reproduces exactly the ids it wrote before.
Being an ordinary transform, it composes with a merge:
Next steps¶
- Tables — use ROIs to slice the image data you just learned to read.
- Masked images and labels — work object-by-object using a segmentation.
- Images API reference — every method on
ImageandLabel.