Conversion of ITK BSpline to knot-vector representation of splinepy

Colleagues of mine use a spline library called splinepy. It has some nice features, I wanted to try out, using some ITK BSplineTransforms I have obtained from image registration. However, I wasn’t able to convert them into the representation which splinepy accepts and also my colleagues struggled to understand how a BSplineTransform is actually created in ITK.

I think the fundamental difference is, that in splinepy, I’m required to give a knot vector and the positions of the control points, while in ITK I just give displacements of control points, which are on a regular grid. Finding the CP coordinates and converting displacements to positions is not a big deal, however, we were not able to produce the correct knot vector, which would give the same spline. I tested my conversion by transforming points in ITK and by requesting the point in splinepy. However, I can only make it work when using a spline of degree 1.

I tried to lookup the original literature, on which the ITK implementation is based on.
What I could find is, that the ITK implementation is apparently based upon the work of Unser et al. - however, I wasn’t able to deduce from skimming the paper how the spline is actually implemented and what the knot vector might be, but I may just have missed important information in this paper (perhaps because at first glance they used yet another notation than in The NURBS Book or in the paper by Lee et al)… Reading in the ITK source code wasn’t a success either, but again, I may just have not found the important bits.

I believe that the issue is in the way the spline is setup in combination with the transformation domain. I know that ITK will place control points outside the transformation domain, depending on the order (btw, I believe that “order” in ITK actually means “degree”?! A p-degree spline has order p+1, but I think that ITK uses the term order instead of degree?)
If I remember correctly, if the spline is written down in the way as Lee et al does it (see Eq.1), then the spline is not interpolating until the edge of the control point grid - hence it should not have knot multiplicity p+1 at the ends of the knot vector. But messing around with different knot vectors has not worked…

To show what is already working, here is an example:

import SimpleITK as sitk
import splinepy as sp
import numpy as np

u = np.array([
    0.0, 0.0,
    0.0, 0.0,
    0.0, 0.0,
    0.0, 0.0,
    0.1, 0.2,
    0.0, 0.0,
    0.0, 0.0,
    0.0, 0.0,
    -0.1, 0.3,
]).reshape(9, 2)

# splinepy:
s = sp.BSpline(degrees=[1, 1], knot_vectors=[[0, 0, 0.5, 1, 1], [0, 0, 0.5, 1, 1]], control_points=sp.utils.data.cartesian_product(np.array([[0, 0.5, 1], [0, 0.5, 1]])) + u)

# SimpleITK
bs = sitk.BSplineTransform(2, 1)  # Create a new BSpline, dimension=2, order=1
# 3x3 grid, origin=0,0, spacing=0.5,0.5, direction=unity
bs.SetFixedParameters((3.0, 3.0, 0.0, 0.0, 0.5, 0.5, 1.0, 0.0, 0.0, 1.0))
bs.SetParameters(u.flatten(order='F'))

test_points = [[0, 0], [0.5, 0.5], [0.3, 0.75], [1, 0], [0.25, 0.33]]
for point in test_points:
    print(f"p={str(point) :15s}    splinepy: {str(s.evaluate([point])[0].tolist()):30s}    sitk: {str(list(bs.TransformPoint(point))):30s}")

with the output:

p=[0, 0]             splinepy: [0.0, 0.0]                        sitk: [0.0, 0.0]                    
p=[0.5, 0.5]         splinepy: [0.6, 0.7]                        sitk: [0.6, 0.7]                    
p=[0.3, 0.75]        splinepy: [0.32999999999999996, 0.81]       sitk: [0.32999999999999996, 0.81]   
p=[1, 0]             splinepy: [1.0, 0.0]                        sitk: [1.0, 0.0]                    
p=[0.25, 0.33]       splinepy: [0.28300000000000003, 0.396]      sitk: [0.28300000000000003, 0.396]  

which looks really good! But here is with one degree higher:

# Assuming I need tripple knot multiplicity at the ends...
# Assuming I need to have the parametric coordinate running from -0.5 to 1.5, as the
# ITK BSpline will have this as the control point grid.
s = sp.BSpline(degrees=[2, 2], knot_vectors=[[-0.5, -0.5, -0.5, 1.5, 1.5, 1.5], [-0.5, -0.5, -0.5, 1.5, 1.5, 1.5]], control_points=sp.utils.data.cartesian_product(np.array([[-0.5, 0.5, 1.5], [-0.5, 0.5, 1.5]])) + u)

