├── .github
├── ISSUE_TEMPLATE.md
├── TEST_FAIL_TEMPLATE.md
├── dependabot.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .pre-commit-config.yaml
├── LICENSE
├── README.md
├── examples
├── optimise_1d_grid_model.ipynb
└── optimise_2d_grid_model.ipynb
├── pyproject.toml
├── src
└── torch_cubic_spline_grids
│ ├── __init__.py
│ ├── _base_cubic_grid.py
│ ├── _constants.py
│ ├── b_spline_grids.py
│ ├── catmull_rom_grids.py
│ ├── interpolate_grids.py
│ ├── interpolate_pieces.py
│ ├── pad_grids.py
│ └── utils.py
└── tests
├── __init__.py
├── test_grid_optimisation.py
├── test_grids.py
├── test_interpolate_grid.py
├── test_interpolate_pieces.py
├── test_modules.py
├── test_pad_grid.py
└── test_utils.py
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | * torch-cubic-b-spline-grid version:
2 | * Python version:
3 | * Operating System:
4 |
5 | ### Description
6 |
7 | Describe what you were trying to get done.
8 | Tell us what happened, what went wrong, and what you expected to happen.
9 |
10 | ### What I Did
11 |
12 | ```
13 | Paste the command(s) you ran and the output.
14 | If there was a crash, please include the traceback here.
15 | ```
16 |
--------------------------------------------------------------------------------
/.github/TEST_FAIL_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "{{ env.TITLE }}"
3 | labels: [bug]
4 | ---
5 | The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC
6 |
7 | The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }}
8 | with commit: {{ sha }}
9 |
10 | Full run: https://github.com/{{ repo }}/actions/runs/{{ env.RUN_ID }}
11 |
12 | (This post will be updated if another test fails, as long as this issue remains open.)
13 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
2 |
3 | version: 2
4 | updates:
5 | - package-ecosystem: "github-actions"
6 | directory: "/"
7 | schedule:
8 | interval: "weekly"
9 | commit-message:
10 | prefix: "ci(dependabot):"
11 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | push:
8 | branches:
9 | - main
10 | tags:
11 | - "v*"
12 | pull_request: {}
13 | workflow_dispatch:
14 |
15 | jobs:
16 | check-manifest:
17 | name: Check Manifest
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v5
21 | - uses: actions/setup-python@v6
22 | with:
23 | python-version: "3.x"
24 | - run: pip install check-manifest && check-manifest
25 |
26 | test:
27 | name: ${{ matrix.platform }} (${{ matrix.python-version }})
28 | runs-on: ${{ matrix.platform }}
29 | strategy:
30 | fail-fast: false
31 | matrix:
32 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
33 | platform: [
34 | ubuntu-latest,
35 | # macos-latest,
36 | # windows-latest,
37 | ]
38 |
39 | steps:
40 | - name: Cancel Previous Runs
41 | uses: styfle/cancel-workflow-action@0.12.1
42 | with:
43 | access_token: ${{ github.token }}
44 |
45 | - uses: actions/checkout@v5
46 |
47 | - name: Set up Python ${{ matrix.python-version }}
48 | uses: actions/setup-python@v6
49 | with:
50 | python-version: ${{ matrix.python-version }}
51 |
52 | - name: Install dependencies
53 | run: |
54 | python -m pip install -U pip
55 | python -m pip install -e .
56 | python -m pip install pytest pytest-cov
57 |
58 | - name: Test with pytest
59 | run: python -m pytest
60 | env:
61 | PLATFORM: ${{ matrix.platform }}
62 |
63 | - name: Coverage
64 | uses: codecov/codecov-action@v5
65 |
66 | deploy:
67 | name: Deploy
68 | needs: test
69 | if: "success() && startsWith(github.ref, 'refs/tags/')"
70 | runs-on: ubuntu-latest
71 |
72 | steps:
73 | - uses: actions/checkout@v5
74 |
75 | - name: Set up Python
76 | uses: actions/setup-python@v6
77 | with:
78 | python-version: "3.x"
79 |
80 | - name: install
81 | run: |
82 | git tag
83 | pip install -U pip
84 | pip install -U build twine
85 | python -m build
86 | twine check dist/*
87 | ls -lh dist
88 |
89 | - name: Build and publish
90 | run: twine upload dist/*
91 | env:
92 | TWINE_USERNAME: __token__
93 | TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }}
94 |
95 | - uses: softprops/action-gh-release@v2
96 | with:
97 | generate_release_notes: true
98 |
99 | # [WIP]
100 | # https://python-semantic-release.readthedocs.io/en/latest/automatic-releases/github-actions.html
101 | # release:
102 | # runs-on: ubuntu-latest
103 | # concurrency: release
104 |
105 | # steps:
106 | # - uses: actions/checkout@v5
107 | # with:
108 | # fetch-depth: 0
109 |
110 | # - name: Python Semantic Release
111 | # uses: relekang/python-semantic-release@master
112 | # with:
113 | # github_token: ${{ secrets.GITHUB_TOKEN }}
114 | # repository_username: __token__
115 | # repository_password: ${{ secrets.TWINE_API_KEY }}
116 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 | .pytest_cache/
49 |
50 | # Translations
51 | *.mo
52 | *.pot
53 |
54 | # Django stuff:
55 | *.log
56 | local_settings.py
57 |
58 | # Flask stuff:
59 | instance/
60 | .webassets-cache
61 |
62 | # Scrapy stuff:
63 | .scrapy
64 |
65 | # Sphinx documentation
66 | docs/_build/
67 |
68 | # PyBuilder
69 | target/
70 |
71 | # Jupyter Notebook
72 | .ipynb_checkpoints
73 |
74 | # pyenv
75 | .python-version
76 |
77 | # celery beat schedule file
78 | celerybeat-schedule
79 |
80 | # SageMath parsed files
81 | *.sage.py
82 |
83 | # dotenv
84 | .env
85 |
86 | # virtualenv
87 | .venv
88 | venv/
89 | ENV/
90 |
91 | # Spyder project settings
92 | .spyderproject
93 | .spyproject
94 |
95 | # Rope project settings
96 | .ropeproject
97 |
98 | # mkdocs documentation
99 | /site
100 |
101 | # mypy
102 | .mypy_cache/
103 |
104 | # IDE settings
105 | .vscode/
106 |
107 | src/torch_cubic_spline_grids/_version.py
108 | src/torch_cubic_spline_grids/_version.py
109 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autoupdate_schedule: monthly
3 | autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]"
4 | autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate"
5 |
6 | default_install_hook_types: [pre-commit, commit-msg]
7 |
8 | repos:
9 | - repo: https://github.com/compilerla/conventional-pre-commit
10 | rev: v1.3.0
11 | hooks:
12 | - id: conventional-pre-commit
13 | stages: [commit-msg]
14 |
15 | - repo: https://github.com/pre-commit/pre-commit-hooks
16 | rev: v4.3.0
17 | hooks:
18 | - id: check-docstring-first
19 | - id: end-of-file-fixer
20 | - id: trailing-whitespace
21 | - id: debug-statements
22 |
23 | - repo: https://github.com/astral-sh/ruff-pre-commit
24 | rev: v0.4.10
25 | hooks:
26 | - id: ruff
27 | args: [--fix]
28 | - id: ruff-format
29 |
30 | - repo: https://github.com/abravalheri/validate-pyproject
31 | rev: v0.10.1
32 | hooks:
33 | - id: validate-pyproject
34 |
35 | - repo: https://github.com/pre-commit/mirrors-mypy
36 | rev: v1.15.0
37 | hooks:
38 | - id: mypy
39 | files: "^src/"
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD License
2 |
3 | Copyright (c) 2023, Alister Burt
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # torch-cubic-spline-grids
2 |
3 | [](https://github.com/alisterburt/torch-cubic-spline-grids/raw/main/LICENSE)
4 | [](https://pypi.org/project/torch-cubic-spline-grids)
5 | [](https://python.org)
6 | [](https://github.com/alisterburt/torch-cubic-spline-grids/actions/workflows/ci.yml)
7 | [](https://codecov.io/gh/alisterburt/torch-cubic-spline-grids)
8 |
9 | *Cubic spline interpolation on multidimensional grids in PyTorch.*
10 |
11 | The primary goal of this package is to provide learnable, continuous
12 | parametrisations of 1-4D spaces.
13 |
14 | ---
15 |
16 | ## Overview
17 |
18 | `torch_cubic_spline_grids` provides a set of PyTorch components called grids.
19 |
20 | Grids are defined by
21 | - their dimensionality (1d, 2d, 3d, 4d...)
22 | - the number of points covering each dimension (`resolution`)
23 | - the number of values stored on each grid point (`n_channels`)
24 | - how we interpolate between values on grid points
25 |
26 | All grids in this package consist of uniformly spaced points covering the full
27 | extent of each dimension.
28 |
29 | ### First steps
30 | Let's make a simple 2D grid with one value on each grid point.
31 |
32 | ```python
33 | import torch
34 | from torch_cubic_spline_grids import CubicBSplineGrid2d
35 |
36 | grid = CubicBSplineGrid2d(resolution=(5, 3), n_channels=1)
37 | ```
38 |
39 | - `grid.ndim` is `2`
40 | - `grid.resolution` is `(5, 3)` (or `(h, w)`)
41 | - `grid.n_channels` is `1`
42 | - `grid.data.shape` is `(1, 5, 3)` (or `(c, h, w)`)
43 |
44 | In words, the grid extends over two dimensions `(h, w)` with 5 points
45 | in `h` and `3` points in `w`.
46 | There is one value stored at each point on the 2D grid.
47 | The grid data is stored in a tensor of shape `(c, *grid_resolution)`.
48 |
49 | We can obtain the value (interpolant) at any continuous point on the grid.
50 | The grid coordinate system extends from `[0, 1]` along each grid dimension.
51 | The interpolant is obtained by sequential application of
52 | cubic spline interpolation along each dimension of the grid.
53 |
54 | ```python
55 | coords = torch.rand(size=(10, 2)) # values in [0, 1]
56 | interpolants = grid(coords)
57 | ```
58 |
59 | - `interpolants.shape` is `(10, 1)`
60 |
61 | ### Optimisation
62 |
63 | Values at each grid point can be optimised by minimising a loss function associated with grid interpolants.
64 | In this way the continuous space of the grid can be made to more accurately model a 1-4D space.
65 |
66 |
67 |
68 |
69 |
70 | The image above shows the values of 6 control points on a 1D grid being optimised such
71 | that interpolating between them with cubic B-spline interpolation approximates a single oscillation of a sine wave.
72 |
73 | Notebooks are available for this
74 | [1D example](./examples/optimise_1d_grid_model.ipynb)
75 | and a similar
76 | [2D example](./examples/optimise_2d_grid_model.ipynb).
77 |
78 | ### Types of grids
79 |
80 | `torch_cubic_spline_grids` provides grids which can be interpolated with **cubic
81 | B-spline** interpolation or **cubic Catmull-Rom spline** interpolation.
82 |
83 | | spline | continuity | interpolating? |
84 | |--------------------|------------|----------------|
85 | | cubic B-spline | C2 | No |
86 | | Catmull-Rom spline | C1 | Yes |
87 |
88 | If your need the resulting curve to intersect the data on the grid you should
89 | use the cubic Catmull-Rom spline grids
90 |
91 | - `CubicCatmullRomGrid1d`
92 | - `CubicCatmullRomGrid2d`
93 | - `CubicCatmullRomGrid3d`
94 | - `CubicCatmullRomGrid4d`
95 |
96 | If you require continuous second derivatives then the cubic B-spline grids are more
97 | suitable.
98 |
99 | - `CubicBSplineGrid1d`
100 | - `CubicBSplineGrid2d`
101 | - `CubicBSplineGrid3d`
102 | - `CubicBSplineGrid4d`
103 |
104 | ### Regularisation
105 |
106 | The number of points in each dimension should be chosen such that interpolating on the
107 | grid can approximate the underlying phenomenon being modelled without overfitting.
108 | A low resolution grid provides a regularising effect by smoothing the model.
109 |
110 |
111 | ## Installation
112 |
113 | `torch_cubic_spline_grids` is available on PyPI
114 |
115 | ```shell
116 | pip install torch-cubic-spline-grids
117 | ```
118 |
119 |
120 | ## Related work
121 |
122 | This is a PyTorch implementation of the way
123 | [Warp](http://warpem.com/warp/#) models continuous deformation
124 | fields and locally variable optical parameters in cryo-EM images.
125 | The approach is described in
126 | [Dimitry Tegunov's paper](https://doi.org/10.1038/s41592-019-0580-y):
127 |
128 | > Many methods in Warp are based on a continuous parametrization of 1- to
129 | > 3-dimensional spaces.
130 | > This parameterization is achieved by spline interpolation between points on a coarse,
131 | > uniform grid, which is computationally efficient.
132 | > A grid extends over the entirety of each dimension that needs to be modeled.
133 | > The grid resolution is defined by the number of control points in each dimension
134 | > and is scaled according to physical constraints
135 | > (for example, the number of frames or pixels) and available signal.
136 | > The latter provides regularization to prevent overfitting of sparse data with too many
137 | > parameters.
138 | > When a parameter described by the grid is retrieved for a point in space (and time),
139 | > for example for a particle (frame), B-spline interpolation is performed at that point
140 | > on the grid.
141 | > To fit a grid’s parameters, in general, a cost function associated with the
142 | > interpolants at specific positions on the grid is optimized.
143 |
144 | ---
145 |
146 | For a fantastic introduction to splines I recommend
147 | [Freya Holmer](https://www.youtube.com/watch?v=jvPPXbo87ds)'s YouTube video.
148 |
149 | [The Continuity of Splines - YouTube](https://youtu.be/jvPPXbo87ds)
150 |
--------------------------------------------------------------------------------
/examples/optimise_1d_grid_model.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 1,
6 | "outputs": [],
7 | "source": [
8 | "import torch\n",
9 | "from matplotlib import pyplot as plt\n",
10 | "\n",
11 | "from torch_cubic_spline_grids import CubicBSplineGrid1d"
12 | ],
13 | "metadata": {
14 | "collapsed": false,
15 | "pycharm": {
16 | "name": "#%%\n"
17 | }
18 | }
19 | },
20 | {
21 | "cell_type": "markdown",
22 | "source": [
23 | "some variables we can set..."
24 | ],
25 | "metadata": {
26 | "collapsed": false,
27 | "pycharm": {
28 | "name": "#%% md\n"
29 | }
30 | }
31 | },
32 | {
33 | "cell_type": "code",
34 | "execution_count": 2,
35 | "outputs": [],
36 | "source": [
37 | "N_CONTROL_POINTS = 6\n",
38 | "N_OBSERVATIONS_PER_ITERATION = 20"
39 | ],
40 | "metadata": {
41 | "collapsed": false,
42 | "pycharm": {
43 | "name": "#%%\n"
44 | }
45 | }
46 | },
47 | {
48 | "cell_type": "markdown",
49 | "source": [
50 | "initialise our optimisable parameters, a 1D grid of `N_CONTROL_POINTS` uniformly spaced\n",
51 | "points"
52 | ],
53 | "metadata": {
54 | "collapsed": false,
55 | "pycharm": {
56 | "name": "#%% md\n"
57 | }
58 | }
59 | },
60 | {
61 | "cell_type": "code",
62 | "execution_count": 3,
63 | "outputs": [],
64 | "source": [
65 | "grid_1d = CubicBSplineGrid1d(resolution=N_CONTROL_POINTS)"
66 | ],
67 | "metadata": {
68 | "collapsed": false,
69 | "pycharm": {
70 | "name": "#%%\n"
71 | }
72 | }
73 | },
74 | {
75 | "cell_type": "markdown",
76 | "source": [
77 | "define a function for making observations over the interval `[0, 1]` covering our 1d\n",
78 | "grid"
79 | ],
80 | "metadata": {
81 | "collapsed": false,
82 | "pycharm": {
83 | "name": "#%% md\n"
84 | }
85 | }
86 | },
87 | {
88 | "cell_type": "code",
89 | "execution_count": 4,
90 | "outputs": [],
91 | "source": [
92 | "def make_observations(n, add_noise: bool = False):\n",
93 | " x = torch.rand(n) # in range [0, 1]\n",
94 | " y = torch.sin(2 * torch.pi * x)\n",
95 | " if add_noise is True:\n",
96 | " y += torch.normal(torch.zeros(n), std=0.5)\n",
97 | " return x, y"
98 | ],
99 | "metadata": {
100 | "collapsed": false,
101 | "pycharm": {
102 | "name": "#%%\n"
103 | }
104 | }
105 | },
106 | {
107 | "cell_type": "markdown",
108 | "source": [
109 | "initialise the optimiser"
110 | ],
111 | "metadata": {
112 | "collapsed": false,
113 | "pycharm": {
114 | "name": "#%% md\n"
115 | }
116 | }
117 | },
118 | {
119 | "cell_type": "code",
120 | "execution_count": 5,
121 | "outputs": [],
122 | "source": [
123 | "optimiser = torch.optim.Adam(grid_1d.parameters(), lr=0.02)"
124 | ],
125 | "metadata": {
126 | "collapsed": false,
127 | "pycharm": {
128 | "name": "#%%\n"
129 | }
130 | }
131 | },
132 | {
133 | "cell_type": "markdown",
134 | "source": [
135 | "optimise the values at the control points such that interpolating between them with\n",
136 | "cubic B-spline interpolation fits the data"
137 | ],
138 | "metadata": {
139 | "collapsed": false,
140 | "pycharm": {
141 | "name": "#%% md\n"
142 | }
143 | }
144 | },
145 | {
146 | "cell_type": "code",
147 | "execution_count": 6,
148 | "outputs": [
149 | {
150 | "data": {
151 | "text/plain": "[]"
152 | },
153 | "execution_count": 6,
154 | "metadata": {},
155 | "output_type": "execute_result"
156 | },
157 | {
158 | "data": {
159 | "text/plain": "",
160 | "image/png": "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\n"
161 | },
162 | "metadata": {},
163 | "output_type": "display_data"
164 | }
165 | ],
166 | "source": [
167 | "# some plotting stuff\n",
168 | "fig, ax = plt.subplots()\n",
169 | "control_point_x = torch.linspace(0, 1, N_CONTROL_POINTS)\n",
170 | "decay = 1\n",
171 | "ax.scatter(control_point_x, grid_1d.data, color='white')\n",
172 | "\n",
173 | "# actually optimising!\n",
174 | "for i in range(500):\n",
175 | " # make (noisy) observations of the data we want to model\n",
176 | " x, y = make_observations(N_OBSERVATIONS_PER_ITERATION, add_noise=True)\n",
177 | "\n",
178 | " # what does the model predict for our observations?\n",
179 | " prediction = grid_1d(x).squeeze()\n",
180 | "\n",
181 | " # zero gradients and calculate loss between observations and model prediction\n",
182 | " optimiser.zero_grad()\n",
183 | " loss = torch.sum((prediction - y)**2)**0.5\n",
184 | "\n",
185 | " # backpropagate loss and update values at points on grid\n",
186 | " loss.backward()\n",
187 | " optimiser.step()\n",
188 | "\n",
189 | " # plot\n",
190 | " if i % 10 == 0:\n",
191 | " decay *= 0.99\n",
192 | " ax.scatter(control_point_x, grid_1d.data, color='blue', alpha=1 - decay)\n",
193 | "\n",
194 | " x = torch.linspace(0, 1, 1000)\n",
195 | " y = grid_1d(x).squeeze()\n",
196 | " ax.plot(x, y.detach(), alpha=1 - decay, color='blue')\n",
197 | "\n",
198 | "x = torch.linspace(0, 1, 1000)\n",
199 | "y = torch.sin(x * 2 * torch.pi)\n",
200 | "ax.plot(x, y, ls='--', color='orange')"
201 | ],
202 | "metadata": {
203 | "collapsed": false,
204 | "pycharm": {
205 | "name": "#%%\n"
206 | }
207 | }
208 | },
209 | {
210 | "cell_type": "markdown",
211 | "source": [
212 | "this model has very little capacity to overfit to noisy data because of the small\n",
213 | "number of control points on our grid (parameters)"
214 | ],
215 | "metadata": {
216 | "collapsed": false,
217 | "pycharm": {
218 | "name": "#%% md\n"
219 | }
220 | }
221 | }
222 | ],
223 | "metadata": {
224 | "kernelspec": {
225 | "display_name": "Python 3",
226 | "language": "python",
227 | "name": "python3"
228 | },
229 | "language_info": {
230 | "codemirror_mode": {
231 | "name": "ipython",
232 | "version": 2
233 | },
234 | "file_extension": ".py",
235 | "mimetype": "text/x-python",
236 | "name": "python",
237 | "nbconvert_exporter": "python",
238 | "pygments_lexer": "ipython2",
239 | "version": "2.7.6"
240 | }
241 | },
242 | "nbformat": 4,
243 | "nbformat_minor": 0
244 | }
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | # https://peps.python.org/pep-0517/
2 | [build-system]
3 | requires = ["hatchling", "hatch-vcs"]
4 | build-backend = "hatchling.build"
5 |
6 | # https://peps.python.org/pep-0621/
7 | [project]
8 | name = "torch-cubic-spline-grids"
9 | description = "Cubic spline interpolation on multidimensional grids in PyTorch"
10 | readme = "README.md"
11 | requires-python = ">=3.9"
12 | license = {text = "BSD 3-Clause License"}
13 | authors = [
14 | {email = "alisterburt@gmail.com"},
15 | {name = "Alister Burt"},
16 | ]
17 | classifiers = [
18 | "Development Status :: 3 - Alpha",
19 | "License :: OSI Approved :: BSD License",
20 | "Programming Language :: Python :: 3",
21 | "Programming Language :: Python :: 3.9",
22 | "Programming Language :: Python :: 3.10",
23 | "Programming Language :: Python :: 3.11",
24 | "Programming Language :: Python :: 3.12",
25 | "Programming Language :: Python :: 3.13",
26 | ]
27 | dynamic = ["version"]
28 | dependencies = [
29 | "torch",
30 | "numpy",
31 | "einops",
32 | "typing-extensions",
33 | ]
34 |
35 | # extras
36 | # https://peps.python.org/pep-0621/#dependencies-optional-dependencies
37 | [project.optional-dependencies]
38 | test = ["pytest>=6.0", "pytest-cov"]
39 | dev = [
40 | "ipython",
41 | "mypy",
42 | "pdbpp",
43 | "pre-commit",
44 | "pytest-cov",
45 | "pytest",
46 | "rich",
47 | "ruff",
48 | ]
49 |
50 | [project.urls]
51 | homepage = "https://github.com/alisterburt/torch-cubic-spline-grids"
52 | repository = "https://github.com/alisterburt/torch-cubic-spline-grids"
53 |
54 | # same as console_scripts entry point
55 | # [project.scripts]
56 | # spam-cli = "spam:main_cli"
57 |
58 | # Entry points
59 | # https://peps.python.org/pep-0621/#entry-points
60 | # [project.entry-points."spam.magical"]
61 | # tomatoes = "spam:main_tomatoes"
62 |
63 | # https://hatch.pypa.io/latest/config/metadata/
64 | [tool.hatch.version]
65 | source = "vcs"
66 |
67 | # https://hatch.pypa.io/latest/config/build/#file-selection
68 | # [tool.hatch.build.targets.sdist]
69 | # include = ["/src", "/tests"]
70 |
71 |
72 | # https://github.com/charliermarsh/ruff
73 | [tool.ruff]
74 | line-length = 88
75 | target-version = "py38"
76 |
77 | [tool.ruff.lint]
78 | extend-select = [
79 | "E", # style errors
80 | "F", # flakes
81 | "D", # pydocstyle
82 | "I001", # isort
83 | "U", # pyupgrade
84 | # "N", # pep8-naming
85 | # "S", # bandit
86 | "C", # flake8-comprehensions
87 | "B", # flake8-bugbear
88 | "A001", # flake8-builtins
89 | "RUF", # ruff-specific rules
90 | ]
91 | extend-ignore = [
92 | "D100", # Missing docstring in public module
93 | "D107", # Missing docstring in __init__
94 | "D203", # 1 blank line required before class docstring
95 | "D212", # Multi-line docstring summary should start at the first line
96 | "D213", # Multi-line docstring summary should start at the second line
97 | "D413", # Missing blank line after last section
98 | "D416", # Section name should end with a colon
99 | ]
100 |
101 | [tool.ruff.lint.per-file-ignores]
102 | "tests/*.py" = ["D"]
103 |
104 | [tool.ruff.lint.isort]
105 | combine-as-imports = true
106 |
107 | [tool.ruff.format]
108 | # Prefer single quotes over double quotes.
109 | quote-style = "single"
110 |
111 | # https://docs.pytest.org/en/6.2.x/customize.html
112 | [tool.pytest.ini_options]
113 | minversion = "6.0"
114 | pythonpath = "src"
115 | testpaths = ["tests"]
116 | filterwarnings = [
117 | "error",
118 | ]
119 |
120 | # https://mypy.readthedocs.io/en/stable/config_file.html
121 | [tool.mypy]
122 | files = "src/**/"
123 | strict = true
124 | disallow_any_generics = false
125 | disallow_subclassing_any = false
126 | show_error_codes = true
127 | pretty = true
128 |
129 |
130 | # https://coverage.readthedocs.io/en/6.4/config.html
131 | [tool.coverage.report]
132 | exclude_lines = [
133 | "pragma: no cover",
134 | "if TYPE_CHECKING:",
135 | "@overload",
136 | "except ImportError",
137 | ]
138 |
139 | # https://github.com/mgedmin/check-manifest#configuration
140 | [tool.check-manifest]
141 | ignore = [
142 | ".github_changelog_generator",
143 | ".pre-commit-config.yaml",
144 | ".ruff_cache/**/*",
145 | "tests/**/*",
146 | "tox.ini",
147 | ]
148 |
149 | # https://python-semantic-release.readthedocs.io/en/latest/configuration.html
150 | [tool.semantic_release]
151 | version_source = "tag_only"
152 | branch = "main"
153 | changelog_sections="feature,fix,breaking,documentation,performance,chore,:boom:,:sparkles:,:children_crossing:,:lipstick:,:iphone:,:egg:,:chart_with_upwards_trend:,:ambulance:,:lock:,:bug:,:zap:,:goal_net:,:alien:,:wheelchair:,:speech_balloon:,:mag:,:apple:,:penguin:,:checkered_flag:,:robot:,:green_apple:,Other"
154 | # commit_parser=semantic_release.history.angular_parser
155 | build_command = "pip install build && python -m build"
156 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/__init__.py:
--------------------------------------------------------------------------------
1 | """Cubic B-spline interpolation on multidimensional grids in PyTorch."""
2 |
3 | from importlib.metadata import PackageNotFoundError, version
4 |
5 | try:
6 | __version__ = version('torch-cubic-b-spline-grid')
7 | except PackageNotFoundError:
8 | __version__ = 'uninstalled'
9 |
10 | __author__ = 'Alister Burt'
11 | __email__ = 'alisterburt@gmail.com'
12 |
13 | from .b_spline_grids import (
14 | CubicBSplineGrid1d,
15 | CubicBSplineGrid2d,
16 | CubicBSplineGrid3d,
17 | CubicBSplineGrid4d,
18 | )
19 | from .catmull_rom_grids import (
20 | CubicCatmullRomGrid1d,
21 | CubicCatmullRomGrid2d,
22 | CubicCatmullRomGrid3d,
23 | CubicCatmullRomGrid4d,
24 | )
25 |
26 | __all__ = [
27 | 'CubicBSplineGrid1d',
28 | 'CubicBSplineGrid2d',
29 | 'CubicBSplineGrid3d',
30 | 'CubicBSplineGrid4d',
31 | 'CubicCatmullRomGrid1d',
32 | 'CubicCatmullRomGrid2d',
33 | 'CubicCatmullRomGrid3d',
34 | 'CubicCatmullRomGrid4d',
35 | ]
36 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/_base_cubic_grid.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, Optional, Tuple
2 |
3 | import einops
4 | import torch
5 | from typing_extensions import Self
6 |
7 | from torch_cubic_spline_grids.utils import (
8 | MonotonicityType,
9 | batch,
10 | coerce_to_multichannel_grid,
11 | )
12 |
13 |
14 | class CubicSplineGrid(torch.nn.Module):
15 | """Base class for continuous parametrisations of multidimensional spaces."""
16 |
17 | ndim: int
18 | _data: torch.nn.Parameter
19 | _interpolation_function: Callable
20 | _interpolation_matrix: torch.Tensor
21 | _minibatch_size: int
22 |
23 | def __init__(
24 | self,
25 | resolution: Optional[Tuple[int, ...]] = None,
26 | n_channels: int = 1,
27 | minibatch_size: int = 1_000_000,
28 | monotonicity: Optional[MonotonicityType] = None,
29 | ):
30 | super().__init__()
31 | if resolution is None:
32 | resolution = (2,) * self.ndim
33 | grid_shape = (n_channels, *resolution)
34 | self.data = torch.zeros(size=grid_shape)
35 | self._minibatch_size = minibatch_size
36 | self._monotonicity = monotonicity
37 | self.register_buffer(
38 | name='interpolation_matrix',
39 | tensor=self._interpolation_matrix,
40 | persistent=False,
41 | )
42 |
43 | def _interpolate(self, u: torch.Tensor) -> torch.Tensor:
44 | return self._interpolation_function(
45 | self._data,
46 | u,
47 | matrix=self.interpolation_matrix,
48 | monotonicity=self._monotonicity,
49 | )
50 |
51 | def forward(self, u: torch.Tensor) -> torch.Tensor:
52 | u = self._coerce_to_batched_coordinates(u) # (b, d)
53 |
54 | interpolated = [
55 | self._interpolate(minibatch_u)
56 | for minibatch_u in batch(u, n=self._minibatch_size)
57 | ] # List[Tensor[(b, d)]]
58 | interpolated = torch.cat(interpolated, dim=0) # (b, d)
59 | return self._unpack_interpolated_output(interpolated)
60 |
61 | @classmethod
62 | def from_grid_data(cls, data: torch.Tensor) -> Self:
63 | """Instantiate a grid from existing grid data.
64 |
65 | Parameters
66 | ----------
67 | data: torch.Tensor
68 | (c, *grid_dimensions) or (*grid_dimensions) array of multichannel values at
69 | each grid point.
70 | """
71 | grid = cls()
72 | grid.data = data
73 | return grid
74 |
75 | @property
76 | def data(self) -> torch.Tensor:
77 | return self._data.detach()
78 |
79 | @data.setter
80 | def data(self, grid_data: torch.Tensor) -> None:
81 | grid_data = coerce_to_multichannel_grid(grid_data, grid_ndim=self.ndim)
82 | self._data = torch.nn.Parameter(grid_data)
83 |
84 | @property
85 | def n_channels(self) -> int:
86 | return int(self._data.size(0))
87 |
88 | @property
89 | def resolution(self) -> Tuple[int, ...]:
90 | return tuple(self._data.shape[1:])
91 |
92 | def _coerce_to_batched_coordinates(self, u: torch.Tensor) -> torch.Tensor:
93 | u = torch.atleast_1d(torch.as_tensor(u, dtype=torch.float32))
94 | self._input_is_coordinate_like = u.shape[-1] == self.ndim
95 | if self._input_is_coordinate_like is False and self.ndim == 1:
96 | u = einops.rearrange(u, '... -> ... 1') # add singleton coord dimension
97 | else:
98 | u = torch.atleast_2d(u) # add batch dimension if missing
99 | u, self._packed_shapes = einops.pack([u], pattern='* coords')
100 | if u.shape[-1] != self.ndim:
101 | ndim = u.shape[-1]
102 | raise ValueError(
103 | f'Cannot interpolate on a {self.ndim}D grid with {ndim}D coordinates'
104 | )
105 | return u
106 |
107 | def _unpack_interpolated_output(self, interpolated: torch.Tensor) -> torch.Tensor:
108 | [interpolated] = einops.unpack(
109 | interpolated, packed_shapes=self._packed_shapes, pattern='* coords'
110 | )
111 | if self._input_is_coordinate_like is False and self.ndim == 1:
112 | interpolated = einops.rearrange(interpolated, '... 1 -> ...')
113 | return interpolated
114 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/_constants.py:
--------------------------------------------------------------------------------
1 | import torch
2 |
3 | # Described in Freya Holmer's video "The Continuity of Splines"
4 | # https://youtu.be/jvPPXbo87ds?t=3462
5 |
6 | CUBIC_B_SPLINE_MATRIX = (1 / 6) * torch.tensor([[1, 4, 1, 0],
7 | [-3, 0, 3, 0],
8 | [3, -6, 3, 0],
9 | [-1, 3, -3, 1]])
10 |
11 | CUBIC_CATMULL_ROM_MATRIX = (1 / 2) * torch.tensor([[0, 2, 0, 0],
12 | [-1, 0, 1, 0],
13 | [2, -5, 4, -1],
14 | [-1, 3, -3, 1]])
15 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/b_spline_grids.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, Optional, Sequence, Tuple, Union
2 |
3 | import torch
4 |
5 | from torch_cubic_spline_grids._base_cubic_grid import CubicSplineGrid
6 | from torch_cubic_spline_grids._constants import CUBIC_B_SPLINE_MATRIX
7 | from torch_cubic_spline_grids.interpolate_grids import (
8 | interpolate_grid_1d as _interpolate_grid_1d,
9 | interpolate_grid_2d as _interpolate_grid_2d,
10 | interpolate_grid_3d as _interpolate_grid_3d,
11 | interpolate_grid_4d as _interpolate_grid_4d,
12 | )
13 | from torch_cubic_spline_grids.utils import MonotonicityType
14 |
15 | CoordinateLike = Union[float, Sequence[float], torch.Tensor]
16 |
17 |
18 | class _CubicBSplineGrid(CubicSplineGrid):
19 | _interpolation_matrix = CUBIC_B_SPLINE_MATRIX
20 |
21 |
22 | class CubicBSplineGrid1d(_CubicBSplineGrid):
23 | """Continuous parametrisation of a 1D space with a specific resolution."""
24 |
25 | ndim: int = 1
26 | _interpolation_function: Callable = staticmethod(_interpolate_grid_1d)
27 |
28 | def __init__(
29 | self,
30 | resolution: Optional[Union[int, Tuple[int]]] = None,
31 | n_channels: int = 1,
32 | minibatch_size: int = 1_000_000,
33 | monotonicity: Optional[MonotonicityType] = None,
34 | ):
35 | if isinstance(resolution, int):
36 | resolution = (resolution,)
37 | super().__init__(
38 | resolution=resolution,
39 | n_channels=n_channels,
40 | minibatch_size=minibatch_size,
41 | monotonicity=monotonicity,
42 | )
43 |
44 |
45 | class CubicBSplineGrid2d(_CubicBSplineGrid):
46 | """Continuous parametrisation of a 2D space with a specific resolution."""
47 |
48 | ndim: int = 2
49 | _interpolation_function: Callable = staticmethod(_interpolate_grid_2d)
50 |
51 |
52 | class CubicBSplineGrid3d(_CubicBSplineGrid):
53 | """Continuous parametrisation of a 3D space with a specific resolution."""
54 |
55 | ndim: int = 3
56 | _interpolation_function: Callable = staticmethod(_interpolate_grid_3d)
57 |
58 |
59 | class CubicBSplineGrid4d(_CubicBSplineGrid):
60 | """Continuous parametrisation of a 4D space with a specific resolution."""
61 |
62 | ndim: int = 4
63 | _interpolation_function: Callable = staticmethod(_interpolate_grid_4d)
64 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/catmull_rom_grids.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, Optional, Sequence, Tuple, Union
2 |
3 | import torch
4 |
5 | from torch_cubic_spline_grids._base_cubic_grid import CubicSplineGrid
6 | from torch_cubic_spline_grids._constants import CUBIC_CATMULL_ROM_MATRIX
7 | from torch_cubic_spline_grids.interpolate_grids import (
8 | interpolate_grid_1d as _interpolate_grid_1d,
9 | interpolate_grid_2d as _interpolate_grid_2d,
10 | interpolate_grid_3d as _interpolate_grid_3d,
11 | interpolate_grid_4d as _interpolate_grid_4d,
12 | )
13 | from torch_cubic_spline_grids.utils import MonotonicityType
14 |
15 | CoordinateLike = Union[float, Sequence[float], torch.Tensor]
16 |
17 |
18 | class _CubicCatmullRomGrid(CubicSplineGrid):
19 | _interpolation_matrix = CUBIC_CATMULL_ROM_MATRIX
20 |
21 |
22 | class CubicCatmullRomGrid1d(_CubicCatmullRomGrid):
23 | """Continuous parametrisation of a 1D space with a specific resolution."""
24 |
25 | ndim: int = 1
26 | _interpolation_function: Callable = staticmethod(_interpolate_grid_1d)
27 |
28 | def __init__(
29 | self,
30 | resolution: Optional[Union[int, Tuple[int]]] = None,
31 | n_channels: int = 1,
32 | minibatch_size: int = 1_000_000,
33 | monotonicity: Optional[MonotonicityType] = None,
34 | ):
35 | if isinstance(resolution, int):
36 | resolution = (resolution,)
37 | super().__init__(
38 | resolution=resolution,
39 | n_channels=n_channels,
40 | minibatch_size=minibatch_size,
41 | monotonicity=monotonicity,
42 | )
43 |
44 |
45 | class CubicCatmullRomGrid2d(_CubicCatmullRomGrid):
46 | """Continuous parametrisation of a 2D space with a specific resolution."""
47 |
48 | ndim: int = 2
49 | _interpolation_function: Callable = staticmethod(_interpolate_grid_2d)
50 |
51 |
52 | class CubicCatmullRomGrid3d(_CubicCatmullRomGrid):
53 | """Continuous parametrisation of a 3D space with a specific resolution."""
54 |
55 | ndim: int = 3
56 | _interpolation_function: Callable = staticmethod(_interpolate_grid_3d)
57 |
58 |
59 | class CubicCatmullRomGrid4d(_CubicCatmullRomGrid):
60 | """Continuous parametrisation of a 4D space with a specific resolution."""
61 |
62 | ndim: int = 4
63 | _interpolation_function: Callable = staticmethod(_interpolate_grid_4d)
64 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/interpolate_grids.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import einops
4 | import torch
5 |
6 | from torch_cubic_spline_grids.interpolate_pieces import (
7 | interpolate_pieces_1d,
8 | interpolate_pieces_2d,
9 | interpolate_pieces_3d,
10 | interpolate_pieces_4d,
11 | )
12 | from torch_cubic_spline_grids.pad_grids import (
13 | pad_grid_1d,
14 | pad_grid_2d,
15 | pad_grid_3d,
16 | pad_grid_4d,
17 | )
18 | from torch_cubic_spline_grids.utils import (
19 | MonotonicityType,
20 | interpolants_to_interpolation_data_1d,
21 | transform_to_monotonic_nd,
22 | )
23 |
24 |
25 | def interpolate_grid_1d(
26 | grid: torch.Tensor,
27 | u: torch.Tensor,
28 | matrix: torch.Tensor,
29 | monotonicity: Optional[MonotonicityType] = None,
30 | ) -> torch.Tensor:
31 | """Uniform cubic spline interpolation on a 1D grid.
32 |
33 | The range [0, 1] covers all data points in the 1D grid.
34 |
35 | Parameters
36 | ----------
37 | grid: torch.Tensor
38 | `(c, w)` array of `w` values in `c` channels to be interpolated.
39 | u: torch.Tensor
40 | `(b, 1)` array of query points in the range `[0, 1]` covering the `w`
41 | dimension of `grid`.
42 | matrix: torch.Tensor
43 | `(4, 4)` characteristic matrix for the spline.
44 | monotonicity: str
45 | when either 'increasing' or 'decreasing' is specified, ensures
46 | that control points of spline are monotonic.
47 |
48 | Returns
49 | -------
50 | interpolated: torch.Tensor
51 | `(b, c)` array of interpolated values in each channel.
52 | """
53 | if grid.ndim == 1:
54 | grid = einops.rearrange(grid, 'w -> 1 w')
55 | _, w = grid.shape
56 |
57 | # handle interpolation at edges by extending grid of control points according to
58 | # local gradients
59 | grid = pad_grid_1d(grid)
60 |
61 | # find control point indices and interpolation coordinate
62 | idx, t = interpolants_to_interpolation_data_1d(u[:, 0], n_samples=w)
63 | if monotonicity:
64 | grid = transform_to_monotonic_nd(grid, ndims=1, monotonicity=monotonicity)
65 | control_points = grid[..., idx] # (c, b, 4)
66 | control_points = einops.rearrange(control_points, 'c b p -> b c p')
67 |
68 | # interpolate
69 | return interpolate_pieces_1d(control_points, t, matrix=matrix)
70 |
71 |
72 | def interpolate_grid_2d(
73 | grid: torch.Tensor,
74 | u: torch.Tensor,
75 | matrix: torch.Tensor,
76 | monotonicity: Optional[MonotonicityType] = None,
77 | ) -> torch.Tensor:
78 | """Uniform cubic B-spline interpolation on a 2D grid.
79 |
80 | Parameters
81 | ----------
82 | grid: torch.Tensor
83 | `(c, h, w)` multichannel 2D grid.
84 | u: torch.Tensor
85 | `(b, 2)` array of values in the range `[0, 1]`.
86 | `[0, 1]` in `u[:, 0]` covers dim -2 (h) of `grid`
87 | `[0, 1]` in `u[:, 1]` covers dim -1 (w) of `grid`
88 | matrix: torch.Tensor
89 | `(4, 4)` characteristic matrix for the spline.
90 | monotonicity: str
91 | when either 'increasing' or 'decreasing' is specified, ensures
92 | that control points of spline are monotonic.
93 |
94 | Returns
95 | -------
96 | `(b, c)` array of interpolated values in each channel.
97 | """
98 | if grid.ndim == 2:
99 | grid = einops.rearrange(grid, 'h w -> 1 h w')
100 | _, h, w = grid.shape
101 |
102 | # pad grid to handle interpolation at edges.
103 | grid = pad_grid_2d(grid)
104 |
105 | # find control point indices and interpolation coordinate in each dim
106 | idx_h, t_h = interpolants_to_interpolation_data_1d(u[:, 0], n_samples=h)
107 | idx_w, t_w = interpolants_to_interpolation_data_1d(u[:, 1], n_samples=w)
108 |
109 | # construct (4, 4) grids of control points and 2D interpolant then interpolate
110 | idx_h = einops.repeat(idx_h, 'b h -> b h w', w=4)
111 | idx_w = einops.repeat(idx_w, 'b w -> b h w', h=4)
112 | if monotonicity:
113 | grid = transform_to_monotonic_nd(grid, ndims=2, monotonicity=monotonicity)
114 | control_points = grid[..., idx_h, idx_w] # (c, b, 4, 4)
115 | control_points = einops.rearrange(control_points, 'c b h w -> b c h w')
116 |
117 | t = einops.rearrange([t_h, t_w], 'hw b -> b hw')
118 | return interpolate_pieces_2d(control_points, t, matrix=matrix)
119 |
120 |
121 | def interpolate_grid_3d(
122 | grid: torch.Tensor,
123 | u: torch.Tensor,
124 | matrix: torch.Tensor,
125 | monotonicity: Optional[MonotonicityType] = None,
126 | ) -> torch.Tensor:
127 | """Uniform cubic B-spline interpolation on a 3D grid.
128 |
129 | Parameters
130 | ----------
131 | grid: torch.Tensor
132 | `(c, d, h, w)` multichannel 3D grid.
133 | u: torch.Tensor
134 | `(b, 3)` array of values in the range [0, 1].
135 | [0, 1] in b[:, 0] covers depth dim `d` of `grid`
136 | [0, 1] in b[:, 1] covers height dim `h` of `grid`
137 | [0, 1] in b[:, 2] covers width dim `w` of `grid`
138 | matrix: torch.Tensor
139 | `(4, 4)` characteristic matrix for the spline.
140 | monotonicity: str
141 | when either 'increasing' or 'decreasing' is specified, ensures
142 | that control points of spline are monotonic.
143 |
144 | Returns
145 | -------
146 | `(b, c)` array of c-dimensional interpolated values
147 | """
148 | if grid.ndim == 3:
149 | grid = einops.rearrange(grid, 'd h w -> 1 d h w')
150 | _, n_samples_d, n_samples_h, n_samples_w = grid.shape
151 |
152 | # expand grid to handle interpolation at edges
153 | grid = pad_grid_3d(grid)
154 |
155 | # find control point indices and interpolation coordinate in each dim
156 | idx_d, t_d = interpolants_to_interpolation_data_1d(u[:, 0], n_samples_d)
157 | idx_h, t_h = interpolants_to_interpolation_data_1d(u[:, 1], n_samples_h)
158 | idx_w, t_w = interpolants_to_interpolation_data_1d(u[:, 2], n_samples_w)
159 |
160 | # construct (4, 4, 4) grids of control points and 3D interpolant then interpolate
161 | idx_d = einops.repeat(idx_d, 'b d -> b d h w', h=4, w=4)
162 | idx_h = einops.repeat(idx_h, 'b h -> b d h w', d=4, w=4)
163 | idx_w = einops.repeat(idx_w, 'b w -> b d h w', d=4, h=4)
164 | if monotonicity:
165 | grid = transform_to_monotonic_nd(grid, ndims=3, monotonicity=monotonicity)
166 | control_points = grid[:, idx_d, idx_h, idx_w] # (c, b, 4, 4, 4)
167 | control_points = einops.rearrange(control_points, 'c b d h w -> b c d h w')
168 |
169 | t = einops.rearrange([t_d, t_h, t_w], 'dhw b -> b dhw')
170 | return interpolate_pieces_3d(control_points, t, matrix=matrix)
171 |
172 |
173 | def interpolate_grid_4d(
174 | grid: torch.Tensor,
175 | u: torch.Tensor,
176 | matrix: torch.Tensor,
177 | monotonicity: Optional[MonotonicityType] = None,
178 | ) -> torch.Tensor:
179 | """Uniform cubic B-spline interpolation on a 4D grid.
180 |
181 | Parameters
182 | ----------
183 | grid: torch.Tensor
184 | `(c, u, d, h, w)` multichannel 4D grid.
185 | u: torch.Tensor
186 | `(b, 4)` array of values in the range [0, 1].
187 | [0, 1] in b[:, 0] covers time dim `u` of `grid`
188 | [0, 1] in b[:, 1] covers depth dim `d` of `grid`
189 | [0, 1] in b[:, 2] covers height dim `h` of `grid`
190 | [0, 1] in b[:, 3] covers width dim `w` of `grid`
191 | matrix: torch.Tensor
192 | `(4, 4)` characteristic matrix for the spline.
193 | monotonicity: str
194 | when either 'increasing' or 'decreasing' is specified, ensures
195 | that control points of spline are monotonic.
196 |
197 | Returns
198 | -------
199 | `(b, c)` array of c-dimensional interpolated values
200 | """
201 | if grid.ndim == 4:
202 | grid = einops.rearrange(grid, 't d h w -> 1 t d h w')
203 | _, t, d, h, w = grid.shape
204 |
205 | # expand grid to handle interpolation at edges
206 | grid = pad_grid_4d(grid)
207 |
208 | # find control point indices and interpolation coordinate in each dim
209 | idx_t, t_t = interpolants_to_interpolation_data_1d(u[:, 0], n_samples=t)
210 | idx_d, t_d = interpolants_to_interpolation_data_1d(u[:, 1], n_samples=d)
211 | idx_h, t_h = interpolants_to_interpolation_data_1d(u[:, 2], n_samples=h)
212 | idx_w, t_w = interpolants_to_interpolation_data_1d(u[:, 3], n_samples=w)
213 |
214 | # construct (4, 4, 4, 4) grids of control points and 4D interpolant then interpolate
215 | idx_t = einops.repeat(idx_t, 'b t -> b t d h w', d=4, h=4, w=4)
216 | idx_d = einops.repeat(idx_d, 'b d -> b t d h w', t=4, h=4, w=4)
217 | idx_h = einops.repeat(idx_h, 'b h -> b t d h w', t=4, d=4, w=4)
218 | idx_w = einops.repeat(idx_w, 'b w -> b t d h w', t=4, d=4, h=4)
219 | if monotonicity:
220 | grid = transform_to_monotonic_nd(grid, ndims=3, monotonicity=monotonicity)
221 | control_points = grid[:, idx_t, idx_d, idx_h, idx_w] # (c, b, 4, 4, 4, 4)
222 | control_points = einops.rearrange(control_points, 'c b t d h w -> b c t d h w')
223 |
224 | t = einops.rearrange([t_t, t_d, t_h, t_w], 'tdhw b -> b tdhw')
225 | return interpolate_pieces_4d(control_points, t, matrix=matrix)
226 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/interpolate_pieces.py:
--------------------------------------------------------------------------------
1 | """Interpolate 'pieces' for piecewise uniform cubic B-spline interpolation."""
2 |
3 | import einops
4 | import torch
5 |
6 |
7 | def interpolate_pieces_1d(
8 | control_points: torch.Tensor, t: torch.Tensor, matrix: torch.Tensor
9 | ) -> torch.Tensor:
10 | """Batched uniform 1D cubic spline interpolation.
11 |
12 | ```
13 | [0, u, u^2, u^3] * [a00, a01, a02, a03] * [p0]
14 | [a10, a11, a12, a13] [p1]
15 | [a20, a21, a22, a23] [p2]
16 | [a30, a31, a32, a33] [p3]
17 | ```
18 | c.f. Freya Holmer - "The Continuity of Splines": https://youtu.be/jvPPXbo87ds?t=3462
19 |
20 | Parameters
21 | ----------
22 | control_points: torch.Tensor
23 | `(b, c, 4)` batch of 4 uniformly spaced control points `[p0, p1, p2, p3]`
24 | in `c` channels.
25 | t: torch.Tensor
26 | `(b, )` batch of interpolants in the range [0, 1] covering the interpolation
27 | interval between `p1` and `p2`
28 | matrix: torch.Tensor
29 | `(4, 4)` characteristic matrix for the spline.
30 |
31 | Returns
32 | -------
33 | interpolated: torch.Tensor
34 | `(b, c)` array of per-channel interpolants of `control_points` at `u`.
35 | """
36 | t = einops.rearrange([t**0, t, t**2, t**3], 'u b -> b 1 1 u')
37 | control_points = einops.rearrange(control_points, 'b c p -> b c p 1')
38 | interpolated = t @ matrix @ control_points
39 | return einops.rearrange(interpolated, 'b c 1 1 -> b c')
40 |
41 |
42 | def interpolate_pieces_2d(
43 | control_points: torch.Tensor, t: torch.Tensor, matrix: torch.Tensor
44 | ) -> torch.Tensor:
45 | """Batched uniform 2D cubic B-spline interpolation.
46 |
47 | Parameters
48 | ----------
49 | control_points: torch.Tensor
50 | `(b, c, 4, 4)` batch of 2D multichannel grids of uniformly spaced control
51 | points `[p0, p1, p2, p3]` for cubic B-spline interpolation.
52 | t: torch.Tensor
53 | `(b, 2)` batch of values in the range `[0, 1]` defining the position of 2D
54 | points to be interpolated within the interval `[p1, p2]` along dim 1 and 2 of
55 | the 2D grid of control points.
56 | matrix: torch.Tensor
57 | `(4, 4)` characteristic matrix for the spline.
58 |
59 | Returns
60 | -------
61 | interpolated:
62 | `(b, n)` batch of n-dimensional interpolated values.
63 | """
64 | # extract (b, c, 4) control points at each height along width dim of (h, w) grid
65 | h0, h1, h2, h3 = einops.rearrange(control_points, 'b c h w -> h b c w')
66 |
67 | # separate u into components along height and width dimensions
68 | t_h, t_w = einops.rearrange(t, 'b hw -> hw b')
69 |
70 | # 1d interpolation along width dim at each height
71 | p0 = interpolate_pieces_1d(control_points=h0, t=t_w, matrix=matrix)
72 | p1 = interpolate_pieces_1d(control_points=h1, t=t_w, matrix=matrix)
73 | p2 = interpolate_pieces_1d(control_points=h2, t=t_w, matrix=matrix)
74 | p3 = interpolate_pieces_1d(control_points=h3, t=t_w, matrix=matrix)
75 |
76 | # 1d interpolation of result along height dim
77 | control_points = einops.rearrange([p0, p1, p2, p3], 'p b c -> b c p')
78 | return interpolate_pieces_1d(control_points=control_points, t=t_h, matrix=matrix)
79 |
80 |
81 | def interpolate_pieces_3d(
82 | control_points: torch.Tensor, t: torch.Tensor, matrix: torch.Tensor
83 | ) -> torch.Tensor:
84 | """Batched uniform 3D cubic B-spline interpolation.
85 |
86 | Parameters
87 | ----------
88 | control_points: torch.Tensor
89 | `(b, c, 4, 4, 4)` batch of `(4, 4, 4)` multichannel grids of uniformly
90 | spaced control points for cubic B-spline interpolation.
91 | t: torch.Tensor
92 | `(b, 3)` batch of values in the range `[0, 1]` defining the position of 3D
93 | points to be interpolated within the interval `[p1, p2]` along dim -3,
94 | -2 and -1 of `control_points`
95 | matrix: torch.Tensor
96 | `(4, 4)` characteristic matrix for the spline.
97 |
98 | Returns
99 | -------
100 | interpolated:
101 | `(b, c)` batch interpolated values in each channel.
102 | """
103 | # extract (b, c, 4, 4) 2D control point planes at each point along the depth dim
104 | d0, d1, d2, d3 = einops.rearrange(control_points, 'b c d h w -> d b c h w')
105 |
106 | # separate u into components along depth and (height, width) dimensions
107 | t_d = t[:, 0]
108 | t_hw = t[:, [1, 2]]
109 |
110 | # 2d interpolation on each (height, width) plane at each depth
111 | p0 = interpolate_pieces_2d(control_points=d0, t=t_hw, matrix=matrix)
112 | p1 = interpolate_pieces_2d(control_points=d1, t=t_hw, matrix=matrix)
113 | p2 = interpolate_pieces_2d(control_points=d2, t=t_hw, matrix=matrix)
114 | p3 = interpolate_pieces_2d(control_points=d3, t=t_hw, matrix=matrix)
115 |
116 | # 1d interpolation of result along depth dim
117 | control_points = einops.rearrange([p0, p1, p2, p3], 'p b c -> b c p')
118 | return interpolate_pieces_1d(control_points=control_points, t=t_d, matrix=matrix)
119 |
120 |
121 | def interpolate_pieces_4d(
122 | control_points: torch.Tensor, t: torch.Tensor, matrix: torch.Tensor
123 | ) -> torch.Tensor:
124 | """Batched 4D cubic B-spline interpolation.
125 |
126 | Parameters
127 | ----------
128 | control_points: torch.Tensor
129 | `(b, c, 4, 4, 4, 4)` batch of multichannel `(4, 4, 4, 4)` grids of uniformly
130 | spaced control points for cubic B-spline interpolation.
131 | t: torch.Tensor
132 | `(b, 4)` batch of values in the range `[0, 1]` defining the position of 4D
133 | points to be interpolated within the interval `[p1, p2]` along dims -4, -3,
134 | -2 and -1 of `control_points`.
135 | matrix: torch.Tensor
136 | `(4, 4)` characteristic matrix for the spline.
137 |
138 | Returns
139 | -------
140 | interpolated:
141 | `(b, n)` batch of n-dimensional interpolated values.
142 | """
143 | # extract (b, c, 4, 4, 4) 3D control point grids at each point along the time dim
144 | t0, t1, t2, t3 = einops.rearrange(control_points, 'b c u d h w -> u b c d h w')
145 |
146 | # separate u into components along time and (depth, height, width) dimensions
147 | t_t = t[:, 0]
148 | t_dhw = t[:, [1, 2, 3]]
149 |
150 | # 3D interpolation on each 3D grid along time dimension
151 | p0 = interpolate_pieces_3d(control_points=t0, t=t_dhw, matrix=matrix)
152 | p1 = interpolate_pieces_3d(control_points=t1, t=t_dhw, matrix=matrix)
153 | p2 = interpolate_pieces_3d(control_points=t2, t=t_dhw, matrix=matrix)
154 | p3 = interpolate_pieces_3d(control_points=t3, t=t_dhw, matrix=matrix)
155 |
156 | # 1d interpolation of result along time dim
157 | control_points = einops.rearrange([p0, p1, p2, p3], 'p b c -> b c p')
158 | return interpolate_pieces_1d(control_points=control_points, t=t_t, matrix=matrix)
159 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/pad_grids.py:
--------------------------------------------------------------------------------
1 | import einops
2 | import torch
3 |
4 |
5 | def pad_grid_1d(grid: torch.Tensor) -> torch.Tensor:
6 | """Pad in the last dimension according to local gradients.
7 |
8 | e.g. [0, 1, 2] -> [-1, 0, 1, 2, 3]
9 |
10 | grid: torch.Tensor
11 | `(..., w)` array of values to be padded in last dimension.
12 |
13 | Returns
14 | -------
15 | padded_grid: torch.Tensor
16 | `(..., w+2)` padded array.
17 | """
18 | # remove singleton dimension if necessary
19 | w = grid.shape[-1]
20 | if w == 1:
21 | grid = einops.repeat(grid, '... w -> ... (repeat w)', repeat=2)
22 |
23 | # find values for padding at each end of width dim
24 | start = grid[..., 0] - (grid[..., 1] - grid[..., 0])
25 | end = grid[..., -1] + (grid[..., -1] - grid[..., -2])
26 |
27 | # reintroduce width dim lost during indexing
28 | start = einops.rearrange(start, '... -> ... 1')
29 | end = einops.repeat(end, '... -> ... 1')
30 | return torch.cat([start, grid, end], dim=-1)
31 |
32 |
33 | def pad_grid_2d(grid: torch.Tensor) -> torch.Tensor:
34 | """Pad a 2D grid of values according to local gradients.
35 |
36 | ```
37 | e.g. [[-3, -2, -1, 0]
38 | [[0, 1] [-1, 0, 1, 2]
39 | [2, 3]] -> [ 1, 2, 3, 4]
40 | [ 3, 4, 5, 6]]
41 | ```
42 |
43 | Parameters
44 | ----------
45 | grid: torch.Tensor
46 | `(..., h, w)` array of values to be padded in height and width dimensions.
47 |
48 | Returns
49 | -------
50 | padded_grid: torch.Tensor
51 | `(..., h+2, w+2)` padded array.
52 | """
53 | # remove singleton dimension if necessary
54 | h = grid.shape[-2]
55 | if h == 1:
56 | grid = einops.repeat(grid, '... h w -> ... (repeat h) w', repeat=2)
57 | grid = pad_grid_1d(grid) # pad width dim (..., h, w+2)
58 |
59 | # find values for padding at each end of height dim
60 | h_start = grid[..., 0, :] - (grid[..., 1, :] - grid[..., 0, :])
61 | h_end = grid[..., -1, :] + (grid[..., -1, :] - grid[..., -2, :])
62 |
63 | # reintroduce height dim lost through indexing
64 | h_start = einops.rearrange(h_start, '... w -> ... 1 w')
65 | h_end = einops.rearrange(h_end, '... w -> ... 1 w')
66 |
67 | # pad height dim
68 | return torch.cat([h_start, grid, h_end], dim=-2)
69 |
70 |
71 | def pad_grid_3d(grid: torch.Tensor) -> torch.Tensor:
72 | """Pad a 3D grid of values according to local gradients.
73 |
74 | Parameters
75 | ----------
76 | grid: torch.Tensor
77 | `(..., d, h, w)` array of values to be padded in depth, height and width
78 | dimensions.
79 |
80 | Returns
81 | -------
82 | padded_grid: torch.Tensor
83 | `(..., d+2, h+2, w+2)` padded array.
84 | """
85 | # remove singleton dimension if necessary
86 | d = grid.shape[-3]
87 | if d == 1:
88 | grid = einops.repeat(grid, '... d h w -> ... (repeat d) h w', repeat=2)
89 |
90 | # pad in height and width dims
91 | grid = pad_grid_2d(grid)
92 |
93 | # find values for padding at each end of depth dim
94 | d_start = grid[..., 0, :, :] - (grid[..., 1, :, :] - grid[..., 0, :, :])
95 | d_end = grid[..., -1, :, :] + (grid[..., -1, :, :] - grid[..., -2, :, :])
96 |
97 | # reintroduce depth dim dropped by indexing
98 | d_start = einops.rearrange(d_start, '... h w -> ... 1 h w')
99 | d_end = einops.rearrange(d_end, '... h w -> ... 1 h w')
100 | return torch.cat([d_start, grid, d_end], dim=-3)
101 |
102 |
103 | def pad_grid_4d(grid: torch.Tensor) -> torch.Tensor:
104 | """Pad a 4D grid of values according to local gradients.
105 |
106 | Parameters
107 | ----------
108 | grid: torch.Tensor
109 | `(..., u, d, h, w)` array of values to be padded in time, depth, height and
110 | width dimensions.
111 |
112 | Returns
113 | -------
114 | padded_grid: torch.Tensor
115 | `(..., u+2, d+2, h+2, w+2)` grid
116 | """
117 | # remove singleton dimension if necessary
118 | t = grid.shape[-4]
119 | if t == 1:
120 | grid = einops.repeat(grid, '... u d h w -> ... (repeat u) d h w', repeat=2)
121 |
122 | # pad in height and width dims
123 | grid = pad_grid_3d(grid) # (..., u, d+2, h+2, w+2)
124 |
125 | # find values for padding at each end of time dim
126 | dt_start = grid[..., 1, :, :, :] - grid[..., 0, :, :, :]
127 | t_start = grid[..., 0, :, :, :] - dt_start
128 | dt_end = grid[..., -1, :, :, :] - grid[..., -2, :, :, :]
129 | t_end = grid[..., -1, :, :, :] + dt_end
130 |
131 | # reintroduce time dim dropped by indexing
132 | t_start = einops.rearrange(t_start, '... d h w -> ... 1 d h w')
133 | t_end = einops.rearrange(t_end, '... d h w -> ... 1 d h w')
134 | return torch.cat([t_start, grid, t_end], dim=-4)
135 |
--------------------------------------------------------------------------------
/src/torch_cubic_spline_grids/utils.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Sequence
2 | from typing import Callable, Iterable, Literal, Tuple
3 |
4 | import einops
5 | import torch
6 |
7 |
8 | def generate_sample_positions_for_padded_grid_1d(
9 | n_samples: int, device: torch.device
10 | ) -> torch.Tensor:
11 | """Generate a 1D vector of sample coordinates for a padded grid.
12 |
13 | Coordinate system is [0, 1] covering each dimension, pre-padding.
14 | e.g. for 6 samples on a padded grid
15 | `[-0.333, 0, 0.333, 0.666, 1, 1.333]`
16 |
17 |
18 | Parameters
19 | ----------
20 | n_samples: int
21 | The number of samples on the grid prior to padding.
22 | device: torch.device
23 | The torch device on which to store the tensor.
24 |
25 | Returns
26 | -------
27 | sample_coordinates: torch.Tensor
28 | The coordinates in the [0, 1] coordinate system of each sample on the
29 | padded grid.
30 | """
31 | du = 1 / (n_samples - 1)
32 | sample_coordinates = torch.linspace(-du, 1 + du, steps=n_samples + 2, device=device)
33 |
34 | # fix for numerical stability issues around 0 and 1
35 | # ensures valid control point indices are selected
36 | epsilon = 1e-6
37 | sample_coordinates[1] = 0 - epsilon
38 | sample_coordinates[-2] = 1 + epsilon
39 | return sample_coordinates
40 |
41 |
42 | def find_control_point_idx_1d(
43 | sample_positions: torch.Tensor, query_points: torch.Tensor
44 | ) -> torch.Tensor:
45 | """Find indices of four control points required for cubic interpolation.
46 |
47 | E.g. for sample positions `[0, 1, 2, 3, 4, 5]` and query point `2.5` the control
48 | point indices would be `[1, 2, 3, 4]` as `2.5` lies between `2` and `3`
49 |
50 | Parameters
51 | ----------
52 | sample_positions: torch.Tensor
53 | Monotonically increasing 1D array of sample positions.
54 | query_points: torch.Tensor
55 | `(b, )` array of query points for which control point indices.
56 |
57 | Returns
58 | -------
59 | control_point_idx: torch.Tensor
60 | `(b, 4)` array of indices for control points.
61 | """
62 | # find index of upper bound of interval for each query point
63 | sample_positions = sample_positions.contiguous()
64 | query_points = query_points.contiguous()
65 | iub_idx = torch.searchsorted(sample_positions, query_points, side='right')
66 |
67 | # generate (b, 4) array of indices of control points [s0, s1, s2, s3]
68 | # required for cubic interpolation
69 | s0_idx = iub_idx - 2
70 | s1_idx = iub_idx - 1
71 | s2_idx = iub_idx
72 | s3_idx = iub_idx + 1
73 | return einops.rearrange([s0_idx, s1_idx, s2_idx, s3_idx], 's b -> b s')
74 |
75 |
76 | def interpolants_to_interpolation_data_1d(
77 | interpolants: torch.Tensor, n_samples: int
78 | ) -> Tuple[torch.Tensor, torch.Tensor]:
79 | """Find the necessary data for piecewise cubic interpolation on a padded grid.
80 |
81 | Two pieces of data are required for piecewise cubic interpolation
82 | - four control points `[p0, p1, p2, p3]`
83 | - the interpolation coordinate
84 |
85 | The interpolation coordinate is a value in the range [0, 1] telling us how far into
86 | the interval `[p1, p2]` a query point is.
87 |
88 | This function returns the indices of the control points and the interpolation
89 | coordinate for a 1D grid. Returning the indices rather than the control points
90 | makes this more flexible for use in multidimensional grid interpolation which
91 | requires reusing and combining control point indices across dimensions.
92 |
93 | Parameters
94 | ----------
95 | interpolants: torch.Tensor
96 | `(b, )` batch of values in range [0, 1] covering the dimension being
97 | interpolated.
98 | n_samples: int
99 | The number of samples on the grid being interpolated (prior to padding).
100 |
101 | Returns
102 | -------
103 | control_point_idx, interpolation_coordinate: Tuple[torch.Tensor, torch.Tensor]
104 | The indices of control points `[p0, p1, p2, p3]` on a padded 1D grid and the
105 | interpolation coordinate associated with the interval `[p1, p2]`.
106 | """
107 | interpolants = torch.clamp(interpolants, min=0, max=1)
108 | device = interpolants.device
109 | if n_samples > 1:
110 | grid_u = generate_sample_positions_for_padded_grid_1d(n_samples, device=device)
111 | control_point_idx = find_control_point_idx_1d(
112 | sample_positions=grid_u, query_points=interpolants
113 | )
114 | u_p1 = grid_u[control_point_idx[:, 1]]
115 | du = 1 / (n_samples - 1)
116 | interpolation_coordinate = (interpolants - u_p1) / du
117 | else:
118 | control_point_idx = einops.repeat(
119 | torch.tensor([0, 1, 2, 3]), 'p -> b p', b=len(interpolants)
120 | )
121 | interpolation_coordinate = einops.repeat(
122 | torch.tensor([0.5], device=device), '1 -> b', b=len(interpolants)
123 | )
124 | return control_point_idx, interpolation_coordinate
125 |
126 |
127 | def coerce_to_multichannel_grid(grid: torch.Tensor, grid_ndim: int) -> torch.Tensor:
128 | """If missing, add a channel dimension to a multidimensional grid.
129 |
130 | e.g. for a 2D (h, w) grid
131 | `h w -> 1 h w`
132 | `c h w -> c h w`
133 | """
134 | grid_is_multichannel = grid.ndim == grid_ndim + 1
135 | grid_is_single_channel = grid.ndim == grid_ndim
136 | if grid_is_single_channel is False and grid_is_multichannel is False:
137 | raise ValueError(f'expected a {grid_ndim}D grid, got {grid.ndim}')
138 | if grid_is_single_channel:
139 | grid = einops.rearrange(grid, '... -> 1 ...')
140 | return grid
141 |
142 |
143 | MonotonicityType = Literal['increasing', 'decreasing']
144 |
145 |
146 | def transform_to_monotonic_nd(
147 | tensor: torch.Tensor, ndims: int, monotonicity: MonotonicityType
148 | ) -> torch.Tensor:
149 | """Transform tensor values, so they are monotonic across dimensions.
150 |
151 | Parameters
152 | ----------
153 | tensor: torch.Tensor
154 | a tensor of the arbitrary shape.
155 | ndims: int
156 | the number of the dimensions counting from the last to the first, for which
157 | elements should be monotonic.
158 | monotonicity: str
159 | Either 'decreasing' or 'increasing'.
160 |
161 | Returns
162 | -------
163 | tensor: torch.Tensor
164 | a tensor, with elements monotonic for the last `ndims` dimensions.
165 | """
166 | monotonicity_function: Callable
167 |
168 | if monotonicity == 'increasing':
169 | monotonicity_function = torch.cummax
170 | elif monotonicity == 'decreasing':
171 | monotonicity_function = torch.cummin
172 | elif monotonicity != '':
173 | raise ValueError(f'Unsupported monotonicity type "{monotonicity}" specified.')
174 |
175 | for dim in range(1, ndims + 1):
176 | tensor, _ = monotonicity_function(tensor, dim=-dim)
177 |
178 | return tensor
179 |
180 |
181 | def batch(iterable: Sequence, n: int = 1) -> Iterable[Iterable]:
182 | """Split an iterable into batches of constant length."""
183 | max_len = len(iterable)
184 | for idx in range(0, max_len, n):
185 | yield iterable[idx : min(idx + n, max_len)]
186 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teamtomo/torch-cubic-spline-grids/9894cef28da6ae8055a956d83bcdee522907c672/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_grid_optimisation.py:
--------------------------------------------------------------------------------
1 | import einops
2 | import torch
3 | from torch_cubic_spline_grids import (
4 | CubicBSplineGrid1d,
5 | CubicBSplineGrid2d,
6 | CubicBSplineGrid3d,
7 | CubicBSplineGrid4d,
8 | )
9 |
10 |
11 | def test_1d_grid_optimisation():
12 | grid_resolution = 6
13 | n_observations_per_iteration = 100
14 | grid = CubicBSplineGrid1d(resolution=grid_resolution, n_channels=1)
15 |
16 | def f(x: torch.Tensor, add_noise: bool = False):
17 | y = torch.sin(x * 2 * torch.pi)
18 | if add_noise is True:
19 | y += torch.normal(mean=torch.zeros(len(y)), std=0.3)
20 | return y
21 |
22 | optimiser = torch.optim.SGD(lr=0.1, params=grid.parameters())
23 | for _i in range(5000):
24 | x = torch.rand(size=(n_observations_per_iteration,))
25 | observations = f(x, add_noise=True)
26 | prediction = grid(x).squeeze()
27 | loss = torch.mean(torch.abs(prediction - observations))
28 | loss.backward()
29 | optimiser.step()
30 | optimiser.zero_grad()
31 |
32 | x = torch.linspace(0, 1, steps=100)
33 | ground_truth = f(x)
34 | prediction = grid(x).squeeze()
35 | mean_absolute_error = torch.mean(torch.abs(prediction - ground_truth))
36 | assert mean_absolute_error.item() < 0.02
37 |
38 |
39 | def test_1d_grid_optimization_decreasing():
40 | grid_resolution = 8
41 | n_observations_per_iteration = 100
42 | grid = CubicBSplineGrid1d(
43 | resolution=grid_resolution, n_channels=1, monotonicity='decreasing'
44 | )
45 |
46 | def f(x: torch.Tensor, add_noise: bool = False):
47 | y = torch.exp(-5 * x)
48 | if add_noise is True:
49 | y += torch.normal(mean=torch.zeros(len(y)), std=0.4)
50 | return y
51 |
52 | optimiser = torch.optim.SGD(lr=0.1, params=grid.parameters())
53 | for _i in range(5000):
54 | x = torch.rand(size=(n_observations_per_iteration,))
55 | observations = f(x, add_noise=True)
56 | prediction = grid(x).squeeze()
57 | loss = torch.mean(torch.abs(prediction - observations))
58 | loss.backward()
59 | optimiser.step()
60 | optimiser.zero_grad()
61 |
62 | x = torch.linspace(0, 1, steps=100)
63 | prediction = grid(x).squeeze()
64 |
65 | eps = torch.tensor(1e-5, dtype=prediction.dtype)
66 | non_increasing = torch.diff(prediction, dim=-1) <= eps
67 | assert non_increasing.all().item()
68 |
69 |
70 | def test_2d_grid_optimisation():
71 | grid_resolution = (3, 3)
72 | n_observations_per_iteration = 100
73 | grid = CubicBSplineGrid2d(resolution=grid_resolution, n_channels=1)
74 |
75 | def f(x: torch.Tensor, add_noise: bool = False):
76 | centered = x - 0.5
77 | y = torch.sqrt(torch.sum(centered**2, dim=-1)) # (x**2 + y**2) ** 0.5
78 | if add_noise is True:
79 | y += torch.normal(mean=torch.zeros(len(y)), std=0.3)
80 | return y
81 |
82 | optimiser = torch.optim.SGD(lr=0.3, params=grid.parameters())
83 | for _i in range(1000):
84 | x = torch.rand(size=(n_observations_per_iteration, 2))
85 | observations = f(x, add_noise=True)
86 | prediction = grid(x).squeeze()
87 | loss = torch.mean((prediction - observations) ** 2)
88 | loss.backward()
89 | optimiser.step()
90 | optimiser.zero_grad()
91 |
92 | _x = torch.linspace(0, 1, steps=100)
93 | x = torch.meshgrid(_x, _x, indexing='xy')
94 | x = einops.rearrange([*x], 'xy h w -> (h w) xy')
95 | ground_truth = f(x)
96 | prediction = grid(x).squeeze()
97 | mean_absolute_error = torch.mean(torch.abs(prediction - ground_truth))
98 | assert mean_absolute_error.item() < 0.02
99 |
100 |
101 | def test_3d_grid_optimisation():
102 | grid_resolution = (3, 3, 3)
103 | n_observations_per_iteration = 1000
104 | grid = CubicBSplineGrid3d(resolution=grid_resolution, n_channels=1)
105 |
106 | def f(x: torch.Tensor, add_noise: bool = False):
107 | centered = x - 0.5
108 | y = torch.sqrt(torch.sum(centered**2, dim=-1)) # (x**2 + y**2 + z**2) ** 0.5
109 | if add_noise is True:
110 | y += torch.normal(mean=torch.zeros(len(y)), std=0.3)
111 | return y
112 |
113 | optimiser = torch.optim.SGD(lr=0.3, params=grid.parameters())
114 | for _i in range(1000):
115 | x = torch.rand(size=(n_observations_per_iteration, 3))
116 | observations = f(x, add_noise=True)
117 | prediction = grid(x).squeeze()
118 | loss = torch.mean((prediction - observations) ** 2)
119 | loss.backward()
120 | optimiser.step()
121 | optimiser.zero_grad()
122 |
123 | _x = torch.linspace(0, 1, steps=100)
124 | x = torch.meshgrid(_x, _x, _x, indexing='xy')
125 | x = einops.rearrange([*x], 'xyz d h w -> (d h w) xyz')
126 | ground_truth = f(x)
127 | prediction = grid(x).squeeze()
128 | mean_absolute_error = torch.mean(torch.abs(prediction - ground_truth))
129 | assert mean_absolute_error.item() < 0.02
130 |
131 |
132 | def test_4d_grid_optimisation():
133 | grid_resolution = (3, 3, 3, 3)
134 | n_observations_per_iteration = 1000
135 | grid = CubicBSplineGrid4d(resolution=grid_resolution, n_channels=1)
136 |
137 | def f(x: torch.Tensor, add_noise: bool = False):
138 | centered = x - 0.5
139 | y = torch.sqrt(torch.sum(centered**2, dim=-1))
140 | if add_noise is True:
141 | y += torch.normal(mean=torch.zeros(len(y)), std=0.3)
142 | return y
143 |
144 | optimiser = torch.optim.SGD(lr=0.9, params=grid.parameters())
145 | for _i in range(1000):
146 | x = torch.rand(size=(n_observations_per_iteration, 4))
147 | observations = f(x, add_noise=True)
148 | prediction = grid(x).squeeze()
149 | loss = torch.mean((prediction - observations) ** 2)
150 | loss.backward()
151 | optimiser.step()
152 | optimiser.zero_grad()
153 |
154 | _x = torch.linspace(0, 1, steps=10)
155 | x = torch.meshgrid(_x, _x, _x, _x, indexing='xy')
156 | x = einops.rearrange([*x], 'xyz u d h w -> (u d h w) xyz')
157 | ground_truth = f(x)
158 | prediction = grid(x).squeeze()
159 | mean_absolute_error = torch.mean(torch.abs(prediction - ground_truth))
160 | assert mean_absolute_error.item() < 0.02
161 |
--------------------------------------------------------------------------------
/tests/test_grids.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import torch
3 |
4 | from torch_cubic_spline_grids import (
5 | CubicBSplineGrid1d,
6 | CubicBSplineGrid2d,
7 | CubicBSplineGrid3d,
8 | CubicBSplineGrid4d,
9 | CubicCatmullRomGrid1d,
10 | CubicCatmullRomGrid2d,
11 | CubicCatmullRomGrid3d,
12 | CubicCatmullRomGrid4d,
13 | )
14 |
15 |
16 | @pytest.mark.parametrize(
17 | 'grid_cls', [CubicBSplineGrid1d, CubicCatmullRomGrid1d]
18 | )
19 | def test_1d_grid_direct_instantiation(grid_cls):
20 | """Test grid instantiation with different types for resolution argument."""
21 | grid = grid_cls()
22 | assert isinstance(grid, grid_cls)
23 | assert grid.data.shape == (1, 2)
24 |
25 | grid = grid_cls(resolution=5, n_channels=3)
26 | assert isinstance(grid, grid_cls)
27 | assert grid.data.shape == (3, 5)
28 |
29 | grid = grid_cls(resolution=(5,), n_channels=3)
30 | assert isinstance(grid, grid_cls)
31 | assert grid.data.shape == (3, 5)
32 |
33 |
34 | @pytest.mark.parametrize(
35 | 'grid_cls', [CubicBSplineGrid1d, CubicCatmullRomGrid1d]
36 | )
37 | def test_1d_grid_instantiation_from_existing_data(grid_cls):
38 | """Test grid instantiation from existing data."""
39 | grid = grid_cls.from_grid_data(data=torch.zeros(3, 5))
40 | assert grid.ndim == 1
41 | assert grid.resolution == (5,)
42 | assert grid.n_channels == 3
43 | assert isinstance(grid._data, torch.nn.Parameter)
44 |
45 |
46 | @pytest.mark.parametrize(
47 | 'grid_cls', [CubicBSplineGrid1d, CubicCatmullRomGrid1d]
48 | )
49 | def test_calling_1d_grid(grid_cls):
50 | """Test calling 1d grid."""
51 | grid = grid_cls()
52 | expected = torch.tensor([0.])
53 | for arg in (0.5, [0.5], torch.tensor([0.5])):
54 | result = grid(arg)
55 | assert torch.allclose(result, expected)
56 |
57 |
58 | @pytest.mark.parametrize(
59 | 'grid_cls', [CubicBSplineGrid1d, CubicCatmullRomGrid1d]
60 | )
61 | def test_1d_grid_with_singleton_dimension(grid_cls):
62 | """Test that a 2D grid with a singleton dimension can be used."""
63 | # singleton in width dim
64 | grid = grid_cls(resolution=1)
65 | result = grid(0.5)
66 | assert torch.allclose(result, torch.tensor([0.0]))
67 |
68 |
69 | @pytest.mark.parametrize(
70 | 'grid_cls', [CubicBSplineGrid1d, CubicCatmullRomGrid1d]
71 | )
72 | def test_calling_1d_grid_with_stacked_coords(grid_cls):
73 | """Test calling a 1d grid with a multidimensional array of coordinates."""
74 | grid = grid_cls(resolution=1)
75 | h, w = 4, 4
76 |
77 | # no explicit coordinate dimension
78 | result = grid(torch.rand(size=(h, w)))
79 | assert result.shape == (h, w)
80 | assert torch.allclose(result, torch.tensor([0]).float())
81 |
82 | # with explicit coordinate dimension
83 | result = grid(torch.rand(size=(h, w, 1)))
84 | assert result.shape == (h, w, 1)
85 | assert torch.allclose(result, torch.tensor([0]).float())
86 |
87 |
88 | def test_interpolation_matrix_device():
89 | """Interpolation matrix should move when Module moves to a different device."""
90 | grid = CubicBSplineGrid1d(resolution=3)
91 | assert grid.interpolation_matrix.device == torch.device('cpu')
92 | grid.to(torch.device('meta'))
93 | assert grid.interpolation_matrix.device == torch.device('meta')
94 |
95 |
96 | def test_grid_device():
97 | """Grid data should move when Module moves to a different device."""
98 | grid = CubicBSplineGrid1d(resolution=3)
99 | assert grid.data.device == torch.device('cpu')
100 | grid.to(torch.device('meta'))
101 | assert grid.data.device == torch.device('meta')
102 |
103 |
104 | @pytest.mark.parametrize(
105 | 'grid_cls', [CubicBSplineGrid2d, CubicCatmullRomGrid2d]
106 | )
107 | def test_2d_grid_direct_instantiation(grid_cls):
108 | grid = grid_cls()
109 | assert isinstance(grid, grid_cls)
110 | assert grid.data.shape == (1, 2, 2)
111 |
112 | grid = grid_cls(resolution=(5, 4), n_channels=3)
113 | assert isinstance(grid, grid_cls)
114 | assert grid.data.shape == (3, 5, 4)
115 |
116 |
117 | @pytest.mark.parametrize(
118 | 'grid_cls', [CubicBSplineGrid2d, CubicCatmullRomGrid2d]
119 | )
120 | def test_2d_grid_instantiation_from_existing_data(grid_cls):
121 | """Test grid instantiation from existing data."""
122 | grid = grid_cls.from_grid_data(data=torch.zeros(3, 5, 4))
123 | assert grid.ndim == 2
124 | assert grid.resolution == (5, 4)
125 | assert grid.n_channels == 3
126 | assert isinstance(grid._data, torch.nn.Parameter)
127 |
128 |
129 | @pytest.mark.parametrize(
130 | 'grid_cls', [CubicBSplineGrid2d, CubicCatmullRomGrid2d]
131 | )
132 | def test_calling_2d_grid(grid_cls):
133 | """Test calling 2d grid."""
134 | grid = grid_cls()
135 | expected = torch.tensor([0., 0.])
136 | for arg in ([0.5, 0.5], torch.tensor([0.5, 0.5])):
137 | result = grid(arg)
138 | assert torch.allclose(result, expected)
139 |
140 |
141 | @pytest.mark.parametrize(
142 | 'grid_cls', [CubicBSplineGrid2d, CubicCatmullRomGrid2d]
143 | )
144 | def test_2d_grid_with_singleton_dimension(grid_cls):
145 | """Test that a 2D grid with a singleton dimension can be used."""
146 | # singleton in width dim
147 | grid = grid_cls(resolution=(2, 1))
148 | result = grid([0.5, 0.5])
149 | assert torch.allclose(result, torch.tensor([0.0, 0.0]))
150 |
151 | # singleton in height dim
152 | grid = grid_cls(resolution=(1, 2))
153 | result = grid([0.5, 0.5])
154 | assert torch.allclose(result, torch.tensor([0.0, 0.0]))
155 |
156 |
157 | @pytest.mark.parametrize(
158 | 'grid_cls', [CubicBSplineGrid2d, CubicCatmullRomGrid2d]
159 | )
160 | def test_calling_2d_grid_with_stacked_coordinates(grid_cls):
161 | """Test calling a 2D grid with stacked coordinates."""
162 | grid = grid_cls(resolution=(2, 2), n_channels=1)
163 | result = grid(torch.rand(size=(5, 5, 2)))
164 | assert result.shape == (5, 5, 1)
165 |
166 | grid = grid_cls(resolution=(2, 2), n_channels=2)
167 | result = grid(torch.rand(size=(5, 5, 2)))
168 | assert result.shape == (5, 5, 2)
169 |
170 |
171 | @pytest.mark.parametrize(
172 | 'grid_cls', [CubicBSplineGrid3d, CubicCatmullRomGrid3d]
173 | )
174 | def test_3d_grid_direct_instantiation(grid_cls):
175 | grid = grid_cls()
176 | assert isinstance(grid, grid_cls)
177 | assert grid.data.shape == (1, 2, 2, 2)
178 |
179 | grid = grid_cls(resolution=(5, 4, 3), n_channels=2)
180 | assert isinstance(grid, grid_cls)
181 | assert grid.data.shape == (2, 5, 4, 3)
182 |
183 |
184 | @pytest.mark.parametrize(
185 | 'grid_cls', [CubicBSplineGrid3d, CubicCatmullRomGrid3d]
186 | )
187 | def test_3d_grid_instantiation_from_existing_data(grid_cls):
188 | """Test grid instantiation from existing data."""
189 | grid = grid_cls.from_grid_data(data=torch.zeros(2, 5, 4, 3))
190 | assert grid.ndim == 3
191 | assert grid.resolution == (5, 4, 3)
192 | assert grid.n_channels == 2
193 | assert isinstance(grid._data, torch.nn.Parameter)
194 |
195 |
196 | @pytest.mark.parametrize(
197 | 'grid_cls', [CubicBSplineGrid3d, CubicCatmullRomGrid3d]
198 | )
199 | def test_calling_3d_grid(grid_cls):
200 | """Test calling 3d grid."""
201 | grid = grid_cls()
202 | expected = torch.tensor([0., 0., 0.])
203 | for arg in ([0.5, 0.5, 0.5], torch.tensor([0.5, 0.5, 0.5])):
204 | result = grid(arg)
205 | assert torch.allclose(result, expected)
206 |
207 |
208 | @pytest.mark.parametrize(
209 | 'grid_cls', [CubicBSplineGrid3d, CubicCatmullRomGrid3d]
210 | )
211 | def test_calling_3d_grid_with_stacked_coordinates(grid_cls):
212 | """Test calling 3d grid with stacked coordinates."""
213 | grid = grid_cls()
214 | d, h, w = 4, 4, 4
215 | result = grid(torch.rand(size=(d, h, w, 3)))
216 | assert result.shape == (d, h, w, 1)
217 |
218 |
219 | @pytest.mark.parametrize(
220 | 'grid_cls', [CubicBSplineGrid3d, CubicCatmullRomGrid3d]
221 | )
222 | def test_3d_grid_with_singleton_dimension(grid_cls):
223 | """Test that a 3D grid with a singleton dimension can be used."""
224 | # singleton in width dim
225 | grid = grid_cls(resolution=(2, 2, 1))
226 | result = grid([0.5, 0.5, 0.5])
227 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0]))
228 |
229 | # singleton in height dim
230 | grid = grid_cls(resolution=(2, 1, 2))
231 | result = grid([0.5, 0.5, 0.5])
232 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0]))
233 |
234 | # singleton in depth dim
235 | grid = grid_cls(resolution=(1, 2, 2))
236 | result = grid([0.5, 0.5, 0.5])
237 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0]))
238 |
239 |
240 | @pytest.mark.parametrize(
241 | 'grid_cls', [CubicBSplineGrid4d, CubicCatmullRomGrid4d]
242 | )
243 | def test_4d_grid_direct_instantiation(grid_cls):
244 | grid = grid_cls()
245 | assert isinstance(grid, grid_cls)
246 | assert grid.data.shape == (1, 2, 2, 2, 2)
247 |
248 | grid = grid_cls(resolution=(6, 5, 4, 3), n_channels=2)
249 | assert isinstance(grid, grid_cls)
250 | assert grid.data.shape == (2, 6, 5, 4, 3)
251 |
252 |
253 | @pytest.mark.parametrize(
254 | 'grid_cls', [CubicBSplineGrid4d, CubicCatmullRomGrid4d]
255 | )
256 | def test_4d_grid_instantiation_from_existing_data(grid_cls):
257 | """Test grid instantiation from existing data."""
258 | grid = grid_cls.from_grid_data(data=torch.zeros(2, 6, 5, 4, 3))
259 | assert grid.ndim == 4
260 | assert grid.resolution == (6, 5, 4, 3)
261 | assert grid.n_channels == 2
262 | assert isinstance(grid._data, torch.nn.Parameter)
263 |
264 |
265 | @pytest.mark.parametrize(
266 | 'grid_cls', [CubicBSplineGrid4d, CubicCatmullRomGrid4d]
267 | )
268 | def test_calling_4d_grid(grid_cls):
269 | """Test calling 4d grid."""
270 | grid = grid_cls()
271 | expected = torch.tensor([0., 0., 0., 0.])
272 | for arg in ([0.5, 0.5, 0.5, 0.5], torch.tensor([0.5, 0.5, 0.5, 0.5])):
273 | result = grid(arg)
274 | assert torch.allclose(result, expected)
275 |
276 |
277 | @pytest.mark.parametrize(
278 | 'grid_cls', [CubicBSplineGrid4d, CubicCatmullRomGrid4d]
279 | )
280 | def test_calling_4d_grid_with_stacked_coordinates(grid_cls):
281 | """Test calling 3d grid with stacked coordinates."""
282 | grid = grid_cls()
283 | t, d, h, w = 2, 4, 4, 4
284 | result = grid(torch.rand(size=(t, d, h, w, 4)))
285 | assert result.shape == (t, d, h, w, 1)
286 |
287 |
288 | @pytest.mark.parametrize(
289 | 'grid_cls', [CubicBSplineGrid4d, CubicCatmullRomGrid4d]
290 | )
291 | def test_4d_grid_with_singleton_dimension(grid_cls):
292 | """Test that a 4D grid with a singleton dimension can be used."""
293 | # singleton in width dim
294 | grid = grid_cls(resolution=(2, 2, 2, 1))
295 | result = grid([0.5, 0.5, 0.5, 0.5])
296 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0, 0.0]))
297 |
298 | # singleton in height dim
299 | grid = grid_cls(resolution=(2, 2, 1, 2))
300 | result = grid([0.5, 0.5, 0.5, 0.5])
301 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0, 0.0]))
302 |
303 | # singleton in depth dim
304 | grid = grid_cls(resolution=(2, 1, 2, 2))
305 | result = grid([0.5, 0.5, 0.5, 0.5])
306 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0, 0.0]))
307 |
308 | # singleton in time dim
309 | grid = grid_cls(resolution=(1, 2, 2, 2))
310 | result = grid([0.5, 0.5, 0.5, 0.5])
311 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0, 0.0]))
312 |
313 | # multiple singletons
314 | grid = grid_cls(resolution=(1, 1, 1, 1))
315 | result = grid([0.5, 0.5, 0.5, 0.5])
316 | assert torch.allclose(result, torch.tensor([0.0, 0.0, 0.0, 0.0]))
317 |
--------------------------------------------------------------------------------
/tests/test_interpolate_grid.py:
--------------------------------------------------------------------------------
1 | import torch
2 |
3 | from torch_cubic_spline_grids import interpolate_grids
4 | from torch_cubic_spline_grids._constants import CUBIC_B_SPLINE_MATRIX
5 |
6 |
7 | def test_interpolate_grid_1d():
8 | """Check that 1d interpolation works as expected."""
9 | grid = torch.tensor([0, 1, 2, 3, 4, 5]).float()
10 | u = torch.tensor([0.5]).view((1, 1))
11 | result = interpolate_grids.interpolate_grid_1d(
12 | grid, u, matrix=CUBIC_B_SPLINE_MATRIX
13 | )
14 | expected = torch.tensor([[2.5]])
15 | assert torch.allclose(result, expected)
16 |
17 |
18 | def test_interpolate_grid_1d_approx():
19 | """Check that 1D interpolation approximates a function."""
20 | grid_x = torch.linspace(0, 2 * torch.pi, steps=50)
21 | grid_y = torch.sin(grid_x)
22 | sample_x = torch.linspace(0, 1, steps=1000).view((-1, 1))
23 | sample_y = interpolate_grids.interpolate_grid_1d(
24 | grid_y, sample_x, matrix=CUBIC_B_SPLINE_MATRIX
25 | )
26 | ground_truth_y = torch.sin(sample_x * 2 * torch.pi)
27 | mean_absolute_error = torch.mean(torch.abs(sample_y - ground_truth_y))
28 | assert mean_absolute_error <= 0.01
29 |
30 |
31 | def test_interpolate_grid_2d():
32 | """Check that 2D interpolation works."""
33 | grid = torch.tensor(
34 | [[0, 1, 2, 3],
35 | [4, 5, 6, 7],
36 | [8, 9, 10, 11],
37 | [12, 13, 14, 15]]
38 | ).float()
39 | u = torch.tensor([0.5, 0.5]).view(1, 2)
40 | result = interpolate_grids.interpolate_grid_2d(
41 | grid, u, matrix=CUBIC_B_SPLINE_MATRIX
42 | )
43 | expected = torch.tensor([7.5])
44 | assert torch.allclose(result, expected)
45 |
46 |
47 | def test_interpolate_grid_3d():
48 | """Check that 3D interpolation works."""
49 | grid = torch.tensor(
50 | [[[0, 1, 2, 3],
51 | [4, 5, 6, 7],
52 | [8, 9, 10, 11],
53 | [12, 13, 14, 15]],
54 | [[16, 17, 18, 19],
55 | [20, 21, 22, 23],
56 | [24, 25, 26, 27],
57 | [28, 29, 30, 31]],
58 | [[32, 33, 34, 35],
59 | [36, 37, 38, 39],
60 | [40, 41, 42, 43],
61 | [44, 45, 46, 47]],
62 | [[48, 49, 50, 51],
63 | [52, 53, 54, 55],
64 | [56, 57, 58, 59],
65 | [60, 61, 62, 63]]],
66 | ).float()
67 | u = torch.tensor([[0.5, 0.5, 0.5]]).view(1, 3)
68 | result = interpolate_grids.interpolate_grid_3d(grid, u,
69 | matrix=CUBIC_B_SPLINE_MATRIX)
70 | expected = torch.tensor([31.5])
71 | assert torch.allclose(result, expected)
72 |
73 |
74 | def test_interpolate_grid_4d():
75 | """Check that 4D interpolation works as expected."""
76 | grid = torch.tensor(
77 | [[[[0, 1, 2, 3],
78 | [4, 5, 6, 7],
79 | [8, 9, 10, 11],
80 | [12, 13, 14, 15]],
81 | [[16, 17, 18, 19],
82 | [20, 21, 22, 23],
83 | [24, 25, 26, 27],
84 | [28, 29, 30, 31]],
85 | [[32, 33, 34, 35],
86 | [36, 37, 38, 39],
87 | [40, 41, 42, 43],
88 | [44, 45, 46, 47]],
89 | [[48, 49, 50, 51],
90 | [52, 53, 54, 55],
91 | [56, 57, 58, 59],
92 | [60, 61, 62, 63]]],
93 | [[[64, 65, 66, 67],
94 | [68, 69, 70, 71],
95 | [72, 73, 74, 75],
96 | [76, 77, 78, 79]],
97 | [[80, 81, 82, 83],
98 | [84, 85, 86, 87],
99 | [88, 89, 90, 91],
100 | [92, 93, 94, 95]],
101 | [[96, 97, 98, 99],
102 | [100, 101, 102, 103],
103 | [104, 105, 106, 107],
104 | [108, 109, 110, 111]],
105 | [[112, 113, 114, 115],
106 | [116, 117, 118, 119],
107 | [120, 121, 122, 123],
108 | [124, 125, 126, 127]]],
109 | [[[128, 129, 130, 131],
110 | [132, 133, 134, 135],
111 | [136, 137, 138, 139],
112 | [140, 141, 142, 143]],
113 | [[144, 145, 146, 147],
114 | [148, 149, 150, 151],
115 | [152, 153, 154, 155],
116 | [156, 157, 158, 159]],
117 | [[160, 161, 162, 163],
118 | [164, 165, 166, 167],
119 | [168, 169, 170, 171],
120 | [172, 173, 174, 175]],
121 | [[176, 177, 178, 179],
122 | [180, 181, 182, 183],
123 | [184, 185, 186, 187],
124 | [188, 189, 190, 191]]],
125 | [[[192, 193, 194, 195],
126 | [196, 197, 198, 199],
127 | [200, 201, 202, 203],
128 | [204, 205, 206, 207]],
129 | [[208, 209, 210, 211],
130 | [212, 213, 214, 215],
131 | [216, 217, 218, 219],
132 | [220, 221, 222, 223]],
133 | [[224, 225, 226, 227],
134 | [228, 229, 230, 231],
135 | [232, 233, 234, 235],
136 | [236, 237, 238, 239]],
137 | [[240, 241, 242, 243],
138 | [244, 245, 246, 247],
139 | [248, 249, 250, 251],
140 | [252, 253, 254, 255]]]]
141 | ).float()
142 | u = torch.tensor([0.5, 0.5, 0.5, 0.5]).view(1, 4)
143 | result = interpolate_grids.interpolate_grid_4d(
144 | grid, u, matrix=CUBIC_B_SPLINE_MATRIX
145 | )
146 | expected = torch.tensor([127.5])
147 | assert torch.allclose(result, expected)
148 |
--------------------------------------------------------------------------------
/tests/test_interpolate_pieces.py:
--------------------------------------------------------------------------------
1 | import torch
2 |
3 | from torch_cubic_spline_grids import interpolate_pieces
4 | from torch_cubic_spline_grids._constants import CUBIC_B_SPLINE_MATRIX
5 |
6 |
7 | def test_interpolate_pieces_1d():
8 | """test that cubic B-spline interpolation results in expected values."""
9 | t = 0.5
10 | points = torch.tensor([-1.5, 5.1, 2.2, 6.8])
11 | result = interpolate_pieces.interpolate_pieces_1d(
12 | control_points=points.view(1, 1, 4), # (b, c, 4)
13 | t=torch.tensor([t]),
14 | matrix=CUBIC_B_SPLINE_MATRIX,
15 | )
16 | expected = torch.tensor([1, t, t ** 2, t ** 3]) @ CUBIC_B_SPLINE_MATRIX @ points
17 | assert torch.allclose(result, expected)
18 |
19 |
20 | def test_interpolate_pieces_1d_with_batched_queries():
21 | """test batched evaluation over one 'piece' (set of 4 control points)."""
22 | pieces = torch.tensor([[0, 1, 2, 3]]).float().view(1, 1, 4) # (b, c, 4)
23 | t = torch.tensor([0, 0.5, 1]) # (b, )
24 | result = interpolate_pieces.interpolate_pieces_1d(
25 | pieces, t, matrix=CUBIC_B_SPLINE_MATRIX
26 | )
27 |
28 | # cubic b spline intepolation should be equivalent to linear interpolation
29 | # for four control points on the same line
30 | expected = torch.tensor([1, 1.5, 2]).view((3, 1))
31 | assert torch.allclose(result, expected)
32 |
33 |
34 | def test_interpolate_pieces_1d_with_batched_pieces_and_queries():
35 | """test batched evaluation over batched 'pieces' (sets of 4 control points)."""
36 | pieces = torch.tensor(
37 | [[0, 1, 2, 3],
38 | [2, 3, 4, 5]]
39 | ).float().view(2, 1, 4) # (b, c, 4)
40 | t = torch.tensor([0.5, 0.5]) # (b, )
41 | result = interpolate_pieces.interpolate_pieces_1d(
42 | pieces, t, matrix=CUBIC_B_SPLINE_MATRIX
43 | ) # (b, c)
44 |
45 | # cubic b spline intepolation should be equivalent to linear interpolation
46 | # for four control points on the same line
47 | expected = torch.tensor([[1.5, 3.5]]).view(2, 1)
48 | assert torch.allclose(result, expected)
49 |
50 |
51 | def test_interpolate_pieces_2d():
52 | """test evaluation of 2D cubic B-spline interpolation."""
53 | control_points = torch.tensor(
54 | [[0, 1, 2, 3],
55 | [4, 5, 6, 7],
56 | [8, 9, 10, 11],
57 | [12, 13, 14, 15]]
58 | ).float().view(1, 1, 4, 4)
59 | t = torch.tensor([0.5, 0.5]).view(1, 2)
60 | result = interpolate_pieces.interpolate_pieces_2d(
61 | control_points, t, matrix=CUBIC_B_SPLINE_MATRIX
62 | )
63 | expected = torch.tensor([7.5])
64 | assert torch.allclose(result, expected)
65 |
66 |
67 | def test_interpolate_pieces_3d():
68 | """test evaluation of 3D cubic B-spline interpolation."""
69 | control_points = torch.tensor(
70 | [[[0, 1, 2, 3],
71 | [4, 5, 6, 7],
72 | [8, 9, 10, 11],
73 | [12, 13, 14, 15]],
74 | [[16, 17, 18, 19],
75 | [20, 21, 22, 23],
76 | [24, 25, 26, 27],
77 | [28, 29, 30, 31]],
78 | [[32, 33, 34, 35],
79 | [36, 37, 38, 39],
80 | [40, 41, 42, 43],
81 | [44, 45, 46, 47]],
82 | [[48, 49, 50, 51],
83 | [52, 53, 54, 55],
84 | [56, 57, 58, 59],
85 | [60, 61, 62, 63]]],
86 | ).float().view(1, 1, 4, 4, 4)
87 | t = torch.tensor([[0.5, 0.5, 0.5]]).view(1, 3)
88 | result = interpolate_pieces.interpolate_pieces_3d(
89 | control_points, t, matrix=CUBIC_B_SPLINE_MATRIX
90 | )
91 | expected = torch.tensor([31.5])
92 | assert torch.allclose(result, expected)
93 |
94 |
95 | def test_interpolate_pieces_4d():
96 | """test evaluation of 4D cubic B-spline interpolation."""
97 | control_points = torch.tensor(
98 | [[[[0, 1, 2, 3],
99 | [4, 5, 6, 7],
100 | [8, 9, 10, 11],
101 | [12, 13, 14, 15]],
102 | [[16, 17, 18, 19],
103 | [20, 21, 22, 23],
104 | [24, 25, 26, 27],
105 | [28, 29, 30, 31]],
106 | [[32, 33, 34, 35],
107 | [36, 37, 38, 39],
108 | [40, 41, 42, 43],
109 | [44, 45, 46, 47]],
110 | [[48, 49, 50, 51],
111 | [52, 53, 54, 55],
112 | [56, 57, 58, 59],
113 | [60, 61, 62, 63]]],
114 | [[[64, 65, 66, 67],
115 | [68, 69, 70, 71],
116 | [72, 73, 74, 75],
117 | [76, 77, 78, 79]],
118 | [[80, 81, 82, 83],
119 | [84, 85, 86, 87],
120 | [88, 89, 90, 91],
121 | [92, 93, 94, 95]],
122 | [[96, 97, 98, 99],
123 | [100, 101, 102, 103],
124 | [104, 105, 106, 107],
125 | [108, 109, 110, 111]],
126 | [[112, 113, 114, 115],
127 | [116, 117, 118, 119],
128 | [120, 121, 122, 123],
129 | [124, 125, 126, 127]]],
130 | [[[128, 129, 130, 131],
131 | [132, 133, 134, 135],
132 | [136, 137, 138, 139],
133 | [140, 141, 142, 143]],
134 | [[144, 145, 146, 147],
135 | [148, 149, 150, 151],
136 | [152, 153, 154, 155],
137 | [156, 157, 158, 159]],
138 | [[160, 161, 162, 163],
139 | [164, 165, 166, 167],
140 | [168, 169, 170, 171],
141 | [172, 173, 174, 175]],
142 | [[176, 177, 178, 179],
143 | [180, 181, 182, 183],
144 | [184, 185, 186, 187],
145 | [188, 189, 190, 191]]],
146 | [[[192, 193, 194, 195],
147 | [196, 197, 198, 199],
148 | [200, 201, 202, 203],
149 | [204, 205, 206, 207]],
150 | [[208, 209, 210, 211],
151 | [212, 213, 214, 215],
152 | [216, 217, 218, 219],
153 | [220, 221, 222, 223]],
154 | [[224, 225, 226, 227],
155 | [228, 229, 230, 231],
156 | [232, 233, 234, 235],
157 | [236, 237, 238, 239]],
158 | [[240, 241, 242, 243],
159 | [244, 245, 246, 247],
160 | [248, 249, 250, 251],
161 | [252, 253, 254, 255]]]]
162 | ).float().view(1, 1, 4, 4, 4, 4)
163 | t = torch.tensor([0.5, 0.5, 0.5, 0.5]).view(1, 4)
164 | result = interpolate_pieces.interpolate_pieces_4d(
165 | control_points, t, matrix=CUBIC_B_SPLINE_MATRIX
166 | )
167 | expected = torch.tensor([127.5])
168 | assert torch.allclose(result, expected)
169 |
--------------------------------------------------------------------------------
/tests/test_modules.py:
--------------------------------------------------------------------------------
1 | from torch_cubic_spline_grids import (
2 | CubicBSplineGrid1d,
3 | CubicBSplineGrid2d,
4 | CubicBSplineGrid3d,
5 | CubicBSplineGrid4d,
6 | )
7 |
8 |
9 | def test_grid_class_instantiation():
10 | grid_classes = [
11 | CubicBSplineGrid1d,
12 | CubicBSplineGrid2d,
13 | CubicBSplineGrid3d,
14 | CubicBSplineGrid4d
15 | ]
16 | for grid_class in grid_classes:
17 | instance = grid_class()
18 | assert isinstance(instance, grid_class)
19 | assert len(list(instance.parameters())) > 0
--------------------------------------------------------------------------------
/tests/test_pad_grid.py:
--------------------------------------------------------------------------------
1 | import torch
2 |
3 | from torch_cubic_spline_grids import pad_grids
4 |
5 |
6 | def test_pad_1d():
7 | grid = torch.arange(3)
8 | padded_grid = pad_grids.pad_grid_1d(grid)
9 | expected = torch.tensor([-1, 0, 1, 2, 3])
10 | assert torch.allclose(padded_grid, expected)
11 |
12 |
13 | def test_pad_2d():
14 | grid = torch.tensor(
15 | [[0, 1],
16 | [2, 3]]
17 | )
18 | padded_grid = pad_grids.pad_grid_2d(grid)
19 | expected = torch.tensor(
20 | [[-3, -2, -1, 0],
21 | [-1, 0, 1, 2],
22 | [1, 2, 3, 4],
23 | [3, 4, 5, 6]]
24 | )
25 | assert torch.allclose(padded_grid, expected)
26 |
27 |
28 | def test_pad_3d():
29 | grid = torch.tensor(
30 | [[[0, 1],
31 | [2, 3]],
32 | [[4, 5],
33 | [6, 7]]]
34 | )
35 | padded_grid = pad_grids.pad_grid_3d(grid)
36 | expected = torch.tensor(
37 | [[[-7, -6, -5, -4],
38 | [-5, -4, -3, -2],
39 | [-3, -2, -1, 0],
40 | [-1, 0, 1, 2]],
41 |
42 | [[-3, -2, -1, 0],
43 | [-1, 0, 1, 2],
44 | [1, 2, 3, 4],
45 | [3, 4, 5, 6]],
46 |
47 | [[1, 2, 3, 4],
48 | [3, 4, 5, 6],
49 | [5, 6, 7, 8],
50 | [7, 8, 9, 10]],
51 |
52 | [[5, 6, 7, 8],
53 | [7, 8, 9, 10],
54 | [9, 10, 11, 12],
55 | [11, 12, 13, 14]]]
56 | )
57 | assert torch.allclose(padded_grid, expected)
58 |
59 |
60 | def test_pad_4d():
61 | grid = torch.tensor(
62 | [[[[0, 1],
63 | [2, 3]],
64 | [[4, 5],
65 | [6, 7]]],
66 | [[[8, 9],
67 | [10, 11]],
68 | [[12, 13],
69 | [14, 15]]]]
70 | )
71 | padded_grid = pad_grids.pad_grid_4d(grid)
72 | expected = torch.tensor(
73 | [[[[-15, -14, -13, -12],
74 | [-13, -12, -11, -10],
75 | [-11, -10, -9, -8],
76 | [-9, -8, -7, -6]],
77 | [[-11, -10, -9, -8],
78 | [-9, -8, -7, -6],
79 | [-7, -6, -5, -4],
80 | [-5, -4, -3, -2]],
81 | [[-7, -6, -5, -4],
82 | [-5, -4, -3, -2],
83 | [-3, -2, -1, 0],
84 | [-1, 0, 1, 2]],
85 | [[-3, -2, -1, 0],
86 | [-1, 0, 1, 2],
87 | [1, 2, 3, 4],
88 | [3, 4, 5, 6]]],
89 | [[[-7, -6, -5, -4],
90 | [-5, -4, -3, -2],
91 | [-3, -2, -1, 0],
92 | [-1, 0, 1, 2]],
93 | [[-3, -2, -1, 0],
94 | [-1, 0, 1, 2],
95 | [1, 2, 3, 4],
96 | [3, 4, 5, 6]],
97 | [[1, 2, 3, 4],
98 | [3, 4, 5, 6],
99 | [5, 6, 7, 8],
100 | [7, 8, 9, 10]],
101 | [[5, 6, 7, 8],
102 | [7, 8, 9, 10],
103 | [9, 10, 11, 12],
104 | [11, 12, 13, 14]]],
105 | [[[1, 2, 3, 4],
106 | [3, 4, 5, 6],
107 | [5, 6, 7, 8],
108 | [7, 8, 9, 10]],
109 | [[5, 6, 7, 8],
110 | [7, 8, 9, 10],
111 | [9, 10, 11, 12],
112 | [11, 12, 13, 14]],
113 | [[9, 10, 11, 12],
114 | [11, 12, 13, 14],
115 | [13, 14, 15, 16],
116 | [15, 16, 17, 18]],
117 | [[13, 14, 15, 16],
118 | [15, 16, 17, 18],
119 | [17, 18, 19, 20],
120 | [19, 20, 21, 22]]],
121 | [[[9, 10, 11, 12],
122 | [11, 12, 13, 14],
123 | [13, 14, 15, 16],
124 | [15, 16, 17, 18]],
125 | [[13, 14, 15, 16],
126 | [15, 16, 17, 18],
127 | [17, 18, 19, 20],
128 | [19, 20, 21, 22]],
129 | [[17, 18, 19, 20],
130 | [19, 20, 21, 22],
131 | [21, 22, 23, 24],
132 | [23, 24, 25, 26]],
133 | [[21, 22, 23, 24],
134 | [23, 24, 25, 26],
135 | [25, 26, 27, 28],
136 | [27, 28, 29, 30]]]]
137 | )
138 | assert torch.allclose(padded_grid, expected)
139 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | import einops
2 | import torch
3 |
4 | from torch_cubic_spline_grids.utils import find_control_point_idx_1d, batch
5 |
6 |
7 | def test_find_control_points():
8 | sample_positions = torch.tensor([0, 1, 2, 3, 4, 5, 6])
9 |
10 | # sample between points should yield four closest points
11 | result = find_control_point_idx_1d(sample_positions, torch.tensor([2.5]))
12 | expected = torch.tensor([[1, 2, 3, 4]])
13 | assert torch.allclose(result, expected)
14 |
15 | # sample on point should be included as lower bound in interval
16 | result = find_control_point_idx_1d(sample_positions, torch.tensor([2]))
17 | expected = torch.tensor([[1, 2, 3, 4]])
18 | assert torch.allclose(result, expected)
19 |
20 | # check the same is true for 3, the upper bound of the same interval
21 | result = find_control_point_idx_1d(sample_positions, torch.tensor([3]))
22 | expected = torch.tensor([[2, 3, 4, 5]])
23 | assert torch.allclose(result, expected)
24 |
25 |
26 | def test_batch():
27 | """All items should be present in minibatches."""
28 | l = [0, 1, 2, 3, 4, 5, 6]
29 | minibatches = [minibatch for minibatch in batch(l, n=3)]
30 | expected = [[0, 1, 2], [3, 4, 5], [6]]
31 | assert minibatches == expected
32 |
33 |
34 | def test_restacking_batch():
35 | """Ensure entries get restacked by cat the same way as they are unstacked."""
36 | batched_input = torch.rand(size=(10, 3)) # (b, d)
37 | minibatches = [minibatch for minibatch in batch(batched_input, n=3)]
38 | restacked_minibatches = torch.cat(minibatches, dim=0)
39 | assert torch.allclose(batched_input, restacked_minibatches)
40 |
--------------------------------------------------------------------------------