diff --git a/deeptrack/aberrations.py b/deeptrack/aberrations.py index 6c9b41b3f..7256c3ceb 100644 --- a/deeptrack/aberrations.py +++ b/deeptrack/aberrations.py @@ -397,7 +397,7 @@ def get( n: int | list[int], m: int | list[int], coefficient: float | list[float], - **kwargs: dict[str, Any], + **kwargs: Any, ) -> np.ndarray: """Applies the Zernike phase aberration to the input pupil function. @@ -564,7 +564,7 @@ def __init__( self: "Piston", *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the Piston class. @@ -623,7 +623,7 @@ def __init__( self: VerticalTilt, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the VerticalTilt class. @@ -682,7 +682,7 @@ def __init__( self: HorizontalTilt, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the HorizontalTilt class. @@ -743,7 +743,7 @@ def __init__( self: ObliqueAstigmatism, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the ObliqueAstigmatism class. @@ -802,7 +802,7 @@ def __init__( self: Defocus, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the Defocus class. @@ -861,7 +861,7 @@ def __init__( self: Astigmatism, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the Astigmatism class. @@ -909,7 +909,7 @@ def __init__( self: ObliqueTrefoil, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: super().__init__(*args, n=3, m=-3, coefficient=coefficient, **kwargs) @@ -930,7 +930,7 @@ def __init__( self: VerticalComa, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: super().__init__(*args, n=3, m=-1, coefficient=coefficient, **kwargs) @@ -951,7 +951,7 @@ def __init__( self: HorizontalComa, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: super().__init__(*args, n=3, m=1, coefficient=coefficient, **kwargs) @@ -972,7 +972,7 @@ def __init__( self: Trefoil, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: super().__init__(*args, n=3, m=3, coefficient=coefficient, **kwargs) @@ -993,6 +993,6 @@ def __init__( self: SphericalAberration, *args: tuple[Any, ...], coefficient: PropertyLike[float | list[float]] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> None: super().__init__(*args, n=4, m=0, coefficient=coefficient, **kwargs) diff --git a/deeptrack/elementwise.py b/deeptrack/elementwise.py index a40c83a76..72427c6cd 100644 --- a/deeptrack/elementwise.py +++ b/deeptrack/elementwise.py @@ -145,8 +145,6 @@ class ElementwiseFeature(Feature): Returns the result of applying the function to the input array. """ - - __gpu_compatible__: bool = True def __init__( self: ElementwiseFeature, diff --git a/deeptrack/features.py b/deeptrack/features.py index a30d9892d..c313b0938 100644 --- a/deeptrack/features.py +++ b/deeptrack/features.py @@ -123,19 +123,21 @@ def merge_features( from __future__ import annotations +import array_api_compat as apc import itertools import operator import random -from typing import Any, Callable, Iterable, Literal +from typing import Any, Callable, Iterable, Literal, TYPE_CHECKING import numpy as np -import matplotlib.animation as animation +from numpy.typing import NDArray import matplotlib.pyplot as plt +from matplotlib import animation from pint import Quantity from scipy.spatial.distance import cdist from deeptrack import units -from deeptrack.backend import config, xp +from deeptrack.backend import config, TORCH_AVAILABLE, xp from deeptrack.backend.core import DeepTrackNode from deeptrack.backend.units import ConversionTable, create_context from deeptrack.image import Image @@ -143,6 +145,68 @@ def merge_features( from deeptrack.sources import SourceItem from deeptrack.types import ArrayLike, PropertyLike +if TORCH_AVAILABLE: + import torch + +__all__ = [ + "Feature", # TODO **GV** + "StructuralFeature", + "Chain", + "Branch", + "DummyFeature", + "Value", + "ArithmeticOperationFeature", + "Add", + "Subtract", + "Multiply", + "Divide", + "FloorDivide", + "Power", + "LessThan", + "LessThanOrEquals", + "LessThanOrEqual", + "GreaterThan", + "GreaterThanOrEquals", + "GreaterThanOrEqual", + "Equals", + "Equal", + "Stack", + "Arguments", + "Probability", + "Repeat", + "Combine", + "Slice", + "Bind", + "BindResolve", + "BindUpdate", + "ConditionalSetProperty", + "ConditionalSetFeature", + "Lambda", + "Merge", + "OneOf", + "OneOfDict", + "LoadImage", # TODO **MG** + "SampleToMasks", # TODO **MG** + "AsType", # TODO **MG** + "ChannelFirst2d", # TODO **AL** + "Upscale", # TODO **AL** + "NonOverlapping", # TODO **AL** + "Store", # TODO **JH** + "Squeeze", + "Unsqueeze", + "ExpandDims", + "MoveAxis", + "Transpose", + "Permute", + "OneHot", + "TakeProperties", # TODO **JH** +] + + +if TYPE_CHECKING: + import torch + + MERGE_STRATEGY_OVERRIDE: int = 0 MERGE_STRATEGY_APPEND: int = 1 @@ -150,32 +214,56 @@ def merge_features( class Feature(DeepTrackNode): """Base feature class. - Features define the image generation process. All features operate on lists - of images. Most features, such as noise, apply some tranformation to all - images in the list. This transformation can be additive, such as adding - some Gaussian noise or a background illumination, or non-additive, such as - introducing Poisson noise or performing a low-pass filter. This - transformation is defined by the method `get(image, **kwargs)`, which all - implementations of the class `Feature` need to define. + Features define the image generation process. + + All features operate on lists of images. Most features, such as noise, + apply a tranformation to all images in the list. This transformation can be + additive, such as adding some Gaussian noise or a background illumination, + or non-additive, such as introducing Poisson noise or performing a low-pass + filter. This transformation is defined by the `get(image, **kwargs)` + method, which all implementations of the class `Feature` need to define. + This method operates on a single image at a time. + + Whenever a Feature is initialized, it wraps all keyword arguments passed to + the constructor as `Property` objects, and stored in the `properties` + attribute as a `PropertyDict`. + + When a Feature is resolved, the current value of each property is sent as + input to the get method. + + **Computational Backends and Data Types** + + This class also provides mechanisms for managing numerical types and + computational backends. + + Supported backends include NumPy and PyTorch. The active backend is + determined at initialization and stored in the `_backend` attribute, which + is used internally to control how computations are executed. The backend + can be switched using the `.numpy()` and `.torch()` methods. + + Numerical types used in computation (float, int, complex, and bool) can be + configured using the `.dtype()` method. The chosen types are retrieved + via the properties `float_dtype`, `int_dtype`, `complex_dtype`, and + `bool_dtype`. These are resolved dynamically using the backend's internal + type resolution system and are used in downstream computations. - Whenever a Feature is initiated, all keyword arguments passed to the - constructor will be wrapped as a `Property`, and stored in the `properties` - attribute as a `PropertyDict`. When a Feature is resolved, the current - value of each property is sent as input to the get method. + The computational device (e.g., "cpu" or a specific GPU) is managed through + the `.to()` method and accessed via the `device` property. This is + especially relevant for PyTorch backends, which support GPU acceleration. Parameters ---------- - _input: np.ndarray or list of np.ndarray or Image or list of Image, - optional. - A list of np.ndarray or `DeepTrackNode` objects or a single np.ndarray - or an `Image` object representing the input data for the feature. This - parameter specifies what the feature will process. If left empty, no - initial input is set. - **kwargs: dict of str and Any + _input: Any, optional. + The input data for the feature. If left empty, no initial input is set. + It is most commonly a NumPy array, PyTorch tensor, or Image object, or + a list of NumPy arrays, PyTorch tensors, or Image objects; however, it + can be anything. + **kwargs: Any Keyword arguments to configure the feature. Each keyword argument is wrapped as a `Property` and added to the `properties` attribute, allowing dynamic sampling and parameterization during the feature's - execution. + execution. These properties are passed to the `get()` method when a + feature is resolved. Attributes ---------- @@ -185,6 +273,37 @@ class Feature(DeepTrackNode): dynamically sample values during pipeline execution. A sampled copy of this dictionary is passed to the `get` function and appended to the properties of the output image. + _input: DeepTrackNode + A node representing the input data for the feature. It is most commonly + a NumPy array, PyTorch tensor, or Image object, or a list of NumPy + arrays, PyTorch tensors, or Image objects; however, it can be anything. + It supports lazy evaluation and graph traversal. + _random_seed: DeepTrackNode + A node representing the feature’s random seed. This allows for + deterministic behavior when generating random elements, and ensures + reproducibility during evaluation. + arguments: Feature | None + An optional `Feature` whose properties are bound to this feature. This + allows dynamic property sharing and centralized parameter management + in complex pipelines. + __list_merge_strategy__: int + Specifies how the output of `.get(image, **kwargs)` is merged with the + current `_input`. Options include: + - `MERGE_STRATEGY_OVERRIDE` (0, default): `_input` is replaced by the + new output. + - `MERGE_STRATEGY_APPEND` (1): The output is appended to the end of + `_input`. + __distributed__: bool + Determines whether `.get(image, **kwargs)` is applied to each element + of the input list independently (`__distributed__ = True`) or to the + list as a whole (`__distributed__ = False`). + __conversion_table__: ConversionTable + Defines the unit conversions used by the feature to convert its + properties into the desired units. + _wrap_array_with_image: bool + Internal flag that determines whether arrays are wrapped as `Image` + instances during evaluation. When `True`, image metadata and properties + are preserved and propagated. It defaults to `False`. float_dtype: np.dtype The data type of the float numbers. int_dtype: np.dtype @@ -197,43 +316,31 @@ class Feature(DeepTrackNode): The device on which the feature is executed. _backend: Config The computational backend. - __list_merge_strategy__: int - Specifies how the output of `.get(image, **kwargs)` is merged with the - input list. Options include: - - `MERGE_STRATEGY_OVERRIDE` (0, default): The input list is replaced by - the new list. - - `MERGE_STRATEGY_APPEND` (1): The new list is appended to the end of - the input list. - __distributed__: bool - Determines whether `.get(image, **kwargs)` is applied to each element - of the input list independently (`__distributed__ = True`) or to the - list as a whole (`__distributed__ = False`). - __property_memorability__: int - Specifies whether to store the feature’s properties in the output - image. Properties with a memorability value of `1` or lower are stored - by default. - __conversion_table__: ConversionTable - Defines the unit conversions used by the feature to convert its - properties into the desired units. - __gpu_compatible__: bool - Indicates whether the feature can use GPU acceleration. When enabled, - GPU execution is triggered based on input size or backend settings. Methods ------- - `get(image: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> Image | list[Image]` - Abstract method that defines how the feature transforms the input. - `__call__(image_list: np.ndarray | list[np.ndarray] | Image | list[Image] | None = None, _ID: tuple[int, ...] = (), **kwargs: Any) -> Any` - Executes the feature or pipeline on the input and applies property + `get(image: Any, **kwargs: Any) -> Any` + Abstract method that defines how the feature transforms the input. The + input is most commonly a NumPy array, PyTorch tensor, or Image object, + but it can be anything. + `__call__(image_list: Any, _ID: tuple[int, ...], **kwargs: Any) -> Any` + It executes the feature or pipeline on the input and applies property overrides from `kwargs`. - `store_properties(x: bool = True, recursive: bool = True) -> None` - Controls whether the properties are stored in the output `Image` object. - `torch(dtype: torch.dtype | None = None, device: torch.device | None = None, permute_mode: str = "never") -> 'Feature'` - Converts the feature into a PyTorch-compatible feature. - `batch(batch_size: int = 32) -> tuple | list[Image]` - Batches the feature for repeated execution. - `action(_ID: tuple[int, ...] = ()) -> Image | list[Image]` - Core logic to create or transform the image. + `store_properties(toggle: bool, recursive: bool) -> Feature` + It controls whether the properties are stored in the output `Image` + object. + `torch(device: torch.device or None, recursive: bool) -> 'Feature'` + It sets the backend to torch. + `numpy(recursice: bool) -> Feature` + It set the backend to numpy. + `dtype(float: Literal["float32", "float64", "default"] or None, int: Literal["int16", "int32", "int64", "default"] or None, complex: Literal["complex64", "complex128", "default"] or None, bool: Literal["bool", "default"] or None) -> Feature` + It set the dtype to be used during evaluation. + `to(device: str or torch.device) -> Feature` + It set the device to be used during evaluation. + `batch(batch_size: int) -> tuple` + It batches the feature for repeated execution. + `action(_ID: tuple[int, ...]) -> Any | list[Any]` + Implement the core logic to create or transform the input(s). `update(**global_arguments: Any) -> Feature` Refreshes the feature to create a new image. `add_feature(feature: Feature) -> Feature` @@ -242,90 +349,98 @@ class Feature(DeepTrackNode): Sets the random seed for the feature, ensuring deterministic behavior. `bind_arguments(arguments: Feature) -> Feature` Binds another feature’s properties as arguments to this feature. - `_normalize(**properties: dict[str, Any]) -> dict[str, Any]` - Normalizes the properties of the feature. `plot(input_image: np.ndarray | list[np.ndarray] | Image | list[Image] | None = None, resolve_kwargs: dict | None = None, interval: float | None = None, **kwargs) -> Any` Visualizes the output of the feature. + + **Private and internal methods.** + `_normalize(**properties: dict[str, Any]) -> dict[str, Any]` + Normalizes the properties of the feature. `_process_properties(propertydict: dict[str, Any]) -> dict[str, Any]` Preprocesses the input properties before calling the `get` method. `_activate_sources(x: Any) -> None` Activates sources in the input data. `__getattr__(key: str) -> Any` Custom attribute access for the Feature class. - `__iter__() -> Iterable` - Iterates over the feature. + `__iter__() -> Feature` + It returns an iterator for the feature. `__next__() -> Any` - Returns the next element in the feature. + It return the next element iterating over the feature. `__rshift__(other: Any) -> Feature` - Allows chaining of features. + It allows chaining of features. `__rrshift__(other: Any) -> Feature` - Allows right chaining of features. + It allows right chaining of features. `__add__(other: Any) -> Feature` - Overrides add operator. + It overrides add operator. `__radd__(other: Any) -> Feature` - Overrides right add operator. + It overrides right add operator. `__sub__(other: Any) -> Feature` - Overrides subtraction operator. + It overrides subtraction operator. `__rsub__(other: Any) -> Feature` - Overrides right subtraction operator. + It overrides right subtraction operator. `__mul__(other: Any) -> Feature` - Overrides multiplication operator. + It overrides multiplication operator. `__rmul__(other: Any) -> Feature` - Overrides right multiplication operator. + It overrides right multiplication operator. `__truediv__(other: Any) -> Feature` - Overrides division operator. + It overrides division operator. `__rtruediv__(other: Any) -> Feature` - Overrides right division operator. + It overrides right division operator. `__floordiv__(other: Any) -> Feature` - Overrides floor division operator. + It overrides floor division operator. `__rfloordiv__(other: Any) -> Feature` - Overrides right floor division operator. + It overrides right floor division operator. `__pow__(other: Any) -> Feature` - Overrides power operator. + It overrides power operator. `__rpow__(other: Any) -> Feature` - Overrides right power operator. + It overrides right power operator. `__gt__(other: Any) -> Feature` - Overrides greater than operator. + It overrides greater than operator. `__rgt__(other: Any) -> Feature` - Overrides right greater than operator. + It overrides right greater than operator. `__lt__(other: Any) -> Feature` - Overrides less than operator. + It overrides less than operator. `__rlt__(other: Any) -> Feature` - Overrides right less than operator. + It overrides right less than operator. `__le__(other: Any) -> Feature` - Overrides less than or equal to operator. + It overrides less than or equal to operator. `__rle__(other: Any) -> Feature` - Overrides right less than or equal to operator. + It overrides right less than or equal to operator. `__ge__(other: Any) -> Feature` - Overrides greater than or equal to operator. + It overrides greater than or equal to operator. `__rge__(other: Any) -> Feature` - Overrides right greater than or equal to operator. + It overrides right greater than or equal to operator. `__xor__(other: Any) -> Feature` - Overrides XOR operator. + It overrides XOR operator. `__and__(other: Feature) -> Feature` - Overrides AND operator. + It overrides AND operator. `__rand__(other: Feature) -> Feature` - Overrides right AND operator. + It overrides right AND operator. `__getitem__(key: Any) -> Feature` - Allows direct slicing of the data. - `_format_input(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> list[Image]` - Formats the input data for the feature. - `_process_and_get(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> list[Image]` - Calls the `get` method according to the `__distributed__` attribute. - `_process_output(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> None` - Processes the output of the feature. + It allows direct slicing of the data. + `_format_input(image_list: Any, **kwargs: Any) -> list[Any or Image]` + It formats the input data for the feature. + `_process_and_get(image_list: Any, **kwargs: Any) -> list[Any or Image]` + It calls the `get` method according to the `__distributed__` attribute. + `_process_output(image_list: Any, **kwargs: Any) -> None` + It processes the output of the feature. `_image_wrapped_format_input(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> list[Image]` - Ensures the input is a list of Image. - `_no_wrap_format_input(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> list[Image]` - Ensures the input is a list of Image. - `_no_wrap_process_and_get(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> list[Image]` - Calls the `get` method according to the `__distributed__` attribute. + It ensures the input is a list of Image. + `_no_wrap_format_input(image_list: Any, **kwargs: Any) -> list[Any]` + It ensures the input is a list of Image. `_image_wrapped_process_and_get(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> list[Image]` - Calls the `get` method according to the `__distributed__` attribute. + It calls the `get()` method according to the `__distributed__` + attribute. + `_no_wrap_process_and_get(image_list: Any | list[Any], **kwargs: Any) -> list[Any]` + It calls the `get()` method according to the `__distributed__` + attribute. `_image_wrapped_process_output(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> None` - Processes the output of the feature. - `_no_wrap_process_output(image_list: np.ndarray | list[np.ndarray] | Image | list[Image], **kwargs: Any) -> None` - Processes the output of the feature. + It processes the output of the feature. + `_no_wrap_process_output(image_list: Any | list[Any], **kwargs: Any) -> None` + It processes the output of the feature. + + Examples + -------- + TODO """ @@ -336,15 +451,15 @@ class Feature(DeepTrackNode): __list_merge_strategy__ = MERGE_STRATEGY_OVERRIDE __distributed__ = True - __property_memorability__ = 1 __conversion_table__ = ConversionTable() - __gpu_compatible__ = False _wrap_array_with_image: bool = False + _float_dtype: str _int_dtype: str _complex_dtype: str _device: str | torch.device + _backend: Literal["numpy", "torch"] @property def float_dtype(self) -> np.dtype | torch.dtype: @@ -360,7 +475,7 @@ def int_dtype(self) -> np.dtype | torch.dtype: def complex_dtype(self) -> np.dtype | torch.dtype: """The dtype of the complex numbers.""" return xp.get_complex_dtype(self._complex_dtype) - + @property def bool_dtype(self) -> np.dtype | torch.dtype: """The dtype of the boolean numbers.""" @@ -374,24 +489,25 @@ def device(self) -> str | torch.device: def __init__( self: Feature, _input: Any = [], - **kwargs: dict[str, Any], - ) -> None: + **kwargs: Any, + ): """Initialize a new Feature instance. Parameters ---------- - _input: np.ndarray or list[np.ndarray] or Image or list of Images, optional - The initial input(s) for the feature, often images or other data. - If not provided, defaults to an empty list. - **kwargs: dict of str to Any + _input: Any, optional + The initial input(s) for the feature. It is most commonly a NumPy + array, PyTorch tensor, or Image object, or a list of NumPy arrays, + PyTorch tensors, or Image objects; however, it can be anything. If + not provided, defaults to an empty list. + **kwargs: Any Keyword arguments that are wrapped into `Property` instances and stored in `self.properties`, allowing for dynamic or parameterized - behavior. - If not provided, defaults to an empty list. - + behavior. If not provided, it defaults to an empty list. + """ - # store backend on initialization + # Store backend on initialization. self._backend = config.get_backend() # Store the dtype and device on initialization. @@ -428,25 +544,26 @@ def __init__( def get( self: Feature, - image: np.ndarray | list[np.ndarray] | Image | list[Image], - **kwargs: dict[str, Any], - ) -> Image | list[Image]: - """Transform an image [abstract method]. + image: Any, + **kwargs: Any, + ) -> Any: + """Transform an input (abstract method). Abstract method that defines how the feature transforms the input. The current value of all properties will be passed as keyword arguments. Parameters ---------- - image: np.ndarray or list of np.ndarray or Image or list of Images - The image or list of images to transform. - **kwargs: dict of str to Any + image: Any + The input to transform. It is most commonly a NumPy array, PyTorch + tensor, or Image object, but it can be anything. + **kwargs: Any The current value of all properties in `properties`, as well as any global arguments passed to the feature. Returns ------- - Image or list of Images + Any The transformed image or list of images. Raises @@ -460,9 +577,9 @@ def get( def __call__( self: Feature, - image_list: np.ndarray | list[np.ndarray] | Image | list[Image] = None, + image_list: Any = None, _ID: tuple[int, ...] = (), - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Any: """Execute the feature or pipeline. @@ -476,10 +593,13 @@ def __call__( Parameters ---------- - image_list: np.ndarrray or list[np.ndarrray] or Image or list of Images, optional - The input to the feature or pipeline. If `None`, the feature uses - previously set input values or propagates properties. - **kwargs: dict of str to Any + image_list: Any, optional + The input to the feature or pipeline. It is most commonly a NumPy + array, PyTorch tensor, or Image object, or a list of NumPy arrays, + PyTorch tensors, or Image objects; however, it can be anything. It + defaults to `None`, in which case the feature uses the previous set + input values or propagates properties. + **kwargs: Any Additional parameters passed to the pipeline. These override properties with matching names. For example, calling `feature(x, value=4)` executes `feature` on the input `x` while @@ -489,14 +609,39 @@ def __call__( Returns ------- Any - The output of the feature or pipeline after execution. - + The output of the feature or pipeline after execution. This is + typically a NumPy array, PyTorch tensor, or Image object, or a list + of NumPy arrays, PyTorch tensors, or Image objects. + + Examples + -------- + >>> import deeptrack as dt + + Deafine a feature: + >>> feature = dt.Add(value=2) + + Call this feature with an input: + >>> import numpy as np + >>> + >>> feature(np.array([1, 2, 3])) + array([3, 4, 5]) + + Execute the feature with previously set input: + >>> feature() # Uses stored input + array([3, 4, 5]) + + Override a property: + >>> feature(np.array([1, 2, 3]), value=10) + array([11, 12, 13]) + """ + with config.with_backend(self._backend): # If image_list is as Source, activate it. self._activate_sources(image_list) - # Potentially fragile. Maybe a special variable dt._last_input instead? + # Potentially fragile. + # Maybe a special variable dt._last_input instead? # If the input is not empty, set the value of the input. if ( image_list is not None @@ -506,43 +651,46 @@ def __call__( ): self._input.set_value(image_list, _ID=_ID) - # A dict to store the values of self.arguments before updating them. + elif image_list is None and self._input(_ID=_ID) is not None: + self._input.set_value(self._input(_ID=_ID), _ID=_ID) + + # A dict to store values of self.arguments before updating them. original_values = {} - # If there are no self.arguments, instead propagate the values of the - # kwargs to all properties in the computation graph. + # If there are no self.arguments, instead propagate the values of + # the kwargs to all properties in the computation graph. if kwargs and self.arguments is None: propagate_data_to_dependencies(self, **kwargs) - # If there are self.arguments, update the values of self.arguments to - # match kwargs. + # If there are self.arguments, update the values of self.arguments + # to match kwargs. if isinstance(self.arguments, Feature): for key, value in kwargs.items(): if key in self.arguments.properties: original_values[key] = \ self.arguments.properties[key](_ID=_ID) - self.arguments.properties[key].set_value(value, _ID=_ID) + self.arguments.properties[key]\ + .set_value(value, _ID=_ID) - # This executes the feature. DeepTrackNode will determine if it needs - # to be recalculated. If it does, it will call the `action` method. + # This executes the feature. DeepTrackNode will determine if it + # needs to be recalculated. If it does, it will call the `action` + # method. output = super().__call__(_ID=_ID) - # If there are self.arguments, reset the values of self.arguments to - # their original values. + # If there are self.arguments, reset the values of self.arguments + # to their original values. for key, value in original_values.items(): self.arguments.properties[key].set_value(value, _ID=_ID) return output - resolve = __call__ - def store_properties( self: Feature, toggle: bool = True, recursive: bool = True, - ) -> None: + ) -> Feature: """Control whether to return an Image object. If selected `True`, the output of the evaluation of the feature is an @@ -551,9 +699,49 @@ def store_properties( Parameters ---------- toggle: bool - If `True`, store properties. If `False`, do not store. + If `True` (default), store properties. If `False`, do not store. recursive: bool - If `True`, also set the same behavior for all dependent features. + If `True` (default), also set the same behavior for all dependent + features. If `False`, it does not. + + Returns + ------- + Feature + self + + Examples + -------- + >>> import deeptrack as dt + + Create a feature and enable property storage: + >>> feature = dt.Add(value=2) + >>> feature.store_properties(True) + + Evaluate the feature and inspect the stored properties: + >>> import numpy as np + >>> + >>> output = feature(np.array([1, 2, 3])) + >>> isinstance(output, dt.Image) + True + >>> output.get_property("value") + 2 + + Disable property storage: + >>> feature.store_properties(False) + >>> output = feature(np.array([1, 2, 3])) + >>> isinstance(output, dt.Image) + False + + Apply recursively to a pipeline: + >>> feature1 = dt.Add(value=1) + >>> feature2 = dt.Multiply(value=2) + >>> pipeline = feature1 >> feature2 + >>> pipeline.store_properties(True, recursive=True) + >>> output = pipeline(np.array([1, 2])) + >>> output.get_property("value") + 1 + >>> output.get_property("value", get_one=False) + [1, 2] """ @@ -564,9 +752,11 @@ def store_properties( if isinstance(dependency, Feature): dependency.store_properties(toggle, recursive=False) + return self + def torch( self: Feature, - device: torch.device = None, + device: torch.device | None = None, recursive: bool = True, ) -> Feature: """Set the backend to torch. @@ -574,15 +764,58 @@ def torch( Parameters ---------- device: torch.device, optional - The target device of the output (e.g., cpu or cuda). + The target device of the output (e.g., cpu or cuda). It defaults to + `None`. recursive: bool, optional - If `True`, also convert all dependent features. + If `True` (default), it also convert all dependent features. If + `False`, it does not. Returns ------- Feature self + Examples + -------- + >>> import deeptrack as dt + >>> import torch + + Create a feature and switch to the PyTorch backend: + >>> feature = dt.Multiply(value=2) + >>> feature.torch() + + Call the feature on a torch tensor: + >>> input_tensor = torch.tensor([1.0, 2.0, 3.0]) + >>> output = feature(input_tensor) + >>> output + tensor([2., 4., 6.]) + + Switch to GPU if available (CUDA): + >>> if torch.cuda.is_available(): + ... device = torch.device("cuda") + ... feature.torch(device=device) + ... output = feature(torch.tensor([1.0, 2.0, 3.0], device=device)) + ... output.device.type + 'cuda' + + Switch to GPU if available (MPS): + >>> if (torch.backends.mps.is_available() + ... and torch.backends.mps.is_built()): + ... device = torch.device("mps") + ... feature.torch(device=device) + ... output = feature(torch.tensor([1.0, 2.0, 3.0], device=device)) + ... output.device.type + 'mps' + + Apply recursively in a pipeline: + >>> f1 = dt.Add(value=1) + >>> f2 = dt.Multiply(value=2) + >>> pipeline = f1 >> f2 + >>> pipeline.torch() + >>> output = pipeline(torch.tensor([1.0, 2.0])) + >>> output + tensor([4., 6.]) + """ self._backend = "torch" @@ -594,19 +827,45 @@ def torch( self.invalidate() return self - def numpy(self: Feature, recursive: bool = True) -> Feature: + def numpy( + self: Feature, + recursive: bool = True, + ) -> Feature: """Set the backend to numpy. Parameters ---------- recursive: bool, optional - If `True`, also convert all dependent features. + If `True` (default), also convert all dependent features. Returns ------- Feature self + Examples + -------- + >>> import deeptrack as dt + >>> import numpy as np + + Create a feature and ensure it uses the NumPy backend: + >>> feature = dt.Add(value=5) + >>> feature.numpy() + + Evaluate the feature on a NumPy array: + >>> output = feature(np.array([1, 2, 3])) + >>> output + array([6, 7, 8]) + + Apply recursively in a pipeline: + >>> f1 = dt.Multiply(value=2) + >>> f2 = dt.Subtract(value=1) + >>> pipeline = f1 >> f2 + >>> pipeline.numpy() + >>> output = pipeline(np.array([1, 2, 3])) + >>> output + array([1, 3, 5]) + """ self._backend = "numpy" @@ -623,22 +882,53 @@ def dtype( int: Literal["int16", "int32", "int64", "default"] | None = None, complex: Literal["complex64", "complex128", "default"] | None = None, bool: Literal["bool", "default"] | None = None, - ) -> None: + ) -> Feature: """Set the dtype to be used during evaluation. - This alters the dtype used for array creation, but does not - automatically cast the type. + It alters the dtype used for array creation, but does not automatically + cast the type. Parameters ---------- float: str, optional - The float dtype to set. + The float dtype to set. It can be `"float32"`, `"float64"`, + `"default"`, or `None`. It defaults to `None`. int: str, optional - The int dtype to set. + The int dtype to set. It can be `"int16"`, `"int32"`, `"int64"`, + `"default"`, or `None`. It defaults to `None`. complex: str, optional - The complex dtype to set. + The complex dtype to set. It can be `"complex64"`, `"complex128"`, + `"default"`, or `None`. It defaults to `None`. bool: str, optional - The bool dtype to set. + The bool dtype to set. It cna be `"bool"`, `"default"`, or `None`. + It defaults to `None`. + + Returns + ------- + Feature + self + + Examples + -------- + >>> import deeptrack as dt + + Set float and int data types for a feature: + >>> feature = dt.Multiply(value=2) + >>> feature.dtype(float="float32", int="int16") + >>> feature.float_dtype + dtype('float32') + >>> feature.int_dtype + dtype('int16') + + Use complex numbers in the feature: + >>> feature.dtype(complex="complex128") + >>> feature.complex_dtype + dtype('complex128') + + Reset float dtype to default: + >>> feature.dtype(float="default") + >>> feature.float_dtype # resolved from config + dtype('float64') # depending on backend config """ @@ -651,21 +941,59 @@ def dtype( if bool is not None: self._bool_dtype = bool - def to(self: Feature, device: str | torch.device): - """Set the device to be used during evaluation. + return self - If the backend is numpy, this can only be "cpu". + def to( + self: Feature, + device: str | torch.device, + ) -> Feature: + """Set the device to be used during evaluation. Parameters ---------- device: str or torch.device - The device to use. + The device to use. If the backend is numpy, this can only be "cpu". + + Returns + ------- + Feature + self + + Examples + -------- + >>> import deeptrack as dt + >>> import torch + + Create a feature and assign a device (for torch backend): + >>> feature = dt.Add(value=1) + >>> feature.torch() + >>> feature.to(torch.device("cpu")) + >>> feature.device + device(type='cpu') + + Move the feature to GPU (if available): + >>> if torch.cuda.is_available(): + ... feature.to(torch.device("cuda")) + ... feature.device + device(type='cuda') + + Use Apple MPS device on Apple Silicon (if supported): + >>> if (torch.backends.mps.is_available() + ... and torch.backends.mps.is_built()): + ... feature.to(torch.device("mps")) + ... feature.device + device(type='mps') """ self._device = device - def batch(self: Feature, batch_size: int = 32) -> tuple | list[Image]: + return self + + def batch( + self: Feature, + batch_size: int = 32, + ) -> tuple: """Batch the feature. This method produces a batch of outputs by repeatedly calling @@ -674,14 +1002,38 @@ def batch(self: Feature, batch_size: int = 32) -> tuple | list[Image]: Parameters ---------- batch_size: int - The number of times to sample or generate data. + The number of times to sample or generate data. It defaults to 32. Returns ------- - tuple or list of Images - A tuple of stacked arrays (if the outputs are NumPy arrays or - torch tensors) or a list of images if the outputs are not - stackable. + tuple + A tuple where each element corresponds to one component of the + output. If the outputs are NumPy arrays or PyTorch tensors, each + element is a stacked array. + + Examples + -------- + >>> import deeptrack as dt + + Define a feature that adds a random value to a fixed array: + >>> import numpy as np + >>> + >>> feature = ( + ... dt.Value(value=np.array([[-1, 1]])) + ... >> dt.Add(value=lambda: np.random.rand()) + ... ) + + Evaluate the feature once: + >>> output = feature() + >>> output + array([[-0.77378939, 1.22621061]]) + + Generate a batch of outputs: + >>> batch = feature.batch(batch_size=3) + >>> batch + (array([[-0.2375814 , 1.7624186 ], + [-0.65764878, 1.34235122], + [-0.87449525, 1.12550475]]),) """ @@ -689,35 +1041,91 @@ def batch(self: Feature, batch_size: int = 32) -> tuple | list[Image]: results = list(zip(*results)) for idx, r in enumerate(results): - - if isinstance(r[0], np.ndarray): - results[idx] = np.stack(r) - else: - import torch - - if isinstance(r[0], torch.Tensor): - results[idx] = torch.stack(r) + results[idx] = xp.stack(r) return tuple(results) def action( self: Feature, _ID: tuple[int, ...] = (), - ) -> Image | list[Image]: - """Core logic to create or transform the image. + ) -> Any | list[Any]: + """Core logic to create or transform the input. - This method creates or transforms the input image by calling the - `get()` method with the correct inputs. + This method is the central point where the feature's transformation is + actually executed. It retrieves the input data, evaluates the current + values of all properties, formats the input into a list of `Image` + objects, and applies the `get()` method to perform the desired + transformation. + Depending on the configuration, the transformation can be applied to + each element of the input independently or to the full list at once. + + The outputs are optionally post-processed, and then merged back into + the input according to the configured merge strategy. Parameters + + The behavior of this method is influenced by several class attributes: + + - `__distributed__`: If `True` (default), the `get()` method is applied + independently to each input in the input list. If `False`, the + `get()` method is applied to the entire list at once. + + - `__list_merge_strategy__`: Determines how the outputs returned by + `get()` are combined with the original inputs: + * `MERGE_STRATEGY_OVERRIDE` (default): The output replaces the + input. + * `MERGE_STRATEGY_APPEND`: The output is appended to the input + list. + + - `_wrap_array_with_image`: If `True`, input arrays are wrapped as + `Image` instances and their properties are preserved. Otherwise, + they are treated as raw arrays. + + - `_process_properties()`: This hook can be overridden to pre-process + properties before they are passed to `get()` (e.g., for unit + normalization). + + - `_process_output()`: Handles post-processing of the output images, + including appending feature properties and binding argument features. + ---------- - _ID: tuple of int - The unique identifier for the current execution. + _ID: tuple[int], optional + The unique identifier for the current execution. It defaults to (). Returns ------- - Image or list of Images - The resolved image or list of resolved images. + Any or list[Any] + The resolved output or list of resolved outputs. If only a single + output is generated, the result is unwrapped for convenience. + + Examples + -------- + >>> import deeptrack as dt + + Define a feature that adds a sampled value: + >>> import numpy as np + >>> + >>> feature = ( + ... dt.Value(value=np.array([1, 2, 3])) + ... >> dt.Add(value=0.5) + ... ) + + Execute core logic manually: + >>> output = feature.action() + >>> output + array([1.5, 2.5, 3.5]) + + Use a list of inputs: + >>> feature = ( + ... dt.Value(value=[ + ... np.array([1, 2, 3]), + ... np.array([4, 5, 6]), + ... ]) + ... >> dt.Add(value=0.5) + ... ) + >>> output = feature.action() + >>> output + [array([1.5, 2.5, 3.5]), array([4.5, 5.5, 6.5])] """ @@ -728,7 +1136,7 @@ def action( feature_input = self.properties(_ID=_ID).copy() # Call the _process_properties hook, default does nothing. - # For example, it can be used to ensure properties are formatted + # For example, it can be used to ensure properties are formatted # correctly or to rescale properties. feature_input = self._process_properties(feature_input) if _ID != (): @@ -758,6 +1166,7 @@ def action( else: return image_list + # **GV** def update( self: Feature, **global_arguments: Any, @@ -795,6 +1204,7 @@ def update( return self + # **GV** def add_feature( self: Feature, feature: Feature, @@ -818,6 +1228,7 @@ def add_feature( return feature + # **GV** def seed( self: Feature, _ID: tuple[int, ...] = (), @@ -833,68 +1244,45 @@ def seed( np.random.seed(self._random_seed(_ID=_ID)) + # **GV** def bind_arguments( self: Feature, arguments: Feature, ) -> Feature: - """Binds another feature’s properties as arguments to this feature. + """Bind another feature’s properties as arguments to this feature. This method allows properties of `arguments` to be dynamically linked - to this feature, enabling shared configurations across multiple features. - It is commonly used in advanced feature pipelines. + to this feature, enabling shared configurations across multiple + features. It is commonly used in advanced feature pipelines. - See Also - -------- - features.Arguments - A utility that helps manage and propagate feature arguments efficiently. + This method is often used in combination with the `Arguments` Feature, + which provides a utility that helps manage and propagate feature + arguments efficiently. Parameters ---------- arguments: Feature - The feature whose properties will be bound as arguments to this feature. + The feature whose properties will be bound as arguments to this + feature. Returns ------- Feature The current feature instance with bound arguments. - """ - - self.arguments = arguments - - return self - - def _normalize( - self: Feature, - **properties: dict[str, Any], - ) -> dict[str, Any]: - """Normalizes the properties. - - This method handles all unit normalizations and conversions. For each class in - the method resolution order (MRO), it checks if the class has a - `__conversion_table__` attribute. If found, it calls the `convert` method of - the conversion table using the properties as arguments. - Parameters - ---------- - **properties: dict of str to Any - The properties to be normalized and converted. + Examples + -------- + TODO method alone - Returns - ------- - dict of str to Any - The normalized and converted properties. + TODO use with Arguments """ - for cl in type(self).mro(): - if hasattr(cl, "__conversion_table__"): - properties = cl.__conversion_table__.convert(**properties) + self.arguments = arguments - for key, val in properties.items(): - if isinstance(val, Quantity): - properties[key] = val.magnitude - return properties + return self + # **GV** def plot( self: Feature, input_image: np.ndarray | list[np.ndarray] | Image | list[Image] = None, @@ -914,7 +1302,7 @@ def plot( Parameters ---------- - input_image: np.ndarray or list np.ndarray or Image or list of Image, optional + input_image: np.ndarray or Image or list[np.ndarray or Image], optional The input image or list of images passed as an argument to the `resolve` call. If `None`, uses previously set input values or propagates properties. resolve_kwargs: dict, optional @@ -945,59 +1333,100 @@ def plot( plt.imshow(output_image, **kwargs) return plt.gca() - else: - # Assume video - fig = plt.figure() - images = [] - plt.axis("off") - for image in output_image: - images.append([plt.imshow(image, **kwargs)]) + # Assume video + fig = plt.figure() + images = [] + plt.axis("off") + for image in output_image: + images.append([plt.imshow(image, **kwargs)]) + if not interval: + if isinstance(output_image[0], Image): + interval = output_image[0].get_property("interval") or (1 / 30 * 1000) + else: + interval = 1 / 30 * 1000 - if not interval: - if isinstance(output_image[0], Image): - interval = output_image[0].get_property("interval") or (1 / 30 * 1000) - else: - interval = (1 / 30 * 1000) + anim = animation.ArtistAnimation( + fig, images, interval=interval, blit=True, repeat_delay=0 + ) - anim = animation.ArtistAnimation( - fig, images, interval=interval, blit=True, repeat_delay=0 - ) + try: + get_ipython # Throws NameError if not in Notebook + display(HTML(anim.to_jshtml())) + return anim - try: - get_ipython # Throws NameError if not in Notebook - display(HTML(anim.to_jshtml())) - return anim + except NameError: + # Not in an notebook + plt.show() - except NameError: - # Not in an notebook + except RuntimeError: + # In notebook, but animation failed + import ipywidgets as widgets + + def plotter(frame=0): + plt.imshow(output_image[frame][:, :, 0], **kwargs) plt.show() - except RuntimeError: - # In notebook, but animation failed - import ipywidgets as widgets + return widgets.interact( + plotter, + frame=widgets.IntSlider( + value=0, min=0, max=len(images) - 1, step=1 + ), + ) + + # **GV** + def _normalize( + self: Feature, + **properties: dict[str, Any], + ) -> dict[str, Any]: + """Normalize the properties. + + This method handles all unit normalizations and conversions. For each + class in the method resolution order (MRO), it checks if the class has + a `__conversion_table__` attribute. If found, it calls the `convert` + method of the conversion table using the properties as arguments. - def plotter(frame=0): - plt.imshow(output_image[frame][:, :, 0], **kwargs) - plt.show() + Parameters + ---------- + **properties: dict[str, Any] + The properties to be normalized and converted. + + Returns + ------- + dict[str, Any] + The normalized and converted properties. + + Examples + -------- + TODO + + """ + + for cl in type(self).mro(): + if hasattr(cl, "__conversion_table__"): + properties = cl.__conversion_table__.convert(**properties) - return widgets.interact( - plotter, - frame=widgets.IntSlider( - value=0, min=0, max=len(images) - 1, step=1 - ), - ) + for key, val in properties.items(): + if isinstance(val, Quantity): + properties[key] = val.magnitude + return properties + # **GV** def _process_properties( self: Feature, propertydict: dict[str, Any], ) -> dict[str, Any]: - """Preprocesses the input properties before calling `.get()`. + """Preprocess the input properties before calling `.get()`. This method acts as a preprocessing hook for subclasses, allowing them to modify or normalize input properties before the feature's main computation. + Notes: + - Calls `_normalize()` internally to standardize input properties. + - Subclasses may override this method to implement additional + preprocessing steps. + Parameters ---------- propertydict: dict[str, Any] @@ -1009,17 +1438,17 @@ def _process_properties( dict[str, Any] The processed property dictionary after normalization. - Notes - ----- - - Calls `_normalize()` internally to standardize input properties. - - Subclasses may override this method to implement additional - preprocessing steps. - + Examples + -------- + TODO + """ propertydict = self._normalize(**propertydict) + return propertydict + # **GV** def _activate_sources( self: Feature, x: Any, @@ -1052,6 +1481,7 @@ def _activate_sources( if isinstance(source, SourceItem): source() + # **GV** def __getattr__( self: Feature, key: str, @@ -1118,31 +1548,156 @@ def __getattr__( def __iter__( self: Feature, - ) -> Iterable: - """ Returns an infinite iterator that continuously yields feature - values. + ) -> Feature: + """Return self as an iterator over feature values. + + This makes the `Feature` object compatible with Python's iterator + protocol. Each call to `next(feature)` generates a new output by + resampling its properties and resolving the pipeline. + + Returns + ------- + Feature + Returns self, which defines `__next__()` to yield outputs. + + Examples + -------- + >>> import deeptrack as dt + + Create feature: + >>> import numpy as np + >>> + >>> feature = dt.Value(value=lambda: np.random.rand()) + + Use the feature in a loop: + >>> for sample in feature: + ... print(sample) + ... if sample > 0.5: + ... break + 0.43126475134786546 + 0.3270413736199965 + 0.6734339603677173 """ - while True: - yield from next(self) + return self + + #TODO **BM** TBE? Previous implementation, not standard in Python + # while True: + # yield from next(self) def __next__( self: Feature, ) -> Any: - """Returns the next resolved feature in the sequence. - + """Return the next resolved feature in the sequence. + + This method allows a `Feature` to be used as an iterator that yields + a new result at each step. It is called automatically by `next(feature)` + or when used in iteration. + + Each call to `__next__()` triggers a resampling of all properties and + evaluation of the pipeline using `self.update().resolve()`. + + Returns + ------- + Any + A newly generated output from the feature. + + Examples + -------- + >>> import deeptrack as dt + + Create a feature: + >>> import numpy as np + >>> + >>> feature = dt.Value(value=lambda: np.random.rand()) + + Get a single sample: + >>> next(feature) + 0.41251758103924216 + """ - yield self.update().resolve() + return self.update().resolve() + + #TODO **BM** TBE? Previous implementation, not standard in Python + # yield self.update().resolve() def __rshift__( self: Feature, other: Any, ) -> Feature: """Chains this feature with another feature or function using '>>'. - - """ + + This operator enables pipeline-style chaining. The expression: + + >>> feature >> other + + creates a new pipeline where the output of `feature` is passed as + input to `other`. + + If `other` is a `Feature` or `DeepTrackNode`, this returns a + `Chain(feature, other)`. If `other` is a callable (e.g., a function), + it is wrapped using `dt.Lambda(lambda: other)` and chained + similarly. The lambda returns the function itself, which is then + automatically called with the upstream feature’s output during + evaluation. + + If `other` is neither a `DeepTrackNode` nor a callable, the operator + is not implemented and returns `NotImplemented`, which may lead to a + `TypeError` if no matching reverse operator is defined. + + Parameters + ---------- + other: Any + The feature, node, or callable to chain after `self`. + + Returns + ------- + Feature + A new chained feature combining `self` and `other`. + + Raises + ------ + TypeError + If `other` is not a `DeepTrackNode` or callable, the operator + returns `NotImplemented`, which may raise a `TypeError` if no + matching reverse operator is defined. + + Examples + -------- + >>> import deeptrack as dt + + Chain two features: + >>> feature1 = dt.Value(value=[1, 2, 3]) + >>> feature2 = dt.Add(value=1) + >>> pipeline = feature1 >> feature2 + >>> result = pipeline() + >>> result + [2, 3, 4] + + Chain with a callable (e.g., NumPy function): + >>> import numpy as np + >>> + >>> feature = dt.Value(value=np.array([1, 2, 3])) + >>> function = np.mean + >>> pipeline = feature >> function + >>> result = pipeline() + >>> result + 2.0 + + This is equivalent to: + >>> pipeline = feature >> dt.Lambda(lambda: function) + + The lambda returns the function object. During evaluation, DeepTrack + internally calls that function with the resolved output of `feature`. + + Attempting to chain with an unsupported object raises a TypeError: + >>> feature >> "invalid" + ... + TypeError: unsupported operand type(s) for >>: 'Value' and 'str' + + """ if isinstance(other, DeepTrackNode): return Chain(self, other) @@ -1159,8 +1714,77 @@ def __rrshift__( self: Feature, other: Any, ) -> Feature: - """Chains another feature or function with this feature using '<<'. - + """Chains another feature or value with this feature using '>>'. + + This operator supports chaining when the `Feature` appears on the + right-hand side of a pipeline. The expression: + + >>> other >> feature + + triggers `feature.__rrshift__(other)` if `other` does not implement + `__rshift__`, or if its implementation returns `NotImplemented`. + + If `other` is a `Feature`, this is equivalent to: + + >>> dt.Chain(other, feature) + + If `other` is a raw value (e.g., a list or array), it is wrapped using + `dt.Value(value=other)` before chaining: + + >>> dt.Chain(dt.Value(value=other), feature) + + Parameters + ---------- + other: Any + The value or feature to be evaluated before this feature. + + Returns + ------- + Feature + A new chained feature where `other` is evaluated first. + + Raises + ------ + TypeError + If `other` is not a supported type, this method returns + `NotImplemented`, which may raise a `TypeError` if no matching + forward operator is defined. + + Notes + ----- + This method enables chaining where a `Feature` appears on the + right-hand side of the `>>` operator. It is triggered when the + left-hand operand does not implement `__rshift__`, or when its + implementation returns `NotImplemented`. + + This is particularly useful when chaining two `Feature` instances or + when the left-hand operand is a custom class designed to delegate + chaining behavior. For example: + + >>> pipeline = dt.Value(value=[1, 2, 3]) >> dt.Add(value=1) + + In this case, if `dt.Value` does not handle `__rshift__`, Python will + fall back to calling `Add.__rrshift__(...)`, which constructs the + chain. + + However, this mechanism does **not** apply to built-in types like + `int`, `float`, or `list`. Due to limitations in Python's operator + overloading, expressions like: + + >>> 1 >> dt.Add(value=1) + >>> [1, 2, 3] >> dt.Add(value=1) + + will raise `TypeError`, because Python does not delegate to the + right-hand operand’s `__rrshift__` method for built-in types. + + To chain a raw value into a feature, wrap it explicitly using + `dt.Value`: + + >>> dt.Value(1) >> dt.Add(value=1) + + This is functionally equivalent and avoids the need for fallback + behavior. + """ if isinstance(other, Feature): @@ -1172,25 +1796,127 @@ def __rrshift__( return NotImplemented def __add__( - self: Feature, - other: Any + self: Feature, + other: Any, ) -> Feature: """Adds another value or feature using '+'. - + + This operator is shorthand for chaining with `dt.Add`. The expression: + + >>> feature + other + + is equivalent to: + + >>> feature >> dt.Add(value=other) + + Internally, this method constructs a new `Add` feature and uses the + right-shift operator (`>>`) to chain the current feature into it. + + Parameters + ---------- + other: Any + The value or `Feature` to be added. It is passed to `dt.Add` as + the `value` argument. + + Returns + ------- + Feature + A new feature that adds `other` to the output of `self`. + + Examples + -------- + >>> import deeptrack as dt + + Add a constant value to a static input: + >>> feature = dt.Value(value=[1, 2, 3]) + >>> pipeline = feature + 5 + >>> result = pipeline() + >>> result + [6, 7, 8] + + This is equivalent to: + >>> pipeline = f >> dt.Add(value=5) + + Add a dynamic feature that samples values at each call: + >>> import numpy as np + >>> + >>> noise = dt.Value(value=lambda: np.random.rand()) + >>> pipeline = feature + noise + >>> result = pipeline.update()() + >>> result + [1.325563919290048, 2.325563919290048, 3.325563919290048] + + This is equivalent to: + >>> pipeline = feature >> dt.Add(value=noise) + """ - + return self >> Add(other) def __radd__( - self: Feature, + self: Feature, other: Any ) -> Feature: """Adds this feature to another value using right '+'. - + + This operator is the right-hand version of `+`, enabling expressions + where the `Feature` appears on the right-hand side. The expression: + + >>> other + feature + + is equivalent to: + + >>> dt.Value(value=other) >> dt.Add(value=feature) + + Internally, this method constructs a `Value` feature from `other` and + chains it into an `Add` feature that adds the current feature as a + dynamic value. + + Parameters + ---------- + other: Any + A constant or `Feature` to which `self` will be added. It is + passed as the input to `Value`. + + Returns + ------- + Feature + A new feature that adds `self` to `other`. + + Examples + -------- + >>> import deeptrack as dt + + Add a feature to a constant: + >>> feature = dt.Value(value=[1, 2, 3]) + >>> pipeline = 5 + feature + >>> result = pipeline() + >>> result + [6, 7, 8] + + This is equivalent to: + >>> pipeline = dt.Value(value=5) >> dt.Add(value=feature) + + Add a feature to a dynamic value: + >>> import numpy as np + >>> + >>> noise = dt.Value(value=lambda: np.random.rand()) + >>> pipeline = noise + feature + >>> result = pipeline.update()() + >>> result + [1.5254613210875014, 2.5254613210875014, 3.5254613210875014] + + This is equivalent to: + >>> pipeline = ( + ... dt.Value(value=lambda: np.random.rand()) + ... >> dt.Add(value=feature) + ... ) + """ - + return Value(other) >> Add(self) + #TODO **MG** def __sub__( self: Feature, other: Any @@ -1198,9 +1924,10 @@ def __sub__( """Subtracts another value or feature using '-'. """ - + return self >> Subtract(other) + #TODO **MG** def __rsub__( self: Feature, other: Any @@ -1208,8 +1935,10 @@ def __rsub__( """Subtracts this feature from another value using right '-'. """ + return Value(other) >> Subtract(self) + #TODO **MG** def __mul__( self: Feature, other: Any @@ -1217,9 +1946,10 @@ def __mul__( """Multiplies this feature with another value using '*'. """ - + return self >> Multiply(other) + #TODO **MG** def __rmul__( self: Feature, other: Any @@ -1230,148 +1960,165 @@ def __rmul__( return Value(other) >> Multiply(self) + #TODO **AL** def __truediv__( self: Feature, other: Any ) -> Feature: """Divides this feature by another value using '/'. - + """ - + return self >> Divide(other) + #TODO **AL** def __rtruediv__( self: Feature, other: Any ) -> Feature: """Divides another value by this feature using right '/'. - + """ - + return Value(other) >> Divide(self) + #TODO **AL** def __floordiv__( self: Feature, other: Any ) -> Feature: """Performs floor division using '//'. - + """ - + return self >> FloorDivide(other) + #TODO **AL** def __rfloordiv__( self: Feature, other: Any ) -> Feature: """Performs right floor division using '//'. - + """ - + return Value(other) >> FloorDivide(self) + #TODO **JH** def __pow__( self: Feature, other: Any ) -> Feature: """Raises this feature to a power using '**'. - + """ - + return self >> Power(other) + #TODO **JH** def __rpow__( self: Feature, other: Any ) -> Feature: """Raises another value to this feature as a power using right '**'. - + """ - + return Value(other) >> Power(self) + #TODO **JH** def __gt__( - self: Feature, - other: Any + self: Feature, + other: Any, ) -> Feature: - """Checks if this feature is greater than another using '>'.""" + """Checks if this feature is greater than another using '>'. + + """ + return self >> GreaterThan(other) + #TODO **JH** def __rgt__( self: Feature, other: Any ) -> Feature: """Checks if another value is greater than this feature using right '>'. - + """ - + return Value(other) >> GreaterThan(self) + #TODO **JH** def __lt__( self: Feature, other: Any ) -> Feature: """Checks if this feature is less than another using '<'. - + """ - + return self >> LessThan(other) + #TODO **JH** def __rlt__( self: Feature, other: Any ) -> Feature: - """Checks if another value is less than this feature using - right '<'. - + """Checks if another value is less than this feature using right '<'. + """ return Value(other) >> LessThan(self) + #TODO **JH** def __le__( self: Feature, other: Any ) -> Feature: """Checks if this feature is less than or equal to another using '<='. - + """ - + return self >> LessThanOrEquals(other) + #TODO **JH** def __rle__( self: Feature, other: Any ) -> Feature: """Checks if another value is less than or equal to this feature using right '<='. - + """ - + return Value(other) >> LessThanOrEquals(self) + #TODO **JH** def __ge__( self: Feature, other: Any ) -> Feature: """Checks if this feature is greater than or equal to another using '>='. - + """ - + return self >> GreaterThanOrEquals(other) + #TODO **JH** def __rge__( self: Feature, other: Any ) -> Feature: """Checks if another value is greater than or equal to this feature using right '>='. - + """ return Value(other) >> GreaterThanOrEquals(self) + #TODO **JH** def __xor__( self: Feature, other: Any, @@ -1382,16 +2129,18 @@ def __xor__( return Repeat(self, other) + #TODO **JH** def __and__( self: Feature, other: Any, ) -> Feature: """Stacks this feature with another using '&'. - + """ return self >> Stack(other) + #TODO **JH** def __rand__( self: Feature, other: Any, @@ -1405,63 +2154,179 @@ def __rand__( def __getitem__( self: Feature, slices: Any, - ) -> 'Feature': + ) -> Feature: """Allows direct slicing of the feature's output. - + + This operator enables syntax like: + + >>> feature[:, 0] + + to extract a slice from the output of the feature, just as one would + with a NumPy array or PyTorch tensor. + + Internally, this is equivalent to chaining with `dt.Slice`, and the + expression: + + >>> feature[slices] + + is equivalent to: + + >>> feature >> dt.Slice(slices) + + If the slice is not already a tuple (i.e., a single index or slice), + it is wrapped in one. The resulting tuple is converted to a list to + allow sampling of dynamic slices at runtime. + + Parameters + ---------- + slices: Any + The slice or index to apply to the feature output. Can be an int, + slice object, or a tuple of them. + + Returns + ------- + Feature + A new feature that applies slicing to the output of the current + feature. + + Examples + -------- + >>> import deeptrack as dt + + Create a feature: + >>> import numpy as np + >>> + >>> feature = dt.Value(value=np.arange(9).reshape(3, 3)) + >>> feature() + array([[0, 1, 2], + [3, 4, 5], + [6, 7, 8]]) + + Slice a row: + >>> sliced = feature[1] + >>> sliced() + array([3, 4, 5]) + + This is equivalent to: + >>> sliced = feature >> dt.Slice([1]) + + Slice with multiple axes: + >>> sliced = feature[1:, 1:] + >>> sliced() + array([[4, 5], + [7, 8]]) + + This is equivalent to: + >>> sliced = feature >> dt.Slice([slice(1, None), slice(1, None)]) + """ - + if not isinstance(slices, tuple): slices = (slices,) - # We make it a list to ensure that each element is sampled - # independently. + # Make it a list to ensure that each element is sampled independently. slices = list(slices) return self >> Slice(slices) - # private properties to dispatch based on config + # Private properties to dispatch based on config. @property - def _format_input(self): - """Selects the appropriate input formatting function based on - configuration. - + def _format_input(self: Feature) -> Callable[[Any], list[Any or Image]]: + """Select the appropriate input formatting function for configuration. + + Returns either `_image_wrapped_format_input` or + `_no_wrap_format_input`, depending on whether image metadata + (properties) should be preserved and processed downstream. + + This selection is controlled by the `_wrap_array_with_image` flag. + + Returns + ------- + Callable + A function that formats the input into a list of Image objects or + raw arrays, depending on the configuration. + """ if self._wrap_array_with_image: return self._image_wrapped_format_input - else: - return self._no_wrap_format_input + + return self._no_wrap_format_input @property - def _process_and_get(self): - """Selects the appropriate processing function based on configuration. - + def _process_and_get(self: Feature) -> Callable[[Any], list[Any or Image]]: + """Select the appropriate processing function based on configuration. + + Returns a method that applies the feature’s transformation (`get`) to + the input data, either with or without wrapping and preserving `Image` + metadata. + + The decision is based on the `_wrap_array_with_image` flag: + - If `True`, returns `_image_wrapped_process_and_get` + - If `False`, returns `_no_wrap_process_and_get` + + Returns + ------- + Callable + A function that applies `.get()` to the input, either preserving + or ignoring metadata depending on configuration. + """ if self._wrap_array_with_image: return self._image_wrapped_process_and_get - else: - return self._no_wrap_process_and_get + + return self._no_wrap_process_and_get @property - def _process_output(self): - """Selects the appropriate output processing function based on - configuration. - + def _process_output(self: Feature) -> Callable[[Any], None]: + """Select the appropriate output processing function for configuration. + + Returns a method that post-processes the outputs of the feature, + typically after the `get()` method has been called. The selected method + depends on whether the feature is configured to wrap outputs in `Image` + objects (`_wrap_array_with_image = True`). + + - If `True`, returns `_image_wrapped_process_output`, which appends + feature properties to each `Image`. + - If `False`, returns `_no_wrap_process_output`, which extracts raw + array values from any `Image` instances. + + Returns + ------- + Callable + A post-processing function for the feature output. + """ if self._wrap_array_with_image: return self._image_wrapped_process_output - else: - return self._no_wrap_process_output + + return self._no_wrap_process_output def _image_wrapped_format_input( self: Feature, - image_list: np.ndarray | list[np.ndarray] | Image | list[Image], - **kwargs: dict[str, Any], + image_list: np.ndarray | list[np.ndarray] | Image | list[Image] | None, + **kwargs: Any, ) -> list[Image]: - """Wraps input data as Image instances before processing. - + """Wrap input data as Image instances before processing. + + This method ensures that all elements in the input are `Image` + objects. If any raw arrays are provided, they are wrapped in `Image`. + This allows features to propagate metadata and store properties in the + output. + + Parameters + ---------- + image_list: np.ndarray or list[np.ndarray] or Image or list[Image] or None + The input to the feature. If not a list, it is converted into a + single-element list. If `None`, it returns an empty list. + + Returns + ------- + list[Image] + A list where all items are instances of `Image`. + """ if image_list is None: @@ -1473,12 +2338,26 @@ def _image_wrapped_format_input( return [(Image(image)) for image in image_list] def _no_wrap_format_input( - self: Feature, - image_list: np.ndarray | list[np.ndarray] | Image | list[Image], - **kwargs: dict[str, Any], - ) -> list[Image]: - """Processes input data without wrapping it as Image instances. - + self: Feature, + image_list: Any, + **kwargs: Any, + ) -> list[Any]: + """Process input data without wrapping it as Image instances. + + This method returns the input list as-is (after ensuring it is a list). + It is used when metadata is not needed or performance is a concern. + + Parameters + ---------- + image_list: Any + The input to the feature. If not already a list, it is wrapped in + one. If `None`, it returns an empty list. + + Returns + ------- + list[Any] + A list of raw input elements, without any transformation. + """ if image_list is None: @@ -1489,42 +2368,38 @@ def _no_wrap_format_input( return image_list - def _no_wrap_process_and_get( + def _image_wrapped_process_and_get( self: Feature, - image_list: np.ndarray | list[np.ndarray] | Image | list[Image], + image_list: Image | list[Image] | Any | list[Any], **feature_input: dict[str, Any], ) -> list[Image]: - """Processes input data without additional wrapping and retrieves - results. - - """ + """Processes input data while maintaining Image properties. - if self.__distributed__: - # Call get on each image in list, and merge properties from - # corresponding image - return [self.get(x, **feature_input) for x in image_list] + This method applies the `get()` method to the input while ensuring that + output values are wrapped as `Image` instances and preserve the + properties of the corresponding input images. - else: - # Call get on entire list. - new_list = self.get(image_list, **feature_input) + If `__distributed__ = True`, `get()` is called separately for each + input image. If `False`, the full list is passed to `get()` at once. - if not isinstance(new_list, list): - new_list = [new_list] + Parameters + ---------- + image_list: Image or list[Image] or Any or list[Any] + The input data to be processed. + **feature_input: dict[str, Any] + The keyword arguments containing the sampled properties to pass + to the `get()` method. - return new_list + Returns + ------- + list[Image] + The list of processed images, with properties preserved. - def _image_wrapped_process_and_get( - self: Feature, - image_list: np.ndarray | list[np.ndarray] | Image | list[Image], - **feature_input: dict[str, Any], - ) -> list[Image]: - """Processes input data while maintaining Image properties. - """ if self.__distributed__: - # Call get on each image in list, and merge properties from - # corresponding image + # Call get on each image in list, and merge properties from + # corresponding image. results = [] @@ -1538,45 +2413,108 @@ def _image_wrapped_process_and_get( return results - else: - # Call get on entire list. - new_list = self.get(image_list, **feature_input) + # ELse, call get on entire list. + new_list = self.get(image_list, **feature_input) + + if not isinstance(new_list, list): + new_list = [new_list] - if not isinstance(new_list, list): - new_list = [new_list] + for idx, image in enumerate(new_list): + if not isinstance(image, Image): + new_list[idx] = Image(image) + return new_list + + def _no_wrap_process_and_get( + self: Feature, + image_list: Any | list[Any], + **feature_input: dict[str, Any], + ) -> list[Any]: + """Process input data without additional wrapping and retrieve results. - for idx, image in enumerate(new_list): - if not isinstance(image, Image): - new_list[idx] = Image(image) - return new_list + This method applies the `get()` method to the input without wrapping + results in `Image` objects, and without propagating or merging metadata. + + If `__distributed__ = True`, `get()` is called separately for each + element in the input list. If `False`, the full list is passed to + `get()` at once. + + Parameters + ---------- + image_list: Any or list[Any] + The input data to be processed. + **feature_input: dict + The keyword arguments containing the sampled properties to pass + to the `get()` method. + + Returns + ------- + list[Any] + The list of processed outputs (raw arrays, tensors, etc.). + + """ + + if self.__distributed__: + # Call get on each image in list, and merge properties from + # corresponding image + + return [self.get(x, **feature_input) for x in image_list] + + # Else, call get on entire list. + new_list = self.get(image_list, **feature_input) + + if not isinstance(new_list, list): + new_list = [new_list] + + return new_list def _image_wrapped_process_output( self: Feature, - image_list: np.ndarray | list[np.ndarray] | Image | list[Image], + image_list: Image | list[Image] | Any | list[Any], feature_input: dict[str, Any], ) -> None: - """Appends feature properties and input data to each Image. - + """Append feature properties and input data to each Image. + + This method is called after `get()` when the feature is set to wrap + its outputs in `Image` instances. It appends the sampled properties + (from `feature_input`) to the metadata of each `Image`. If the feature + is bound to an `arguments` object, those properties are also appended. + + Parameters + ---------- + image_list: list[Image] + The output images from the feature. + feature_input: dict[str, Any] + The resolved property values used during this evaluation. + """ for index, image in enumerate(image_list): - if self.arguments: image.append(self.arguments.properties()) - image.append(feature_input) def _no_wrap_process_output( self: Feature, - image_list: np.ndarray | list[np.ndarray] | Image | list[Image], + image_list: Any | list[Any], feature_input: dict[str, Any], ) -> None: - """Extracts and updates raw values from Image instances. - + """Extract and update raw values from Image instances. + + This method is called after `get()` when the feature is not configured + to wrap outputs as `Image` instances. If any `Image` objects are + present in the output list, their underlying array values are extracted + using `.value` (i.e., `image._value`). + + Parameters + ---------- + image_list: list[Any] + The list of outputs returned by the feature. + feature_input: dict[str, Any] + The resolved property values used during this evaluation (unused). + """ for index, image in enumerate(image_list): - if isinstance(image, Image): image_list[index] = image._value @@ -1627,44 +2565,46 @@ def propagate_data_to_dependencies(feature: Feature, **kwargs: dict[str, Any]) - class StructuralFeature(Feature): - """Provides the structure of a feature set without input transformations. + """Provide the structure of a feature set without input transformations. - A `StructuralFeature` does not directly transform the input data or add new - properties. Instead, it is commonly used as a logical or organizational - tool to structure and manage feature sets within a pipeline. + A `StructuralFeature` does not modify the input data or introduce new + properties. Instead, it serves as a logical and organizational tool for + grouping, chaining, or structuring pipelines. - Since `StructuralFeature` does not override the `__init__` or `get` - methods, it inherits the behavior of the base `Feature` class. + This feature is typically used to: + - group or chain sub-features (e.g., `Chain`) + - apply conditional or sequential logic (e.g., `Probability`) + - organize pipelines without affecting data flow (e.g., `Combine`) + + `StructuralFeature` inherits all behavior from `Feature`, without + overriding `__init__` or `get`. Attributes ---------- - __property_verbosity__: int - Controls whether this feature’s properties are included in the output - image’s property list. A value of `2` means that this feature’s - properties are not included. - __distributed__: bool - Determines whether the feature’s `get` method is applied to each - element in the input list (`__distributed__ = True`) or to the entire - list as a whole (`__distributed__ = False`). - - Notes - ----- - Structural features are typically used for tasks like grouping or chaining - features, applying sequential or conditional logic, or structuring - pipelines without directly modifying the data. + __property_verbosity__ : int + Controls whether this feature's properties appear in the output image's + property list. A value of `2` hides them from output. + __distributed__ : bool + If `True`, applies `get` to each element in a list individually. + If `False`, processes the entire list as a single unit. It defaults to + `False`. """ - __property_verbosity__: int = 2 # Hide properties from logs or output. - __distributed__: bool = False # Process the entire image list in one call. + __property_verbosity__: int = 2 # Hide properties from logs or output + __distributed__: bool = False # Process the entire image list in one call class Chain(StructuralFeature): """Resolve two features sequentially. - This feature applies two features sequentially, passing the output of the - first feature as the input to the second. It enables building feature - chains that execute complex transformations by combining simple operations. + Applies two features sequentially: the output of `feature_1` is passed as + input to `feature_2`. This allows combining simple operations into complex + pipelines. + + This is equivalent to using the `>>` operator: + + >>> dt.Chain(A, B) ≡ A >> B Parameters ---------- @@ -1673,54 +2613,46 @@ class Chain(StructuralFeature): feature_2: Feature The second feature in the chain, which processes the output from `feature_1`. - **kwargs: dict of str to Any, optional + **kwargs: Any, optional Additional keyword arguments passed to the parent `StructuralFeature` (and, therefore, `Feature`). Methods ------- - `get(image: np.ndarray | list[np.ndarray] | Image | list[Image], _ID: tuple[int, ...], **kwargs: dict[str, Any]) -> Image | list[Image]` + `get(image: Any, _ID: tuple[int, ...], **kwargs: Any) -> Any` Apply the two features in sequence on the given input image. - Notes - ----- - This feature is used to combine simple operations into a pipeline without the - need for explicit function chaining. It is syntactic sugar for creating - sequential feature pipelines. - Examples -------- >>> import deeptrack as dt - >>> import numpy as np Create a feature chain where the first feature adds a constant offset, and the second feature multiplies the result by a constant: - >>> A = dt.Add(value=10) >>> M = dt.Multiply(value=0.5) - - Chain the features: - >>> chain = A >> M + >>> + >>> chain = A >> M Equivalent to: >>> chain = dt.Chain(A, M) Create a dummy image: - >>> dummy_image = np.ones((2, 4)) + >>> import numpy as np + >>> + >>> dummy_image = np.zeros((2, 4)) Apply the chained features: - >>> transformed_image = chain(dummy_image) - >>> print(transformed_image) - [[5.5 5.5 5.5 5.5] - [5.5 5.5 5.5 5.5]] + >>> chain(dummy_image) + array([[5., 5., 5., 5.], + [5., 5., 5., 5.]]) """ def __init__( - self: Feature, + self: Chain, feature_1: Feature, feature_2: Feature, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the chain with two sub-features. @@ -1734,10 +2666,10 @@ def __init__( feature_1: Feature The first feature to be applied. feature_2: Feature - The second feature, applied after `feature_1`. - **kwargs: dict of str to Any, optional - Additional keyword arguments passed to the parent constructor (e.g., - name, properties). + The second feature, applied to the result of `feature_1`. + **kwargs: Any + Additional keyword arguments passed to the parent constructor + (e.g., name, properties). """ @@ -1748,33 +2680,33 @@ def __init__( def get( self: Feature, - image: np.ndarray | list[np.ndarray] | Image | list[Image], + image: Any, _ID: tuple[int, ...] = (), - **kwargs: dict[str, Any], - ) -> Image | list[Image]: + **kwargs: Any, + ) -> Any: """Apply the two features sequentially to the given input image(s). - This method first applies `feature_1` to the input image(s) and then passes - the output through `feature_2`. + This method first applies `feature_1` to the input image(s) and then + passes the output through `feature_2`. Parameters ---------- - image: np.ndarray or list np.ndarray or Image or list of Image - The input data, which can be an `Image` or a list of `Image` objects, - to transform sequentially. - _ID: tuple of int, optional - A unique identifier for caching or parallel execution. Defaults to an - empty tuple. - **kwargs: dict of str to Any - Additional parameters passed to or sampled by the features. These are - generally unused here, as each sub-feature fetches its required properties - internally. + image: Any + The input data to transform sequentially. Most typically, this is + a NumPy array, a PyTorch tensor, or an Image. + _ID: tuple[int, ...], optional + A unique identifier for caching or parallel execution. It defaults + to an empty tuple. + **kwargs: Any + Additional parameters passed to or sampled by the features. These + are generally unused here, as each sub-feature fetches its required + properties internally. Returns ------- - Image or list of Images - The final output after `feature_1` and then `feature_2` have processed - the input. + Any + The final output after `feature_1` and then `feature_2` have + processed the input. """ @@ -1790,27 +2722,26 @@ class DummyFeature(Feature): """A no-op feature that simply returns the input unchanged. This class can serve as a container for properties that don't directly - transform the data but need to be logically grouped. Since it inherits - transform the data but need to be logically grouped. Since it inherits - from `Feature`, any keyword arguments passed to the constructor are - stored as `Property` instances in `self.properties`, enabling dynamic - behavior or parameterization without performing any transformations - on the input data. + transform the data but need to be logically grouped. + + Since it inherits from `Feature`, any keyword arguments passed to the + constructor are stored as `Property` instances in `self.properties`, + enabling dynamic behavior or parameterization without performing any + transformations on the input data. Parameters ---------- - _input: np.ndarray or list np.ndarray or Image or list of Images, optional - An optional input (image or list of images) that can be set for - the feature. By default, an empty list. - **kwargs: dict of str to Any + _input: Any, optional + An optional input (typically an image or list of images) that can be + set for the feature. It defaults to an empty list []. + **kwargs: Any Additional keyword arguments are wrapped as `Property` instances and stored in `self.properties`. Methods ------- - `get(image: np.ndarray | list np.ndarray | Image | list[Image], **kwargs: dict[str, Any]) -> Image | list[Image]` - Simply returns the input image(s) unchanged. - + `get(image: Any, **kwargs: Any) -> Any` + It simply returns the input image(s) unchanged. Examples -------- @@ -1828,30 +2759,31 @@ class DummyFeature(Feature): >>> output_image = dummy_feature(dummy_image) Verify the output is identical to the input: - >>> print(np.array_equal(dummy_image, output_image)) + >>> np.array_equal(dummy_image, output_image) True Access the properties stored in DummyFeature: - >>> print(dummy_feature.properties["value"]()) + >>> dummy_feature.properties["value"]() 42 """ def get( - self: Feature, - image: np.ndarray | list[np.ndarray] | Image | list[Image], + self: DummyFeature, + image: Any, **kwargs: Any, - )-> Image | list[Image]: + ) -> Any: """Return the input image or list of images unchanged. - This method simply returns the input without applying any transformation. + This method simply returns the input without any transformation. It adheres to the `Feature` interface by accepting additional keyword - arguments for consistency, although they are not used in this method. + arguments for consistency, although they are not used. Parameters ---------- - image: np.ndarray or list np.ndarray or Image or list of Image - The image or list of images to pass through without modification. + image: Any + The input (typically an image or list of images) to pass through + without modification. **kwargs: Any Additional properties sampled from `self.properties` or passed externally. These are unused here but provided for consistency @@ -1859,8 +2791,9 @@ def get( Returns ------- - Image or list of Images - The same `image` object that was passed in. + Any + The same input that was passed in (typically an image or list of + images). """ @@ -1871,18 +2804,18 @@ class Value(Feature): """Represents a constant (per evaluation) value in a DeepTrack pipeline. This feature holds a constant value (e.g., a scalar or array) and supplies - it on demand to other parts of the pipeline. It does not transform the - input image but instead returns the stored value. + it on demand to other parts of the pipeline. + + Wen called with an image, it does not transform the input image but instead + returns the stored value. Parameters ---------- - value: PropertyLike[float], optional - The numerical value to store. Defaults to 0. If an `Image` is provided, - a warning is issued recommending conversion to a NumPy array for - The numerical value to store. Defaults to 0. If an `Image` is provided, - a warning is issued recommending conversion to a NumPy array for - performance reasons. - **kwargs: dict of str to Any + value: PropertyLike[float or array], optional + The numerical value to store. It defaults to 0. + If an `Image` is provided, a warning is issued recommending conversion + to a NumPy array or a PyTorch tensor for performance reasons. + **kwargs: Any Additional named properties passed to the `Feature` constructor. Attributes @@ -1894,45 +2827,68 @@ class Value(Feature): Methods ------- - `get(image: Any, value: float, **kwargs: dict[str, Any]) -> float` + `get(image: Any, value: float, **kwargs: Any) -> float or array` Returns the stored value, ignoring the input image. - Examples -------- >>> import deeptrack as dt Initialize a constant value and retrieve it: >>> value = dt.Value(42) - >>> print(value()) + >>> value() 42 Override the value at call time: - >>> print(value(value=100)) + >>> value(value=100) 100 + Initialize a constant array value and retrieve it: + >>> import numpy as np + >>> + >>> arr_value = dt.Value(np.arange(4)) + >>> arr_value() + array([0, 1, 2, 3]) + + Override the array value at call time: + >>> arr_value(value=np.array([10, 20, 30, 40])) + array([10, 20, 30, 40]) + + Initialize a constant PyTorch tensor value and retrieve it: + >>> import torch + >>> + >>> tensor_value = dt.Value(torch.tensor([1., 2., 3.])) + >>> tensor_value() + tensor([1., 2., 3.]) + + Override the tensor value at call time: + >>> tensor_value(value=torch.tensor([10., 20., 30.])) + tensor([10., 20., 30.]) + """ __distributed__: bool = False # Process as a single batch. def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any] + self: Value, + value: PropertyLike[float | ArrayLike] = 0, + **kwargs: Any, ): """Initialize the `Value` feature to store a constant value. This feature holds a constant numerical value and provides it to the - pipeline as needed. If an `Image` object is supplied, a warning is - issued to encourage converting it to a NumPy array for performance + pipeline as needed. + + If an `Image` object is supplied, a warning is issued to encourage + converting it to a NumPy array or a PyTorch tensor for performance optimization. Parameters ---------- - value: PropertyLike[float], optional + value: PropertyLike[float or array], optional The initial value to store. If an `Image` is provided, a warning is - raised. Defaults to 0. - **kwargs: dict of str to Any + raised. It defaults to 0. + **kwargs: Any Additional keyword arguments passed to the `Feature` constructor, such as custom properties or the feature name. @@ -1941,19 +2897,21 @@ def __init__( if isinstance(value, Image): import warnings warnings.warn( - "Setting dt.Value value as an Image object is likely to lead " - "to performance deterioration. Consider converting it to a " - "numpy array using np.array." + "Passing an Image object as the value to dt.Value may lead to " + "performance deterioration. Consider converting the Image to " + "a NumPy array with np.array(image), or to a PyTorch tensor " + "with torch.tensor(np.array(image)).", + DeprecationWarning, ) super().__init__(value=value, **kwargs) def get( - self: Feature, - image: Any, - value: float, - **kwargs: dict[str, Any] - ) -> float: + self: Value, + image: Any, + value: float | ArrayLike[Any], + **kwargs: Any, + ) -> float | ArrayLike[Any]: """Return the stored value, ignoring the input image. The `get` method simply returns the stored numerical value, allowing @@ -1964,16 +2922,16 @@ def get( image: Any Input data typically processed by features. For `Value`, this is ignored and does not affect the output. - value: float + value: float or array The current value to return. This may be the initial value or an overridden value supplied during the method call. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments, which are ignored but included for consistency with the feature interface. Returns ------- - float + float or array The stored or overridden `value`, returned unchanged. """ @@ -1986,7 +2944,10 @@ class ArithmeticOperationFeature(Feature): This feature performs an arithmetic operation (e.g., addition, subtraction, multiplication) on the input data. The inputs can be single values or lists - of values. If a list is passed, the operation is applied to each element. + of values. + + If a list is passed, the operation is applied to each element. + If both inputs are lists of different lengths, the shorter list is cycled. Parameters @@ -1994,10 +2955,10 @@ class ArithmeticOperationFeature(Feature): op: Callable[[Any, Any], Any] The arithmetic operation to apply, such as a built-in operator (`operator.add`, `operator.mul`) or a custom callable. - value: float or int or list of float or int, optional - The second operand for the operation. Defaults to 0. If a list is + value: float or int or list[float or int], optional + The second operand for the operation. It defaults to 0. If a list is provided, the operation will apply element-wise. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature`. Attributes @@ -2005,12 +2966,10 @@ class ArithmeticOperationFeature(Feature): __distributed__: bool Indicates that this feature’s `get(...)` method processes the input as a whole (`False`) rather than distributing calls for individual items. - __gpu_compatible__: bool - Specifies that the feature is compatible with GPU processing (`True`). Methods ------- - `get(image: Any | list of Any, value: float | int | list[float] | int, **kwargs: dict[str, Any]) -> list[Any]` + `get(image: Any, value: float or int or list[float or int], **kwargs: Any) -> list[Any]` Apply the arithmetic operation element-wise to the input data. Examples @@ -2032,58 +2991,64 @@ class ArithmeticOperationFeature(Feature): """ __distributed__: bool = False - __gpu_compatible__: bool = True - def __init__( - self: Feature, + self: ArithmeticOperationFeature, op: Callable[[Any, Any], Any], - value: float | int | list[float | int] = 0, - **kwargs: dict[str, Any], + value: PropertyLike[ + float + | int + | ArrayLike + | list[float | int | ArrayLike] + ] = 0, + **kwargs: Any, ): """Initialize the ArithmeticOperationFeature. Parameters ---------- op: Callable[[Any, Any], Any] - The arithmetic operation to apply, such as `operator.add`, `operator.mul`, - or any custom callable that takes two arguments. - value: float or int or list of float or int, optional + The arithmetic operation to apply, such as `operator.add`, + `operator.mul`, or any custom callable that takes two arguments and + returns a single output value. + value: PropertyLike[float or int or array or list[float or int or array]], optional The second operand(s) for the operation. If a list is provided, the - operation is applied element-wise. Defaults to 0. - **kwargs: dict of str to Any - Additional keyword arguments passed to the parent `Feature` constructor. + operation is applied element-wise. It defaults to 0. + **kwargs: Any + Additional keyword arguments passed to the parent `Feature` + constructor. """ super().__init__(value=value, **kwargs) + self.op = op - def get( - self: Feature, - image: Any | list[Any], - value: float | int | list[float | int], + def get( + self: ArithmeticOperationFeature, + image: Any, + value: float | int | ArrayLike | list[float | int | ArrayLike], **kwargs: Any, ) -> list[Any]: """Apply the operation element-wise to the input data. Parameters ---------- - image: Any or list of Any + image: Any or list[Any] The input data, either a single value or a list of values, to be transformed by the arithmetic operation. - value: float, int, or list of float or int + value: float or int or array or list[float or int or array] The second operand(s) for the operation. If a single value is provided, it is broadcast to match the input size. If a list is provided, it will be cycled to match the length of the input list. - **kwargs: dict of str to Any + **kwargs: Any Additional parameters or property overrides. These are generally unused in this context but provided for compatibility with the `Feature` interface. Returns ------- - list of Any + list[Any] A list containing the results of applying the operation to the input data element-wise. @@ -2110,9 +3075,9 @@ class Add(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to add to the input. Defaults to 0. - **kwargs: dict of str to Any + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to add to the input. It defaults to 0. + **kwargs: Any Additional keyword arguments passed to the parent constructor. Examples @@ -2126,29 +3091,40 @@ class Add(ArithmeticOperationFeature): Alternatively, the pipeline can be created using operator overloading: >>> pipeline = dt.Value([1, 2, 3]) + 5 + >>> pipeline.resolve() + [6, 7, 8] Or: >>> pipeline = 5 + dt.Value([1, 2, 3]) + >>> pipeline.resolve() + [6, 7, 8] Or, more explicitly: >>> input_value = dt.Value([1, 2, 3]) >>> sum_feature = dt.Add(value=5) >>> pipeline = sum_feature(input_value) + >>> pipeline.resolve() + [6, 7, 8] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: Add, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the Add feature. Parameters ---------- - value: PropertyLike[float], optional - The value to add to the input. Defaults to 0. - **kwargs: dict of str to Any + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to add to the input. It defaults to 0. + **kwargs: Any Additional keyword arguments passed to the parent `Feature`. """ @@ -2163,9 +3139,9 @@ class Subtract(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to subtract from the input. Defaults to 0. - **kwargs: dict of str to Any + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to subtract from the input. It defaults to 0. + **kwargs: Any Additional keyword arguments passed to the parent constructor. Examples @@ -2179,29 +3155,40 @@ class Subtract(ArithmeticOperationFeature): Alternatively, the pipeline can be created using operator overloading: >>> pipeline = dt.Value([1, 2, 3]) - 2 + >>> pipeline.resolve() + [-1, 0, 1] Or: >>> pipeline = -2 + dt.Value([1, 2, 3]) + >>> pipeline.resolve() + [-1, 0, 1] Or, more explicitly: >>> input_value = dt.Value([1, 2, 3]) >>> sub_feature = dt.Subtract(value=2) >>> pipeline = sub_feature(input_value) + >>> pipeline.resolve() + [-1, 0, 1] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: Subtract, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the Subtract feature. Parameters ---------- - value: PropertyLike[float], optional - The value to subtract from the input. Defaults to 0. - **kwargs: dict of str to Any + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to subtract from the input. it defaults to 0. + **kwargs: Any Additional keyword arguments passed to the parent `Feature`. """ @@ -2216,8 +3203,8 @@ class Multiply(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to multiply the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to multiply the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2232,28 +3219,39 @@ class Multiply(ArithmeticOperationFeature): Alternatively, this pipeline can be created using: >>> pipeline = dt.Value([1, 2, 3]) * 5 + >>> pipeline.resolve() + [5, 10, 15] Or: >>> pipeline = 5 * dt.Value([1, 2, 3]) + >>> pipeline.resolve() + [5, 10, 15] Or, more explicitly: >>> input_value = dt.Value([1, 2, 3]) >>> mul_feature = dt.Multiply(value=5) >>> pipeline = mul_feature(input_value) + >>> pipeline.resolve() + [5, 10, 15] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: Multiply, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the Multiply feature. Parameters ---------- - value: PropertyLike[float], optional - The value to multiply the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to multiply the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2269,8 +3267,8 @@ class Divide(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to divide the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to divide the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2279,34 +3277,45 @@ class Divide(ArithmeticOperationFeature): >>> import deeptrack as dt Start by creating a pipeline using `Divide`: - >>> pipeline = Value([1, 2, 3]) >> Divide(value=5) + >>> pipeline = dt.Value([1, 2, 3]) >> dt.Divide(value=5) >>> pipeline.resolve() [0.2 0.4 0.6] Equivalently, this pipeline can be created using: - >>> pipeline = Value([1, 2, 3]) / 5 + >>> pipeline = dt.Value([1, 2, 3]) / 5 + >>> pipeline.resolve() + [0.2 0.4 0.6] Which is not equivalent to: - >>> pipeline = 5 / Value([1, 2, 3]) # Different result. + >>> pipeline = 5 / dt.Value([1, 2, 3]) # Different result + >>> pipeline.resolve() + [5.0, 2.5, 1.6666666666666667] Or, more explicitly: - >>> input_value = Value([1, 2, 3]) - >>> truediv_feature = Divide(value=5) + >>> input_value = dt.Value([1, 2, 3]) + >>> truediv_feature = dt.Divide(value=5) >>> pipeline = truediv_feature(input_value) + >>> pipeline.resolve() + [0.2 0.4 0.6] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: Divide, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the Divide feature. Parameters ---------- - value: PropertyLike[float], optional - The value to divide the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to divide the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2326,8 +3335,8 @@ class FloorDivide(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to floor-divide the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to floor-divide the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2338,32 +3347,43 @@ class FloorDivide(ArithmeticOperationFeature): Start by creating a pipeline using `FloorDivide`: >>> pipeline = dt.Value([-3, 3, 6]) >> dt.FloorDivide(value=5) >>> pipeline.resolve() - [0.2 0.4 0.6] + [-1, 0, 1] Equivalently, this pipeline can be created using: >>> pipeline = dt.Value([-3, 3, 6]) // 5 + >>> pipeline.resolve() + [-1, 0, 1] Which is not equivalent to: - >>> pipeline = 5 // dt.Value([-3, 3, 6]) # Different result. + >>> pipeline = 5 // dt.Value([-3, 3, 6]) # Different result + >>> pipeline.resolve() + [-2, 1, 0] Or, more explicitly: >>> input_value = dt.Value([-3, 3, 6]) >>> floordiv_feature = dt.FloorDivide(value=5) - >>> pipeline = feature(floordiv_input_value) + >>> pipeline = floordiv_feature(input_value) + >>> pipeline.resolve() + [-1, 0, 1] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: FloorDivide, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the FloorDivide feature. Parameters ---------- - value: PropertyLike[float], optional - The value to fllor-divide the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to fllor-divide the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2379,8 +3399,8 @@ class Power(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to take the power of the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to take the power of the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2395,28 +3415,39 @@ class Power(ArithmeticOperationFeature): Equivalently, this pipeline can be created using: >>> pipeline = dt.Value([1, 2, 3]) ** 3 + >>> pipeline.resolve() + [1, 8, 27] Which is not equivalent to: - >>> pipeline = 3 ** dt.Value([1, 2, 3]) # Different result. + >>> pipeline = 3 ** dt.Value([1, 2, 3]) # Different result + >>> pipeline.resolve() + [3, 9, 27] Or, more explicitly: >>> input_value = dt.Value([1, 2, 3]) - >>> pow_feature = Power(value=3) + >>> pow_feature = dt.Power(value=3) >>> pipeline = pow_feature(input_value) + >>> pipeline.resolve() + [1, 8, 27] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: Power, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the Power feature. Parameters ---------- - value: PropertyLike[float], optional - The value to take the power of the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to take the power of the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2432,8 +3463,8 @@ class LessThan(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to compare (<) with the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to compare (<) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2444,32 +3475,43 @@ class LessThan(ArithmeticOperationFeature): Start by creating a pipeline using `LessThan`: >>> pipeline = dt.Value([1, 2, 3]) >> dt.LessThan(value=2) >>> pipeline.resolve() - [True False False] + [True, False, False] Equivalently, this pipeline can be created using: >>> pipeline = dt.Value([1, 2, 3]) < 2 + >>> pipeline.resolve() + [True, False, False] Which is not equivalent to: - >>> pipeline = 2 < dt.Value([1, 2, 3]) # Different result. + >>> pipeline = 2 < dt.Value([1, 2, 3]) # Different result + >>> pipeline.resolve() + [False, False, True] Or, more explicitly: >>> input_value = dt.Value([1, 2, 3]) >>> lt_feature = dt.LessThan(value=2) >>> pipeline = lt_feature(input_value) + >>> pipeline.resolve() + [True, False, False] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: LessThan, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the LessThan feature. Parameters ---------- - value: PropertyLike[float], optional - The value to compare (<) with the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to compare (<) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2485,8 +3527,8 @@ class LessThanOrEquals(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to compare (<=) with the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to compare (<=) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2497,32 +3539,43 @@ class LessThanOrEquals(ArithmeticOperationFeature): Start by creating a pipeline using `LessThanOrEquals`: >>> pipeline = dt.Value([1, 2, 3]) >> dt.LessThanOrEquals(value=2) >>> pipeline.resolve() - [True True False] + [True, True, False] Equivalently, this pipeline can be created using: >>> pipeline = dt.Value([1, 2, 3]) <= 2 + >>> pipeline.resolve() + [True, True, False] Which is not equivalent to: - >>> pipeline = 2 <= dt.Value([1, 2, 3]) # Different result. + >>> pipeline = 2 <= dt.Value([1, 2, 3]) # Different result + >>> pipeline.resolve() + [False, True, True] Or, more explicitly: >>> input_value = dt.Value([1, 2, 3]) >>> le_feature = dt.LessThanOrEquals(value=2) >>> pipeline = le_feature(input_value) + >>> pipeline.resolve() + [True, True, False] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: LessThanOrEquals, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the LessThanOrEquals feature. Parameters ---------- - value: PropertyLike[float], optional - The value to compare (<=) with the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to compare (<=) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2541,8 +3594,8 @@ class GreaterThan(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to compare (>) with the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to compare (>) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2553,32 +3606,43 @@ class GreaterThan(ArithmeticOperationFeature): Start by creating a pipeline using `GreaterThan`: >>> pipeline = dt.Value([1, 2, 3]) >> dt.GreaterThan(value=2) >>> pipeline.resolve() - [False False True] + [False, False, True] Equivalently, this pipeline can be created using: >>> pipeline = dt.Value([1, 2, 3]) > 2 + >>> pipeline.resolve() + [False, False, True] Which is not equivalent to: - >>> pipeline = 2 > dt.Value([1, 2, 3]) # Different result. + >>> pipeline = 2 > dt.Value([1, 2, 3]) # Different result + >>> pipeline.resolve() + [True, False, False] Or, most explicitly: >>> input_value = dt.Value([1, 2, 3]) >>> gt_feature = dt.GreaterThan(value=2) >>> pipeline = gt_feature(input_value) + >>> pipeline.resolve() + [False, False, True] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: GreaterThan, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the GreaterThan feature. Parameters ---------- - value: PropertyLike[float], optional - The value to compare (>) with the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to compare (>) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2594,8 +3658,8 @@ class GreaterThanOrEquals(ArithmeticOperationFeature): Parameters ---------- - value: PropertyLike[int or float], optional - The value to compare (<=) with the input. Defaults to 0. + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to compare (<=) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments passed to the parent constructor. @@ -2606,32 +3670,43 @@ class GreaterThanOrEquals(ArithmeticOperationFeature): Start by creating a pipeline using `GreaterThanOrEquals`: >>> pipeline = dt.Value([1, 2, 3]) >> dt.GreaterThanOrEquals(value=2) >>> pipeline.resolve() - [False True True] + [False, True, True] Equivalently, this pipeline can be created using: >>> pipeline = dt.Value([1, 2, 3]) >= 2 + >>> pipeline.resolve() + [False, True, True] Which is not equivalent to: - >>> pipeline = 2 >= dt.Value([1, 2, 3]) # Different result. + >>> pipeline = 2 >= dt.Value([1, 2, 3]) # Different result + >>> pipeline.resolve() + [True, True, False] Or, more explicitly: >>> input_value = dt.Value([1, 2, 3]) >>> ge_feature = dt.GreaterThanOrEquals(value=2) >>> pipeline = ge_feature(input_value) + >>> pipeline.resolve() + [False, True, True] """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: GreaterThanOrEquals, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the GreaterThanOrEquals feature. Parameters ---------- - value: PropertyLike[float], optional - The value to compare (>=) with the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to compare (>=) with the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2646,16 +3721,9 @@ def __init__( class Equals(ArithmeticOperationFeature): """Determine whether input is equal to a given value. - This feature performs element-wise comparison (==) between the input and a + This feature performs element-wise comparison between the input and a specified value. - Parameters - ---------- - value: PropertyLike[int or float], optional - The value to compare (==) with the input. Defaults to 0. - **kwargs: Any - Additional keyword arguments passed to the parent constructor. - Notes ----- - Unlike other arithmetic operators, `Equals` does not define `__eq__` @@ -2665,6 +3733,13 @@ class Equals(ArithmeticOperationFeature): expressions involving `Feature` instances but not for comparisons involving regular Python objects. - Always use `>>` to apply `Equals` correctly in a feature chain. + + Parameters + ---------- + value: PropertyLike[int or float or array or list[int or floar or array]], optional + The value to compare (==) with the input. It defaults to 0. + **kwargs: Any + Additional keyword arguments passed to the parent constructor. Examples -------- @@ -2673,11 +3748,19 @@ class Equals(ArithmeticOperationFeature): Start by creating a pipeline using `Equals`: >>> pipeline = dt.Value([1, 2, 3]) >> dt.Equals(value=2) >>> pipeline.resolve() - [False True False] + [False, True, False] + + Or: + >>> input_values = [1, 2, 3] + >>> eq_feature = dt.Equals(value=2) + >>> output_values = eq_feature(input_values) + >>> print(output_values) + [False, True, False] + + These are the **only correct ways** to apply `Equals` in a pipeline. - This is the **only correct way** to apply `Equals` in a feature pipeline. + The following approaches are **incorrect**: - ### Incorrect Approaches Using `==` directly on a `Feature` instance **does not work** because `Feature` does not override `__eq__`: >>> pipeline = dt.Value([1, 2, 3]) == 2 # Incorrect @@ -2693,16 +3776,21 @@ class Equals(ArithmeticOperationFeature): """ def __init__( - self: Feature, - value: PropertyLike[float] = 0, - **kwargs: dict[str, Any], + self: Equals, + value: PropertyLike[ + float + | int + | ArrayLike[Any] + | list[float | int | ArrayLike[Any]] + ] = 0, + **kwargs: Any, ): """Initialize the Equals feature. Parameters ---------- - value: PropertyLike[float], optional - The value to compare (==) with the input. Defaults to 0. + value: PropertyLike[float or int or array or list[float or int or array]], optional + The value to compare with the input. It defaults to 0. **kwargs: Any Additional keyword arguments. @@ -2725,7 +3813,7 @@ class Stack(Feature): it is automatically converted into a list to maintain consistency in the output format. - If B is a feature, `Stack` can be visualized as:: + If B is a feature, `Stack` can be visualized as: >>> A >> Stack(B) = [*A(), *B()] @@ -2733,7 +3821,7 @@ class Stack(Feature): ---------- value: PropertyLike[Any] The feature or data to stack with the input. - **kwargs: dict of str to Any + **kwargs: Any Additional arguments passed to the parent `Feature` class. Attributes @@ -2744,7 +3832,7 @@ class Stack(Feature): Methods ------- - `get(image: Any, value: Any, **kwargs: dict[str, Any]) -> list[Any]` + `get(image: Any, value: Any, **kwargs: Any) -> list[Any]` Concatenate the input with the value. Examples @@ -2753,23 +3841,44 @@ class Stack(Feature): Start by creating a pipeline using `Stack`: >>> pipeline = dt.Value([1, 2, 3]) >> dt.Stack(value=[4, 5]) - >>> print(pipeline.resolve()) + >>> pipeline.resolve() [1, 2, 3, 4, 5] Equivalently, this pipeline can be created using: >>> pipeline = dt.Value([1, 2, 3]) & [4, 5] + >>> pipeline.resolve() + [1, 2, 3, 4, 5] Or: - >>> pipeline = [4, 5] & dt.Value([1, 2, 3]) # Different result. + >>> pipeline = [4, 5] & dt.Value([1, 2, 3]) # Different result + >>> pipeline.resolve() + [4, 5, 1, 2, 3] + + Note + ---- + If a feature is called directly, its result is cached internally. This can + affect how it behaves when reused in chained pipelines. For exmaple: + >>> stack_feature = dt.Stack(value=2) + >>> _ = stack_feature(1) # Evaluate the feature and cache the output + >>> (1 & stack_feature)() + [1, 1, 2] + + To ensure consistent behavior when reusing a feature after calling it, + reset its state using instead: + >>> stack_feature = dt.Stack(value=2) + >>> _ = stack_feature(1) + >>> stack_feature.update() # clear cached state + >>> (1 & stack_feature)() + [1, 2] """ __distributed__: bool = False def __init__( - self: Feature, + self: Stack, value: PropertyLike[Any], - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the Stack feature. @@ -2777,7 +3886,7 @@ def __init__( ---------- value: PropertyLike[Any] The feature or data to stack with the input. - **kwargs: dict of str to Any + **kwargs: Any Additional arguments passed to the parent `Feature` class. """ @@ -2785,10 +3894,10 @@ def __init__( super().__init__(value=value, **kwargs) def get( - self: Feature, + self: Stack, image: Any | list[Any], value: Any | list[Any], - **kwargs: dict[str, Any], + **kwargs: Any, ) -> list[Any]: """Concatenate the input with the value. @@ -2802,7 +3911,7 @@ def get( value: Any or list[Any] The feature or data to stack with the input. Can be a single element or a list. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments (not used here). Returns @@ -2827,114 +3936,116 @@ def get( class Arguments(Feature): """A convenience container for pipeline arguments. - The `Arguments` feature allows dynamic control of pipeline behavior by - providing a container for arguments that can be modified or overridden at - runtime. This is particularly useful when working with parameterized - pipelines, such as toggling behaviors based on whether an image is a label + The `Arguments` feature allows dynamic control of pipeline behavior by + providing a container for arguments that can be modified or overridden at + runtime. This is particularly useful when working with parametrized + pipelines, such as toggling behaviors based on whether an image is a label or a raw input. Methods ------- - `get(image: Any, **kwargs: dict[str, Any]) -> Any` - Passes the input image through unchanged, while allowing for property - overrides. + `get(image: Any, **kwargs: Any) -> Any` + It passes the input image through unchanged, while allowing for + property overrides. Examples -------- >>> import deeptrack as dt - >>> from tempfile import NamedTemporaryFile - >>> from PIL import Image as PIL_Image - >>> import os - Create a temporary image: + Create a temporary image file: + >>> import numpy as np + >>> import PIL, tempfile + >>> >>> test_image_array = (np.ones((50, 50)) * 128).astype(np.uint8) - >>> temp_png = NamedTemporaryFile(suffix=".png", delete=False) - >>> PIL_Image.fromarray(test_image_array).save(temp_png.name) + >>> temp_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + >>> PIL.Image.fromarray(test_image_array).save(temp_png.name) A typical use-case is: >>> arguments = dt.Arguments(is_label=False) >>> image_pipeline = ( - ... dt.LoadImage(path=temp_png.name) >> - ... dt.Gaussian(sigma = (1 - arguments.is_label) * 5) + ... dt.LoadImage(path=temp_png.name) + ... >> dt.Gaussian(sigma=arguments.is_label) # Image with no noise ... ) >>> image_pipeline.bind_arguments(arguments) - - >>> image = image_pipeline() # Image with added noise. - >>> print(image.std()) - 5.041072178933536 + >>> + >>> image = image_pipeline() + >>> image.std() + 0.0 Change the argument: - >>> image = image_pipeline(is_label=True) # Image with no noise. - >>> print(image.std()) - 0.0 + >>> image = image_pipeline(is_label=True) # Image with added noise + >>> image.std() + 1.0104364326447652 Remove the temporary image: + >>> import os + >>> >>> os.remove(temp_png.name) For a non-mathematical dependence, create a local link to the property as follows: >>> arguments = dt.Arguments(is_label=False) >>> image_pipeline = ( - ... dt.LoadImage(path=temp_png.name) >> - ... dt.Gaussian( - ... is_label=arguments.is_label, - ... sigma=lambda is_label: 0 if is_label else 5 + ... dt.LoadImage(path=temp_png.name) + ... >> dt.Gaussian( + ... local_is_label=arguments.is_label, + ... sigma=lambda local_is_label: 1 if local_is_label else 0, ... ) ... ) >>> image_pipeline.bind_arguments(arguments) - Keep in mind that, if any dependent property is non-deterministic, they may + Keep in mind that, if any dependent property is non-deterministic, it may permanently change: - >>> arguments = dt.Arguments(noise_max_sigma=5) + >>> arguments = dt.Arguments(noise_max=1) >>> image_pipeline = ( - ... dt.LoadImage(path=temp_png.name) >> - ... dt.Gaussian( - ... noise_max_sigma=arguments.noise_max_sigma, - ... sigma=lambda noise_max_sigma: np.random.rand()*noise_max_sigma + ... dt.LoadImage(path=temp_png.name) + ... >> dt.Gaussian( + ... noise_max=arguments.noise_max, + ... sigma=lambda noise_max: np.random.rand() * noise_max, ... ) ... ) >>> image_pipeline.bind_arguments(arguments) - >>> image_pipeline.store_properties() - + >>> image_pipeline.store_properties() # Store image properties + >>> >>> image = image_pipeline() - >>> print(image.get_property("sigma")) - 1.1838819055669947 + >>> image.std(), image.get_property("sigma") + (0.8464173007136401, 0.8423390304699889) - >>> image = image_pipeline(noise_max_sigma=0) - >>> print(image.get_property("sigma")) - 0.0 + >>> image = image_pipeline(noise_max=0) + >>> image.std(), image.get_property("sigma") + (0.0, 0.0) As with any feature, all arguments can be passed by deconstructing the properties dict: >>> arguments = dt.Arguments(is_label=False, noise_sigma=5) >>> image_pipeline = ( - ... dt.LoadImage(path=temp_png.name) >> - ... dt.Gaussian( + ... dt.LoadImage(path=temp_png.name) + ... >> dt.Gaussian( ... sigma=lambda is_label, noise_sigma: ( ... 0 if is_label else noise_sigma - ... ) - ... **arguments.properties + ... ), + ... **arguments.properties, ... ) ... ) >>> image_pipeline.bind_arguments(arguments) - - >>> image = image_pipeline() # Image with added noise. - >>> print(image.std()) + >>> + >>> image = image_pipeline() # Image with added noise + >>> image.std() 5.002151761964336 - >>> image = image_pipeline(is_label=True) # Raw image with no noise. - >>> print(image.std()) + >>> image = image_pipeline(is_label=True) # Raw image with no noise + >>> image.std() 0.0 """ def get( - self: Feature, + self: Arguments, image: Any, - **kwargs: dict[str, Any] + **kwargs: Any, ) -> Any: - """Process the input image and allow property overrides. + """Return the input image and allow property overrides. This method does not modify the input image but provides a mechanism for overriding arguments dynamically during pipeline execution. @@ -2959,10 +4070,12 @@ def get( class Probability(StructuralFeature): """Resolve a feature with a certain probability. - This feature conditionally applies a given feature to an input image based - on a specified probability. A random number is sampled, and if it is less - than `probability`, the feature is resolved; otherwise, the input image - remains unchanged. + This feature conditionally applies a given feature to an input based on a + sampled uniform random number. If the sampled number is less than the + specified probability, the feature is resolved; otherwise, the input is + returned unchanged. + + To resample the decision, call `.update()` before evaluating the feature. Parameters ---------- @@ -2970,103 +4083,123 @@ class Probability(StructuralFeature): The feature to resolve conditionally. probability: PropertyLike[float] The probability (between 0 and 1) of resolving the feature. - *args: list[Any], optional + *args: Any Positional arguments passed to the parent `StructuralFeature` class. - **kwargs: dict of str to Any, optional + **kwargs: Any Additional keyword arguments passed to the parent `StructuralFeature` class. Methods ------- - `get(image: np.ndarray, probability: float, random_number: float, **kwargs: dict[str, Any]) -> np.ndarray` + `get(image: Any, probability: float, random_number: float, **kwargs: Any) -> Any` Resolves the feature if the sampled random number is less than the specified probability. Examples -------- >>> import deeptrack as dt - >>> import numpy as np - In this example, the `Add` feature is applied to the input image with - a 70% chance. Define a feature and wrap it with `Probability`: + In this example, the `Add` feature is applied to the input image with a 70% + chance. + + Define a feature and wrap it with `Probability`: >>> add_feature = dt.Add(value=2) >>> probabilistic_feature = dt.Probability(add_feature, probability=0.7) Define an input image: - >>> input_image = np.ones((5, 5)) + >>> import numpy as np + >>> + >>> input_image = np.zeros((2, 3)) Apply the feature: + >>> probabilistic_feature.update() # Update the random number >>> output_image = probabilistic_feature(input_image) + With 70% probability, the output is: + >>> output_image + array([[2., 2., 2.], + [2., 2., 2.]]) + + With 30% probability, it remains: + >>> output_image + array([[0., 0., 0.], + [0., 0., 0.]]) + """ def __init__( - self: Feature, + self: Probability, feature: Feature, probability: PropertyLike[float], - *args: list[Any], - **kwargs: dict[str, Any], + *args: Any, + **kwargs: Any, ): """Initialize the Probability feature. + The random number is initialized when this feature is initialized. + It can be updated using the `update()` method. + Parameters ---------- feature: Feature The feature to resolve conditionally. probability: PropertyLike[float] The probability (between 0 and 1) of resolving the feature. - *args: list[Any], optional - Positional arguments passed to the parent `StructuralFeature` class. - **kwargs: dict of str to Any, optional - Additional keyword arguments passed to the parent `StructuralFeature` class. + *args: Any + Positional arguments passed to the parent `StructuralFeature` + class. + **kwargs: Any + Additional keyword arguments passed to the parent + `StructuralFeature` class. """ - + super().__init__( - *args, - probability=probability, - random_number=np.random.rand, + *args, + probability=probability, + random_number=np.random.rand, **kwargs, ) - self.feature = self.add_feature(feature) + self.feature = self.add_feature(feature) def get( - self: Feature, - image: np.ndarray, + self: Probability, + image: Any, probability: float, random_number: float, - **kwargs: dict[str, Any], - ) -> np.ndarray: - """Resolve the feature if a random number is less than the probability. + **kwargs: Any, + ) -> Any: + """Resolve the feature if random number is less than probability. Parameters ---------- - image: np.ndarray - The input image to process. + image: Any or list[Any] + The input to process. probability: float The probability (between 0 and 1) of resolving the feature. random_number: float - A random number sampled to determine whether to resolve the - feature. - **kwargs: dict of str to Any - Additional arguments passed to the feature's `resolve` method. + A random number sampled to determine whether to resolve the + feature. It is initialized when this feature is initialized. + It can be updated using the `update()` method. + **kwargs: Any + Additional arguments passed to the feature's `resolve()` method. Returns ------- - np.ndarray - The processed image. If the feature is resolved, this is the output of the feature; - otherwise, it is the unchanged input image. + Any + The processed image. If the feature is resolved, this is the output + of the feature; otherwise, it is the unchanged input image. """ - + if random_number < probability: image = self.feature.resolve(image, **kwargs) return image -class Repeat(Feature): - """Applies a feature multiple times in sequence. +class Repeat(StructuralFeature): + """Apply a feature multiple times in sequence. The `Repeat` feature iteratively applies another feature, passing the output of each iteration as the input to the next. This enables chained @@ -3074,9 +4207,12 @@ class Repeat(Feature): number of repetitions is defined by `N`. Each iteration operates with its own set of properties, and the index of - the current iteration is accessible via `_ID` or `replicate_index`. - `_ID` is extended to include the current iteration index, ensuring - deterministic behavior when needed. + the current iteration is accessible via `_ID`. `_ID` is extended to include + the current iteration index, ensuring deterministic behavior when needed. + + This is equivalent to using the `^` operator: + + >>> dt.Repeat(A, 3) ≡ A ^ 3 Parameters ---------- @@ -3084,19 +4220,13 @@ class Repeat(Feature): The feature to be repeated. N: int The number of times to apply the feature in sequence. - **kwargs: dict of str to Any - - Attributes - ---------- - __distributed__: bool - Always `False` for `Repeat`, since it processes sequentially rather - than distributing computation across inputs. + **kwargs: Any Methods ------- - `get(image: Any, N: int, _ID: tuple[int, ...], **kwargs: dict[str, Any]) -> Any` - Applies the feature `N` times in sequence, passing the output of each - iteration as the input to the next. + `get(image: Any, N: int, _ID: tuple[int, ...], **kwargs: Any) -> Any` + It applies the feature `N` times in sequence, passing the output of + each iteration as the input to the next. Examples -------- @@ -3105,32 +4235,25 @@ class Repeat(Feature): Define an `Add` feature that adds `10` to its input: >>> add_ten = dt.Add(value=10) - Apply this feature **3 times** using `Repeat`: + Apply this feature 3 times using `Repeat`: >>> pipeline = dt.Repeat(add_ten, N=3) Process an input list: - >>> print(pipeline.resolve([1, 2, 3])) + >>> pipeline.resolve([1, 2, 3]) [31, 32, 33] - Step-by-step breakdown: - - Iteration 1: `[1, 2, 3] + 10 → [11, 12, 13]` - - Iteration 2: `[11, 12, 13] + 10 → [21, 22, 23]` - - Iteration 3: `[21, 22, 23] + 10 → [31, 32, 33]` - Alternative shorthand using `^` operator: >>> pipeline = dt.Add(value=10) ^ 3 - >>> print(pipeline.resolve([1, 2, 3])) + >>> pipeline.resolve([1, 2, 3]) [31, 32, 33] """ - __distributed__: bool = False - def __init__( - self: Feature, - feature: Feature, - N: int, - **kwargs: dict[str, Any], + self: Repeat, + feature: Feature, + N: int, + **kwargs: Any, ): """Initialize the Repeat feature. @@ -3146,7 +4269,7 @@ def __init__( N: int The number of times to sequentially apply `feature`, passing the output of each iteration as the input to the next. - **kwargs: dict of str to Any + **kwargs: Any Keyword arguments that override properties dynamically at each iteration and are also passed to the parent `Feature` class. @@ -3156,18 +4279,22 @@ def __init__( self.feature = self.add_feature(feature) def get( - self: Feature, + self: Repeat, image: Any, N: int, _ID: tuple[int, ...] = (), - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Any: - """Sequentially apply the feature `N` times. + """Sequentially apply the feature N times. This method applies the feature `N` times, passing the output of each iteration as the input to the next. The `_ID` tuple is updated at each iteration, ensuring dynamic property updates and reproducibility. + Each iteration uses the output of the previous one. This makes `Repeat` + suitable for building recursive, cumulative, or progressive + transformations. + Parameters ---------- image: Any @@ -3178,7 +4305,7 @@ def get( _ID: tuple[int, ...], optional A unique identifier for tracking the iteration index, ensuring reproducibility, caching, and dynamic property updates. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the feature. Returns @@ -3188,15 +4315,18 @@ def get( of the feature. """ - + + if not isinstance(N, int) or N < 0: + raise ValueError("N must be a non-negative integer.") + for n in range(N): - index = _ID + (n,) # Track iteration index. + index = _ID + (n,) # Track iteration index image = self.feature( image, _ID=index, - replicate_index=index, # Pass replicate_index for legacy. + replicate_index=index, # Legacy property ) return image @@ -3205,80 +4335,88 @@ def get( class Combine(StructuralFeature): """Combine multiple features into a single feature. - This feature sequentially resolves a list of features and returns their - results as a list. Each feature in the `features` parameter operates on - the same input, and their outputs are aggregated into a single list. + This feature applies a list of features to the same input and returns their + outputs as a list. It is useful for computing multiple parallel outputs + from the same data (e.g., branches in a feature graph). Parameters ---------- - features: list of Features - A list of features to combine. Each feature will be resolved in the - order they appear in the list. - **kwargs: dict of str to Any, optional + features: list[Feature] + A list of features to combine. Each feature will be applied in order, + and their outputs collected into a list. + **kwargs: Any Additional keyword arguments passed to the parent `StructuralFeature` class. Methods ------- - `get(image_list: Any, **kwargs: dict[str, Any]) -> list[Any]` + `get(image: Any, **kwargs: Any) -> list[Any]` Resolves each feature in the `features` list on the input image and returns their results as a list. Examples -------- >>> import deeptrack as dt - >>> import numpy as np - Define a list of features to combine `GaussianBlur` and `Add`: - >>> blur_feature = dt.GaussianBlur(sigma=2) - >>> add_feature = dt.Add(value=10) + Define a list of features: + >>> add_1 = dt.Add(value=1) + >>> add_2 = dt.Add(value=2) + >>> add_3 = dt.Add(value=3) Combine the features: - >>> combined_feature = dt.Combine([blur_feature, add_feature]) + >>> combined_feature = dt.Combine([add_1, add_2, add_3]) Define an input image: - >>> input_image = np.ones((10, 10)) + >>> import numpy as np + >>> + >>> input_image = np.zeros((2, 3)) Apply the combined feature: >>> output_list = combined_feature(input_image) + >>> output_list + [array([[1., 1., 1.], + [1., 1., 1.]]), + array([[2., 2., 2.], + [2., 2., 2.]]), + array([[3., 3., 3.], + [3., 3., 3.]])] """ - __distributed__: bool = False - def __init__( - self: Feature, - features: list[Feature], - **kwargs: dict[str, Any] + self: Combine, + features: list[Feature], + **kwargs: Any, ): """Initialize the Combine feature. Parameters ---------- - features: list of Features + features: list[Feature] A list of features to combine. Each feature is added as a dependency to ensure proper execution in the computation graph. - **kwargs: dict of str to Any, optional + **kwargs: Any Additional keyword arguments passed to the parent `StructuralFeature` class. """ super().__init__(**kwargs) + self.features = [self.add_feature(f) for f in features] def get( - self: Feature, - image_list: Any, - **kwargs: dict[str, Any] + self: Combine, + image: Any, + **kwargs: Any, ) -> list[Any]: """Resolve each feature in the `features` list on the input image. Parameters ---------- - image_list: Any + image: Any The input image or list of images to process. - **kwargs: dict of str to Any + **kwargs: Any Additional arguments passed to each feature's `resolve` method. Returns @@ -3288,49 +4426,60 @@ def get( """ - return [f(image_list, **kwargs) for f in self.features] + return [f(image, **kwargs) for f in self.features] class Slice(Feature): - """Dynamically applies array indexing to input Image(s). - - This feature allows **dynamic slicing** of an image using integer indices, - slice objects, or ellipses (`...`). While normal array indexing is preferred - for static cases, `Slice` is useful when the slicing parameters **must be - computed dynamically** based on other properties. + """Dynamically applies array indexing to inputs. + + This feature allows dynamic slicing of an image using integer indices, + slice objects, or ellipses (`...`). + + While normal array indexing is preferred for static cases, `Slice` is + useful when the slicing parameters must be computed dynamically based on + other properties. Parameters ---------- - slices: Iterable[int | slice | ...] + slices: tuple[int or slice or ellipsis] or list[int or slice or ellipsis] The slicing instructions for each dimension. Each element corresponds to a dimension in the input image. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Methods ------- - `get(image: np.ndarray, slices: tuple[int | slice | ...], **kwargs: dict[str, Any]) -> np.ndarray` + `get(image: array or list[array], slices: Iterable[int or slice or ellipsis], **kwargs: Any) -> array or list[array]` Applies the specified slices to the input image. Examples -------- >>> import deeptrack as dt - >>> import numpy as np - **Recommended Approach: Use Normal Indexing for Static Slicing** + Recommended approach: Use normal indexing for static slicing: + >>> import numpy as np + >>> >>> feature = dt.DummyFeature() - >>> static_slicing = feature[:, 1:2, ::-2] + >>> static_slicing = feature[0:2, ::2, :] >>> result = static_slicing.resolve(np.arange(27).reshape((3, 3, 3))) - >>> print(result) - - **Using `Slice` for Dynamic Slicing (when necessary)** - If slices depend on computed properties, use `Slice`: + >>> result + array([[[ 0, 1, 2], + [ 6, 7, 8]], + [[ 9, 10, 11], + [15, 16, 17]]]) + + Using `Slice` for dynamic slicing (when necessary when slices depend on + computed properties): >>> feature = dt.DummyFeature() >>> dynamic_slicing = feature >> dt.Slice( - ... slices=(slice(None), slice(1, 2), slice(None, None, -2)) + ... slices=(slice(0, 2), slice(None, None, 2), slice(None)) ... ) >>> result = dynamic_slicing.resolve(np.arange(27).reshape((3, 3, 3))) - >>> print(result) + >>> result + array([[[ 0, 1, 2], + [ 6, 7, 8]], + [[ 9, 10, 11], + [15, 16, 17]]]) In both cases, slices can be defined dynamically based on feature properties. @@ -3338,22 +4487,18 @@ class Slice(Feature): """ def __init__( - self: Feature, - slices: PropertyLike[ - Iterable[ - PropertyLike[int] | PropertyLike[slice] | PropertyLike[...] - ] - ], - **kwargs: dict[str, Any], + self: Slice, + slices: PropertyLike[Iterable[int | slice | Ellipsis]], + **kwargs: Any, ): """Initialize the Slice feature. Parameters ---------- - slices: list[int | slice | ...] or tuple[int | slice | ...] + slices: Iterable[int or slice or ellipsis] The slicing instructions for each dimension, specified as a list or tuple of integers, slice objects, or ellipses (`...`). - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -3361,36 +4506,37 @@ def __init__( super().__init__(slices=slices, **kwargs) def get( - self: Feature, - image: np.ndarray, - slices: tuple[Any, ...] | Any, - **kwargs: dict[str, Any], - ): + self: Slice, + image: ArrayLike[Any] | list[ArrayLike[Any]], + slices: slice | tuple[int | slice | Ellipsis, ...], + **kwargs: Any, + ) -> ArrayLike[Any] | list[ArrayLike[Any]]: """Apply the specified slices to the input image. Parameters ---------- - image: np.ndarray - The input image to be sliced. - slices: tuple[int | slice | ellipsis, ...] | int | slice | ellipsis - The slicing instructions for the input image. Each element in the - tuple corresponds to a dimension in the input image. If a single - element is provided, it is converted to a tuple. - **kwargs: dict of str to Any + image: array or list[array] + The input image(s) to be sliced. + slices: slice ellipsis or tuple[int or slice or ellipsis, ...] + The slicing instructions for the input image. Typically it is a + tuple. Each element in the tuple corresponds to a dimension in the + input image. If a single element is provided, it is converted to a + tuple. + **kwargs: Any Additional keyword arguments (unused in this implementation). Returns ------- - np.ndarray - The sliced image. + array or list[array] + The sliced image(s). """ try: - # Convert slices to a tuple if possible. + # Convert slices to a tuple if possible slices = tuple(slices) except ValueError: - # Leave slices as is if conversion fails. + # Leave slices as is if conversion fails pass return image[slices] @@ -3408,38 +4554,40 @@ class Bind(StructuralFeature): ---------- feature: Feature The child feature - **kwargs: dict of str to Any + **kwargs: Any Properties to send to child Methods ------- - `get(image: Any, **kwargs: dict[str, Any]) -> Any` - Resolves the child feature with the provided arguments. + `get(image: Any, **kwargs: Any) -> Any` + It resolves the child feature with the provided arguments. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Start by creating a `Gaussian` feature: >>> gaussian_noise = dt.Gaussian() - Dynamically modify the behavior of the feature using `Bind`: - >>> bound_feature = dt.Bind(gaussian_noise, mu = -5, sigma=2) - + Create a test image: + >>> import numpy as np + >>> >>> input_image = np.zeros((512, 512)) + + Bind fixed values to the parameters: + >>> bound_feature = dt.Bind(gaussian_noise, mu=-5, sigma=2) + + Resolve the bound feature: >>> output_image = bound_feature.resolve(input_image) - >>> print(np.mean(output_image), np.std(output_image)) - -4.9954959040123152 1.9975296489398942 + >>> round(np.mean(output_image), 1), round(np.std(output_image), 1) + (-5.0, 2.0) """ - __distributed__: bool = False - def __init__( - self: Feature, - feature: Feature, - **kwargs: dict[str, Any] + self: Bind, + feature: Feature, + **kwargs: Any, ): """Initialize the Bind feature. @@ -3447,18 +4595,19 @@ def __init__( ---------- feature: Feature The child feature to bind. - **kwargs: dict of str to Any + **kwargs: Any Properties or arguments to pass to the child feature. """ super().__init__(**kwargs) + self.feature = self.add_feature(feature) def get( - self: Feature, - image: Any, - **kwargs: dict[str, Any] + self: Bind, + image: Any, + **kwargs: Any, ) -> Any: """Resolve the child feature with the dynamically provided arguments. @@ -3466,7 +4615,7 @@ def get( ---------- image: Any The input data or image to process. - **kwargs: dict of str to Any + **kwargs: Any Properties or arguments to pass to the child feature during resolution. @@ -3495,28 +4644,24 @@ class BindUpdate(StructuralFeature): ---------- feature: Feature The child feature to bind with specific arguments. - **kwargs: dict of str to Any + **kwargs: Any Properties to send to the child feature during updates. Methods ------- - `get(image: Any, **kwargs: dict[str, Any]) -> Any` - Resolves the child feature with the provided arguments. + `get(image: Any, **kwargs: Any) -> Any` + It resolves the child feature with the provided arguments. Warnings -------- - This feature is deprecated and may be removed in a future release. - It is recommended to use `Bind` instead for equivalent functionality. - - Notes - ----- - The current implementation is not guaranteed to be exactly equivalent to - prior implementations. + Deprecation: This feature is deprecated and may be removed in a future + release. It is recommended to use `Bind` instead for equivalent + functionality. Further, the current implementation is not guaranteed to be + exactly equivalent to prior implementations. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Start by creating a `Gaussian` feature: >>> gaussian_noise = dt.Gaussian() @@ -3524,19 +4669,19 @@ class BindUpdate(StructuralFeature): Dynamically modify the behavior of the feature using `BindUpdate`: >>> bound_feature = dt.BindUpdate(gaussian_noise, mu = 5, sigma=3) + >>> import numpy as np + >>> >>> input_image = np.zeros((512, 512)) >>> output_image = bound_feature.resolve(input_image) - >>> print(np.mean(output_image), np.std(output_image)) - 4.998501486851294 3.0020269383538176 + >>> round(np.mean(output_image), 1), round(np.std(output_image), 1) + (5.0, 3.0) """ - __distributed__: bool = False - def __init__( self: Feature, feature: Feature, - **kwargs: dict[str, Any] + **kwargs: Any, ): """Initialize the BindUpdate feature. @@ -3544,12 +4689,12 @@ def __init__( ---------- feature: Feature The child feature to bind with specific arguments. - **kwargs: dict of str to Any + **kwargs: Any Properties to send to the child feature during updates. Warnings -------- - Emits a deprecation warning, encouraging the use of `Bind` instead. + It emits a deprecation warning, encouraging the use of `Bind` instead. """ @@ -3564,12 +4709,13 @@ def __init__( ) super().__init__(**kwargs) + self.feature = self.add_feature(feature) def get( - self: Feature, - image: Any, - **kwargs: dict[str, Any] + self: Feature, + image: Any, + **kwargs: Any, ) -> Any: """Resolve the child feature with the provided arguments. @@ -3577,7 +4723,7 @@ def get( ---------- image: Any The input data or image to process. - **kwargs: dict of str to Any + **kwargs: Any Properties or arguments to pass to the child feature during resolution. @@ -3600,64 +4746,65 @@ class ConditionalSetProperty(StructuralFeature): the given properties are applied; otherwise, the child feature remains unchanged. - **Note**: It is advisable to use `dt.Arguments` instead when possible, - since this feature **overwrites** properties, which may affect future - calls to the feature. + It is advisable to use `Arguments` instead when possible, since this + feature overwrites properties, which may affect future calls to the + feature. + + If `condition` is a string, the condition must be explicitly passed when + resolving. + + The properties applied do not persist unless explicitly stored. Parameters ---------- feature: Feature The child feature whose properties will be modified conditionally. - condition: PropertyLike[str] or PropertyLike[bool] - Either a boolean value (`True`/`False`) or the name of a boolean + condition: PropertyLike[str or bool] or None + Either a boolean value (`True`, `False`) or the name of a boolean property in the feature’s property dictionary. If the condition evaluates to `True`, the specified properties are applied. - **kwargs: dict[str, Any] + **kwargs: Any The properties to be applied to the child feature if `condition` is `True`. - Attributes - ---------- - __distributed__: bool - Indicates whether this feature distributes computation across inputs. - Methods ------- - `get(image: Any, condition: str | bool, **kwargs: dict[str, Any]) -> Any` + `get(image: Any, condition: str or bool, **kwargs: Any) -> Any` Resolves the child feature, conditionally applying the specified properties. - Notes - ----- - - If `condition` is a string, the condition must be explicitly passed when - resolving. - - The properties applied **do not persist** unless explicitly stored. + Warnings + -------- + Deprecation: This feature is deprecated and may be removed in a future + release. It is recommended to use `Arguments` instead. Examples -------- >>> import deeptrack as dt + + Define an image: >>> import numpy as np + >>> + >>> image = np.ones((512, 512)) Define a `Gaussian` noise feature: >>> gaussian_noise = dt.Gaussian(sigma=0) --- Using a boolean condition --- - Apply `sigma=5` **only if** `condition=True`: + Apply `sigma=5` only if `condition=True`: >>> conditional_feature = dt.ConditionalSetProperty( - ... gaussian_noise, sigma=5 + ... gaussian_noise, sigma=5, ... ) - Define an image: - >>> image = np.ones((512, 512)) - Resolve with condition met: - >>> noisy_image = conditional_feature.update(image, condition=True) - >>> print(noisy_image.std()) # Should be ~5 - 4.987707046984823 + >>> noisy_image = conditional_feature(image, condition=True) + >>> round(noisy_image.std(), 1) + 5.0 Resolve without condition: - >>> clean_image = conditional_feature.update(image, condition=False) - >>> print(clean_image.std()) # Should be 0 + >>> conditional_feature.update() # Essential to reset the property + >>> clean_image = conditional_feature(image, condition=False) + >>> round(clean_image.std(), 1) 0.0 --- Using a string-based condition --- @@ -3667,24 +4814,23 @@ class ConditionalSetProperty(StructuralFeature): ... ) Resolve with condition met: - >>> noisy_image = conditional_feature.update(image, is_noisy=True) - >>> print(noisy_image.std()) # Should be ~5 - 5.006310381139811 + >>> noisy_image = conditional_feature(image, is_noisy=True) + >>> round(noisy_image.std(), 1) + 5.0 Resolve without condition: - >>> clean_image = conditional_feature.update(image, is_noisy=False) - >>> print(clean_image.std()) # Should be 0 + >>> conditional_feature.update() + >>> clean_image = conditional_feature(image, is_noisy=False) + >>> round(clean_image.std(), 1) 0.0 - - """ - __distributed__: bool = False + """ def __init__( - self: Feature, + self: ConditionalSetProperty, feature: Feature, condition: PropertyLike[str | bool] | None = None, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the ConditionalSetProperty feature. @@ -3692,27 +4838,36 @@ def __init__( ---------- feature: Feature The child feature to conditionally modify. - condition: PropertyLike[str or bool] + condition: PropertyLike[str or bool] or None A boolean value or the name of a boolean property in the feature's property dictionary. If the condition evaluates to `True`, the specified properties are applied. - **kwargs: dict of str to Any + **kwargs: Any Properties to apply to the child feature if the condition is `True`. """ + import warnings + + warnings.warn( + "ConditionalSetFeature is deprecated and may be removed in a " + "future release. Please use Arguments instead when possible.", + DeprecationWarning, + ) + if isinstance(condition, str): kwargs.setdefault(condition, True) super().__init__(condition=condition, **kwargs) + self.feature = self.add_feature(feature) def get( - self: Feature, + self: ConditionalSetProperty, image: Any, condition: str | bool, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Any: """Resolve the child, conditionally applying specified properties. @@ -3724,7 +4879,7 @@ def get( A boolean value or the name of a boolean property in the feature's property dictionary. If the condition evaluates to `True`, the specified properties are applied. - **kwargs:: dict of str to Any + **kwargs:: Any Additional properties to apply to the child feature if the condition is `True`. @@ -3756,7 +4911,7 @@ class ConditionalSetFeature(StructuralFeature): The `condition` parameter specifies either: - A boolean value (default is `True`). - - The name of a property to listen to. For example, if + - The name of a property to listen to. For example, if `condition="is_label"`, the selected feature can be toggled as follows: >>> feature.resolve(is_label=True) # Resolves `on_true` @@ -3766,6 +4921,8 @@ class ConditionalSetFeature(StructuralFeature): Both `on_true` and `on_false` are updated during each call, even if only one is resolved. + It is advisable to use `Arguments` instead when possible. + Parameters ---------- on_false: Feature, optional @@ -3778,23 +4935,27 @@ class ConditionalSetFeature(StructuralFeature): The name of the conditional property or a boolean value. If a string is provided, its value is retrieved from `kwargs` or `self.properties`. If not found, the default value is `True`. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `StructuralFeature`. - Attributes - ---------- - __distributed__: bool - Indicates whether this feature distributes computation across inputs. - Methods ------- - `get(image: Any, condition: str | bool, **kwargs: dict[str, Any]) -> Any` + `get(image: Any, condition: str or bool, **kwargs: Any) -> Any` Resolves the appropriate feature based on the condition. + Warnings + -------- + Deprecation: This feature is deprecated and may be removed in a future + release. It is recommended to use `Arguments` instead. + Examples -------- >>> import deeptrack as dt + + Define an image: >>> import numpy as np + >>> + >>> image = np.ones((512, 512)) Define two `Gaussian` noise features: >>> true_feature = dt.Gaussian(sigma=0) @@ -3802,26 +4963,23 @@ class ConditionalSetFeature(StructuralFeature): --- Using a boolean condition --- Combine the features into a conditional set feature. - If not provided explicitely, condition is assumed to be True: + If not provided explicitely, the condition is assumed to be True: >>> conditional_feature = dt.ConditionalSetFeature( - ... on_true=true_feature, - ... on_false=false_feature, + ... on_true=true_feature, + ... on_false=false_feature, ... ) - Define an image: - >>> image = np.ones((512, 512)) - - Resolve based on the condition: - >>> clean_image = conditional_feature(image) # If not specified, default is True - >>> print(clean_image.std()) # Should be 0 + Resolve based on the condition. If not specified, default is True: + >>> clean_image = conditional_feature(image) + >>> round(clean_image.std(), 1) 0.0 >>> noisy_image = conditional_feature(image, condition=False) - >>> print(noisy_image.std()) # Should be ~5 - 4.987707046984823 + >>> round(noisy_image.std(), 1) + 5.0 >>> clean_image = conditional_feature(image, condition=True) - >>> print(clean_image.std()) # Should be 0 + >>> round(clean_image.std(), 1) 0.0 --- Using a string-based condition --- @@ -3834,23 +4992,21 @@ class ConditionalSetFeature(StructuralFeature): Resolve based on the conditions: >>> noisy_image = conditional_feature(image, is_noisy=False) - >>> print(noisy_image.std()) # Should be ~5 - 5.006310381139811 + >>> round(noisy_image.std(), 1) + 5.0 >>> clean_image = conditional_feature(image, is_noisy=True) - >>> print(clean_image.std()) # Should be 0 + >>> round(clean_image.std(), 1) 0.0 """ - __distributed__: bool = False - def __init__( - self: Feature, + self: ConditionalSetFeature, on_false: Feature | None = None, on_true: Feature | None = None, condition: PropertyLike[str | bool] = True, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the ConditionalSetFeature. @@ -3861,18 +5017,26 @@ def __init__( on_true: Feature, optional The feature to resolve if the condition evaluates to `True`. condition: str or bool, optional - The name of the property to listen to, or a boolean value. Defaults - to `"is_label"`. - **kwargs:: dict of str to Any + The name of the property to listen to, or a boolean value. It + defaults to `True`. + **kwargs:: Any Additional keyword arguments for the parent `StructuralFeature`. """ + import warnings + + warnings.warn( + "ConditionalSetFeature is deprecated and may be removed in a " + "future release. Please use Arguments instead when possible.", + DeprecationWarning, + ) + if isinstance(condition, str): kwargs.setdefault(condition, True) super().__init__(condition=condition, **kwargs) - + # Add the child features to the dependency graph if provided. if on_true: self.add_feature(on_true) @@ -3883,11 +5047,11 @@ def __init__( self.on_false = on_false def get( - self: Feature, + self: ConditionalSetFeature, image: Any, *, condition: str | bool, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Resolve the appropriate feature based on the condition. @@ -3899,7 +5063,7 @@ def get( The name of the conditional property or a boolean value. If a string is provided, it is looked up in `kwargs` to get the actual boolean value. - **kwargs:: dict of str to Any + **kwargs:: Any Additional keyword arguments to pass to the resolved feature. Returns @@ -3917,25 +5081,20 @@ def get( _condition = kwargs.get(condition, False) # Resolve the appropriate feature. - if _condition: - if self.on_true: - return self.on_true(image) - else: - return image - else: - if self.on_false: - return self.on_false(image) - else: - return image + if _condition and self.on_true: + return self.on_true(image) + if not _condition and self.on_false: + return self.on_false(image) + return image class Lambda(Feature): - """Apply a user-defined function to each image in the input. + """Apply a user-defined function to the input. - This feature allows applying a custom function to individual images in the - input pipeline. The `function` parameter must be wrapped in an - **outer function** that can depend on other properties of the pipeline. - The **inner function** processes a single image. + This feature allows applying a custom function to individual inputs in the + input pipeline. The `function` parameter must be wrapped in an **outer + function** that can depend on other properties of the pipeline. + The **inner function** processes a single input. Parameters ---------- @@ -3948,7 +5107,7 @@ class Lambda(Feature): Methods ------- - `get(image: np.ndarray | Image, function: Callable[[Image], Image], **kwargs: dict[str, Any]) -> Image` + `get(image: Any, function: Callable[[Any], Any], **kwargs: Any) -> Any` Applies the custom function to the input image. Examples @@ -3965,36 +5124,40 @@ class Lambda(Feature): Create a `Lambda` feature that scales images by a factor of 5: >>> lambda_feature = dt.Lambda(function=scale_function_factory, scale=5) - Apply the feature to an image: - >>> input_image = np.ones((5, 5)) + Create an image: + >>> import numpy as np + >>> + >>> input_image = np.ones((2, 3)) + >>> input_image + array([[1., 1., 1.], + [1., 1., 1.]]) + + Apply the feature to the image: >>> output_image = lambda_feature(input_image) - >>> print(output_image) - [[5. 5. 5. 5. 5.] - [5. 5. 5. 5. 5.] - [5. 5. 5. 5. 5.] - [5. 5. 5. 5. 5.] - [5. 5. 5. 5. 5.]] - + >>> output_image + array([[5., 5., 5.], + [5., 5., 5.]]) + """ def __init__( self: Feature, - function: Callable[..., Callable[[Image], Image]], - **kwargs: dict[str, Any], + function: Callable[..., Callable[[Any], Any]], + **kwargs: Any, ): """Initialize the Lambda feature. - This feature applies a user-defined function to process an image. The + This feature applies a user-defined function to process an input. The `function` parameter must be a callable that returns another function, - where the inner function operates on the image. + where the inner function operates on the input. Parameters ---------- - function: Callable[..., Callable[[Image], Image]] + function: Callable[..., Callable[[Any], Any]] A callable that produces a function. The outer function can accept additional arguments from the pipeline, while the inner function - processes a single image. - **kwargs: dict[str, Any] + processes a single input. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -4003,30 +5166,30 @@ def __init__( def get( self: Feature, - image: np.ndarray | Image, - function: Callable[[Image], Image], - **kwargs: dict[str, Any], - ) -> Image: - """Apply the custom function to the input image. + image: Any, + function: Callable[[Any], Any], + **kwargs: Any, + ) -> Any: + """Apply the custom function to the input. - This method applies a user-defined function to transform the input - image. The function should be a callable that takes an image as input - and returns a modified version of it. + This method applies a user-defined function to transform the input. The + function should be a callable that takes an input and returns a + modified version of it. Parameters ---------- - image: np.ndarray or Image - The input image to be processed. - function: Callable[[Image], Image] - A callable function that takes an image and returns a transformed - image. - **kwargs: dict of str to Any + image: Any + The input to be processed. + function: Callable[[Any], Any] + A callable function that takes an input and returns a transformed + output. + **kwargs: Any Additional keyword arguments (unused in this implementation). Returns ------- - Image - The transformed image after applying the function. + Any + The transformed output after applying the function. """ @@ -4034,42 +5197,41 @@ def get( class Merge(Feature): - """Apply a custom function to a list of images. + """Apply a custom function to a list of inputs. - This feature allows applying a user-defined function to a list of images. + This feature allows applying a user-defined function to a list of inputs. The `function` parameter must be a callable that returns another function, where: - The **outer function** can depend on other properties in the pipeline. - - The **inner function** takes a list of images and returns a single - image or a list of images. + - The **inner function** takes a list of inputs and returns a single + outputs or a list of outputs. - **Note:** The function must be wrapped in an **outer layer** to enable - dependencies on other properties while ensuring correct execution. + The function must be wrapped in an outer layer to enable dependencies on + other properties while ensuring correct execution. Parameters ---------- - function: Callable[..., Callable[[list[np.ndarray] | list[Image]], np.ndarray | list[np.ndarray] | Image | list[Image]]] - A callable that produces a function. The **outer function** can depend - on other properties of the pipeline, while the **inner function** - processes a list of images and returns either a single image or a list - of images. - **kwargs: dict[str, Any] + function: Callable[..., Callable[[list[Any]], Any or list[Any]] + A callable that produces a function. The outer function can depend on + other properties of the pipeline, while the inner function processes a + list of inputs and returns either a single output or a list of outputs. + **kwargs: Any Additional parameters passed to the parent `Feature` class. Attributes ---------- __distributed__: bool Indicates whether this feature distributes computation across inputs. + It defaults to `False`. Methods ------- - `get(list_of_images: list[np.ndarray] | list[Image], function: Callable[[list[np.ndarray] | list[Image]], np.ndarray | list[np.ndarray] | Image | list[Image]], **kwargs: dict[str, Any]) -> Image | list[Image]` - Applies the custom function to the list of images. + `get(list_of_images: list[Any], function: Callable[[list[Any]], Any or list[Any]], **kwargs: Any) -> Any or list[Any]` + Applies the custom function to the list of inputs. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Define a merge function that averages multiple images: >>> def merge_function_factory(): @@ -4080,16 +5242,17 @@ class Merge(Feature): Create a Merge feature: >>> merge_feature = dt.Merge(function=merge_function_factory) + Create some images: + >>> import numpy as np + >>> + >>> image_1 = np.ones((2, 3)) * 2 + >>> image_2 = np.ones((2, 3)) * 4 + Apply the feature to a list of images: - >>> image_1 = np.ones((5, 5)) * 2 - >>> image_2 = np.ones((5, 5)) * 4 >>> output_image = merge_feature([image_1, image_2]) - >>> print(output_image) - [[3. 3. 3. 3. 3.] - [3. 3. 3. 3. 3.] - [3. 3. 3. 3. 3.] - [3. 3. 3. 3. 3.] - [3. 3. 3. 3. 3.]] + >>> output_image + array([[3., 3., 3.], + [3., 3., 3.]]) """ @@ -4105,12 +5268,12 @@ def __init__( Parameters ---------- - function: Callable[..., Callable[list[np.ndarray] | [list[Image]], np.ndarray | list[np.ndarray] | Image | list[Image]]] + function: Callable[..., Callable[list[Any]], Any or list[Any]] A callable that returns a function for processing a list of images. - - The **outer function** can depend on other properties in the pipeline. - - The **inner function** takes a list of images as input and - returns either a single image or a list of images. - **kwargs: dict[str, Any] + The outer function can depend on other properties in the pipeline. + The inner function takes a list of inputs and returns either a + single output or a list of outputs. + **kwargs: Any Additional parameters passed to the parent `Feature` class. """ @@ -4121,19 +5284,18 @@ def get( self: Feature, list_of_images: list[np.ndarray] | list[Image], function: Callable[[list[np.ndarray] | list[Image]], np.ndarray | list[np.ndarray] | Image | list[Image]], - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Image | list[Image]: - """Apply the custom function to a list of images. + """Apply the custom function to a list of inputs. Parameters ---------- - list_of_images: list[np.ndarray] or list[Image] - A list of images to be processed by the function. - function: Callable[[list[np.ndarray] | list[Image]], np.ndarray | list[np.ndarray] | Image | list[Image]] - The function that processes the list of images and returns either: - - A single transformed image (`Image`) - - A list of transformed images (`list[Image]`) - **kwargs: dict[str, Any] + list_of_images: list[Any] + A list of inputs to be processed by the function. + function: Callable[[list[Any]], Any | list[Any]] + The function that processes the list of images and returns either a + single transformed input or a list of transformed inputs. + **kwargs: Any Additional arguments (unused in this implementation). Returns @@ -4147,7 +5309,7 @@ def get( class OneOf(Feature): - """Resolves one feature from a given collection. + """Resolve one feature from a given collection. This feature selects and applies one of multiple features from a given collection. The default behavior selects a feature randomly, but this @@ -4164,25 +5326,25 @@ class OneOf(Feature): key: int | None, optional The index of the feature to resolve from the collection. If not provided, a feature is selected randomly at each execution. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Attributes ---------- __distributed__: bool Indicates whether this feature distributes computation across inputs. + It defaults to `False`. Methods ------- `_process_properties(propertydict: dict) -> dict` - Processes the properties to determine the selected feature index. - `get(image: Any, key: int, _ID: tuple[int, ...], **kwargs: dict[str, Any]) -> Any` - Applies the selected feature to the input image. + It processes the properties to determine the selected feature index. + `get(image: Any, key: int, _ID: tuple[int, ...], **kwargs: Any) -> Any` + It applies the selected feature to the input. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Define multiple features: >>> feature_1 = dt.Add(value=10) @@ -4191,15 +5353,25 @@ class OneOf(Feature): Create a `OneOf` feature that randomly selects a transformation: >>> one_of_feature = dt.OneOf([feature_1, feature_2]) - Apply it to an input image: + Create an input image: + >>> import numpy as np + >>> >>> input_image = np.array([1, 2, 3]) + + Apply the `OneOf` feature to the input image: >>> output_image = one_of_feature(input_image) - >>> print(output_image) # The output depends on the randomly selected feature. + >>> output_image # The output depends on the randomly selected feature. - Use a `key` to apply a specific feature: + Use `key` to apply a specific feature: >>> controlled_feature = dt.OneOf([feature_1, feature_2], key=0) >>> output_image = controlled_feature(input_image) - >>> print(output_image) # Adds 10 to each element. + >>> output_image + array([11, 12, 13]) + + >>> controlled_feature.key.set_value(1) + >>> output_image = controlled_feature(input_image) + >>> output_image + array([2, 4, 6]) """ @@ -4209,31 +5381,33 @@ def __init__( self: Feature, collection: Iterable[Feature], key: int | None = None, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the OneOf feature. Parameters ---------- collection: Iterable[Feature] - A collection of features to choose from. It will be stored as a tuple. + A collection of features to choose from. It will be stored as a + tuple. key: int | None, optional The index of the feature to resolve from the collection. If not provided, a feature is selected randomly at execution. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ super().__init__(key=key, **kwargs) + self.collection = tuple(collection) - + # Add all features in the collection as dependencies. for feature in self.collection: self.add_feature(feature) def _process_properties( - self: Feature, + self: Feature, propertydict: dict, ) -> dict: """Process the properties to determine the feature index. @@ -4265,7 +5439,7 @@ def get( image: Any, key: int, _ID: tuple[int, ...] = (), - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Any: """Apply the selected feature to the input image. @@ -4277,7 +5451,7 @@ def get( The index of the feature to apply from the collection. _ID: tuple[int, ...], optional A unique identifier for caching and parallel processing. - **kwargs: dict of str to Any + **kwargs: Any Additional parameters passed to the selected feature. Returns @@ -4293,9 +5467,9 @@ def get( class OneOfDict(Feature): """Resolve one feature from a dictionary and apply it to an input. - This feature selects a feature from a dictionary and applies it to an input. - The selection is made randomly by default, but it can be controlled using - the `key` argument. + This feature selects a feature from a dictionary and applies it to an + input. The selection is made randomly by default, but it can be controlled + using the `key` argument. If `key` is not specified, a random key from the dictionary is selected, and the corresponding feature is applied. Otherwise, the feature mapped to @@ -4308,42 +5482,53 @@ class OneOfDict(Feature): key: Any | None, optional The key of the feature to resolve from the dictionary. If `None`, a random key is selected. - **kwargs: dict of str to Any + **kwargs: Any Additional parameters passed to the parent `Feature` class. Attributes ---------- __distributed__: bool Indicates whether this feature distributes computation across inputs. + It defaults to `False`. Methods ------- `_process_properties(propertydict: dict) -> dict` - Determines which feature to use based on `key`. - `get(image: Any, key: Any, _ID: tuple[int, ...], **kwargs: dict[str, Any]) -> Any` - Resolves the selected feature and applies it to the input image. + It determines which feature to use based on `key`. + `get(image: Any, key: Any, _ID: tuple[int, ...], **kwargs: Any) -> Any` + It resolves the selected feature and applies it to the input image. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Define a dictionary of features: >>> features_dict = { ... "add": dt.Add(value=10), ... "multiply": dt.Multiply(value=2), ... } + + Create a `OneOfDict` feature that randomly selects a transformation: >>> one_of_dict_feature = dt.OneOfDict(features_dict) - Apply a randomly selected feature: + Creare an image: + >>> import numpy as np + >>> >>> input_image = np.array([1, 2, 3]) + + Apply a randomly selected feature to the image: >>> output_image = one_of_dict_feature(input_image) - >>> print(output_image) + >>> output_image # The output depends on the randomly selected feature. + + Potentially select a different feature: + >>> output_image = one_of_dict_feature.update()(input_image) + >>> output_image Use a specific key to apply a predefined feature: >>> controlled_feature = dt.OneOfDict(features_dict, key="add") >>> output_image = controlled_feature(input_image) - >>> print(output_image) # Adds 10 to each element. + >>> output_image + array([11, 12, 13]) """ @@ -4353,7 +5538,7 @@ def __init__( self: Feature, collection: dict[Any, Feature], key: Any | None = None, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the OneOfDict feature. @@ -4364,12 +5549,13 @@ def __init__( key: Any | None, optional The key of the feature to resolve from the dictionary. If `None`, a random key is selected. - **kwargs: dict of str to Any + **kwargs: Any Additional parameters passed to the parent `Feature` class. """ super().__init__(key=key, **kwargs) + self.collection = collection # Add all features in the dictionary as dependencies. @@ -4377,8 +5563,8 @@ def __init__( self.add_feature(feature) def _process_properties( - self: Feature, - propertydict: dict + self: Feature, + propertydict: dict, ) -> dict: """Determine which feature to apply based on the selected key. @@ -4409,7 +5595,7 @@ def get( image: Any, key: Any, _ID: tuple[int, ...] = (), - **kwargs: dict[str, Any], + **kwargs: Any, )-> Any: """Resolve the selected feature and apply it to the input. @@ -4421,7 +5607,7 @@ def get( The key of the feature to apply from the dictionary. _ID: tuple[int, ...], optional A unique identifier for caching and parallel execution. - **kwargs: dict of str to Any + **kwargs: Any Additional parameters passed to the selected feature. Returns @@ -4449,26 +5635,28 @@ class LoadImage(Feature): The path(s) to the image(s) to load. Can be a single string or a list of strings. load_options: PropertyLike[dict[str, Any]], optional - Additional options passed to the file reader. Defaults to `None`. + Additional options passed to the file reader. It defaults to `None`. as_list: PropertyLike[bool], optional If `True`, the first dimension of the image will be treated as a list. - Defaults to `False`. + It defaults to `False`. ndim: PropertyLike[int], optional - Ensures the image has at least this many dimensions. Defaults to `3`. + Ensures the image has at least this many dimensions. It defaults to + `3`. to_grayscale: PropertyLike[bool], optional - If `True`, converts the image to grayscale. Defaults to `False`. + If `True`, converts the image to grayscale. It defaults to `False`. get_one_random: PropertyLike[bool], optional If `True`, extracts a single random image from a stack of images. Only - used when `as_list` is `True`. Defaults to `False`. + used when `as_list` is `True`. It defaults to `False`. Attributes ---------- __distributed__: bool Indicates whether this feature distributes computation across inputs. + It defaults to `False`. Methods ------- - `get(image: Any, path: str | list[str], load_options: dict[str, Any] | None, ndim: int, to_grayscale: bool, as_list: bool, get_one_random: bool, **kwargs: dict[str, Any]) -> np.ndarray` + `get(image: Any, path: str or list[str], load_options: dict[str, Any] | None, ndim: int, to_grayscale: bool, as_list: bool, get_one_random: bool, **kwargs: Any) -> array` Load the image(s) from disk and process them. Raises @@ -4479,27 +5667,43 @@ class LoadImage(Feature): Examples -------- >>> import deeptrack as dt - >>> import numpy as np - >>> from tempfile import NamedTemporaryFile Create a temporary image file: - >>> temp_file = NamedTemporaryFile(suffix=".npy", delete=False) - >>> np.save(temp_file.name, np.random.rand(100, 100)) + >>> import numpy as np + >>> import os, tempfile + >>> + >>> temp_file = tempfile.NamedTemporaryFile(suffix=".npy", delete=False) + >>> np.save(temp_file.name, np.random.rand(100, 100, 3)) Load the image using `LoadImage`: - >>> load_image_feature = dt.LoadImage(path=temp_file.name, to_grayscale=True) + >>> load_image_feature = dt.LoadImage(path=temp_file.name) >>> loaded_image = load_image_feature.resolve() Print image shape: - >>> print(loaded_image.shape) + >>> loaded_image.shape + (100, 100, 3) - If `to_grayscale=True`, the image is converted to grayscale (single channel). - If `ndim=4`, additional dimensions are added if necessary. + If `to_grayscale=True`, the image is converted to single channel: + >>> load_image_feature = dt.LoadImage( + ... path=temp_file.name, + ... to_grayscale=True, + ... ) + >>> loaded_image = load_image_feature.resolve() + >>> loaded_image.shape + (100, 100, 1) + + If `ndim=4`, additional dimensions are added if necessary: + >>> load_image_feature = dt.LoadImage( + ... path=temp_file.name, + ... ndim=4, + ... ) + >>> loaded_image = load_image_feature.resolve() + >>> loaded_image.shape + (2, 2, 3, 1) Cleanup the temporary file: - >>> import os >>> os.remove(temp_file.name) - + """ __distributed__: bool = False @@ -4512,30 +5716,31 @@ def __init__( ndim: PropertyLike[int] = 3, to_grayscale: PropertyLike[bool] = False, get_one_random: PropertyLike[bool] = False, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the LoadImage feature. Parameters ---------- path: PropertyLike[str or list[str]] - The path(s) to the image(s) to load. Can be a single string or a list - of strings. + The path(s) to the image(s) to load. Can be a single string or a + list of strings. load_options: PropertyLike[dict[str, Any]], optional - Additional options passed to the file reader (e.g., `mode` for OpenCV, - `allow_pickle` for NumPy). Defaults to `None`. + Additional options passed to the file reader (e.g., `mode` for + OpenCV, `allow_pickle` for NumPy). It defaults to `None`. as_list: PropertyLike[bool], optional - If `True`, treats the first dimension of the image as a list of images. - Defaults to `False`. + If `True`, treats the first dimension of the image as a list of + images. It defaults to `False`. ndim: PropertyLike[int], optional - Ensures the image has at least this many dimensions. If the loaded image - has fewer dimensions, extra dimensions are added. Defaults to `3`. + Ensures the image has at least this many dimensions. If the loaded + image has fewer dimensions, extra dimensions are added. It defaults + to `3`. to_grayscale: PropertyLike[bool], optional - If `True`, converts the image to grayscale. Defaults to `False`. + If `True`, converts the image to grayscale. It defaults to `False`. get_one_random: PropertyLike[bool], optional - If `True`, selects a single random image from a stack when `as_list=True`. - Defaults to `False`. - **kwargs: dict of str to Any + If `True`, selects a single random image from a stack when + `as_list=True`. It defaults to `False`. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class, allowing further customization. @@ -4560,8 +5765,8 @@ def get( to_grayscale: bool, as_list: bool, get_one_random: bool, - **kwargs: dict[str, Any], - ) -> np.ndarray: + **kwargs: Any, + ) -> NDArray | torch.Tensor: """Load and process an image or a list of images from disk. This method attempts to load an image using multiple file readers @@ -4572,31 +5777,32 @@ def get( Parameters ---------- - path: str or list of str + path: str or list[str] The file path(s) to the image(s) to be loaded. A single string loads one image, while a list of paths loads multiple images. load_options: dict of str to Any, optional Additional options passed to the file reader (e.g., `allow_pickle` - for NumPy, `mode` for OpenCV). Defaults to `None`. + for NumPy, `mode` for OpenCV). It defaults to `None`. ndim: int Ensures the image has at least this many dimensions. If the loaded image has fewer dimensions, extra dimensions are added. to_grayscale: bool - If `True`, converts the image to grayscale. Defaults to `False`. + If `True`, converts the image to grayscale. It defaults to `False`. as_list: bool If `True`, treats the first dimension as a list of images instead of stacking them into a NumPy array. get_one_random: bool If `True`, selects a single random image from a multi-frame stack - when `as_list=True`. Defaults to `False`. - **kwargs: dict[str, Any] + when `as_list=True`. It defaults to `False`. + **kwargs: Any Additional keyword arguments. Returns ------- - np.ndarray + array The loaded and processed image(s). If `as_list=True`, returns a - list of images; otherwise, returns a single NumPy array. + list of images; otherwise, returns a single NumPy array or PyTorch + tensor. Raises ------ @@ -4624,7 +5830,7 @@ def get( try: import PIL.Image - image = [PIL.Image.open(file, **load_options) + image = [PIL.Image.open(file, **load_options) for file in path] except (IOError, ImportError): import cv2 @@ -4651,7 +5857,7 @@ def get( try: import skimage - skimage.color.rgb2gray(image) + image = skimage.color.rgb2gray(image) except ValueError: import warnings @@ -4661,6 +5867,9 @@ def get( while ndim and image.ndim < ndim: image = np.expand_dims(image, axis=-1) + # Convert to PyTorch tensor if needed. + #TODO + return image @@ -4714,16 +5923,16 @@ class SampleToMasks(Feature): Examples ------- >>> import deeptrack as dt - >>> import matplotlib.pyplot as plt - >>> import numpy as np Define number of particles: >>> n_particles = 12 Define optics and particles: + >>> import numpy as np + >>> >>> optics = dt.Fluorescence(output_region=(0, 0, 64, 64)) >>> particle = dt.PointParticle( - >>> position=lambda: np.random.uniform(5, 55, size=2) + >>> position=lambda: np.random.uniform(5, 55, size=2), >>> ) >>> particles = particle ^ n_particles @@ -4732,7 +5941,7 @@ class SampleToMasks(Feature): >>> sim_mask_pip = particles >> dt.SampleToMasks( ... lambda: lambda particles: particles > 0, ... output_region=optics.output_region, - ... merge_method="or" + ... merge_method="or", ... ) >>> pipeline = sim_im_pip & sim_mask_pip >>> pipeline.store_properties() @@ -4744,12 +5953,14 @@ class SampleToMasks(Feature): >>> positions = np.array(image.get_property("position", get_one=False)) Visualize results: + >>> import matplotlib.pyplot as plt + >>> >>> plt.subplot(1, 2, 1) >>> plt.imshow(image, cmap="gray") >>> plt.title("Original Image") >>> plt.subplot(1, 2, 2) >>> plt.imshow(mask, cmap="gray") - >>> plt.scatter(positions[:,1], positions[:,0], c="r", marker="x", s = 10) + >>> plt.scatter(positions[:,1], positions[:,0], c="y", marker="x", s = 50) >>> plt.title("Mask") >>> plt.show() @@ -4792,7 +6003,7 @@ def get( self: Feature, image: np.ndarray | Image, transformation_function: Callable[[Image], Image], - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Image: """Apply the transformation function to a single image. @@ -4817,7 +6028,7 @@ def get( def _process_and_get( self: Feature, images: list[np.ndarray] | np.ndarray | list[Image] | Image, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Image | np.ndarray: """Process a list of images and generate a multi-layer mask. @@ -4971,49 +6182,50 @@ class AsType(Feature): Parameters ---------- - dtype: PropertyLike[Any], optional - The desired data type for the image. Defaults to `"float64"`. - **kwargs:: dict of str to Any + dtype: PropertyLike[str], optional + The desired data type for the image. It defaults to `"float64"`. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Methods ------- - `get(image: np.ndarray, dtype: str, **kwargs: dict[str, Any]) -> np.ndarray` + `get(image: array, dtype: str, **kwargs: Any) -> array` Convert the data type of the input image. Examples -------- - >>> import numpy as np - >>> from deeptrack.features import AsType + >>> import deeptrack as dt Create an input array: + >>> import numpy as np + >>> >>> input_image = np.array([1.5, 2.5, 3.5]) Apply an AsType feature to convert to `int32`: - >>> astype_feature = AsType(dtype="int32") + >>> astype_feature = dt.AsType(dtype="int32") >>> output_image = astype_feature.get(input_image, dtype="int32") - >>> print(output_image) - [1 2 3] + >>> output_image + array([1, 2, 3], dtype=int32) Verify the data type: - >>> print(output_image.dtype) - int32 + >>> output_image.dtype + dtype('int32') """ def __init__( self: Feature, - dtype: PropertyLike[Any] = "float64", - **kwargs: dict[str, Any], + dtype: PropertyLike[str] = "float64", + **kwargs: Any, ): """ Initialize the AsType feature. Parameters ---------- - dtype: PropertyLike[Any], optional - The desired data type for the image. Defaults to `"float64"`. - **kwargs:: dict of str to Any + dtype: PropertyLike[str], optional + The desired data type for the image. It defaults to `"float64"`. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -5022,16 +6234,17 @@ def __init__( def get( self: Feature, - image: np.ndarray, + image: NDArray | torch.Tensor | Image, dtype: str, - **kwargs: dict[str, Any], - ) -> np.ndarray: + **kwargs: Any, + ) -> NDArray | torch.Tensor | Image: """Convert the data type of the input image. Parameters ---------- - image: np.ndarray - The input image to process. + image: array + The input image to process. It can be a NumPy array, a PyTorch + tensor, or an Image. dtype: str The desired data type for the image. **kwargs: Any @@ -5039,8 +6252,9 @@ def get( Returns ------- - np.ndarray - The input image converted to the specified data type. + array + The input image converted to the specified data type. It can be a + NumPy array, a PyTorch tensor, or an Image. """ @@ -5058,7 +6272,7 @@ class ChannelFirst2d(Feature): Parameters ---------- axis: int, optional - The axis to move to the first position. Defaults to `-1` (last axis). + The axis to move to the first position. It defaults to `-1` (last axis). **kwargs:: dict of str to Any Additional keyword arguments passed to the parent `Feature` class. @@ -5098,7 +6312,7 @@ class ChannelFirst2d(Feature): def __init__( self: Feature, axis: int = -1, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the ChannelFirst2d feature. @@ -5106,7 +6320,7 @@ def __init__( ---------- axis: int, optional The axis to move to the first position. - Defaults to `-1` (last axis). + It defaults to `-1` (last axis). **kwargs:: dict of str to Any Additional keyword arguments passed to the parent `Feature` class. @@ -5118,7 +6332,7 @@ def get( self: Feature, image: np.ndarray, axis: int, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> np.ndarray: """Rearrange the axes of an image to channel-first format. @@ -5180,8 +6394,8 @@ class Upscale(Feature): factor: int or tuple[int, int, int], optional The factor by which to upscale the simulation. If a single integer is provided, it is applied uniformly across all axes. If a tuple of three - integers is provided, each axis is scaled individually. Defaults to 1. - **kwargs: dict of str to Any + integers is provided, each axis is scaled individually. It defaults to 1. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Attributes @@ -5246,7 +6460,7 @@ def __init__( self: Feature, feature: Feature, factor: int | tuple[int, int, int] = 1, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the Upscale feature. @@ -5258,8 +6472,8 @@ def __init__( The factor by which to upscale the simulation. If a single integer is provided, it is applied uniformly across all axes. If a tuple of three integers is provided, each axis is scaled individually. - Defaults to `1`. - **kwargs: dict of str to Any + It defaults to `1`. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -5271,7 +6485,7 @@ def get( self: Feature, image: np.ndarray, factor: int | tuple[int, int, int], - **kwargs: dict[str, Any], + **kwargs: Any, ) -> np.ndarray: """Simulate the pipeline at a higher resolution and return result. @@ -5283,7 +6497,7 @@ def get( The factor by which to upscale the simulation. If a single integer is provided, it is applied uniformly across all axes. If a tuple of three integers is provided, each axis is scaled individually. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the feature. Returns @@ -5313,7 +6527,7 @@ def get( # Downscale the result to the original resolution. import skimage.measure - + image = skimage.measure.block_reduce( image, (factor[0], factor[1]) + (1,) * (image.ndim - 2), np.mean ) @@ -5343,14 +6557,14 @@ class NonOverlapping(Feature): The feature that generates the list of volumes to place non-overlapping. min_distance: float, optional - The minimum distance between volumes in pixels. Defaults to `1`. + The minimum distance between volumes in pixels. It defaults to `1`. It can be negative to allow for partial overlap. max_attempts: int, optional The maximum number of attempts to place volumes without overlap. - Defaults to `5`. + It defaults to `5`. max_iters: int, optional The maximum number of resamplings. If this number is exceeded, a - new list of volumes is generated. Defaults to `100`. + new list of volumes is generated. It defaults to `100`. Attributes ---------- @@ -5464,7 +6678,7 @@ def __init__( min_distance: float = 1, max_attempts: int = 5, max_iters: int = 100, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initializes the NonOverlapping feature. @@ -5478,13 +6692,14 @@ def __init__( The feature that generates the list of volumes. min_distance: float, optional The minimum separation distance **between volume edges**, in - pixels. Defaults to `1`. Negative values allow for partial overlap. + pixels. It defaults to `1`. Negative values allow for partial + overlap. max_attempts: int, optional The maximum number of attempts to place the volumes without - overlap. Defaults to `5`. + overlap. It defaults to `5`. max_iters: int, optional The maximum number of resampling iterations per attempt. If - exceeded, a new list of volumes is generated. Defaults to `100`. + exceeded, a new list of volumes is generated. It defaults to `100`. """ @@ -5501,7 +6716,7 @@ def get( min_distance: float, max_attempts: int, max_iters: int, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> list[np.ndarray]: """Generates a list of non-overlapping 3D volumes within a defined field of view (FOV). @@ -5997,7 +7212,7 @@ class Store(Feature): key: Any The key used to identify the stored output. replace: bool, optional - If `True`, replaces the stored value with a new computation. Defaults + If `True`, replaces the stored value with a new computation. It defaults to `False`. **kwargs:: dict of str to Any Additional keyword arguments passed to the parent `Feature` class. @@ -6049,7 +7264,7 @@ def __init__( feature: Feature, key: Any, replace: bool = False, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the Store feature. @@ -6061,7 +7276,7 @@ def __init__( The key used to identify the stored output. replace: bool, optional If `True`, replaces the stored value with a new computation. - Defaults to `False`. + It defaults to `False`. **kwargs:: dict of str to Any Additional keyword arguments passed to the parent `Feature` class. @@ -6076,7 +7291,7 @@ def get( _: Any, key: Any, replace: bool, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Any: """Evaluate and store the feature output, or return the cached result. @@ -6119,35 +7334,37 @@ class Squeeze(Feature): Parameters ---------- axis: int or tuple[int, ...], optional - The axis or axes to squeeze. Defaults to `None`, squeezing all axes. - **kwargs:: dict of str to Any + The axis or axes to squeeze. It defaults to `None`, squeezing all axes. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Methods ------- - `get(image: np.ndarray, axis: int | tuple[int, ...], **kwargs: dict[str, Any]) -> np.ndarray` - Squeeze the input image by removing singleton dimensions. + `get(image: array, axis: int | tuple[int, ...], **kwargs: Any) -> array` + Squeeze the input image by removing singleton dimensions. The input and + output arrays can be a NumPy array, a PyTorch tensor, or an Image. Examples -------- - >>> import numpy as np - >>> from deeptrack.features import Squeeze + >>> import deeptrack as dt Create an input array with extra dimensions: + >>> import numpy as np + >>> >>> input_image = np.array([[[[1], [2], [3]]]]) - >>> print(input_image.shape) + >>> input_image.shape (1, 1, 3, 1) Create a Squeeze feature: - >>> squeeze_feature = Squeeze(axis=0) + >>> squeeze_feature = dt.Squeeze(axis=0) >>> output_image = squeeze_feature(input_image) - >>> print(output_image.shape) + >>> output_image.shape (1, 3, 1) Without specifying an axis: - >>> squeeze_feature = Squeeze() + >>> squeeze_feature = dt.Squeeze() >>> output_image = squeeze_feature(input_image) - >>> print(output_image.shape) + >>> output_image.shape (3,) """ @@ -6155,16 +7372,16 @@ class Squeeze(Feature): def __init__( self: Squeeze, axis: int | tuple[int, ...] | None = None, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the Squeeze feature. Parameters ---------- axis: int or tuple[int, ...], optional - The axis or axes to squeeze. Defaults to `None`, which squeezes - all axes. - **kwargs:: dict of str to Any + The axis or axes to squeeze. It defaults to `None`, which squeezes + all singleton axes. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -6173,34 +7390,45 @@ def __init__( def get( self: Squeeze, - image: np.ndarray, + image: NDArray | torch.Tensor | Image, axis: int | tuple[int, ...] | None = None, - **kwargs: dict[str, Any], - ) -> np.ndarray: + **kwargs: Any, + ) -> NDArray | torch.Tensor | Image: """Squeeze the input image by removing singleton dimensions. Parameters ---------- - image: np.ndarray - The input image to process. + image: array + The input image to process. The input array can be a NumPy array, a + PyTorch tensor, or an Image. axis: int or tuple[int, ...], optional - The axis or axes to squeeze. Defaults to `None`, which squeezes - all axes. - **kwargs:: dict of str to Any + The axis or axes to squeeze. It defaults to `None`, which squeezes + all singleton axes. + **kwargs: Any Additional keyword arguments (unused here). Returns ------- - np.ndarray - The squeezed image with reduced dimensions. + array + The squeezed image with reduced dimensions. The output array can be + a NumPy array, a PyTorch tensor, or an Image. """ - return np.squeeze(image, axis=axis) + if apc.is_torch_array(image): + if axis is None: + return image.squeeze() + if isinstance(axis, int): + return image.squeeze(axis) + for ax in sorted(axis, reverse=True): + image = image.squeeze(ax) + return image + + return xp.squeeze(image, axis=axis) class Unsqueeze(Feature): - """Unsqueezes the input image to the smallest possible dimension. + """Unsqueeze the input image to the smallest possible dimension. This feature adds new singleton dimensions to the input image at the specified axis or axes. If no axis is specified, it defaults to adding @@ -6209,36 +7437,38 @@ class Unsqueeze(Feature): Parameters ---------- axis: int or tuple[int, ...], optional - The axis or axes where new singleton dimensions should be added. - Defaults to `None`, which adds a singleton dimension at the last axis. - **kwargs:: dict of str to Any + The axis or axes where new singleton dimensions should be added. It + defaults to `None`, which adds a singleton dimension at the last axis. + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Methods ------- - `get(image: np.ndarray, axis: int | tuple[int, ...] | None, **kwargs: dict[str, Any]) -> np.ndarray` - Add singleton dimensions to the input image. + `get(image: array, axis: int | tuple[int, ...] | None, **kwargs: Any) -> array` + Add singleton dimensions to the input image. The input and output + arrays can be a NumPy array, a PyTorch tensor, or an Image. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Create an input array: + >>> import numpy as np + >>> >>> input_image = np.array([1, 2, 3]) - >>> print(input_image.shape) + >>> input_image.shape (3,) - Apply an Unsqueeze feature: + Apply Unsqueeze feature: >>> unsqueeze_feature = dt.Unsqueeze(axis=0) >>> output_image = unsqueeze_feature(input_image) - >>> print(output_image.shape) + >>> output_image.shape (1, 3) - Without specifying an axis: + Without specifying an axis, in unsqueezes the last dimension: >>> unsqueeze_feature = dt.Unsqueeze() >>> output_image = unsqueeze_feature(input_image) - >>> print(output_image.shape) + >>> output_image.shape (3, 1) """ @@ -6246,16 +7476,16 @@ class Unsqueeze(Feature): def __init__( self: Unsqueeze, axis: int | tuple[int, ...] | None = -1, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the Unsqueeze feature. Parameters ---------- axis: int or tuple[int, ...], optional - The axis or axes where new singleton dimensions should be added. - Defaults to -1, which adds a singleton dimension at the last axis. - **kwargs:: dict of str to Any + The axis or axes where new singleton dimensions should be added. It + defaults to -1, which adds a singleton dimension at the last axis. + **kwargs:: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -6264,31 +7494,41 @@ def __init__( def get( self: Unsqueeze, - image: np.ndarray, + image: np.ndarray | torch.Tensor | Image, axis: int | tuple[int, ...] | None = -1, - **kwargs: dict[str, Any], + **kwargs: Any, - ) -> np.ndarray: + ) -> np.ndarray | torch.Tensor | Image: """Add singleton dimensions to the input image. Parameters ---------- - image: np.ndarray - The input image to process. + image: array + The input image to process. The input array can be a NumPy array, a + PyTorch tensor, or an Image. axis: int or tuple[int, ...], optional The axis or axes where new singleton dimensions should be added. - Defaults to -1, which adds a singleton dimension at the last axis. - **kwargs:: dict of str to Any + It defaults to -1, which adds a singleton dimension at the last + axis. + **kwargs: Any Additional keyword arguments (unused here). Returns ------- - np.ndarray - The input image with the specified singleton dimensions added. + array + The input image with the specified singleton dimensions added. The + output array can be a NumPy array, a PyTorch tensor, or an Image. """ - return np.expand_dims(image, axis=axis) + if apc.is_torch_array(image): + if isinstance(axis, int): + axis = (axis,) + for ax in sorted(axis): + image = image.unsqueeze(ax) + return image + + return xp.expand_dims(image, axis=axis) ExpandDims = Unsqueeze @@ -6304,31 +7544,33 @@ class MoveAxis(Feature): Parameters ---------- source: int - The axis to move. + The source position of the axis to move. destination: int The destination position of the axis. - **kwargs:: dict of str to Any + **kwargs:: Any Additional keyword arguments passed to the parent `Feature` class. Methods ------- - `get(image: np.ndarray, source: int, destination: int, **kwargs: dict[str, Any]) -> np.ndarray` - Move the specified axis of the input image to a new position. + `get(image: array, source: int, destination: int, **kwargs: Any) -> array` + Move the specified axis of the input image to a new position. The input + and output array can be a NumPy array, a PyTorch tensor, or an Image. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Create an input array: + >>> import numpy as np + >>> >>> input_image = np.random.rand(2, 3, 4) - >>> print(input_image.shape) + >>> input_image.shape (2, 3, 4) Apply a MoveAxis feature: >>> move_axis_feature = dt.MoveAxis(source=0, destination=2) >>> output_image = move_axis_feature(input_image) - >>> print(output_image.shape) + >>> output_image.shape (3, 4, 2) """ @@ -6337,7 +7579,7 @@ def __init__( self: MoveAxis, source: int, destination: int, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the MoveAxis feature. @@ -6347,7 +7589,7 @@ def __init__( The axis to move. destination: int The destination position of the axis. - **kwargs:: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -6356,31 +7598,41 @@ def __init__( def get( self: MoveAxis, - image: np.ndarray, + image: NDArray | torch.Tensor | Image, source: int, - destination: int, - **kwargs: dict[str, Any], - ) -> np.ndarray: + destination: int, + **kwargs: Any, + ) -> NDArray | torch.Tensor | Image: """Move the specified axis of the input image to a new position. Parameters ---------- - image: np.ndarray - The input image to process. + image: array + The input image to process. The input array can be a NumPy array, a + PyTorch tensor, or an Image. source: int The axis to move. destination: int The destination position of the axis. - **kwargs:: dict of str to Any + **kwargs: Any Additional keyword arguments (unused here). Returns ------- - np.ndarray + array The input image with the specified axis moved to the destination. + The output array can be a NumPy array, a PyTorch tensor, or an + Image. + """ - return np.moveaxis(image, source, destination) + if apc.is_torch_array(image): + axes = list(range(image.ndim)) + axis = axes.pop(source) + axes.insert(destination, axis) + return image.permute(*axes) + + return xp.moveaxis(image, source, destination) class Transpose(Feature): @@ -6395,34 +7647,36 @@ class Transpose(Feature): axes: tuple[int, ...], optional A tuple specifying the permutation of the axes. If `None`, the axes are reversed by default. - **kwargs:: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Methods ------- - `get(image: np.ndarray, axes: tuple[int, ...] | None, **kwargs: dict[str, Any]) -> np.ndarray` - Transpose the axes of the input image + `get(image: array, axes: tuple[int, ...] | None, **kwargs: Any) -> array` + Transpose the axes of the input image(s). The input and output array + can be a NumPy array, a PyTorch tensor, or an Image. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Create an input array: + >>> import numpy as np + >>> >>> input_image = np.random.rand(2, 3, 4) - >>> print(input_image.shape) + >>> input_image.shape (2, 3, 4) Apply a Transpose feature: >>> transpose_feature = dt.Transpose(axes=(1, 2, 0)) >>> output_image = transpose_feature(input_image) - >>> print(output_image.shape) + >>> output_image.shape (3, 4, 2) Without specifying axes: >>> transpose_feature = dt.Transpose() >>> output_image = transpose_feature(input_image) - >>> print(output_image.shape) + >>> output_image.shape (4, 3, 2) """ @@ -6430,7 +7684,7 @@ class Transpose(Feature): def __init__( self: Transpose, axes: tuple[int, ...] | None = None, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the Transpose feature. @@ -6439,7 +7693,7 @@ def __init__( axes: tuple[int, ...], optional A tuple specifying the permutation of the axes. If `None`, the axes are reversed by default. - **kwargs:: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -6448,16 +7702,17 @@ def __init__( def get( self: Transpose, - image: np.ndarray, + image: NDArray | torch.Tensor | Image, axes: tuple[int, ...] | None = None, - **kwargs: dict[str, Any], - ) -> np.ndarray: + **kwargs: Any, + ) -> NDArray | torch.Tensor | Image: """Transpose the axes of the input image. Parameters ---------- - image: np.ndarray - The input image to process. + image: array + The input image to process. The input array can be a NumPy array, a + PyTorch tensor, or an Image. axes: tuple[int, ...], optional A tuple specifying the permutation of the axes. If `None`, the axes are reversed by default. @@ -6466,12 +7721,13 @@ def get( Returns ------- - np.ndarray - The transposed image with rearranged axes. + array + The transposed image with rearranged axes. The output array can be + a NumPy array, a PyTorch tensor, or an Image. """ - return np.transpose(image, axes) + return xp.transpose(image, axes) Permute = Transpose @@ -6488,37 +7744,39 @@ class OneHot(Feature): ---------- num_classes: int The total number of classes for the one-hot encoding. - **kwargs:: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. Methods ------- - `get(image: np.ndarray, num_classes: int, **kwargs: dict[str, Any]) -> np.ndarray` + `get(image: array, num_classes: int, **kwargs: Any) -> array` Convert the input array of class labels into a one-hot encoded array. + The input and output arrays can be a NumPy array, a PyTorch tensor, or + an Image. Examples -------- >>> import deeptrack as dt - >>> import numpy as np Create an input array of class labels: + >>> import numpy as np + >>> >>> input_data = np.array([0, 1, 2]) Apply a OneHot feature: >>> one_hot_feature = dt.OneHot(num_classes=3) - >>> one_hot_feature = dt.OneHot(num_classes=3) >>> one_hot_encoded = one_hot_feature.get(input_data, num_classes=3) - >>> print(one_hot_encoded) - [[1. 0. 0.] - [0. 1. 0.] - [0. 0. 1.]] + >>> one_hot_encoded + array([[1., 0., 0.], + [0., 1., 0.], + [0., 0., 1.]]) """ def __init__( self: OneHot, num_classes: int, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the OneHot feature. @@ -6526,7 +7784,7 @@ def __init__( ---------- num_classes: int The total number of classes for the one-hot encoding. - **kwargs:: dict of str to Any + **kwargs: Any Additional keyword arguments passed to the parent `Feature` class. """ @@ -6535,17 +7793,18 @@ def __init__( def get( self: OneHot, - image: np.ndarray, + image: NDArray | torch.Tensor | Image, num_classes: int, - **kwargs: dict[str, Any], - ) -> np.ndarray: + **kwargs: Any, + ) -> NDArray | torch.Tensor | Image: """Convert the input array of labels into a one-hot encoded array. Parameters ---------- - image: np.ndarray + image: array The input array of class labels. The last dimension should contain - integers representing class indices. + integers representing class indices. The input array can be a NumPy + array, a PyTorch tensor, or an Image. num_classes: int The total number of classes for the one-hot encoding. **kwargs: Any @@ -6553,9 +7812,11 @@ def get( Returns ------- - np.ndarray + array The one-hot encoded array. The last dimension is replaced with - one-hot vectors of length `num_classes`. + one-hot vectors of length `num_classes`. The output array can be a + NumPy array, a PyTorch tensor, or an Image. In all cases, it is of + data type float32 (e.g., np.float32 or torch.float32). """ @@ -6563,8 +7824,13 @@ def get( if image.shape[-1] == 1: image = image[..., 0] + if apc.is_torch_array(image): + return (torch.nn.functional + .one_hot(image, num_classes=num_classes) + .to(dtype=torch.float32)) + # Create the one-hot encoded array. - return np.eye(num_classes)[image] + return xp.eye(num_classes, dtype=np.float32)[image] class TakeProperties(Feature): @@ -6635,7 +7901,7 @@ def __init__( self: TakeProperties, feature: Feature, *names: str, - **kwargs: dict[str, Any], + **kwargs: Any, ): """Initialize the TakeProperties feature. @@ -6645,7 +7911,7 @@ def __init__( The feature from which to extract properties. *names: str One or more names of the properties to extract. -= **kwargs: dict[str, Any], optional += **kwargs: Any, optional Additional keyword arguments passed to the parent `Feature` class. """ @@ -6658,7 +7924,7 @@ def get( image: Any, names: tuple[str, ...], _ID: tuple[int, ...] = (), - **kwargs: dict[str, Any], + **kwargs: Any, ) -> np.ndarray | tuple[np.ndarray, ...]: """Extract the specified properties from the feature pipeline. @@ -6673,8 +7939,8 @@ def get( The names of the properties to extract. _ID: tuple[int, ...], optional A unique identifier for the current computation, ensuring that - dependencies are correctly matched. Defaults to an empty tuple. - **kwargs: dict[str, Any], optional + dependencies are correctly matched. It defaults to an empty tuple. + **kwargs: Any, optional Additional keyword arguments (unused in this method). Returns diff --git a/deeptrack/holography.py b/deeptrack/holography.py index db1a93fc7..994d362fb 100644 --- a/deeptrack/holography.py +++ b/deeptrack/holography.py @@ -188,7 +188,7 @@ def get( self: Rescale, image: Image | np.ndarray, rescale: float, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Image | np.ndarray: """Rescales the image by subtracting the real part of the field before multiplication. @@ -199,7 +199,7 @@ def get( The image to rescale. rescale: float The rescaling factor. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments. Returns @@ -253,7 +253,7 @@ def get( self: FourierTransform, image: Image | np.ndarray, padding: int = 32, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> np.ndarray: """Computes the Fourier transform of the image. @@ -263,7 +263,7 @@ def get( The image to transform. padding: int, optional Number of pixels to pad symmetrically around the image (default is 32). - **kwargs: dict of str to Any + **kwargs: Any Returns ------- @@ -329,7 +329,7 @@ def get( self: InverseFourierTransform, image: Image | np.ndarray, padding: int = 32, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Image | np.ndarray: """Computes the inverse Fourier transform and removes padding. @@ -340,7 +340,7 @@ def get( padding: int, optional Number of pixels removed symmetrically after inverse transformation (default is 32). - **kwargs: dict of str to Any + **kwargs: Any Returns ------- @@ -404,7 +404,7 @@ def get( Tz: np.ndarray, Tzinv: np.ndarray, i: int, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Image | np.ndarray: """Applies the power of the propagation matrix to the image. @@ -419,7 +419,7 @@ def get( i: int Power of the propagation matrix to apply. Negative values apply the inverse. - **kwargs: dict of str to Any + **kwargs: Any Additional keyword arguments. Returns diff --git a/deeptrack/image.py b/deeptrack/image.py index 75fd0d7fe..20ecd4456 100644 --- a/deeptrack/image.py +++ b/deeptrack/image.py @@ -1004,7 +1004,7 @@ def __array_ufunc__( ufunc: np.ufunc, method: str, *inputs: tuple[Any, ...], - **kwargs: dict[str, Any], + **kwargs: Any, ) -> Image | tuple[Image, ...] | None: """Enable Image objects to use NumPy ufuncs. @@ -1211,7 +1211,7 @@ def __torch_function__(self, func, types, args=(), kwargs=None): def __array__( self: Image | np.ndarray, *args: tuple[Any, ...], - **kwargs: dict[str, Any], + **kwargs: Any, ) -> np.ndarray: """Convert the Image object to a NumPy array. diff --git a/deeptrack/optics.py b/deeptrack/optics.py index b9885536d..b014855fd 100644 --- a/deeptrack/optics.py +++ b/deeptrack/optics.py @@ -89,7 +89,7 @@ def _create_volume( pad: int, output_region: Tuple[int, int, int, int], refractive_index_medium: float, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> np.ndarray Combines multiple scatterer objects into a single 3D volume for imaging. @@ -101,7 +101,7 @@ def _pad_volume( limits: np.ndarray, padding: Tuple[int, int, int, int], output_region: Tuple[int, int, int, int], - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> Tuple[np.ndarray, np.ndarray] Pads a volume with zeros to avoid edge effects during imaging. @@ -205,7 +205,7 @@ def __init__( self: Microscope, sample: Feature, objective: Feature, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> None: """Initialize the `Microscope` instance. @@ -490,7 +490,7 @@ def __init__( pupil: Feature = None, illumination: Feature = None, upscale: int = 1, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> None: """Initialize the `Optics` instance. @@ -687,7 +687,7 @@ def _pupil( refractive_index_medium: float, include_aberration: bool = True, defocus: float | ArrayLike[float] = 0, - **kwargs: Dict[str, Any], + **kwargs: Any, ): """Calculates the pupil function at different focal points. @@ -791,7 +791,7 @@ def _pad_volume( limits: ArrayLike[int] = None, padding: ArrayLike[int] = None, output_region: ArrayLike[int] = None, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> tuple: """Pads the volume with zeros to avoid edge effects. @@ -885,7 +885,7 @@ def _pad_volume( def __call__( self: Optics, sample: Feature, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> Microscope: """Creates a Microscope instance with the given sample and optics. @@ -972,8 +972,6 @@ class Fluorescence(Optics): Attributes ---------- - __gpu_compatible__: bool - Indicates whether the class supports GPU acceleration. NA: float Numerical aperture of the optical system. wavelength: float @@ -1018,8 +1016,6 @@ class Fluorescence(Optics): """ - __gpu_compatible__ = True - def get( self: Fluorescence, illuminated_volume: ArrayLike[complex], @@ -1198,8 +1194,6 @@ class Brightfield(Optics): Attributes ---------- - __gpu_compatible__: bool - Indicates whether the class supports GPU acceleration. __conversion_table__: ConversionTable Table used to convert properties of the feature to desired units. NA: float @@ -1247,8 +1241,6 @@ class Brightfield(Optics): """ - __gpu_compatible__ = True - __conversion_table__ = ConversionTable( working_distance=(u.meter, u.meter), ) @@ -1258,7 +1250,7 @@ def get( illuminated_volume: ArrayLike[complex], limits: ArrayLike[int], fields: ArrayLike[complex], - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> Image: """Simulates imaging with brightfield microscopy. @@ -1515,7 +1507,7 @@ def __init__( self: ISCAT, illumination_angle: float = np.pi, amp_factor: float = 1, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the ISCAT class. @@ -1625,7 +1617,7 @@ def get( illuminated_volume: ArrayLike[complex], limits: ArrayLike[int], fields: ArrayLike[complex], - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> Image: """Retrieve the darkfield image of the illuminated volume. @@ -1706,7 +1698,7 @@ def __init__( constant: PropertyLike[float] = 0, vmin: PropertyLike[float] = 0, vmax: PropertyLike[float] = np.inf, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> None: """Initializes the IlluminationGradient class. @@ -1739,7 +1731,7 @@ def get( constant: float, vmin: float, vmax: float, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> ArrayLike[complex]: """Applies the gradient and constant offset to the amplitude of the field. @@ -1866,7 +1858,7 @@ def _create_volume( pad: tuple = (0, 0, 0, 0), output_region: tuple = (None, None, None, None), refractive_index_medium: float = 1.33, - **kwargs: Dict[str, Any], + **kwargs: Any, ) -> tuple: """Converts a list of scatterers into a volumetric representation. diff --git a/deeptrack/scatterers.py b/deeptrack/scatterers.py index 1b45fc861..5646c64ec 100644 --- a/deeptrack/scatterers.py +++ b/deeptrack/scatterers.py @@ -791,8 +791,6 @@ class MieScatterer(Scatterer): """ - __gpu_compatible__ = True - __conversion_table__ = ConversionTable( radius=(u.meter, u.meter), polarization_angle=(u.radian, u.radian), diff --git a/deeptrack/tests/test_features.py b/deeptrack/tests/test_features.py index f19d97207..4fb75ba42 100644 --- a/deeptrack/tests/test_features.py +++ b/deeptrack/tests/test_features.py @@ -12,10 +12,20 @@ import numpy as np -from deeptrack import features, properties, scatterers, units, optics +from deeptrack import ( + features, + optics, + properties, + scatterers, + TORCH_AVAILABLE, + units, +) from deeptrack.image import Image from deeptrack.noises import Gaussian +if TORCH_AVAILABLE: + import torch + def grid_test_features( tester, feature_a, @@ -152,7 +162,6 @@ def test_Feature_basics(self): self.assertIsInstance(F.properties['prop_str'](), str) self.assertEqual(F.properties['prop_str'](), 'a') - def test_Feature_properties_update(self): feature = features.DummyFeature( @@ -174,7 +183,6 @@ def test_Feature_properties_update(self): prop_dict_with_update = feature.properties() self.assertNotEqual(prop_dict, prop_dict_with_update) - def test_Feature_memorized(self): list_of_inputs = [] @@ -211,7 +219,6 @@ def get(self, input, **kwargs): feature([1]) self.assertEqual(len(list_of_inputs), 4) - def test_Feature_dependence(self): A = features.Value(lambda: np.random.rand()) @@ -255,7 +262,6 @@ def test_Feature_dependence(self): self.assertEqual(D(), C() + B()) self.assertEqual(E(), D() + C()) - def test_Feature_validation(self): class ConcreteFeature(features.Feature): @@ -276,7 +282,6 @@ def get(self, input, **kwargs): feature.prop.set_value(2) # Changes value. self.assertFalse(feature.is_valid()) - def test_Feature_store_properties_in_image(self): class FeatureAddValue(features.Feature): @@ -303,7 +308,6 @@ def get(self, image, value_to_add=0, **kwargs): output_image.get_property("value_to_add", get_one=False), [1, 1] ) - def test_Feature_with_dummy_property(self): class FeatureConcreteClass(features.Feature): @@ -320,7 +324,6 @@ def get(self, *args, **kwargs): output_image.get_property("dummy_property", get_one=False), ["foo"] ) - def test_Feature_plus_1(self): class FeatureAddValue(features.Feature): @@ -343,7 +346,6 @@ def get(self, image, value_to_add=0, **kwargs): output_image.get_property("value_to_add", get_one=True), 1 ) - def test_Feature_plus_2(self): class FeatureAddValue(features.Feature): @@ -370,7 +372,6 @@ def get(self, image, value_to_multiply=0, **kwargs): output_image21 = feature21.resolve(input_image) self.assertEqual(output_image21, 1) - def test_Feature_plus_3(self): class FeatureAppendImageOfShape(features.Feature): @@ -391,7 +392,6 @@ def get(self, *args, shape, **kwargs): self.assertEqual(output_image[0].shape, (1, 1)) self.assertEqual(output_image[1].shape, (2, 2)) - def test_Feature_arithmetic(self): inp = features.DummyFeature() @@ -404,7 +404,6 @@ def test_Feature_arithmetic(self): input_2 = [10, 20] self.assertListEqual(pipeline(input_2), [-input_2[0], -input_2[1]]) - def test_Features_chain_lambda(self): value = features.Value(value=1) @@ -417,7 +416,6 @@ def test_Features_chain_lambda(self): output_image = feature() self.assertEqual(output_image, 2) - def test_Feature_repeat(self): feature = features.Value(value=0) \ @@ -428,7 +426,6 @@ def test_Feature_repeat(self): output_image = feature() self.assertEqual(np.array(output_image), np.array(n)) - def test_Feature_repeat_random(self): feature = features.Value(value=0) >> ( @@ -443,7 +440,6 @@ def test_Feature_repeat_random(self): self.assertNotEqual(num_dups, len(values)) self.assertEqual(output_image, sum(values)) - def test_Feature_repeat_nested(self): value = features.Value(0) @@ -454,7 +450,6 @@ def test_Feature_repeat_nested(self): self.assertEqual(feature(), 15) - def test_Feature_repeat_nested_random_times(self): value = features.Value(0) @@ -469,7 +464,6 @@ def test_Feature_repeat_nested_random_times(self): feature.update() self.assertEqual(feature(), feature.feature_2.N() * 5) - def test_Feature_repeat_nested_random_addition(self): value = features.Value(0) @@ -496,7 +490,6 @@ def test_Feature_repeat_nested_random_addition(self): sum(added_values) - 3 * 4, feature() ) - def test_Feature_nested_Duplicate(self): A = features.DummyFeature( @@ -537,7 +530,6 @@ def test_Feature_nested_Duplicate(self): self.assertIn(c - b, range(0, 100)) self.assertIn(dl[ci] - c, range(0, 10)) - def test_Feature_outside_dependence(self): A = features.DummyFeature( @@ -575,7 +567,8 @@ def get(self, image, **kwargs): class Multiplication(features.Feature): """Simple feature that multiplies by a constant.""" def get(self, image, **kwargs): - # 'multiplier' is a property set via self.properties (default: 1). + # 'multiplier' is a property set via self.properties + # (default: 1). return image * self.properties.get("multiplier", 1)() A = Addition(addend=10) @@ -598,11 +591,10 @@ def get(self, image, **kwargs): + A.properties["addend"]()), ) ) - - def test_DummyFeature(self): - """Test that the DummyFeature correctly returns the value of its properties.""" + def test_DummyFeature(self): + # Test that DummyFeature properties are callable and can be updated. feature = features.DummyFeature(a=1, b=2, c=3) self.assertEqual(feature.a(), 1) @@ -618,30 +610,191 @@ def test_DummyFeature(self): feature.c.set_value(6) self.assertEqual(feature.c(), 6) + # Test that DummyFeature returns input unchanged and supports call + # syntax. + feature = features.DummyFeature() + input_array = np.random.rand(10, 10) + output_array = feature.get(input_array) + self.assertIs(output_array, input_array) + # For callability via __call__ (as per DeepTrack2) + output_array_call = feature(input_array) + self.assertIs(output_array_call, input_array) + + # Test with NumPy array + arr = np.zeros((3, 3)) + self.assertIs(feature.get(arr), arr) + self.assertIs(feature(arr), arr) + + # Test with list of NumPy arrays + arr_list = [np.ones((2, 2)), np.zeros((2, 2))] + self.assertEqual(feature.get(arr_list), arr_list) + self.assertEqual(feature(arr_list), arr_list) + + # Test with PyTorch + if TORCH_AVAILABLE: + # Test with PyTorch tensor + tensor = torch.ones(4, 4) + self.assertIs(feature.get(tensor), tensor) + self.assertIs(feature(tensor), tensor) + + # Test with list of PyTorch tensors + tensor_list = [torch.zeros(2, 2), torch.ones(2, 2)] + self.assertEqual(feature.get(tensor_list), tensor_list) + self.assertEqual(feature(tensor_list), tensor_list) + + # Test with Image + img = Image(np.zeros((5, 5))) + self.assertIs(feature.get(img), img) + # feature(img) returns an array, not an Image. + self.assertTrue(np.array_equal(feature(img), img.data)) + # Note: Using feature.get(img) returns the Image object itself, + # while using feature(img) (i.e., calling the feature directly) + # returns the underlying NumPy array (img.data). This behavior + # is by design in DeepTrack2, where the __call__ method extracts + # the raw array from the Image to facilitate downstream processing + # with NumPy and similar libraries. Therefore, when testing or + # using features, always be mindful of whether you want the + # object (Image) or just its data (array). + + # Test with list of Image + img_list = [Image(np.ones((3, 3))), Image(np.zeros((3, 3)))] + self.assertEqual(feature.get(img_list), img_list) + # feature(img_list) returns a list of arrays, not a list of Images. + output = feature(img_list) + self.assertEqual(len(output), len(img_list)) + for arr, img in zip(output, img_list): + self.assertTrue(np.array_equal(arr, img.data)) + # Note: Calling feature(img_list) returns a list of NumPy arrays + # extracted from each Image in img_list, whereas feature.get(img_list) + # returns the original list of Image objects. This difference is + # intentional in DeepTrack2, where the __call__ method is designed to + # yield the underlying array data for easier interoperability with + # NumPy and downstream processing. - def test_Value(self): + def test_Value(self): + # Scalar value tests value = features.Value(value=1) self.assertEqual(value(), 1) self.assertEqual(value.value(), 1) self.assertEqual(value(value=2), 2) + self.assertEqual(value(), 2) self.assertEqual(value.value(), 2) value = features.Value(value=lambda: 1) self.assertEqual(value(), 1) self.assertEqual(value.value(), 1) self.assertNotEqual(value(value=lambda: 2), 2) + self.assertNotEqual(value(), 2) self.assertNotEqual(value.value(), 2) + # NumPy array value tests + arr = np.arange(4) + value_arr = features.Value(value=arr) + self.assertTrue(np.array_equal(value_arr(), arr)) + self.assertTrue(np.array_equal(value_arr.value(), arr)) + # Override with a new array + override_arr = np.array([10, 20, 30, 40]) + self.assertTrue( + np.array_equal(value_arr(value=override_arr), override_arr) + ) + self.assertTrue(np.array_equal(value_arr(), override_arr)) + self.assertTrue(np.array_equal(value_arr.value(), override_arr)) + + # PyTorch tensor value tests + if TORCH_AVAILABLE: + tensor = torch.tensor([1., 2., 3.]) + value_tensor = features.Value(value=tensor) + self.assertTrue(torch.equal(value_tensor(), tensor)) + self.assertTrue(torch.equal(value_tensor.value(), tensor)) + # Override with a new tensor + override_tensor = torch.tensor([10., 20., 30.]) + self.assertTrue(torch.equal(value_tensor(value=override_tensor), override_tensor)) + self.assertTrue(torch.equal(value_tensor(), override_tensor)) + self.assertTrue(torch.equal(value_tensor.value(), override_tensor)) - def test_ArithmeticOperationFeature(self): + def test_ArithmeticOperationFeature(self): + # Basic addition with lists addition_feature = \ features.ArithmeticOperationFeature(operator.add, value=10) input_values = [1, 2, 3, 4] expected_output = [11, 12, 13, 14] output = addition_feature(input_values) - self.assertEqual(output, expected_output) + self.assertEqual(output, expected_output) + + # Scalar input and scalar value + output = addition_feature(5) + self.assertEqual(output, 15) + + # List input, scalar value (broadcast) + input_values = [10, 20, 30] + output = addition_feature(input_values) + self.assertEqual(output, [20, 30, 40]) + + # List input, list value (same length) + addition_feature = features.ArithmeticOperationFeature( + operator.add, value=[1, 2, 3], + ) + input_values = [10, 20, 30] + self.assertEqual(addition_feature(input_values), [11, 22, 33]) + + # List input, list value (different lengths, value list cycles) + addition_feature = features.ArithmeticOperationFeature( + operator.add, value=[1, 2], + ) + input_values = [10, 20, 30, 40, 50] + # value cycles as 1,2,1,2,1 + self.assertEqual(addition_feature(input_values), [11, 22, 31, 42, 51]) + + # NumPy array input, scalar value + addition_feature = features.ArithmeticOperationFeature( + operator.add, value=5, + ) + arr = np.array([1, 2, 3]) + self.assertEqual(addition_feature(arr.tolist()), [6, 7, 8]) + + # NumPy array input, NumPy array value + addition_feature = features.ArithmeticOperationFeature( + operator.add, value=[4, 5, 6], + ) + arr_input = [ + np.array([1, 2]), np.array([3, 4]), np.array([5, 6]), + ] + arr_value = [ + np.array([10, 20]), np.array([30, 40]), np.array([50, 60]), + ] + feature = features.ArithmeticOperationFeature( + lambda a, b: np.add(a, b), value=arr_value, + ) + for output, expected in zip( + feature(arr_input), + [np.array([11, 22]), np.array([33, 44]), np.array([55, 66])], + ): + self.assertTrue(np.array_equal(output, expected)) + + # PyTorch tensor input (if available) + if TORCH_AVAILABLE: + addition_feature = features.ArithmeticOperationFeature( + lambda a, b: a + b, value=5, + ) + tensors = [torch.tensor(1), torch.tensor(2), torch.tensor(3)] + expected = [torch.tensor(6), torch.tensor(7), torch.tensor(8)] + output = addition_feature(tensors) + for out, exp in zip(output, expected): + self.assertTrue(torch.equal(out, exp)) + + # Tensor input, tensor value (elementwise) + t_input = [torch.tensor([1.0, 2.0]), torch.tensor([3.0, 4.0])] + t_value = [torch.tensor([10.0, 20.0]), torch.tensor([30.0, 40.0])] + feature = features.ArithmeticOperationFeature( + lambda a, b: a + b, value=t_value, + ) + for output, expected in zip( + feature(t_input), + [torch.tensor([11.0, 22.0]), torch.tensor([33.0, 44.0])], + ): + self.assertTrue(torch.equal(output, expected)) def test_Add(self): @@ -667,6 +820,7 @@ def test_FloorDivide(self): def test_Power(self): test_operator(self, operator.pow) + def test_LessThan(self): test_operator(self, operator.lt) @@ -685,8 +839,8 @@ def test_GreaterThanOrEquals(self): def test_Equals(self): """ - Notes - ----- + Important Notes + --------------- - Unlike other arithmetic operators, `Equals` does not define `__eq__` (`==`) and `__req__` (`==`) in `DeepTrackNode` and `Feature`, as this would affect Python’s built-in identity comparison. @@ -694,9 +848,8 @@ def test_Equals(self): expressions involving `Feature` instances but not for comparisons involving regular Python objects. - Always use `>>` to apply `Equals` correctly in a feature chain. - """ - + equals_feature = features.Equals(value=2) input_values = np.array([1, 2, 3]) output_values = equals_feature(input_values) @@ -733,51 +886,80 @@ def test_Stack(self): {"value": np.random.rand(10, 10)}, ], lambda a, b: [ - *(a["value"] if isinstance(a["value"], list) else [a["value"]]), - *(b["value"] if isinstance(b["value"], list) else [b["value"]]), + *( + a["value"] + if isinstance(a["value"], list) + else [a["value"]] + ), + *( + b["value"] + if isinstance(b["value"], list) + else [b["value"]] + ), ], operator.__and__, ) - def test_Arguments_feature_passing(self): - """Tests that arguments are correctly passed and updated in a feature pipeline.""" - - # Define Arguments with static and dynamic values - arguments = features.Arguments( - a="foo", - b="bar", - c=lambda a, b: a + b, # "foobar" - d=np.random.rand, # Random float in [0, 1] - ) - - # First feature with dependencies on arguments - f1 = features.DummyFeature( - p1=arguments.a, # "foo" - p2=lambda p1: p1 + "baz" # "foobaz" - ) - - # Second feature dependent on the first - f2 = features.DummyFeature( - p1=f1.p2, # Should be "foobaz" - p2=arguments.d, # Random value - ) - - # Assertions - self.assertEqual(f1.properties['p1'](), "foo") # Check that p1 is set correctly - self.assertEqual(f1.properties['p2'](), "foobaz") # Check lambda evaluation - - self.assertEqual(f2.properties['p1'](), "foobaz") # Check dependency resolution - - # Ensure p2 in f2 is a valid float between 0 and 1 - self.assertTrue(0 <= f2.properties['p2']() <= 1) - - # Ensure `c` was computed correctly - self.assertEqual(arguments.c(), "foobar") # Should concatenate "foo" + "bar" - - # Test that d is dynamic (generates new values) - first_d = arguments.d.update()() - second_d = arguments.d.update()() - self.assertNotEqual(first_d, second_d) # Check that values change + # Stack scalar with scalar + feature = features.Stack(value=2) + result = feature(1) + self.assertEqual(result, [1, 2]) + + # Stack scalar with list + feature = features.Stack(value=[3, 4]) + result = feature(2) + self.assertEqual(result, [2, 3, 4]) + + # Stack list with scalar + feature = features.Stack(value=5) + result = feature([1, 2, 3]) + self.assertEqual(result, [1, 2, 3, 5]) + + # Stack list with list + feature = features.Stack(value=[4, 5]) + result = feature([1, 2, 3]) + self.assertEqual(result, [1, 2, 3, 4, 5]) + + # Stack with empty lists + feature = features.Stack(value=[]) + result = feature([1, 2]) + self.assertEqual(result, [1, 2]) + + feature = features.Stack(value=[1, 2]) + result = feature([]) + self.assertEqual(result, [1, 2]) + + # Stack using Value feature + pipeline = features.Value([1, 2]) >> features.Stack(value=features.Value([3, 4])) + result = pipeline() + self.assertEqual(result, [1, 2, 3, 4]) + + # Stack using & operator (Value & list) + pipeline = features.Value([1, 2]) & [3, 4] + self.assertEqual(pipeline.resolve(), [1, 2, 3, 4]) + + # Stack using & operator (list & Value) + pipeline = [3, 4] & features.Value([1, 2]) + self.assertEqual(pipeline.resolve(), [3, 4, 1, 2]) + + # Stack NumPy arrays + arr1 = np.array([1, 2]) + arr2 = np.array([3, 4]) + feature = features.Stack(value=arr2) + result = feature(arr1) + self.assertEqual(len(result), 2) + self.assertTrue(np.array_equal(result[0], arr1)) + self.assertTrue(np.array_equal(result[1], arr2)) + + # Stack PyTorch tensors + if TORCH_AVAILABLE: + t1 = torch.tensor([1, 2]) + t2 = torch.tensor([3, 4]) + feature = features.Stack(value=t2) + result = feature(t1) + self.assertEqual(len(result), 2) + self.assertTrue(torch.equal(result[0], t1)) + self.assertTrue(torch.equal(result[1], t2)) def test_Arguments(self): @@ -785,17 +967,17 @@ def test_Arguments(self): from PIL import Image as PIL_Image import os - """Creates a temporary test image.""" + # Create a temporary test image. test_image_array = (np.ones((50, 50)) * 128).astype(np.uint8) with NamedTemporaryFile(suffix=".png", delete=False) as temp_png: PIL_Image.fromarray(test_image_array).save(temp_png.name) - try: - """Tests pipeline behavior when toggling `is_label`.""" + try: # Ensure removal of test image. + # Test pipeline behavior when toggling `is_label`. arguments = features.Arguments(is_label=False) image_pipeline = ( - features.LoadImage(path=temp_png.name) >> - Gaussian(sigma=(1 - arguments.is_label) * 5) + features.LoadImage(path=temp_png.name) + >> Gaussian(sigma=(1 - arguments.is_label) * 5) ) image_pipeline.bind_arguments(arguments) @@ -805,15 +987,15 @@ def test_Arguments(self): # Test raw image with `is_label=True` image = image_pipeline(is_label=True) - self.assertAlmostEqual(image.std(), 0.0, places=3) # No noise expected + self.assertAlmostEqual(image.std(), 0.0, places=3) # No noise - """Tests pipeline behavior with dynamically computed sigma.""" + # Test pipeline behavior with dynamically computed sigma. arguments = features.Arguments(is_label=False) image_pipeline = ( - features.LoadImage(path=temp_png.name) >> - Gaussian( + features.LoadImage(path=temp_png.name) + >> Gaussian( is_label=arguments.is_label, - sigma=lambda is_label: 0 if is_label else 5 + sigma=lambda is_label: 0 if is_label else 5, ) ) image_pipeline.bind_arguments(arguments) @@ -824,15 +1006,16 @@ def test_Arguments(self): # Test raw image with `is_label=True` image = image_pipeline(is_label=True) - self.assertAlmostEqual(image.std(), 0.0, places=3) # No noise expected + self.assertAlmostEqual(image.std(), 0.0, places=3) # No noise - """Tests property storage and modification in the pipeline.""" + # Test property storage and modification in the pipeline. arguments = features.Arguments(noise_max_sigma=5) image_pipeline = ( - features.LoadImage(path=temp_png.name) >> - Gaussian( + features.LoadImage(path=temp_png.name) + >> Gaussian( noise_max_sigma=arguments.noise_max_sigma, - sigma=lambda noise_max_sigma: np.random.rand() * noise_max_sigma + sigma=lambda noise_max_sigma: + np.random.rand() * noise_max_sigma, ) ) image_pipeline.bind_arguments(arguments) @@ -847,13 +1030,14 @@ def test_Arguments(self): image = image_pipeline(noise_max_sigma=0) self.assertEqual(image.get_property("sigma"), 0.0) - """Tests passing arguments dynamically using `**arguments.properties`.""" + # Test passing arguments dynamically using **arguments.properties. arguments = features.Arguments(is_label=False, noise_sigma=5) image_pipeline = ( features.LoadImage(path=temp_png.name) >> Gaussian( - sigma=lambda is_label, noise_sigma: 0 if is_label else noise_sigma, - **arguments.properties + sigma=lambda is_label, noise_sigma: + 0 if is_label else noise_sigma, + **arguments.properties, ) ) image_pipeline.bind_arguments(arguments) @@ -864,57 +1048,166 @@ def test_Arguments(self): # Test raw image with `is_label=True` image = image_pipeline(is_label=True) - self.assertAlmostEqual(image.std(), 0.0, places=3) # No noise expected - + self.assertAlmostEqual(image.std(), 0.0, places=3) # No noise + + except Exception: + raise finally: if os.path.exists(temp_png.name): os.remove(temp_png.name) + def test_Arguments_feature_passing(self): + # Tests that arguments are correctly passed and updated. + # + + # Define Arguments with static and dynamic values + arguments = features.Arguments( + a="foo", + b="bar", + c=lambda a, b: a + b, # "foobar" + d=np.random.rand, # Random float in [0, 1] + ) + + # First feature with dependencies on arguments + f1 = features.DummyFeature( + p1=arguments.a, # "foo" + p2=lambda p1: p1 + "baz", # "foobaz" + ) + + # Second feature dependent on the first + f2 = features.DummyFeature( + p1=f1.p2, # Should be "foobaz" + p2=arguments.d, # Random value + ) + + # Assertions + self.assertEqual(f1.properties["p1"](), "foo") # Check that p1 is set + # correctly + self.assertEqual(f1.properties["p2"](), "foobaz") # Check lambda + # evaluation + self.assertEqual(f2.properties["p1"](), "foobaz") # Check dependency + # resolution + + # Ensure p2 in f2 is a valid float between 0 and 1 + self.assertTrue(0 <= f2.properties["p2"]() <= 1) + + # Ensure `c` was computed correctly + self.assertEqual(arguments.c(), "foobar") # Should concatenate + # "foo" + "bar" + + # Test that d is dynamic (generates new values) + first_d = arguments.d.update()() + second_d = arguments.d.update()() + self.assertNotEqual(first_d, second_d) # Check that values change + + def test_Arguments_binding(self): + # Create a dynamic argument container + arguments = features.Arguments(x=10) + + # Create a simple pipeline: Value(100) + x + 1 + pipeline = ( + features.Value(100) + >> features.Add(value=arguments.x) + >> features.Add(1) + ) + + # Evaluate pipeline with default x=10 + result = pipeline() + self.assertEqual(result, 111) # 100 + 10 + 1 + + result_no_binding = pipeline(x=20) + self.assertEqual(result_no_binding, 111) # 100 + 10 + 1 + + # Bind the arguments to the pipeline + pipeline.bind_arguments(arguments) + + # Override x at runtime to 20 + result_binding = pipeline(x=20) + self.assertEqual(result_binding, 121) # 100 + 20 + 1 + def test_Probability(self): - np.random.seed(42) # Set seed for reproducibility + # Set seed for reproducibility of random trials + np.random.seed(42) + input_image = np.ones((5, 5)) add_feature = features.Add(value=2) + + # Helper: Check if feature was applied + def is_transformed(output): + return np.array_equal(output, input_image + 2) + + # 1. Test probabilistic application over many runs probabilistic_feature = features.Probability( - feature = add_feature, + feature=add_feature, probability=0.7 ) - - input_image = np.ones((5, 5)) applied_count = 0 total_runs = 300 for _ in range(total_runs): output_image = probabilistic_feature.update().resolve(input_image) - - if not np.array_equal(output_image, input_image): + if is_transformed(output_image): applied_count += 1 - self.assertTrue(np.array_equal(output_image, input_image + 2)) + else: + self.assertTrue(np.array_equal(output_image, input_image)) observed_probability = applied_count / total_runs - self.assertTrue(0.65 <= observed_probability <= 0.75, f"Observed probability: {observed_probability}") + self.assertTrue(0.65 <= observed_probability <= 0.75, + f"Observed probability: {observed_probability}") + + # 2. Edge case: probability = 0 (feature should never apply) + never_applied = features.Probability(feature=add_feature, + probability=0.0) + output = never_applied.update().resolve(input_image) + self.assertTrue(np.array_equal(output, input_image)) + + # 3. Edge case: probability = 1 (feature should always apply) + always_applied = features.Probability(feature=add_feature, + probability=1.0) + output = always_applied.update().resolve(input_image) + self.assertTrue(is_transformed(output)) + + # 4. Cached behavior: result is the same without update() + cached_feature = features.Probability(feature=add_feature, + probability=1.0) + output_1 = cached_feature.update().resolve(input_image) + output_2 = cached_feature.resolve(input_image) # same random number + self.assertTrue(np.array_equal(output_1, output_2)) + + # 5. Manual override: force behavior using random_number + manual = features.Probability(feature=add_feature, probability=0.5) + + # Should NOT apply (0.9 > 0.5) + output = manual.resolve(input_image, random_number=0.9) + self.assertTrue(np.array_equal(output, input_image)) + + # Should apply (0.1 < 0.5) + output = manual.resolve(input_image, random_number=0.1) + self.assertTrue(is_transformed(output)) def test_Repeat(self): + # Define a simple feature and pipeline add_ten = features.Add(value=10) - pipeline = features.Repeat(add_ten, N=3) input_data = [1, 2, 3] expected_output = [31, 32, 33] + # Test standard Repeat behavior output_data = pipeline.resolve(input_data) + self.assertEqual(output_data, expected_output) - self.assertTrue(np.array_equal(output_data, expected_output), \ - f"Expected {expected_output}, got {output_data}") - + # Test shorthand syntax (^) produces same result pipeline_shorthand = features.Add(value=10) ^ 3 output_data_shorthand = pipeline_shorthand.resolve(input_data) + self.assertEqual(output_data_shorthand, expected_output) - self.assertTrue(np.array_equal(output_data_shorthand, expected_output), \ - f"Shorthand failed. Expected {expected_output}, \ - got {output_data_shorthand}") + # Test dynamic override of N + output_override = pipeline(input_data, N=2) + self.assertEqual(output_override, [21, 22, 23]) def test_Combine(self): @@ -926,60 +1219,60 @@ def test_Combine(self): input_image = np.ones((10, 10)) output_list = combined_feature.resolve(input_image) - self.assertTrue(isinstance(output_list, list), "Output should be a list") - self.assertTrue(len(output_list) == 2, "Output list should contain results of both features") + self.assertTrue(isinstance(output_list, list)) + self.assertTrue(len(output_list) == 2) for output in output_list: - self.assertTrue(output.shape == input_image.shape, "Output shape mismatch") + self.assertTrue(output.shape == input_image.shape) noisy_image = output_list[0] added_image = output_list[1] - self.assertFalse(np.all(noisy_image == 1), "Gaussian noise was not applied") - self.assertTrue(np.allclose(added_image, input_image + 10), "Add operation failed") + self.assertFalse(np.all(noisy_image == 1)) + self.assertTrue(np.allclose(added_image, input_image + 10)) def test_Slice_constant(self): - input = np.arange(9).reshape((3, 3)) + image = np.arange(9).reshape((3, 3)) A = features.DummyFeature() + A0 = A[0] - A1 = A[1] - A22 = A[2, 2] - A12 = A[1, lambda: -1] + a0 = A0.resolve(image) + self.assertEqual(a0.tolist(), image[0].tolist()) - a0 = A0.resolve(input) - a1 = A1.resolve(input) - a22 = A22.resolve(input) - a12 = A12.resolve(input) + A1 = A[1] + a1 = A1.resolve(image) + self.assertEqual(a1.tolist(), image[1].tolist()) - self.assertEqual(a0.tolist(), input[0].tolist()) - self.assertEqual(a1.tolist(), input[1].tolist()) - self.assertEqual(a22, input[2, 2]) - self.assertEqual(a12, input[1, -1]) + A22 = A[2, 2] + a22 = A22.resolve(image) + self.assertEqual(a22, image[2, 2]) + A12 = A[1, lambda: -1] + a12 = A12.resolve(image) + self.assertEqual(a12, image[1, -1]) def test_Slice_colon(self): - input = np.arange(16).reshape((4, 4)) A = features.DummyFeature() A0 = A[0, :1] - A1 = A[1, lambda: 0 : lambda: 4 : lambda: 2] - A2 = A[lambda: slice(0, 4, 1), 2] - A3 = A[lambda: 0 : lambda: 2, :] - a0 = A0.resolve(input) - a1 = A1.resolve(input) - a2 = A2.resolve(input) - a3 = A3.resolve(input) - self.assertEqual(a0.tolist(), input[0, :1].tolist()) + + A1 = A[1, lambda: 0 : lambda: 4 : lambda: 2] + a1 = A1.resolve(input) self.assertEqual(a1.tolist(), input[1, 0:4:2].tolist()) + + A2 = A[lambda: slice(0, 4, 1), 2] + a2 = A2.resolve(input) self.assertEqual(a2.tolist(), input[:, 2].tolist()) - self.assertEqual(a3.tolist(), input[0:2, :].tolist()) + A3 = A[lambda: 0 : lambda: 2, :] + a3 = A3.resolve(input) + self.assertEqual(a3.tolist(), input[0:2, :].tolist()) def test_Slice_ellipse(self): @@ -988,20 +1281,20 @@ def test_Slice_ellipse(self): A = features.DummyFeature() A0 = A[..., :1] - A1 = A[..., lambda: 0 : lambda: 4 : lambda: 2] - A2 = A[lambda: slice(0, 4, 1), ...] - A3 = A[lambda: 0 : lambda: 2, lambda: ...] - a0 = A0.resolve(input) - a1 = A1.resolve(input) - a2 = A2.resolve(input) - a3 = A3.resolve(input) - self.assertEqual(a0.tolist(), input[..., :1].tolist()) + + A1 = A[..., lambda: 0 : lambda: 4 : lambda: 2] + a1 = A1.resolve(input) self.assertEqual(a1.tolist(), input[..., 0:4:2].tolist()) + + A2 = A[lambda: slice(0, 4, 1), ...] + a2 = A2.resolve(input) self.assertEqual(a2.tolist(), input[:, ...].tolist()) - self.assertEqual(a3.tolist(), input[0:2, ...].tolist()) + A3 = A[lambda: 0 : lambda: 2, lambda: ...] + a3 = A3.resolve(input) + self.assertEqual(a3.tolist(), input[0:2, ...].tolist()) def test_Slice_static_dynamic(self): image = np.arange(27).reshape((3, 3, 3)) @@ -1022,35 +1315,28 @@ def test_Slice_static_dynamic(self): def test_Bind(self): - value = features.Value( - value=lambda input_value: input_value, - input_value=10, - ) value = features.Value( value=lambda input_value: input_value, input_value=10, ) pipeline = (value + 10) / value - - pipeline_with_small_input = features.Bind(pipeline, input_value=1) - res = pipeline.update().resolve() self.assertEqual(res, 2) + pipeline_with_small_input = features.Bind(pipeline, input_value=1) res = pipeline_with_small_input.update().resolve() self.assertEqual(res, 11) res = pipeline_with_small_input.update(input_value=10).resolve() self.assertEqual(res, 11) - def test_Bind_gaussian_noise(self): # Define the Gaussian noise feature and bind its properties gaussian_noise = Gaussian() bound_feature = features.Bind(gaussian_noise, mu=-5, sigma=2) # Create the input image - input_image = np.zeros((512, 512)) + input_image = np.zeros((128, 128)) # Resolve the feature to get the output image output_image = bound_feature.resolve(input_image) @@ -1060,10 +1346,8 @@ def test_Bind_gaussian_noise(self): output_std = np.std(output_image) # Assert that the mean and standard deviation are close to the bound values - self.assertAlmostEqual(output_mean, -5, delta=0.2, \ - msg="Mean is not within the expected range") - self.assertAlmostEqual(output_std, 2, delta=0.2, \ - msg="Standard deviation is not within the expected range") + self.assertAlmostEqual(output_mean, -5, delta=0.2) + self.assertAlmostEqual(output_std, 2, delta=0.2) def test_BindResolve(self): @@ -1126,15 +1410,14 @@ def test_BindUpdate(self): res = pipeline_with_small_input.update(input_value=10).resolve() self.assertEqual(res, 11) - - + def test_BindUpdate_gaussian_noise(self): # Define the Gaussian noise feature and bind its properties gaussian_noise = Gaussian() bound_feature = features.BindUpdate(gaussian_noise, mu=5, sigma=3) # Create the input image - input_image = np.zeros((512, 512)) + input_image = np.zeros((128, 128)) # Resolve the feature to get the output image output_image = bound_feature.resolve(input_image) @@ -1143,21 +1426,18 @@ def test_BindUpdate_gaussian_noise(self): output_mean = np.mean(output_image) output_std = np.std(output_image) - # Assert that the mean and standard deviation are close to the bound values - self.assertAlmostEqual(output_mean, 5, \ - delta=0.2, msg="Mean is not within the expected range") - self.assertAlmostEqual(output_std, 3, \ - delta=0.2, msg="Standard deviation is not within the expected range") + # Assert mean and standard deviation close to the bound values + self.assertAlmostEqual(output_mean, 5, delta=0.5) + self.assertAlmostEqual(output_std, 3, delta=0.5) def test_ConditionalSetProperty(self): - """Test that ConditionalSetProperty correctly modifies properties based on condition.""" - """Set up a Gaussian feature and a test image before each test.""" + # Set up a Gaussian feature and a test image before each test. gaussian_noise = Gaussian(sigma=0) image = np.ones((128, 128)) - """Test that sigma is correctly applied when condition is a boolean.""" + # Test that sigma is correctly applied when condition is a boolean. conditional_feature = features.ConditionalSetProperty( gaussian_noise, sigma=5, ) @@ -1170,9 +1450,9 @@ def test_ConditionalSetProperty(self): clean_image = conditional_feature.update()(image, condition=False) self.assertEqual(clean_image.std(), 0) - """Test that sigma is correctly applied when condition is a string property.""" + # Test sigma is correctly applied when condition is string property. conditional_feature = features.ConditionalSetProperty( - gaussian_noise, sigma=5, condition="is_noisy" + gaussian_noise, sigma=5, condition="is_noisy", ) # Test with condition met (should apply sigma=5) @@ -1185,16 +1465,15 @@ def test_ConditionalSetProperty(self): def test_ConditionalSetFeature(self): - - """Set up Gaussian noise features and test image before each test.""" + # Set up Gaussian noise features and test image before each test. true_feature = Gaussian(sigma=0) # Clean image (no noise) false_feature = Gaussian(sigma=5) # Noisy image (sigma=5) image = np.ones((512, 512)) - """Test using a direct boolean condition.""" + # Test using a direct boolean condition. conditional_feature = features.ConditionalSetFeature( on_true=true_feature, - on_false=false_feature + on_false=false_feature, ) # Default condition is True (no noise) @@ -1209,11 +1488,11 @@ def test_ConditionalSetFeature(self): clean_image = conditional_feature(image, condition=True) self.assertEqual(clean_image.std(), 0) - """Test using a string-based condition.""" + # Test using a string-based condition. conditional_feature = features.ConditionalSetFeature( on_true=true_feature, on_false=false_feature, - condition="is_noisy" + condition="is_noisy", ) # Condition is False (sigma=5) @@ -1230,39 +1509,45 @@ def test_Lambda_dependence(self): B = features.DummyFeature( key="a", - prop=lambda key: A.a() if key == "a" - else (A.b() if key == "b" else A.c()), + prop=lambda key: A.a() if key == "a" + else (A.b() if key == "b" + else A.c()), ) B.update() self.assertEqual(B.prop(), 1) - B.key.set_value("a") - self.assertEqual(B.prop(), 1) + B.key.set_value("b") self.assertEqual(B.prop(), 2) + B.key.set_value("c") self.assertEqual(B.prop(), 3) + B.key.set_value("a") + self.assertEqual(B.prop(), 1) def test_Lambda_dependence_twice(self): A = features.DummyFeature(a=1, b=2, c=3) B = features.DummyFeature( key="a", - prop=lambda key: A.a() if key == "a" - else (A.b() if key == "b" else A.c()), + prop=lambda key: A.a() if key == "a" + else (A.b() if key == "b" + else A.c()), prop2=lambda prop: prop * 2, ) B.update() self.assertEqual(B.prop2(), 2) - B.key.set_value("a") - self.assertEqual(B.prop2(), 2) + B.key.set_value("b") self.assertEqual(B.prop2(), 4) + B.key.set_value("c") self.assertEqual(B.prop2(), 6) + B.key.set_value("a") + self.assertEqual(B.prop2(), 2) def test_Lambda_dependence_other_feature(self): @@ -1270,23 +1555,26 @@ def test_Lambda_dependence_other_feature(self): B = features.DummyFeature( key="a", - prop=lambda key: A.a() if key == "a" - else (A.b() if key == "b" else A.c()), + prop=lambda key: A.a() if key == "a" + else (A.b() if key == "b" + else A.c()), prop2=lambda prop: prop * 2, ) - C = features.DummyFeature(B_prop=B.prop2, + C = features.DummyFeature(B_prop=B.prop2, prop=lambda B_prop: B_prop * 2) C.update() self.assertEqual(C.prop(), 4) - B.key.set_value("a") - self.assertEqual(C.prop(), 4) + B.key.set_value("b") self.assertEqual(C.prop(), 8) + B.key.set_value("c") self.assertEqual(C.prop(), 12) + B.key.set_value("a") + self.assertEqual(C.prop(), 4) def test_Lambda_scaling(self): def scale_function_factory(scale=2): @@ -1294,19 +1582,20 @@ def scale_function(image): return image * scale return scale_function - lambda_feature = features.Lambda(function=scale_function_factory, scale=5) + lambda_feature = features.Lambda( + function=scale_function_factory, + scale=5, + ) input_image = np.ones((5, 5)) - output_image = lambda_feature.resolve(input_image) + self.assertTrue(np.array_equal(output_image, np.ones((5, 5)) * 5)) - expected_output = np.ones((5, 5)) * 5 - self.assertTrue(np.array_equal(output_image, expected_output), "Arrays are not equal") - - lambda_feature = features.Lambda(function=scale_function_factory, scale=3) + lambda_feature = features.Lambda( + function=scale_function_factory, + scale=3, + ) output_image = lambda_feature.resolve(input_image) - - expected_output = np.ones((5, 5)) * 3 - self.assertTrue(np.array_equal(output_image, expected_output), "Arrays are not equal") + self.assertTrue(np.array_equal(output_image, np.ones((5, 5)) * 3)) def test_Merge(self): @@ -1320,9 +1609,12 @@ def merge_function(images): image_1 = np.ones((5, 5)) * 2 image_2 = np.ones((5, 5)) * 4 - expected_output = np.ones((5, 5)) * 3 output_image = merge_feature.resolve([image_1, image_2]) - self.assertIsNone(np.testing.assert_array_almost_equal(output_image, expected_output)) + self.assertIsNone( + np.testing.assert_array_almost_equal( + output_image, np.ones((5, 5)) * 3, + ) + ) image_1 = np.ones((5, 5)) * 2 image_2 = np.ones((3, 3)) * 4 @@ -1331,16 +1623,20 @@ def merge_function(images): image_1 = np.ones((5, 5)) * 2 output_image = merge_feature.resolve([image_1]) - self.assertIsNone(np.testing.assert_array_almost_equal(output_image, image_1)) + self.assertIsNone( + np.testing.assert_array_almost_equal( + output_image, image_1, + ) + ) def test_OneOf(self): - """Set up the features and input image for testing.""" + # Set up the features and input image for testing. feature_1 = features.Add(value=10) feature_2 = features.Multiply(value=2) input_image = np.array([1, 2, 3]) - """Test that OneOf applies one of the features randomly.""" + # Test that OneOf applies one of the features randomly. one_of_feature = features.OneOf([feature_1, feature_2]) output_image = one_of_feature.resolve(input_image) @@ -1349,14 +1645,16 @@ def test_OneOf(self): # - self.input_image * 2 (if feature_2 is chosen) expected_outputs = [ input_image + 10, - input_image * 2 + input_image * 2, ] self.assertTrue( - any(np.array_equal(output_image, expected) for expected in expected_outputs), - f"Output {output_image} did not match any expected transformations." + any( + np.array_equal(output_image, expected) + for expected in expected_outputs + ) ) - """Test that OneOf applies the selected feature when `key` is provided.""" + # Test that OneOf applies the selected feature when `key` is provided. controlled_feature = features.OneOf([feature_1, feature_2], key=0) output_image = controlled_feature.resolve(input_image) expected_output = input_image + 10 @@ -1398,7 +1696,6 @@ def test_OneOf_list(self): self.assertRaises(IndexError, lambda: values.update().resolve(key=3)) - def test_OneOf_tuple(self): values = features.OneOf( @@ -1430,7 +1727,6 @@ def test_OneOf_tuple(self): self.assertRaises(IndexError, lambda: values.update().resolve(key=3)) - def test_OneOf_set(self): values = features.OneOf( @@ -1498,35 +1794,33 @@ def test_OneOfDict(self): input_image = np.array([1, 2, 3]) - """Test that OneOfDict selects a feature randomly and applies it correctly.""" + # Test OneOfDict selects a feature randomly and applies it correctly. output_image = one_of_dict_feature.resolve(input_image) expected_outputs = [ input_image + 10, # "add" - input_image * 2, # "multiply" + input_image * 2, # "multiply" ] - self.assertTrue( - any(np.array_equal(output_image, expected) for expected in expected_outputs), - f"Output {output_image} did not match any expected transformations." - ) + self.assertTrue(any(np.array_equal(output_image, expected) + for expected in expected_outputs)) - """Test that OneOfDict selects the correct feature when a key is specified.""" + # Test OneOfDict selects the correct feature when a key is specified. controlled_feature = features.OneOfDict(features_dict, key="add") output_image = controlled_feature.resolve(input_image) - expected_output = input_image + 10 # The "add" feature should be applied + expected_output = input_image + 10 self.assertTrue(np.array_equal(output_image, expected_output)) controlled_feature = features.OneOfDict(features_dict, key="multiply") output_image = controlled_feature.resolve(input_image) - expected_output = input_image * 2 # The "multiply" feature should be applied + expected_output = input_image * 2 self.assertTrue(np.array_equal(output_image, expected_output)) - + def test_LoadImage(self): from tempfile import NamedTemporaryFile from PIL import Image as PIL_Image import os - """Create temporary image files in multiple formats for testing.""" + # Create temporary image files in multiple formats for testing. test_image_array = (np.random.rand(50, 50) * 255).astype(np.uint8) try: @@ -1539,39 +1833,45 @@ def test_LoadImage(self): # png_filename = temp_png.name with NamedTemporaryFile(suffix=".jpg", delete=False) as temp_jpg: - PIL_Image.fromarray(test_image_array).convert("RGB").save(temp_jpg.name) + PIL_Image.fromarray(test_image_array).convert("RGB") \ + .save(temp_jpg.name) # jpg_filename = temp_jpg.name - - """Test loading a .npy file.""" + # Test loading a .npy file. load_feature = features.LoadImage(path=temp_npy.name) loaded_image = load_feature.resolve() - self.assertEqual(loaded_image.shape[:2], test_image_array.shape[:2]) + self.assertEqual(loaded_image.shape[:2], + test_image_array.shape[:2]) - """Test loading a .png file.""" + # Test loading a .png file. load_feature = features.LoadImage(path=temp_png.name) loaded_image = load_feature.resolve() - self.assertEqual(loaded_image.shape[:2], test_image_array.shape[:2]) + self.assertEqual(loaded_image.shape[:2], + test_image_array.shape[:2]) - """Test loading a .jpg file.""" + # Test loading a .jpg file. load_feature = features.LoadImage(path=temp_jpg.name) loaded_image = load_feature.resolve() - self.assertEqual(loaded_image.shape[:2], test_image_array.shape[:2]) - - """Test loading an image and converting it to grayscale.""" - load_feature = features.LoadImage(path=temp_png.name, to_grayscale=True) + self.assertEqual(loaded_image.shape[:2], + test_image_array.shape[:2]) + + # Test loading an image and converting it to grayscale. + load_feature = features.LoadImage(path=temp_png.name, + to_grayscale=True) loaded_image = load_feature.resolve() - self.assertEqual(loaded_image.shape[-1], 1) + self.assertEqual(loaded_image.shape[-1], 1) - """Test ensuring a minimum number of dimensions.""" + # Test ensuring a minimum number of dimensions. load_feature = features.LoadImage(path=temp_png.name, ndim=4) loaded_image = load_feature.resolve() - self.assertGreaterEqual(len(loaded_image.shape), 4) + self.assertGreaterEqual(len(loaded_image.shape), 4) finally: for file in [temp_npy.name, temp_png.name, temp_jpg.name]: os.remove(file) + #TODO: Add a test for loading a list of images. + def test_SampleToMasks(self): # Parameters @@ -1622,6 +1922,7 @@ def test_SampleToMasks(self): def test_AsType(self): + # Test for Numpy arrays. input_image = np.array([1.5, 2.5, 3.5]) data_types = ["float64", "int32", "uint16", "int16", "uint8", "int8"] @@ -1637,6 +1938,12 @@ def test_AsType(self): np.all(output_image == np.array([1, 2, 3], dtype=dtype)) ) + # Test for Image. + #TODO + + # Test for PyTorch tensors. + #TODO + def test_ChannelFirst2d(self): @@ -1671,7 +1978,6 @@ def test_Upscale(self): "The upscaled image should be similar to the original within a tolerance") - def test_NonOverlapping_resample_volume_position(self): nonOverlapping = features.NonOverlapping( @@ -2065,21 +2371,98 @@ def test_Store(self): def test_Squeeze(self): + ### Test with NumPy array + input_image = np.array([[[[3], [2], [1]]], [[[1], [2], [3]]]]) + # shape: (2, 1, 3, 1) + + # Squeeze axis 1 + squeeze_feature = features.Squeeze(axis=1) + output_image = squeeze_feature(input_image) + self.assertEqual(output_image.shape, (2, 3, 1)) + expected_output = np.squeeze(input_image, axis=1) + np.testing.assert_array_equal(output_image, expected_output) - input_image = np.array([[[[3], [2], [1]]],[[[1], [2], [3]]]]) + # Squeeze all singleton dimensions + squeeze_feature = features.Squeeze() + output_image = squeeze_feature(input_image) + self.assertEqual(output_image.shape, (2, 3)) + expected_output = np.squeeze(input_image) + np.testing.assert_array_equal(output_image, expected_output) + + # Squeeze multiple axes + squeeze_feature = features.Squeeze(axis=(1, 3)) + output_image = squeeze_feature(input_image) + self.assertEqual(output_image.shape, (2, 3)) + expected_output = np.squeeze(np.squeeze(input_image, axis=3), axis=1) + np.testing.assert_array_equal(output_image, expected_output) + + ### Test with Image + input_data = np.array([[[[3], [2], [1]]], [[[1], [2], [3]]]]) + # shape: (2, 1, 3, 1) + input_image = features.Image(input_data) squeeze_feature = features.Squeeze(axis=1) output_image = squeeze_feature(input_image) self.assertEqual(output_image.shape, (2, 3, 1)) + expected_output = np.squeeze(input_data, axis=1) + np.testing.assert_array_equal(output_image, expected_output) squeeze_feature = features.Squeeze() output_image = squeeze_feature(input_image) - self.assertEqual(output_image.shape, (2,3)) + self.assertEqual(output_image.shape, (2, 3)) + expected_output = np.squeeze(input_data) + np.testing.assert_array_equal(output_image, expected_output) + + squeeze_feature = features.Squeeze(axis=(1, 3)) + output_image = squeeze_feature(input_image) + self.assertEqual(output_image.shape, (2, 3)) + expected_output = np.squeeze(np.squeeze(input_data, axis=3), axis=1) + np.testing.assert_array_equal(output_image, expected_output) + + ### Test with PyTorch tensor (if available) + if TORCH_AVAILABLE: + input_tensor = torch.tensor([[[[3], [2], [1]]], [[[1], [2], [3]]]]) + # shape: (2, 1, 3, 1) + + squeeze_feature = features.Squeeze(axis=1) + output_tensor = squeeze_feature(input_tensor) + self.assertEqual(output_tensor.shape, (2, 3, 1)) + expected_tensor = input_tensor.squeeze(1) + torch.testing.assert_close(output_tensor, expected_tensor) + + squeeze_feature = features.Squeeze() + output_tensor = squeeze_feature(input_tensor) + self.assertEqual(output_tensor.shape, (2, 3)) + expected_tensor = input_tensor.squeeze() + torch.testing.assert_close(output_tensor, expected_tensor) + + squeeze_feature = features.Squeeze(axis=(1, 3)) + output_tensor = squeeze_feature(input_tensor) + self.assertEqual(output_tensor.shape, (2, 3)) + expected_tensor = input_tensor.squeeze(3).squeeze(1) + torch.testing.assert_close(output_tensor, expected_tensor) def test_Unsqueeze(self): + ### Test with NumPy array + input_image = np.array([1, 2, 3]) - input_image = np.array([1, 2, 3]) # shape (3,) + unsqueeze_feature = features.Unsqueeze(axis=0) + output_image = unsqueeze_feature(input_image) + self.assertEqual(output_image.shape, (1, 3)) + + unsqueeze_feature = features.Unsqueeze() + output_image = unsqueeze_feature(input_image) + self.assertEqual(output_image.shape, (3, 1)) + + # Multiple axes + unsqueeze_feature = features.Unsqueeze(axis=(0, 2)) + output_image = unsqueeze_feature(input_image) + self.assertEqual(output_image.shape, (1, 3, 1)) + + ### Test with Image + input_data = np.array([1, 2, 3]) + input_image = features.Image(input_data) unsqueeze_feature = features.Unsqueeze(axis=0) output_image = unsqueeze_feature(input_image) @@ -2089,41 +2472,153 @@ def test_Unsqueeze(self): output_image = unsqueeze_feature(input_image) self.assertEqual(output_image.shape, (3, 1)) + # Multiple axes + unsqueeze_feature = features.Unsqueeze(axis=(0, 2)) + output_image = unsqueeze_feature(input_image) + self.assertEqual(output_image.shape, (1, 3, 1)) - def test_MoveAxis(self): + ### Test with PyTorch tensor (if available) + if TORCH_AVAILABLE: + input_tensor = torch.tensor([1, 2, 3]) + + unsqueeze_feature = features.Unsqueeze(axis=0) + output_tensor = unsqueeze_feature(input_tensor) + self.assertEqual(output_tensor.shape, (1, 3)) + torch.testing.assert_close(output_tensor, + input_tensor.unsqueeze(0)) + + unsqueeze_feature = features.Unsqueeze() + output_tensor = unsqueeze_feature(input_tensor) + self.assertEqual(output_tensor.shape, (3, 1)) + torch.testing.assert_close(output_tensor, + input_tensor.unsqueeze(-1)) + + # Multiple axes + unsqueeze_feature = features.Unsqueeze(axis=(0, 2)) + output_tensor = unsqueeze_feature(input_tensor) + self.assertEqual(output_tensor.shape, (1, 3, 1)) + expected_tensor = input_tensor.unsqueeze(0).unsqueeze(2) + torch.testing.assert_close(output_tensor, expected_tensor) + + def test_MoveAxis(self): + ### Test with NumPy array input_image = np.random.rand(2, 3, 4) move_axis_feature = features.MoveAxis(source=0, destination=2) output_image = move_axis_feature(input_image) self.assertEqual(output_image.shape, (3, 4, 2)) + ### Test with Image + input_data = np.random.rand(2, 3, 4) + input_image = features.Image(input_data) + + move_axis_feature = features.MoveAxis(source=0, destination=2) + output_image = move_axis_feature(input_image) + self.assertEqual(output_image.shape, (3, 4, 2)) + + ### Test with PyTorch tensor (if available) + if TORCH_AVAILABLE: + input_tensor = torch.rand(2, 3, 4) + + move_axis_feature = features.MoveAxis(source=0, destination=2) + output_tensor = move_axis_feature(input_tensor) + print(output_tensor.shape) + self.assertEqual(output_tensor.shape, (3, 4, 2)) - def test_Transpose(self): + def test_Transpose(self): + ### Test with NumPy array input_image = np.random.rand(2, 3, 4) + # Explicit axes transpose_feature = features.Transpose(axes=(1, 2, 0)) output_image = transpose_feature(input_image) self.assertEqual(output_image.shape, (3, 4, 2)) + expected_output = np.transpose(input_image, (1, 2, 0)) + self.assertTrue(np.allclose(output_image, expected_output)) + # Reversed axes transpose_feature = features.Transpose() output_image = transpose_feature(input_image) self.assertEqual(output_image.shape, (4, 3, 2)) + expected_output = np.transpose(input_image) + self.assertTrue(np.allclose(output_image, expected_output)) + ### Test with Image + input_data = np.random.rand(2, 3, 4) + input_image = features.Image(input_data) - def test_OneHot(self): + transpose_feature = features.Transpose(axes=(1, 2, 0)) + output_image = transpose_feature(input_image) + self.assertEqual(output_image.shape, (3, 4, 2)) + + ### Test with PyTorch tensor (if available) + if TORCH_AVAILABLE: + input_tensor = torch.rand(2, 3, 4) + + # Explicit axes + transpose_feature = features.Transpose(axes=(1, 2, 0)) + output_tensor = transpose_feature(input_tensor) + self.assertEqual(output_tensor.shape, (3, 4, 2)) + expected_tensor = input_tensor.permute(1, 2, 0) + self.assertTrue(torch.allclose(output_tensor, expected_tensor)) + + # Reversed axes + transpose_feature = features.Transpose() + output_tensor = transpose_feature(input_tensor) + self.assertEqual(output_tensor.shape, (4, 3, 2)) + expected_tensor = input_tensor.permute(2, 1, 0) + self.assertTrue(torch.allclose(output_tensor, expected_tensor)) - input_image = np.array([0, 1, 2]) + def test_OneHot(self): + ### Test with NumPy array + input_image = np.array([0, 1, 2]) one_hot_feature = features.OneHot(num_classes=3) - output_image = one_hot_feature.get(input_image, num_classes=3) + output_image = one_hot_feature(input_image) + expected_output = np.array([ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0] - ]) - self.assertTrue(np.array_equal(output_image, expected_output)) + ], dtype=np.float32) + + self.assertEqual(output_image.shape, (3, 3)) + np.testing.assert_array_equal(output_image, expected_output) + + ### Test with singleton last dimension + input_image = np.array([[0], [1], [2]]) # shape (3, 1) + output_image = one_hot_feature(input_image) + self.assertEqual(output_image.shape, (3, 3)) + np.testing.assert_array_equal(output_image, expected_output) + + ### Test with Image + input_data = np.array([0, 1, 2]) + input_image = features.Image(input_data) + output_image = one_hot_feature(input_image) + self.assertEqual(output_image.shape, (3, 3)) + np.testing.assert_array_equal(output_image, expected_output) + + ### Test with PyTorch tensor (if available) + if TORCH_AVAILABLE: + input_tensor = torch.tensor([0, 1, 2]) + output_tensor = one_hot_feature(input_tensor) + + expected_tensor = torch.tensor([ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0] + ], dtype=torch.float32) + + self.assertEqual(output_tensor.shape, (3, 3)) + torch.testing.assert_close(output_tensor, expected_tensor) + + # Test with singleton dimension + input_tensor = torch.tensor([[0], [1], [2]]) + output_tensor = one_hot_feature(input_tensor) + self.assertEqual(output_tensor.shape, (3, 3)) + torch.testing.assert_close(output_tensor, expected_tensor) def test_TakeProperties(self):