├── .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 | [![License](https://img.shields.io/pypi/l/torch-cubic-spline-grids.svg?color=green)](https://github.com/alisterburt/torch-cubic-spline-grids/raw/main/LICENSE) 4 | [![PyPI](https://img.shields.io/pypi/v/torch-cubic-spline-grids.svg?color=green)](https://pypi.org/project/torch-cubic-spline-grids) 5 | [![Python Version](https://img.shields.io/pypi/pyversions/torch-cubic-spline-grids.svg?color=green)](https://python.org) 6 | [![CI](https://github.com/alisterburt/torch-cubic-spline-grids/actions/workflows/ci.yml/badge.svg)](https://github.com/alisterburt/torch-cubic-spline-grids/actions/workflows/ci.yml) 7 | [![codecov](https://codecov.io/gh/alisterburt/torch-cubic-spline-grids/branch/main/graph/badge.svg)](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 | --------------------------------------------------------------------------------