Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Unreleased

### Fixed

- Rectangular affines now have the correct input and output dimensions

### Added

- ProjectAxis transformation for adding and dropping axes

### Changed

- Expose all transforms under `.transforms`, even if they are optional
- BREAKING: Rename `GeometryAdapter` to `ShapelyAdapter`

## 0.5.0 - 2026-06-17

### Added
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,23 @@ All transforms are accessed under the `transformnd.transforms` subpackage.
| `MapAxis` | | Rearrange axes of the input coordinates |
| `Affine` | | Multiply augmented coordinates by an affine transformation matrix. Can represent all of the above transformations. Can be composed with matrix multiplication `aff2 @ aff1`. |
| `ByDimension` | | Apply different transformations to subsets of the input coordinates' dimensions |
| `moving_least_squares.MovingLeastSquares` | `movingleastsquares` | Landmark-based transformation. |
| `thin_plate_splines.ThinPlateSplines` | `thinplatesplines` | Landmark-based transformation. |
| `vector_field.Coordinates` | `vectorfield` for in-memory, `vectorfield-dask` for chunked | Look up output coordinates in a vector field indexed by the input coordinates |
| `vector_field.Displacements` | `vectorfield`, `vectorfield-dask` for chunked | Look up translations in a vector field indexed by the input coordinates, and add them to input coordinates |
| `MovingLeastSquares` | `movingleastsquares` | Landmark-based transformation. |
| `ThinPlateSplines` | `thinplatesplines` | Landmark-based transformation. |
| `Coordinates` | `vectorfield` for in-memory, `vectorfield-dask` for chunked | Look up output coordinates in a vector field indexed by the input coordinates |
| `Displacements` | `vectorfield`, `vectorfield-dask` for chunked | Look up translations in a vector field indexed by the input coordinates, and add them to input coordinates |

Arbitrary transforms can be composed into a `TransformSequence` with `transform1 | transform2`.
A graph of transforms between defined spaces can be traversed using the `TransformGraph`.

## Implemented adapters

- Numpy arrays of shape `(..., D, ...)` (`transformnd.adapters.ReshapeAdapter`)
- `meshio.Mesh` (`transformnd.adapters.meshio.MeshAdapter`)
- `pandas.DataFrame` (`transformnd.adapters.pandas.PandasAdapter`)
- Takes a subset of columns as a coordinate array
- `polars.DataFrame` (`transformnd.adapters.polars.PolarsAdapter`)
- Similar to the pandas adapter
- Currently, only scalar columns are supported (e.g. not a single struct column with fields `x`, `y`, `z`)
- Geometries from `shapely` (`transformnd.adapters.shapely.GeometryAdapter`)
- Geometries from `shapely` (`transformnd.adapters.shapely.ShapelyAdapter`)
- Objects composed of transformable attributes (`transformnd.adapters.AttrAdapter`).

## Additional transforms and adapters
Expand Down
3 changes: 3 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,8 @@ bump level:
git commit -m "Bump to v$(uv version --short)"
git tag -a "v$(uv version --short)" -m "$(changelog entry latest)"

pre-commit:
uv run --group dev prek run --all-files

repl:
uv run --all-groups --all-extras --with ipython ipython
7 changes: 6 additions & 1 deletion src/transformnd/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

See `.pandas.DataFrameAdapter` for an example of creating an adapter
for an external type.

"""

from .base import (
Expand All @@ -23,6 +22,9 @@
ReshapeAdapter,
SimpleAdapter,
)
from .pandas import PandasAdapter
from .polars import PolarsAdapter
from .shapely import ShapelyAdapter

__all__ = [
"BaseAdapter",
Expand All @@ -31,4 +33,7 @@
"FnAdapter",
"AttrAdapter",
"ReshapeAdapter",
"PandasAdapter",
"PolarsAdapter",
"ShapelyAdapter",
]
11 changes: 7 additions & 4 deletions src/transformnd/adapters/pandas.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"""Adapt pandas DataFrames for transformation."""

from collections.abc import Hashable
from typing import TYPE_CHECKING

import pandas as pd
import numpy as np

from ..base import Transform
from .base import BaseAdapter

if TYPE_CHECKING:
import pandas as pd

class PandasAdapter(BaseAdapter[pd.DataFrame, np.ndarray]):

class PandasAdapter(BaseAdapter["pd.DataFrame", np.ndarray]):
def __init__(self, columns: list[Hashable]):
"""Adapt transformation for coordinates stored in a pandas DataFrame.

Expand All @@ -21,8 +24,8 @@ def __init__(self, columns: list[Hashable]):
self.columns = columns

def apply(
self, transform: Transform, df: pd.DataFrame, in_place: bool = False
) -> pd.DataFrame:
self, transform: Transform, df: "pd.DataFrame", in_place: bool = False
) -> "pd.DataFrame":
"""Transform the dataframe, optionally in-place.

Parameters
Expand Down
11 changes: 7 additions & 4 deletions src/transformnd/adapters/polars.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Adapt polars DataFrames for transformation."""

import polars as pl
from typing import TYPE_CHECKING
import numpy as np

from ..base import Transform
from .base import BaseAdapter

if TYPE_CHECKING:
import polars as pl

class PolarsAdapter(BaseAdapter[pl.DataFrame, np.ndarray]):