bs = sitk.BSplineTransform(2, 2)
bs.SetParameters(u.flatten(order='F'))

test_points = [[0, 0], [0.5, 0.5], [0.3, 0.75], [1, 0], [0.25, 0.33]]
for point in test_points:
    print(f"p={str(point) :15s}    splinepy: {str(s.evaluate([point])[0].tolist()):30s}    sitk: {str(list(bs.TransformPoint(point))):30s}")

Here, it fails:

p=[0, 0]             splinepy: [0.013671874999999991, 0.029296874999999997]    sitk: [0.025, 0.05]                 
p=[0.5, 0.5]         splinepy: [0.51875, 0.56875]                sitk: [0.5546875, 0.6171875]        
p=[0.3, 0.75]        splinepy: [0.31625000000000003, 0.81375]    sitk: [0.347546875, 0.851421875]    
p=[1, 0]             splinepy: [1.010546875, 0.03867187500000001]    sitk: [1.0250000000000001, 0.050000000000000135]
p=[0.25, 0.33]       splinepy: [0.27033824218749997, 0.38278605468750004]    sitk: [0.29940546875, 0.42966171875000003]

Maybe someone with more BSpline knowledge can give me a hint how to do that?

@hjmjohnson and/or @ntustison might have advice.

I’ll try to answer some of your specific questions. If you want additional help, perhaps you can be more explicit as to what you’re ultimately trying to accomplish.

The B-splines used in the BsplineTransform (and elsewhere) are uniform B-splines in that the knot vector per parametric dimension is uniformly spaced, e.g., [0, 0.1, 0.2, 0.3, 0.4, 0.5] or [0, 1, 2, 3, …]. The knot vector doesn’t appear in the public API or even the internals of the B-spline classes of which I’m aware because, with uniform B-splines, if one specifies the number of control points per parametric dimension as well as the parametric domain, the uniform knot vector is defined implicitly.

I would guess that the main reason why non-uniform B-splines and non-uniform rational B-splines aren’t used in ITK is because the point-inversion problem, in the context of image applications, makes their use computationally impractical. In other words, with uniform B-splines, there’s a simple linear mapping between the B-spline parametric domain and the voxel domain. As soon as you introduce non-linearity with non-linear knot vectors, you have to employ more complex techniques to determine which parametric point of the B-spline object corresponds to which voxel. And you can imagine that that soon becomes intractable with any moderately sized, densely sampled image. And even setting aside this potential computational burden, it’s not clear what one gains, in the generic image-related application, by introducing non-linearity (and/or rational weights) in the B-spline construction.

And finally, yes, my original understanding of the B-spline “degree” and “order” are different from the verbiage that ITK uses.

2 Likes

Thank you so much! This was the missing information! I always tried to make it work with multiplicity of the nodes.
The only problem I now have is to construct the knot vector correctly in splinepy (there seems to be some issues wrt negative knot values, which may be required to directly map the parametric dimension to the physical dimensions).
I have a 2-degree example up and running now, however it is a bit finicky with the mapping of parametric and physical dimensions… I’ll see if I can make a generic function out of it that works in any case.

Mainly I want to have a function that gives me the splinepy BSpline for a given ITK BSpline - just because many colleagues of mine use splinepy and pitched me features of it :joy:
One feature I wanted to use is to determine the Jacobian at any point of the spline - however, I later found out that this is already implemented in C++ ITK.

1 Like

Mainly I want to have a function that gives me the splinepy BSpline for a given ITK BSpline - just because many colleagues of mine use splinepy and pitched me features of it

Yeah, so this is what I’d be curious about—what are the specific features of SplinePy that your colleagues are using that make it worth the trouble of attempting this conversion? Just as with your Jacobian feature, perhaps that functionality already exists in ITK.

The only problem I now have is to construct the knot vector correctly in splinepy (there seems to > be some issues wrt negative knot values, which may be required to directly map the parametric dimension to the physical dimensions).

