Skip to content

SurfaceTransform class #203

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Jun 22, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
17477ea
Skeleton code to save/load transforms using X5 format.
feilong May 15, 2024
00af8d9
Support I/O of npz format.
feilong May 15, 2024
2ca8630
Normalize the transform so that the sum or the value of each element …
feilong May 16, 2024
3dbcf18
NF: add surface index transforms
Shotgunosine May 17, 2024
ed81087
NF: add surface resampling and surfacecoordinate transform
Shotgunosine May 17, 2024
cbafa5d
NF: surface resampler can load from surfaces
Shotgunosine May 17, 2024
4ba0bdc
Merge branch 'master' into surface
oesteban May 20, 2024
7be1b57
fix flake8
Shotgunosine May 21, 2024
b20a4bc
add .DS_Store
Shotgunosine May 21, 2024
46aca57
DOC: add documentation for suface coordinate transform
Shotgunosine May 21, 2024
6daaef1
TEST: expand test coverage
Shotgunosine May 21, 2024
2496647
NF: Validate inputs to SurfaceResampler
Shotgunosine May 21, 2024
f7784bc
ADD: project-unproject functionality
Shotgunosine Jun 20, 2024
092e745
Merge branch 'nipy:master' into surface
Shotgunosine Jun 20, 2024
d5705fb
clean surface test
Shotgunosine Jun 20, 2024
043816b
Merge branch 'nipy:master' into surface
Shotgunosine Jun 20, 2024
fc06289
FIX: don't drop 3dNwarpApply from the dockerfile
Shotgunosine Jun 20, 2024
4e95fa3
Update Dockerfile
Shotgunosine Jun 21, 2024
884a81e
RF: don't decompose coordinates before transforming
Shotgunosine Jun 21, 2024
288fc7c
TEST: split out project unproject test
Shotgunosine Jun 21, 2024
c3587c1
TEST: cover some more error messages
Shotgunosine Jun 21, 2024
5e8b492
PL: lint surface
Shotgunosine Jun 21, 2024
e6a63d9
RF: reorganize x5 files
Shotgunosine Jun 21, 2024
f9cabec
Update nitransforms/surface.py
Shotgunosine Jun 22, 2024
a4d6df2
Update nitransforms/tests/test_surface.py
Shotgunosine Jun 22, 2024
79b7b50
Update nitransforms/tests/test_surface.py
Shotgunosine Jun 22, 2024
57222fd
fix_rebase
Shotgunosine Jun 22, 2024
2e7c7eb
TEST: drop unused path
Shotgunosine Jun 22, 2024
ef29204
Merge branch 'master' into surface
Shotgunosine Jun 22, 2024
454e289
TEST: Fix surface coordinate transfrom tests
Shotgunosine Jun 22, 2024
2082f76
Merge branch 'surface' of https://github.com/feilong/nitransforms int…
Shotgunosine Jun 22, 2024
bf80e71
PL: fix style regressions
Shotgunosine Jun 22, 2024
f34d609
PL: fix long lines
Shotgunosine Jun 22, 2024
f470a59
Apply suggestions from code review
oesteban Jun 22, 2024
f69faaf
doc: add new module to APIdoc
oesteban Jun 22, 2024
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
124 changes: 124 additions & 0 deletions nitransforms/surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
#
# See COPYING file distributed along with the NiBabel package for the
# copyright and license terms.
#
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
"""Surface transforms."""

import h5py
import numpy as np
import scipy.sparse as sparse

from nitransforms.base import TransformBase


class SurfaceTransform(TransformBase):
"""Represents transforms between surface spaces."""

__slots__ = ("mat",)

def __init__(self, mat):
"""Initialize the transform.

Parameters
----------
mat : array-like, shape (nv1, nv2)
Sparse matrix representing the transform.
"""
super().__init__()
if isinstance(mat, sparse.csr_array):
self.mat = mat
else:
self.mat = sparse.csr_array(mat)

def apply(self, x, inverse=False, normalize="element"):
"""Apply the transform to surface data.

Parameters
----------
x : array-like, shape (..., nv1)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is nv1? number of vertices?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the number of vertices. I use nv1 for the number of vertices of input, and nv2 for the output, given they are likely to be different.

Data to transform.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is "data" in this context?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking typically either functional data (2D data matrices, e.g., response time series) or anatomical data (1D maps, e.g., thickness).

inverse : bool, default=False
Whether to apply the inverse transform. If True, ``x`` has shape
(..., nv2), and the output will have shape (..., nv1).
normalize : {"element", "sum", "none"}, default="element"
Normalization strategy. If "element", the scale of each value in
the output is comparable to each value of the input. If "sum", the
sum of the output is comparable to the sum of the input. If
"none", no normalization is applied.

Returns
-------
y : array-like, shape (..., nv2)
Transformed data.
"""
if normalize not in ("element", "sum", "none"):
raise ValueError("Invalid normalization strategy.")