class PolarsAdapter(BaseAdapter["pl.DataFrame", np.ndarray]):
def __init__(self, columns: list[str]):
"""Adapt transformation for coordinates stored in a polars DataFrame.

Expand All @@ -19,8 +22,8 @@ def __init__(self, columns: list[str]):
self.columns = columns

def apply(
self, transform: Transform, df: pl.DataFrame, in_place: bool = False
) -> pl.DataFrame:
self, transform: Transform, df: "pl.DataFrame", in_place: bool = False
) -> "pl.DataFrame":
"""Transform the dataframe, optionally in-place.

Parameters
Expand Down
16 changes: 10 additions & 6 deletions src/transformnd/adapters/shapely.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import logging
from typing import TYPE_CHECKING

import numpy as np
import shapely
from shapely.geometry.base import BaseGeometry
from shapely.coords import CoordinateSequence

from ..base import Transform, ArrayT
from .base import BaseAdapter

if TYPE_CHECKING:
from shapely.geometry.base import BaseGeometry
from shapely.coords import CoordinateSequence


logger = logging.getLogger(__name__)


def as_numpy(coords: CoordinateSequence) -> np.ndarray:
def as_numpy(coords: "CoordinateSequence") -> np.ndarray:
return np.asarray(coords)


class GeometryAdapter(BaseAdapter[BaseGeometry, ArrayT]):
class ShapelyAdapter(BaseAdapter["BaseGeometry", ArrayT]):
"""Transform shapely geometries.

As well as the generic `apply()`,
Expand All @@ -27,7 +30,7 @@ class GeometryAdapter(BaseAdapter[BaseGeometry, ArrayT]):
N.B. shapely geometries' coordinates are in `XY(Z)` order
"""

def apply[T: BaseGeometry](
def apply[T: "BaseGeometry"](
self,
transform: Transform,
obj: T,
Expand All @@ -51,6 +54,7 @@ def apply[T: BaseGeometry](
T
An object of the same type as the input.
"""
import shapely

def fn(coords: np.ndarray) -> np.ndarray:
c = coords.copy()
Expand Down
14 changes: 13 additions & 1 deletion src/transformnd/transforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,27 @@
from .simple import Identity, Scale, Translate
from .map_axis import MapAxis
from .bijection import Bijection
from .by_dimension import ByDimension
from .project_axis import ProjectAxis, Insert, Remove
from .by_dimension import ByDimension, SubTransform
from .vector_field import Coordinates, Displacements
from .moving_least_squares import MovingLeastSquares
from .thinplate import ThinPlateSplines

__all__ = [
"Affine",
"Identity",
"ProjectAxis",
"Insert",
"Remove",
"Reflect",
"Scale",
"Translate",
"MapAxis",
"Bijection",
"ByDimension",
"SubTransform",
"Coordinates",
"Displacements",
"MovingLeastSquares",
"ThinPlateSplines",
]
4 changes: 2 additions & 2 deletions src/transformnd/transforms/affine.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def __init__(
----------
matrix
Affine transformation matrix,
i.e. a 2D array-like with shape `(Di + 1, Do + 1)`,
i.e. a 2D array-like with shape `(Do + 1, Di + 1)`,
where the bottom row is all 0s except in the rightmost column, which is 1.
spaces
Optional source and target spaces
Expand All @@ -64,7 +64,7 @@ def __init__(
f"Transformation matrix is not affine (expected bottom row {expected}, got {bottom_row})."
)

super().__init__(NDims(m.shape[0] - 1, m.shape[1] - 1), spaces=spaces)
super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1), spaces=spaces)

self.matrix: np.ndarray = m

Expand Down
23 changes: 22 additions & 1 deletion src/transformnd/transforms/by_dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,35 @@


class SubTransform[ArrayT]:
"""Transformation to apply to subsets of the input dimensions and which output dimensions they calculate."""
"""Component of the `ByDimension` transformation.

Transformation to apply to subsets of the input dimensions and which output dimensions they calculate.
"""

def __init__(
self,
transform: Transform[ArrayT],
input_axes: list[int],
output_axes: list[int] | None = None,
):
"""
Parameters
----------
transform
Transformation to apply to the subset of axes.
input_axes
Which axes to apply the transformation to, in order.
The length must match the input dimensionality of `transform`.
output_axes
Which axes to apply the transformation to, in order.
The length must match the input dimensionality of `transform`.
If None, re-use the input axes.

Raises
------
ValueError
`transform`'s dimensionality does not match the input/output axes.
"""

self.input_axes = input_axes
if output_axes is None:
Expand Down
7 changes: 5 additions & 2 deletions src/transformnd/transforms/moving_least_squares.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from array_api_compat import array_namespace
import numpy as np
from typing import Self
from molesq.transform import Transformer as _Transformer

from ..base import Transform
from ..types import NDims, Spaces
Expand All @@ -18,6 +17,8 @@ class MovingLeastSquares(Transform[np.ndarray]):
"""Moving least squares transformation.

Deform based on a matched pairs of source and target control points; see <https://dl.acm.org/doi/10.1145/1141911.1141920>

REQUIRES: `movingleastsquares` extra.
"""

def __init__(
Expand All @@ -39,9 +40,11 @@ def __init__(
spaces
Optional source and target spaces
"""
from molesq.transform import Transformer

s = as_floats(source_control_points)
t = as_floats(target_control_points)
self._transformer = _Transformer(s, t)
self._transformer = Transformer(s, t)
super().__init__(
NDims(
s.shape[1],
Expand Down
Loading
Loading