I realized I didn’t mention another important distinction. Uniform B-splines can be “clamped” or “unclamped”. “Clamped” means that the knot vector value at the two ends are repeated (d+1) times where d is the degree of spline. The mapping of the parametric domain of a clamped uniform B-spline to the B-spline object space is non-linearly spaced at the two extreme ends (“spans”) of the B-spline domain so the point-inversion issues I mentioned previously also come into play. So, to add an additional descriptor to what I mentioned previously, not only are the B-splines in ITK of which I’m aware uniform, but they are also unclamped.

It’s hard to say whether the difficulties you are having with respect to negative knot values are based on the limitations and/or assumptions of SplinePy. But, technically, knot vectors can take on any value, including negative ones. For the ITK B-spline classes that I’ve contributed, I just map the entire image domain to the parametric domain in the range [0, 1] as the limits which implies that some knot vector elements are < 0. But, again, one doesn’t have to worry about the knot vector with unclamped, uniform B-splines.

1 Like

Actually, I think many neat features, like visualization, do only work with clamped splines. Thus, I think the main reason to do it is “because I can” (my excuse is to deepen my understanding of BSplines :sweat_smile:).
Splinepy has a lot of interesting features related to splines in general, such as embedding splines into splines and my colleagues use it mainly for isogeometric-analysis.

There is definitely a limitation in the library and I already filed a bug-report :smiley:

I think I cracked the code (needs some more verification though :D)

  • construct knot vector as: [t_0, t_1, ..., t_m], with m=n+p+1 and n+1 the number of control points and p the degree (in ITK “order”) of the spline and t_{i+1} - t_i = h
  • Assume o is GetTransformDomainOrigin and s is GetTransformDomainPhysicalDimensions and l is GetTransformDomainMeshSize.
  • Then, the knot t_p has to be o and t_{m-p} has to be o+s and any knot in between and outside is spaced accordingly with h = s / l

For example:

import SimpleITK as sitk
import splinepy as sp

u = np.array([
    0.0, 0.0,
    0.0, 0.0,
    0.0, 0.0,
    0.0, 0.0,
    0.1, 0.2,
    0.0, 0.0,
    0.0, 0.0,
    0.0, 0.0,
    -0.1, 0.3
]).reshape(9, 2)

bs = sitk.BSplineTransform(2, 2)
# 3x3 grid, origin=2,2, spacing=0.5,0.5, direction=unity
bs.SetFixedParameters((3.0, 3.0, 2.0, 2.0, 0.5, 0.5, 1.0, 0.0, 0.0, 1.0))
# bs.GetTransformDomainOrigin() = (2.25, 2.25)
# bs.GetTransformDomainPhysicalDimensions() = (0.5, 0.5)
# bs.GetTransformDomainMeshSize() = (1, 1)

s = sp.BSpline(
    degrees=[2, 2],
    #                               origin .. origin + size
    #                                    v     v
    knot_vectors=np.array([[1.25, 1.75, 2.25, 2.75, 3.25, 3.75], [1.25, 1.75, 2.25, 2.75, 3.25, 3.75]]),
    control_points=sp.utils.data.cartesian_product(np.array([[2.0, 2.5, 3.0], [2.0, 2.5, 3.0]])) + u
)

edit: here is my full conversion code: sitk-splinepy/src/sitk_splinepy/_convert.py at main · reox/sitk-splinepy · GitHub
Except for cases, where t_0 gets less than -1, this seems to work great!

I proposed a fix in splinepy that allows for negative numbers in the knot vector and it seems this really allows me to convert any BSplineTransform!
What I found interesting is the point inversion that can be done with splinepy: splinepy.spline.Spline.proximities — splinepy 0.2.0 documentation
But I’m not sure if it really solves all problems :sweat_smile:

Out of curiosity: If I set a non-identity direction in the BSpline (i.e., SetTransformDomainDirection), does this then adjust the control point grid? I.e., would for every control point c_i the new point c_i^\prime = Rc_i, with R being the direction matrix? (And I guess the same transformation has to be applied to the control point displacements?) I actually never ever encountered even an image that has a non-identity direction matrix, so for the data I handle this seems to be a very rare case, but if I already have a conversion script, I could also implement the direction correctly :smiley:

