import numpy as np
import xarray as xr
import pint
import tifffile as tiff
from pathlib import Path
[docs]
class ImageUtils:
_UREG = pint.UnitRegistry()
_DEFAULT_SCALING = {'T': 1.0, 'Z': 1.0, 'Y': 1.0, 'X': 1.0}
_DEFAULT_UNITS = {'T': 'frame', 'Z': 'pixels', 'Y': 'pixels', 'X': 'pixels'}
_DEFAULT_NAME = "image"
[docs]
@staticmethod
def writeTiff(folder: Path|str, img: xr.DataArray, use_imj: bool=True):
"""
Writes a xr.DataArray image to a TIFF file in the specified folder.
The folder is created if it doesn't exist.
The 'name' attribute of the DataArray is used as the filename.
If the name is None, 'image' is used as a default.
"""
if use_imj and img.dtype not in [np.uint8, np.uint16, np.float32]:
raise ValueError("Image data type must be uint8, uint16, or float32 for ImageJ compatibility.")
folder = Path(folder)
if not folder.exists():
folder.mkdir(parents=True, exist_ok=True)
name = ""
if img.name is None:
print("Warning: Image has no name, using 'image' as default name.")
name = ImageUtils._DEFAULT_NAME
else:
name = str(img.name)
n = name.lower()
if not n.endswith(".tif") and not n.endswith(".tiff"):
name += ".tif"
full_path = folder / name
kwargs = {}
if use_imj:
kwargs['imagej'] = True
kwargs['metadata'] = {
'spacing': img.attrs.get('scale', {}),
'unit': img.attrs.get('units', {}),
'axes': ''.join([str(a) for a in img.dims])
}
tiff.imwrite(
full_path,
img.data,
**kwargs
)
[docs]
@staticmethod
def getAnisotropy(img: xr.DataArray) -> float:
"""
Computes the anisotropy factor Z/X for a given image.
Return 1.0 if the image does not have a Z dimension.
Handles different units for Z and X.
"""
if 'Z' in img.dims:
z_spacing = img.attrs.get('scale', {}).get('Z', 1.0)
z_unit = img.attrs.get('units', {}).get('Z', 'um')
x_spacing = img.attrs.get('scale', {}).get('X', 1.0)
x_unit = img.attrs.get('units', {}).get('X', 'um')
z_spacing = z_spacing * ImageUtils._UREG(z_unit)
x_spacing = x_spacing * ImageUtils._UREG(x_unit)
return (z_spacing / x_spacing).to_base_units().magnitude
return 1.0
[docs]
@staticmethod
def copyCalibrationTo(imA: xr.DataArray, imB: xr.DataArray):
"""
Copies the calibration (scale and units) from image A to image B.
Only the axes present in image B will be copied.
If an axis is not present in image A, it will not be copied.
"""
scale_a = imA.attrs.get('scale', {})
scale_b = {}
units_a = imA.attrs.get('units', {})
units_b = {}
for ax in imB.dims:
if ax in scale_a:
scale_b[ax] = scale_a[ax]
if ax in units_a:
units_b[ax] = units_a[ax]
imB.attrs['scale'] = scale_b
imB.attrs['units'] = units_b
[docs]
@staticmethod
def completeCalibrationFrom(imA: xr.DataArray, imB: xr.DataArray):
"""
Completes the calibration of image B from image A.
Only the axes present in image B will be completed.
If an axis already has a scale or unit in image B, it will not be overwritten.
"""
scale_a = imA.attrs.get('scale', {})
scale_b = imB.attrs.get('scale', {})
units_a = imA.attrs.get('units', {})
units_b = imB.attrs.get('units', {})
for ax in imB.dims:
if ax not in scale_b and ax in scale_a:
scale_b[ax] = scale_a[ax]
if ax not in units_b and ax in units_a:
units_b[ax] = units_a[ax]
imB.attrs['scale'] = scale_b
imB.attrs['units'] = units_b
[docs]
@staticmethod
def ensureCalibration(img: xr.DataArray):
"""
Ensures that the image has calibration information (scale and units) for valid axes.
If not present, it sets default values.
"""
scales = {}
units = {}
for ax in img.dims:
scales[ax] = ImageUtils._DEFAULT_SCALING.get(str(ax), 1.0)
units[ax] = ImageUtils._DEFAULT_UNITS.get(str(ax), 'pixels')
img.attrs['scale'] = scales
img.attrs['units'] = units
[docs]
@staticmethod
def ensureAxes(da: xr.DataArray, axes: list[str] = ["T", "Z", "Y", "X"]) -> xr.DataArray:
"""
Forces the DataArray to have the specified axes.
If an axis is missing, it is added with size 1.
"""
if da is None:
return None
for i, ax in enumerate(axes):
if ax not in da.dims:
da = da.expand_dims({ax: 1}, axis=i)
return da
[docs]
@staticmethod
def scaleToTuple(img:xr.DataArray):
"""
Returns the scale of the image as a tuple, in the order of the image's dimensions.
If a dimension does not have a scale, it defaults to _DEFAULT_SCALING.
"""
scale_dict = img.attrs.get("scale", {}).copy()
return tuple([scale_dict.get(
str(dim),
ImageUtils._DEFAULT_SCALING.get(str(dim), 1.0)
) for dim in img.dims])
[docs]
@staticmethod
def unitsToTuple(img:xr.DataArray):
"""
Returns the units of the image as a tuple, in the order of the image's dimensions.
If a dimension does not have a unit, it defaults to _DEFAULT_UNITS.
"""
units_dict = img.attrs.get("units", {}).copy()
return tuple([units_dict.get(
str(dim),
ImageUtils._DEFAULT_UNITS.get(str(dim), "pixels")
) for dim in img.dims])