mat = self.mat.T if inverse else self.mat

if normalize == "element":
sum_ = mat.sum(axis=0)
scale = np.zeros_like(sum_)
mask = sum_ != 0
scale[mask] = 1.0 / sum_[mask]
mat = mat @ sparse.diags(scale)
elif normalize == "sum":
sum_ = mat.sum(axis=1)
scale = np.zeros_like(sum_)
mask = sum_ != 0
scale[mask] = 1.0 / sum_[mask]
mat = sparse.diags(scale) @ mat

y = x @ mat
return y

def _to_hdf5(self, x5_root):
"""Write transform to HDF5 file."""
xform = x5_root.create_group("Transform")
xform.attrs["Type"] = "surface"
xform.create_dataset("data", data=self.mat.data)
xform.create_dataset("indices", data=self.mat.indices)
xform.create_dataset("indptr", data=self.mat.indptr)
xform.create_dataset("shape", data=self.mat.shape)

def to_filename(self, filename, fmt=None):
"""Store the transform."""
if fmt is None:
fmt = "npz" if filename.endswith(".npz") else "X5"

if fmt == "npz":
sparse.save_npz(filename, self.mat)
return filename

with h5py.File(filename, "w") as out_file:
out_file.attrs["Format"] = "X5"
out_file.attrs["Version"] = np.uint16(1)
root = out_file.create_group("/0")
self._to_hdf5(root)

return filename

@classmethod
def from_filename(cls, filename, fmt=None):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from filename would not read sphere.reg and so forth?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is only reading the sparse matrix from a file.
With the code Dylan's working on, we can generate the sparse matrix from the spheres.

"""Load transform from file."""
if fmt is None:
fmt = "npz" if filename.endswith(".npz") else "X5"

if fmt == "npz":
return cls(sparse.load_npz(filename))

if fmt != "X5":
raise ValueError("Only npz and X5 formats are supported.")

with h5py.File(filename, "r") as f:
assert f.attrs["Format"] == "X5"
xform = f["/0/Transform"]
mat = sparse.csr_matrix(
(xform["data"][()], xform["indices"][()], xform["indptr"][()]),
shape=xform["shape"][()],
)
return cls(mat)
58 changes: 58 additions & 0 deletions nitransforms/tests/test_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import os
import tempfile

import numpy as np
import scipy.sparse as sparse

from nitransforms.surface import SurfaceTransform


def test_surface_transform_x5():
mat = sparse.random(10, 10, density=0.5)
xfm = SurfaceTransform(mat)
fn = tempfile.mktemp(suffix=".h5")
print(fn)
xfm.to_filename(fn)

xfm2 = SurfaceTransform.from_filename(fn)
try:
assert xfm.mat.shape == xfm2.mat.shape
np.testing.assert_array_equal(xfm.mat.data, xfm2.mat.data)
np.testing.assert_array_equal(xfm.mat.indices, xfm2.mat.indices)
np.testing.assert_array_equal(xfm.mat.indptr, xfm2.mat.indptr)
except Exception:
os.remove(fn)
raise
os.remove(fn)


def test_surface_transform_npz():
mat = sparse.random(10, 10, density=0.5)
xfm = SurfaceTransform(mat)
fn = tempfile.mktemp(suffix=".npz")
print(fn)
xfm.to_filename(fn)

xfm2 = SurfaceTransform.from_filename(fn)
try:
assert xfm.mat.shape == xfm2.mat.shape
np.testing.assert_array_equal(xfm.mat.data, xfm2.mat.data)
np.testing.assert_array_equal(xfm.mat.indices, xfm2.mat.indices)
np.testing.assert_array_equal(xfm.mat.indptr, xfm2.mat.indptr)
except Exception:
os.remove(fn)
raise
os.remove(fn)


def test_surface_transform_normalization():
mat = np.random.uniform(size=(20, 10))
xfm = SurfaceTransform(mat)
x = np.random.uniform(size=(5, 20))
y_element = xfm.apply(x, normalize="element")
np.testing.assert_array_less(y_element.sum(axis=1), x.sum(axis=1))
y_sum = xfm.apply(x, normalize="sum")
np.testing.assert_allclose(y_sum.sum(axis=1), x.sum(axis=1))
y_none = xfm.apply(x, normalize="none")
assert y_none.sum() != y_element.sum()
assert y_none.sum() != y_sum.sum()