Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make VoxelSize iterable and array-like, remove .is_valid #310

Merged
merged 3 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plantseg/core/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,11 +518,11 @@ def interpolation_order(self, image_default: int = 1) -> int:

def has_valid_voxel_size(self) -> bool:
"""Returns True if the voxel size is valid (not None), False otherwise."""
return self.voxel_size.is_valid
return self.voxel_size.voxels_size is not None

def has_valid_original_voxel_size(self) -> bool:
"""Returns True if the original voxel size is valid (not None), False otherwise."""
return self.original_voxel_size.is_valid
return self.original_voxel_size.voxels_size is not None


def _load_data(path: Path, key: str | None) -> tuple[np.ndarray, VoxelSize]:
Expand Down
97 changes: 49 additions & 48 deletions plantseg/core/voxelsize.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"""Voxel size of an image.

This separates from plantseg.core.image to avoid circular imports.
This module handles voxel size operations to avoid circular imports with plantseg.core.image.
"""

from typing import Optional

import numpy as np
from pydantic import BaseModel, Field, field_validator

from plantseg.functionals.dataprocessing import compute_scaling_factor, compute_scaling_voxelsize
Expand All @@ -15,71 +14,73 @@ class VoxelSize(BaseModel):
Voxel size of an image.

Attributes:
voxels_size (Optional[tuple[float, float, float]]): Size of the voxels in the image.
unit (str): Unit of the voxel size, starting with "u", "µ", or "micro".
voxels_size (tuple[float, float, float] | None): Size of the voxels in the image.
unit (str): Unit of the voxel size, restricted to micrometers (um).
"""

voxels_size: Optional[tuple[float, float, float]] = None
voxels_size: tuple[float, float, float] | None = None
unit: str = Field(default="um")

@field_validator("voxels_size")
@classmethod
def _check_voxel_size(cls, values: Optional[tuple[float, float, float]]):
if values is None:
return values

if any(value <= 0 for value in values):
def _check_voxel_size(cls, value: tuple[float, float, float] | None) -> tuple[float, float, float] | None:
if value is not None and any(v <= 0 for v in value):
raise ValueError("Voxel size must be positive")

return values
return value

@field_validator("unit")
@classmethod
def _check_unit(cls, value: str) -> str:
if value.startswith(("u", "µ", "micro")):
return "um"
raise ValueError("Only micrometers (um) are supported, i.e. units starting with 'u', 'µ', or 'micro'")

def scalefactor_from_voxelsize(self, other: "VoxelSize") -> tuple[float, float, float]:
"""
Compute the scaling factor to rescale an image from the current voxel size to another voxel size.
"""
if self.voxels_size is None or other.voxels_size is None:
raise ValueError("Voxel size is not defined, cannot compute scaling factor")

return compute_scaling_factor(self.voxels_size, other.voxels_size)

def voxelsize_from_factor(self, factor: tuple[float, float, float]) -> "VoxelSize":
"""
Compute the output voxel size after scaling an image with a given scaling factor.
"""
if self.voxels_size is None:
raise ValueError("Voxel size is not defined, cannot compute output voxel size")

return VoxelSize(voxels_size=compute_scaling_voxelsize(self.voxels_size, factor))
raise ValueError("Only micrometers (um) are supported")

@property
def x(self) -> float:
"""Safe access to the voxel size in the x direction. Returns 1.0 if the voxel size is not defined."""
if self.voxels_size is not None:
return self.voxels_size[2]
return 1.0
"""Voxel size in the x direction, or 1.0 if not defined."""
return self.voxels_size[2] if self.voxels_size else 1.0 # pylint: disable=unsubscriptable-object

@property
def y(self) -> float:
"""Safe access to the voxel size in the y direction. Returns 1.0 if the voxel size is not defined."""
if self.voxels_size is not None:
return self.voxels_size[1]
return 1.0
"""Voxel size in the y direction, or 1.0 if not defined."""
return self.voxels_size[1] if self.voxels_size else 1.0 # pylint: disable=unsubscriptable-object

@property
def z(self) -> float:
"""Safe access to the voxel size in the z direction. Returns 1.0 if the voxel size is not defined."""
if self.voxels_size is not None:
return self.voxels_size[0]
return 1.0
"""Voxel size in the z direction, or 1.0 if not defined."""
return self.voxels_size[0] if self.voxels_size else 1.0 # pylint: disable=unsubscriptable-object

@property
def is_valid(self) -> bool:
"""Return True if the voxel size is valid (i.e., not None)."""
return self.voxels_size is not None
def __len__(self) -> int:
"""Return the number of dimensions of the voxel size."""
if self.voxels_size is None:
raise ValueError("Voxel size must be defined to get the length")
return len(self.voxels_size)

def __iter__(self):
"""Allow the VoxelSize instance to be unpacked as a tuple."""
if self.voxels_size is None:
raise ValueError("Voxel size must be defined to iterate")
return iter(self.voxels_size)

def __array__(self):
if self.voxels_size is None:
raise ValueError("Voxel size is not defined")
return np.array(self.voxels_size)

def as_tuple(self) -> tuple[float, float, float]:
"""Convert VoxelSize to a tuple."""
if self.voxels_size is None:
raise ValueError("Voxel size must be defined to convert to tuple")
return self.voxels_size

def scalefactor_from_voxelsize(self, other: "VoxelSize") -> tuple[float, float, float]:
"""Compute the scaling factor to rescale an image from the current voxel size to another."""
if self.voxels_size is None or other.voxels_size is None:
raise ValueError("Both voxel sizes must be defined to compute the scaling factor")
return compute_scaling_factor(self.voxels_size, other.voxels_size)

def voxelsize_from_factor(self, factor: tuple[float, float, float]) -> "VoxelSize":
"""Compute the voxel size after scaling with the given factor."""
if self.voxels_size is None:
raise ValueError("Voxel size must be defined to compute the output voxel size")
return VoxelSize(voxels_size=compute_scaling_voxelsize(self.voxels_size, factor))
3 changes: 1 addition & 2 deletions plantseg/viewer_napari/widgets/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,7 @@ def _on_layer_changed(layer):
ps_image = PlantSegImage.from_napari_layer(layer)
if ps_image.has_valid_voxel_size():
voxel_size_formatted = "("
assert ps_image.voxel_size.voxels_size is not None, "`.has_valid_voxel_size()` should return False"
for vs in ps_image.voxel_size.voxels_size:
for vs in ps_image.voxel_size:
voxel_size_formatted += f"{vs:.2f}, "

voxel_size_formatted = voxel_size_formatted[:-2] + f") {ps_image.voxel_size.unit}"
Expand Down
35 changes: 35 additions & 0 deletions tests/core/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ def test_plantseg_image_from_napari_layer():
assert ps_image.name == "test_image"
assert ps_image.shape == (10, 10, 10)
assert ps_image.voxel_size.voxels_size == voxel_size
assert tuple(ps_image.voxel_size) == voxel_size


def test_plantseg_image_to_napari_layer_tuple():
Expand All @@ -242,6 +243,7 @@ def test_plantseg_image_to_napari_layer_tuple():
layer_tuple = ps_image.to_napari_layer_tuple()

assert isinstance(layer_tuple, tuple)
layer_tuple = tuple(layer_tuple)
np.testing.assert_allclose(layer_tuple[0], ps_image.get_data(normalize_01=True))
assert "metadata" in layer_tuple[1]
assert layer_tuple[2] == ps_image.image_type.value
Expand All @@ -259,3 +261,36 @@ def test_plantseg_image_scale_property():
)
ps_image = PlantSegImage(data, image_props)
assert ps_image.scale == (0.5, 1.0, 1.0)


def test_requires_scaling():
data = np.random.rand(10, 10, 10)

voxel_size = VoxelSize(voxels_size=(0.5, 1.0, 1.0), unit="um")
same_voxel_size = VoxelSize(voxels_size=(0.5, 1.0, 1.0), unit="um")
original_voxel_size = VoxelSize(voxels_size=(1.0, 1.0, 1.0), unit="um")
assert same_voxel_size == voxel_size
assert original_voxel_size != voxel_size

image_props = ImageProperties(
name="scaled_image",
semantic_type=SemanticType.RAW,
voxel_size=voxel_size,
image_layout=ImageLayout.ZYX,
original_voxel_size=original_voxel_size,
)
ps_image = PlantSegImage(data, image_props)
assert ps_image.requires_scaling is True

image_props = ImageProperties(
name="scaled_image",
semantic_type=SemanticType.RAW,
voxel_size=voxel_size,
image_layout=ImageLayout.ZYX,
original_voxel_size=same_voxel_size,
)
ps_image = PlantSegImage(data, image_props)
assert ps_image.requires_scaling is False

assert same_voxel_size == voxel_size
assert original_voxel_size != voxel_size
35 changes: 30 additions & 5 deletions tests/core/test_voxelsize.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import numpy as np
import pytest
from pydantic import ValidationError

Expand Down Expand Up @@ -40,6 +41,14 @@ def test_voxel_size_invalid_initialization():
VoxelSize(voxels_size=(0.2, 0.1))


def test_voxel_size_equality():
voxel_size = VoxelSize(voxels_size=(0.5, 1.0, 1.0), unit="um")
same_voxel_size = VoxelSize(voxels_size=(0.5, 1.0, 1.0), unit="um")
original_voxel_size = VoxelSize(voxels_size=(1.0, 1.0, 1.0), unit="um")
assert same_voxel_size == voxel_size, "Pydanctic models should be equal if their attributes are equal"
assert original_voxel_size != voxel_size, "Pydanctic models should not be equal if their attributes are not equal"


def test_voxel_size_scalefactor_from_voxelsize():
vs1 = VoxelSize(voxels_size=(1.0, 1.0, 1.0), unit="um")
vs2 = VoxelSize(voxels_size=(2.0, 2.0, 2.0), unit="um")
Expand Down Expand Up @@ -81,9 +90,25 @@ def test_voxel_size_properties():
assert vs_empty.z == 1.0


def test_voxel_size_is_valid():
vs = VoxelSize(voxels_size=(1.0, 1.0, 1.0), unit="um")
assert vs.is_valid is True
def test_voxel_size_iter():
vs = VoxelSize(voxels_size=(1.0, 2.0, 3.0), unit="um")
assert list(vs) == [1.0, 2.0, 3.0]

vs_invalid = VoxelSize(unit="um")
assert vs_invalid.is_valid is False
with pytest.raises(TypeError):
assert vs[2] == 3.0, "VoxelSize object is not subscriptable, encouraging users to use .x, .y, .z properties"

for v in vs:
assert v in vs, "VoxelSize object should be iterable"

vs_empty = VoxelSize(unit="um")
with pytest.raises(ValueError):
list(vs_empty)


def test_voxel_size_array():
"""Test the __array__ method"""
vs = VoxelSize(voxels_size=(1.0, 2.0, 3.0), unit="um")
np.testing.assert_allclose(vs, [1.0, 2.0, 3.0])

with pytest.raises(ValueError):
np.array(VoxelSize(unit="um"))
10 changes: 6 additions & 4 deletions tests/io/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ class TestIO:

def _check_voxel_size(self, voxel_size):
assert isinstance(voxel_size, VoxelSize)
(
np.testing.assert_allclose(voxel_size.voxels_size, self.voxel_size.voxels_size, rtol=1e-5),
"Voxel size read from file is not equal to the original voxel size",
np.testing.assert_allclose(
voxel_size.voxels_size,
self.voxel_size,
rtol=1e-5,
err_msg="Voxel size read from file is not equal to the original voxel size",
)

def test_create_read_h5(self, path_h5):
Expand Down Expand Up @@ -54,7 +56,7 @@ def test_create_read_zarr(self, path_zarr):
# Read the voxel size of the Zarr file
voxel_size = read_zarr_voxel_size(path_zarr, "raw")
assert np.allclose(
voxel_size.voxels_size, self.voxel_size.voxels_size
voxel_size.voxels_size, self.voxel_size
), "Voxel size read from Zarr file is not equal to the original voxel size"

data_read2 = smart_load(path_zarr, "raw")
Expand Down
Loading