BSpline’s fixed parameters are grid size, origin, degree, and direction matrix. Here is an example:

#Insight Transform File V1.0
#Transform 0
Transform: BSplineTransform_double_3_3
Parameters: 0.004231334559642586 0.006202430910361544 ...(100KB of numbers) 0.04095649002092384 0.040128300442624845 0.028526514884387694
FixedParameters: 5 20 20 125.3439460779856 -47.57378582702526 -47.57378582702526 5 5 5 1 0 0 0 1 0 0 0 1

yes, but in your example the direction is the identity matrix. What happens if you define the BSplineTransform but set the direction to, for example, 0.72951154, -0.53715283, 0.4234144 , 0.67706066, 0.65489403, -0.3357122 , -0.09696281, 0.53158315, 0.8414378?

tx = sitk.BSplineTransform(3, 3)
print(tx.GetFixedParameters())
# (4.0, 4.0, 4.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)

tx.SetTransformDomainDirection((0.72951154, -0.53715283,  0.4234144 ,  0.67706066,  0.65489403, -0.3357122 , -0.09696281,  0.53158315,  0.8414378))
print(tx.GetFixedParameters())
# (4.0, 4.0, 4.0, -0.6157731099999999, -0.9962424899999999, -1.27605814, 1.0, 1.0, 1.0, 0.72951154, -0.53715283, 0.4234144, 0.67706066, 0.65489403, -0.3357122, -0.09696281, 0.53158315, 0.8414378)

Here, the origin of the BSpline Grid was actually transformed by this rotation matrix, thus so are the control points?
Interestingly, the origin point in the FixedParameters change, but the displacement of the control points (=Parameters) does not when setting a different direction.

BSpline transform is widely used, and sometimes with images which don’t have identity orientation. I don’t remember any issues with it, so I would say it works correctly. I never had to dig as deeply as you are right now, so I don’t know the details. My use boils down to specifying it, passing it to filters, at most invoking TransformPoint(). Are displacements in physical space or index space? Physical units or index units? I don’t know :smiley: Maybe @ntustison still remembers?

Okay, a couple things:

Yes, there are existing techniques to do point inversion. However, this doesn’t solve the problems I mentioned previously. What I wrote was:

As soon as you introduce non-linearity with non-linear knot vectors, you have to employ more complex techniques to determine which parametric point of the B-spline object corresponds to which voxel. And you can imagine that that soon becomes intractable with any moderately sized, densely sampled image.

The authors employ a k-d tree query to do point inversion which definitely falls under the category of “more complex techniques” which would make image-based applications “intractable”.

Regarding image orientation—one thing that can be confusing in working with images and B-splines is the distinction between the physical space domain and the parametric domain. The former obviously has an orientation whereas the latter does not. That’s why with the various applications I’ve written in ITK (N4, B-spline registration, scattered data approximation), the orientation information is basically ignored until I attach it to the output at the end. I use the qualifier “basically” because the input to these filters is often given in physical space so I do have to use the orientation to map the input in physical space to the voxel domain (which also doesn’t have orientation information, strictly speaking) which I use as the parametric domain.

Are displacements in physical space or index space? Physical units or index units?

Physical units.

If you want a longer answer, in addition to the distinction above, it’s also important to keep separate the B-spline object parametric domain and the data domain described by the B-spline object. So in this specific case, the transform is defined over the parametric domain of the image. And since we’re using unclamped, uniform B-splines, we can easily (linearly) map between any parametric point <—> voxel <—> physical point. This parametric domain is distinct from the values of the control points which can be any vector value (e.g., (dx, dy, dz) in physical space as with the B-spline transform). And these two spaces can have completely different dimensionalities. For example, the N4 volumetric bias field is described by a parametric dimension (PD)=3 and data dimension (DD)=1. I aso have a couple examples showing 2-D curve (PD=1, DD=2) and 3-D surface (PD=2, DD=3) fitting with scattered data approximation.

2 Likes

Thanks again for the extensive information!

That sounds plausible. For one usecase, I only would need the inversion at a few hundred to thousand points - thus, I think for that particular usecase it could be interesting.

I see, that makes sense. I also thought that this may be handled separately. As I basically never use anything else than identiy, I did not implement it for now :wink: