├── .github └── workflows │ ├── build.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── doc ├── Makefile └── source │ ├── _templates │ └── classtemplate.rst │ ├── api.rst │ ├── conf.py │ ├── example_notebooks.rst │ ├── examples │ ├── draw_bspline.ipynb │ ├── draw_legendre.ipynb │ ├── factorization_machine.ipynb │ ├── kan_bspline_rat.ipynb │ ├── kan_legendre_rat.ipynb │ └── transformer_mixed_curves.ipynb │ ├── index.rst │ ├── torchcurves.functional.rst │ └── torchcurves.rst ├── logo.png ├── logo_small.png ├── pyproject.toml ├── src └── torchcurves │ ├── __init__.py │ ├── functional │ ├── __init__.py │ ├── _bspline.py │ ├── _legendre.py │ └── _normalization.py │ ├── modules │ ├── __init__.py │ ├── _bspline.py │ ├── _kan_tools.py │ ├── _legendre.py │ └── _normalization.py │ └── types.py ├── tests ├── __init__.py ├── conftest.py ├── test_bspline.py └── test_legendre.py └── uv.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v3 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | run: uv python install ${{ matrix.python-version }} 23 | 24 | - name: Install build dependencies 25 | run: | 26 | uv venv 27 | uv sync --all-groups 28 | 29 | - name: Build package 30 | run: | 31 | uv build 32 | 33 | - name: Archive package 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: wheel_and_source_${{ matrix.python-version }} 37 | path: | 38 | dist 39 | 40 | - name: Build documentation 41 | run: | 42 | sudo apt-get -qq update 43 | sudo apt-get install -y pandoc 44 | cd doc 45 | make html 46 | 47 | - name: Archive documentation 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: docs_${{ matrix.python-version }} 51 | path: | 52 | doc/build/html 53 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install uv 20 | uses: astral-sh/setup-uv@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | run: uv python install ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | uv venv 28 | uv sync --all-groups 29 | 30 | - name: Lint with ruff 31 | run: | 32 | uv run ruff check src/ tests/ 33 | 34 | - name: Format with black 35 | run: | 36 | uv run black --check --line-length 120 src/ tests/ 37 | 38 | - name: Type check with mypy 39 | run: | 40 | uv run mypy src/ 41 | 42 | - name: Test with pytest 43 | run: | 44 | uv run pytest tests/ -v --cov=torch_bspline --cov-report=term-missing 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | MANIFEST 23 | 24 | # Virtual environments 25 | venv/ 26 | ENV/ 27 | env/ 28 | .venv 29 | 30 | # IDEs 31 | .vscode/ 32 | .idea/ 33 | *.swp 34 | *.swo 35 | *~ 36 | 37 | # Testing 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | .pytest_cache/ 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Jupyter 49 | .ipynb_checkpoints/ 50 | 51 | # Documentation 52 | docs/_build/ 53 | docs/_static/ 54 | docs/_templates/ 55 | 56 | # OS 57 | .DS_Store 58 | Thumbs.db 59 | 60 | # UV 61 | .python-version 62 | .ruff_cache 63 | 64 | # Sphinx 65 | doc/source/generated 66 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | # Ruff version. 12 | rev: v0.9.3 13 | hooks: 14 | # Run the linter. 15 | - id: ruff 16 | types_or: [ python, pyi ] 17 | args: [ --fix ] 18 | # Run the formatter. 19 | - id: ruff-format 20 | types_or: [ python, pyi ] 21 | - repo: https://github.com/astral-sh/uv-pre-commit 22 | rev: 0.5.25 23 | hooks: 24 | - id: uv-lock 25 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version, and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.13" 12 | apt_packages: 13 | - pandoc 14 | jobs: 15 | pre_create_environment: 16 | - asdf plugin add uv 17 | - asdf install uv latest 18 | - asdf global uv latest 19 | create_environment: 20 | - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" 21 | install: 22 | - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --all-groups 23 | 24 | sphinx: 25 | configuration: doc/source/conf.py 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # torchcurves 2 | 3 | ![torchcurves logo](https://raw.githubusercontent.com/alexshtf/torchcurves/master/logo.png) 4 | 5 | A PyTorch module for differentiable parametric curves with learnable coefficients, 6 | such as a B-Spline curve with learnable control points. 7 | 8 | This package provides fully differentiable curve implementations that integrate 9 | seamlessly with PyTorch's autograd system. It streamlines use cases such as 10 | continuous numerical embeddings for factorization machines [6] or transformers 11 | [2,3], Kolmogorov-Arnold networks [1], or path planning in robotics. 12 | 13 | ## Docs 14 | - [Documentation site](https://torchcurves.readthedocs.io/en/latest/). 15 | - [Example notebooks](https://torchcurves.readthedocs.io/en/latest/example_notebooks.html) for you to try our 16 | 17 | ## Features 18 | 19 | - **Fully Differentiable**: Custom autograd function ensures gradients flow 20 | properly through the curve evaluation. 21 | - **Batch Processing**: Vectorized operations for efficient batch evaluation. 22 | 23 | ## Installation 24 | 25 | ```bash 26 | pip install torchcurves 27 | ``` 28 | 29 | ```bash 30 | uv add torchcurves 31 | ``` 32 | 33 | ## Use cases 34 | 35 | There are examples in the `examples` directory showing how to build models using 36 | this library. Here we show some simple code snippets to appreciate the library. 37 | 38 | ## Use case 1 - continuous embeddings 39 | 40 | ```python 41 | import torchcurves as tc 42 | from torch import nn 43 | import torch 44 | 45 | 46 | def Net(nn.Module): 47 | def __init__(self, num_categorical, num_numerical, dim, num_knots=10): 48 | super().__init__() 49 | self.cat_emb = nn.Embedding(num_categorical, dim) 50 | self.num_emb = tc.BSplineEmbeddings(num_numerical, dim, knots_config=num_knots) 51 | self.my_super_duper_transformer = MySuperDuperTransformer() 52 | 53 | def forward(self, x_categorical, x_numerical): 54 | embeddings = torch.cat([self.cat_emb(x_categorical), self.num_emb(x_numerical)], axis=-2) 55 | return self.my_super_duper_transformer(embeddings) 56 | ``` 57 | 58 | ## Use case 2 - Kolmogorov-Arnold networks 59 | 60 | A KAN [1] based on the B-Spline basis, along the lines of the original paper: 61 | 62 | ```python 63 | import torchcurves as tc 64 | from torch import nn 65 | 66 | input_dim = 2 67 | intermediate_dim = 5 68 | num_control_points = 10 69 | 70 | kan = nn.Sequential( 71 | # layer 1 72 | tc.BSplineCurve(input_dim, intermediate_dim, knots_config=num_control_points), 73 | tc.Sum(dim=-2), 74 | # layer 2 75 | tc.BSplineCurve(intermediate_dim, intermediate_dim, knots_config=num_control_points), 76 | tc.Sum(dim=-2), 77 | # layer 3 78 | tc.BSplineCurve(intermediate_dim, 1, knots_config=num_control_points), 79 | tc.Sum(dim=-2), 80 | ) 81 | ``` 82 | Yes, we know the original KAN paper used a different curve parametrization, 83 | B-Spline + arcsinh, but the whole point of this repo is showing that KAN 84 | activations can be parametrized in arbitrary ways. 85 | 86 | For example, here is a KAN based on Legendre polynomials of degree 5: 87 | 88 | ```python 89 | import torchcurves as tc 90 | from torch import nn 91 | 92 | input_dim = 2 93 | intermediate_dim = 5 94 | degree = 5 95 | 96 | kan = nn.Sequential( 97 | # layer 1 98 | tc.LegendreCurve(input_dim, intermediate_dim, degree=degree), 99 | tc.Sum(dim=-2), 100 | # layer 2 101 | tc.LegendreCurve(intermediate_dim, intermediate_dim, degree=degree), 102 | tc.Sum(dim=-2), 103 | # layer 3 104 | tc.LegendreCurve(intermediate_dim, 1, degree=degree), 105 | tc.Sum(dim=-2), 106 | ) 107 | ``` 108 | 109 | Since KANs are the primary use case for the `tc.Sum()` layer, we can omit the `dim=-2` argument, but it is provided 110 | here for clarity. 111 | 112 | ## Advanced features 113 | 114 | The curves we provide here typically rely on their inputs to lie in a compact 115 | interval, typically [-1, 1]. Arbitrary inputs need to be normalized to this 116 | interval. We provide two simple out-of-the-box normalization strategies 117 | described below. 118 | 119 | ## Rational scaling 120 | 121 | This is the default strategy — this strategy computes 122 | 123 | ```math 124 | x \to \frac{x}{\sqrt{s^2 + x^2}}, 125 | ``` 126 | 127 | and is based on the paper 128 | >Wang, Z.Q. and Guo, B.Y., 2004. Modified Legendre rational spectral method for the whole line. Journal of Computational Mathematics, pp.457-474. 129 | 130 | In Python it looks like this: 131 | 132 | ```python 133 | tc.BSplineCurve(curve_dim, normalization_fn='rational', normalization_scale=s) 134 | ``` 135 | 136 | ## Clamping 137 | 138 | The inputs are simply clipped to [-1, 1] after scaling, i.e. 139 | 140 | ```math 141 | x \to \max(\min(1, x / s), -1) 142 | ``` 143 | 144 | In Python it looks like this: 145 | 146 | ```python 147 | tc.BSplineCurve(curve_dim, normalization_fn='clamp', normalization_scale=s) 148 | ``` 149 | 150 | ## Custom normalization 151 | 152 | Provide a custom function that maps its input to the designated range after 153 | scaling. Example: 154 | 155 | ```python 156 | def erf_clamp(x: Tensor, scale: float = 1, out_min: float = -1, out_max: float = 1) -> Tensor: 157 | mapped = torch.special.erf(x / scale) 158 | return ((mapped + 1) * (out_max - out_min)) / 2 + out_min 159 | 160 | tc.BSplineCurve(curve_dim, normalization_fn=erf_clamp, normalization_scale=s) 161 | ``` 162 | 163 | ## Example: B-Spline KAN with clamping 164 | 165 | A KAN based on rationally scaled B-Spline basis with the default scale of $s=1$: 166 | 167 | ```python 168 | spline_kan = nn.Sequential( 169 | # layer 1 170 | tc.BSplineCurve(input_dim, intermediate_dim, knots_config=knots, normalization_fn='clamp'), 171 | tc.Sum(), 172 | # layer 2 173 | tc.BSplineCurve(intermediate_dim, intermediate_dim, knots_config=knots, normalization_fn='clamp'), 174 | tc.Sum(), 175 | # layer 3 176 | tc.BSplineCurve(intermediate_dim, 1, knots_config=knots, normalization_fn='clamp'), 177 | tc.Sum(), 178 | ) 179 | ``` 180 | 181 | ### Legendre KAN with rational clamping 182 | 183 | ```python 184 | import torchcurves as tc 185 | from torch import nn 186 | 187 | input_dim = 2 188 | intermediate_dim = 5 189 | degree = 5 190 | 191 | config = dict(degree=degree, normalization_fn="clamp") 192 | kan = nn.Sequential( 193 | # layer 1 194 | tc.LegendreCurve(input_dim, intermediate_dim, **config), 195 | tc.Sum(), 196 | # layer 2 197 | tc.LegendreCurve(intermediate_dim, intermediate_dim, **config), 198 | tc.Sum(), 199 | # layer 3 200 | tc.LegendreCurve(intermediate_dim, 1, **config), 201 | tc.Sum(), 202 | ) 203 | ``` 204 | 205 | 206 | ## Development 207 | 208 | ## Development Installation 209 | 210 | Using [uv](https://github.com/astral-sh/uv) (recommended): 211 | 212 | ```bash 213 | # Clone the repository 214 | git clone https://github.com/alexshtf/torchcurves.git 215 | cd torchcurves 216 | 217 | # Create virtual environment and install 218 | uv venv 219 | uv sync --all-groups 220 | ``` 221 | 222 | ## Running Tests 223 | 224 | ```bash 225 | # Run all tests 226 | uv run pytest 227 | 228 | # Run with coverage 229 | uv run pytest --cov=torchcurves 230 | 231 | # Run specific test file 232 | uv run pytest tests/test_bspline.py -v 233 | ``` 234 | 235 | ## Building the docs 236 | 237 | ```bash 238 | # Prepare API docs 239 | cd docs 240 | make html 241 | ``` 242 | 243 | ## Citation 244 | 245 | If you use this package in your research, please cite: 246 | 247 | ```bibtex 248 | @software{torchcurves, 249 | author = {Shtoff, Alex}, 250 | title = {torchcurves: Differentiable Parametric Curves in PyTorch}, 251 | year = {2025}, 252 | publisher = {GitHub}, 253 | url = {https://github.com/alexshtf/torchcurves} 254 | } 255 | ``` 256 | 257 | ## References 258 | 259 | [1]: Ziming Liu, Yixuan Wang, Sachin Vaidya, Fabian Ruehle, James Halverson, Marin Soljacic, Thomas Y. Hou, Max Tegmark. "KAN: Kolmogorov–Arnold Networks." *ICLR* (2025). \ 260 | [2]: Juergen Schmidhuber. "Learning to control fast-weight memories: An alternative to dynamic recurrent networks." *Neural Computation*, 4(1), pp.131-139. (1992) \ 261 | [3]: Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Łukasz Kaiser, and Illia Polosukhin. "Attention is all you need." *Advances in neural information processing systems* 30 (2017). \ 262 | [4]: Alex Shtoff, Elie Abboud, Rotem Stram, and Oren Somekh. "Function Basis Encoding of Numerical Features in Factorization Machines." *Transactions on Machine Learning Research*. \ 263 | [5]: Rügamer, David. "Scalable Higher-Order Tensor Product Spline Models." In *International Conference on Artificial Intelligence and Statistics*, pp. 1-9. PMLR, 2024. \ 264 | [6]: Steffen Rendle. "Factorization machines." In *2010 IEEE International conference on data mining*, pp. 995-1000. IEEE, 2010. 265 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= uv run sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/source/_templates/classtemplate.rst: -------------------------------------------------------------------------------- 1 | .. role:: hidden 2 | :class: hidden-section 3 | .. currentmodule:: {{ module }} 4 | 5 | 6 | {{ name | underline}} 7 | 8 | .. autoclass:: {{ name }} 9 | :members: 10 | -------------------------------------------------------------------------------- /doc/source/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. toctree:: 5 | torchcurves 6 | torchcurves.functional 7 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | # Make sure we add the source code root to the path 5 | PROJECT_ROOT = Path(__file__).resolve().parents[2] # two levels up from conf.py 6 | sys.path.insert(0, str(PROJECT_ROOT / "src")) # make `import torchcurves` work 7 | 8 | # Configuration file for the Sphinx documentation builder. 9 | # 10 | # For the full list of built-in configuration values, see the documentation: 11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 12 | 13 | # -- Project information ----------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 15 | 16 | project = "TorchCurves" 17 | copyright = "2025, Alex Shtoff" 18 | author = "Alex Shtoff" 19 | 20 | # -- General configuration --------------------------------------------------- 21 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 22 | 23 | 24 | extensions = [ 25 | "sphinx.ext.mathjax", 26 | "sphinx.ext.autodoc", 27 | "sphinx.ext.autosummary", 28 | "sphinx.ext.napoleon", 29 | "myst_parser", 30 | "sphinx_autodoc_typehints", 31 | "nbsphinx", 32 | "sphinx_copybutton", 33 | "sphinxext.opengraph", 34 | ] 35 | 36 | 37 | napoleon_google_docstring = True 38 | napoleon_numpy_docstring = False 39 | 40 | autodoc_typehints = "description" # rely on PEP-484 annotations 41 | 42 | templates_path = ["_templates"] 43 | exclude_patterns = [] 44 | pygments_style = "sphinx" 45 | # mathjax_path = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.0.0/es5/latest?tex-mml-chtml.js" 46 | # mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS_CHTML" 47 | mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" 48 | 49 | 50 | language = "en" 51 | 52 | # -- Options for HTML output ------------------------------------------------- 53 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 54 | 55 | html_theme = "pydata_sphinx_theme" 56 | html_static_path = ["_static"] 57 | -------------------------------------------------------------------------------- /doc/source/example_notebooks.rst: -------------------------------------------------------------------------------- 1 | Example notebooks 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | 7 | examples/draw_bspline 8 | examples/draw_legendre 9 | examples/kan_bspline_rat 10 | examples/kan_legendre_rat 11 | examples/factorization_machine 12 | examples/transformer_mixed_curves 13 | -------------------------------------------------------------------------------- /doc/source/examples/draw_legendre.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "dc2e0d2d", 6 | "metadata": {}, 7 | "source": [ 8 | "# Legendre curve plotting demo\n", 9 | "In this notebook we show the spectral nature of Legendre curves. The parameters we learn are a kind of a frequency\n", 10 | "domain, defining the spectrum of the curves. They oscilate more close to the origin, and less farther away from\n", 11 | "the origin." 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": 11, 17 | "id": "88b0fbb8", 18 | "metadata": {}, 19 | "outputs": [], 20 | "source": [ 21 | "import torch\n", 22 | "import torch.nn.functional as F\n", 23 | "import torchcurves.functional as tcf\n", 24 | "import matplotlib.pyplot as plt" 25 | ] 26 | }, 27 | { 28 | "cell_type": "markdown", 29 | "id": "9d949c41", 30 | "metadata": {}, 31 | "source": [ 32 | "## Define parameters" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": null, 38 | "id": "28e80e56", 39 | "metadata": {}, 40 | "outputs": [], 41 | "source": [ 42 | "degree = 10\n", 43 | "n_coefficients = 1 + degree" 44 | ] 45 | }, 46 | { 47 | "cell_type": "markdown", 48 | "id": "d72eac2f", 49 | "metadata": {}, 50 | "source": [ 51 | "## Define coefficients of various curves" 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": 66, 57 | "id": "d356a784", 58 | "metadata": {}, 59 | "outputs": [ 60 | { 61 | "data": { 62 | "text/plain": [ 63 | "torch.Size([6, 3, 2])" 64 | ] 65 | }, 66 | "execution_count": 66, 67 | "metadata": {}, 68 | "output_type": "execute_result" 69 | } 70 | ], 71 | "source": [ 72 | "num_curves = 3\n", 73 | "dim = 2\n", 74 | "\n", 75 | "t = torch.linspace(-1, 1, n_coefficients)\n", 76 | "\n", 77 | "freq = torch.pi * n_coefficients\n", 78 | "first_coef = torch.stack([torch.sin(freq * t), torch.cos(freq * t)], dim=1)\n", 79 | "second_coef = torch.stack([torch.sin(freq * t) / F.softplus(t), torch.cos(freq * t) / F.softplus(t)], dim=1)\n", 80 | "third_coef = torch.stack([torch.exp(t) / (1 + 5 * (1 + t)), torch.sin(freq * t) / (1 + 10 * (1 + t))], dim=1)\n", 81 | "coefs = torch.stack([first_coef, second_coef, third_coef], dim=1)\n", 82 | "coefs.shape" 83 | ] 84 | }, 85 | { 86 | "cell_type": "markdown", 87 | "id": "dc241927", 88 | "metadata": {}, 89 | "source": [ 90 | "## Sample and draw the Legendre curves with 100 sample points from -1 to 1" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": 67, 96 | "id": "742d7cd8", 97 | "metadata": {}, 98 | "outputs": [], 99 | "source": [ 100 | "sample_points = torch.torch.linspace(-1, 1, 1000)\n", 101 | "curve_args = sample_points.reshape(-1, 1).expand(-1, 3)" 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": 68, 107 | "id": "e60014e9", 108 | "metadata": {}, 109 | "outputs": [ 110 | { 111 | "data": { 112 | "text/plain": [ 113 | "torch.Size([1000, 3, 2])" 114 | ] 115 | }, 116 | "execution_count": 68, 117 | "metadata": {}, 118 | "output_type": "execute_result" 119 | } 120 | ], 121 | "source": [ 122 | "curve_points = tcf.legendre_curves(curve_args, coefs)\n", 123 | "curve_points.shape" 124 | ] 125 | }, 126 | { 127 | "cell_type": "code", 128 | "execution_count": 69, 129 | "id": "35cb8884", 130 | "metadata": {}, 131 | "outputs": [], 132 | "source": [ 133 | "first_curve, second_curve, third_curve = curve_points.unbind(dim=1)" 134 | ] 135 | }, 136 | { 137 | "cell_type": "code", 138 | "execution_count": 70, 139 | "id": "4f5abaed", 140 | "metadata": {}, 141 | "outputs": [ 142 | { 143 | "data": { 144 | "image/png": "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", 145 | "text/plain": [ 146 | "
" 147 | ] 148 | }, 149 | "metadata": {}, 150 | "output_type": "display_data" 151 | } 152 | ], 153 | "source": [ 154 | "plt.plot(*first_curve.unbind(dim=1), label='Curve')\n", 155 | "plt.legend()\n", 156 | "plt.show()" 157 | ] 158 | }, 159 | { 160 | "cell_type": "code", 161 | "execution_count": 71, 162 | "id": "42ff728f", 163 | "metadata": {}, 164 | "outputs": [ 165 | { 166 | "data": { 167 | "image/png": "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ZDGkNCJ6UI6kF8LRxlLWohAREZFyGFLqFJZX4811J+T1eaPC4OVkq3STiIiITBpDSp0PNp5CXmkVuno6YOaQYKWbQ0REZPIYUgCcyi7GN7vOyutiZVlrS/63EBERKc3k3421Wi1e/jUBNRotxkZ6Y1g3T6WbRERERAwpwPqEbOxMypO9J89PilS6OURERFTHpENKRXWt3OVYeGhYF3T2sFe6SURERFTHpEPKp1uTkZ5fDj8XW8weHqp0c4iIiKgRc1Pen+c/W5Lk9ecmRcDO2kLpJhEREVEjljBRno42+HBab1mTMinaV+nmEBER0UVMNqSYmZlhbA8feRAREZH6mOxwDxEREakbQwoRERGpEkMKERERqRJDChEREakSQwoRERGpEkMKERERqRJDChEREakSQwoRERGpEkMKERERqRJDChEREakSQwoRERGpEkMKERERqRJDChEREamSQeyCrNVq5WVRUZHSTSEiIqIWqn/frn8fN8qQUlxcLC8DAwOVbgoRERG14X3cxcWltd8GM21b440eaTQaZGRkwMnJCWZmZn9LaSK8pKWlwdnZGcbO1M7XFM/Z1M7XFM/Z1M7XFM/Z1M63uXMWEUMEFD8/P5ibmxtnT4o4sYCAgMs+RvyHmMovgimerymes6mdrymes6mdrymes6md76XOuS09KPVYOEtERESqxJBCREREqmTwIcXGxgYvvfSSvDQFpna+pnjOpna+pnjOpna+pnjOpna+HXXOBlE4S0RERKbH4HtSiIiIyDgxpBAREZEqMaQQERGRKjGkEBERkSoZXEh57bXXMGTIENjb28PV1bVF33P33XfLlWobH+PHj4cxn7Ooh37xxRfh6+sLOzs7jB49GqdOnYIhuHDhAqZPny4XAxLnO2vWLJSUlFz2e4YPH/635/ihhx6CWi1evBjBwcGwtbXFwIEDsW/fvss+fsWKFejevbt8fHR0NNasWQND05pzXrJkyd+eT/F9hmLbtm2YPHmyXGVTtH3VqlVX/J4tW7agT58+cmZEaGio/D8w1vMV53rx8yuOrKwsGIKFCxeif//+chV0Ly8vTJ06FYmJiVf8PkN+HS9swzm3x+vY4EJKVVUVbr31VsyePbtV3ydCSWZmZsPx/fffw5jP+a233sIHH3yATz75BHv37oWDgwPGjRuHiooKqJ0IKAkJCdiwYQN+++03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", 168 | "text/plain": [ 169 | "
" 170 | ] 171 | }, 172 | "metadata": {}, 173 | "output_type": "display_data" 174 | } 175 | ], 176 | "source": [ 177 | "plt.plot(*second_curve.unbind(dim=1), label='Curve')\n", 178 | "plt.legend()\n", 179 | "plt.show()" 180 | ] 181 | }, 182 | { 183 | "cell_type": "code", 184 | "execution_count": 72, 185 | "id": "beafc604", 186 | "metadata": {}, 187 | "outputs": [ 188 | { 189 | "data": { 190 | "image/png": "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", 191 | "text/plain": [ 192 | "
" 193 | ] 194 | }, 195 | "metadata": {}, 196 | "output_type": "display_data" 197 | } 198 | ], 199 | "source": [ 200 | "plt.plot(*third_curve.unbind(dim=1), label='Curve')\n", 201 | "plt.legend()\n", 202 | "plt.show()" 203 | ] 204 | }, 205 | { 206 | "cell_type": "code", 207 | "execution_count": null, 208 | "id": "6a7e0cf4", 209 | "metadata": {}, 210 | "outputs": [], 211 | "source": [] 212 | } 213 | ], 214 | "metadata": { 215 | "kernelspec": { 216 | "display_name": "torchcurves", 217 | "language": "python", 218 | "name": "python3" 219 | }, 220 | "language_info": { 221 | "codemirror_mode": { 222 | "name": "ipython", 223 | "version": 3 224 | }, 225 | "file_extension": ".py", 226 | "mimetype": "text/x-python", 227 | "name": "python", 228 | "nbconvert_exporter": "python", 229 | "pygments_lexer": "ipython3", 230 | "version": "3.12.8" 231 | } 232 | }, 233 | "nbformat": 4, 234 | "nbformat_minor": 5 235 | } 236 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | TorchCurves documentation 2 | ========================= 3 | .. toctree:: 4 | :caption: API 5 | :maxdepth: 1 6 | 7 | torchcurves 8 | torchcurves.functional 9 | 10 | .. toctree:: 11 | :caption: Examples 12 | :maxdepth: 2 13 | 14 | example_notebooks 15 | -------------------------------------------------------------------------------- /doc/source/torchcurves.functional.rst: -------------------------------------------------------------------------------- 1 | torchcurves.functional 2 | ====================== 3 | 4 | .. currentmodule:: torchcurves.functional 5 | 6 | Normalization 7 | ------------- 8 | Functions for normalizing inputs to the :math:`[-1, 1]` interval, required by most parametric curves. 9 | 10 | .. autosummary:: 11 | :toctree: generated 12 | :nosignatures: 13 | 14 | clamp 15 | rational 16 | 17 | 18 | Parametrized curves 19 | ------------------- 20 | Vectorized parametric curve evaluation functions. 21 | 22 | .. autosummary:: 23 | :toctree: generated 24 | :nosignatures: 25 | 26 | bspline_curves 27 | bspline_embeddings 28 | legendre_curves 29 | 30 | 31 | Utilities 32 | --------- 33 | 34 | .. autosummary:: 35 | :toctree: generated 36 | :nosignatures: 37 | 38 | uniform_augmented_knots 39 | -------------------------------------------------------------------------------- /doc/source/torchcurves.rst: -------------------------------------------------------------------------------- 1 | torchcurves 2 | =========== 3 | 4 | .. automodule:: torchcurves 5 | .. automodule:: torchcurves.modules 6 | 7 | 8 | .. contents:: torchcurves 9 | :depth: 2 10 | :local: 11 | :backlinks: top 12 | 13 | 14 | .. currentmodule:: torchcurves 15 | 16 | Layers 17 | ------ 18 | 19 | .. autosummary:: 20 | :toctree: generated 21 | :nosignatures: 22 | :template: classtemplate.rst 23 | 24 | BSplineEmbeddings 25 | BSplineCurve 26 | LegendreCurve 27 | Sum 28 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexshtf/torchcurves/78e2c4c193edc65aedfdf4804bd068afff3c5214/logo.png -------------------------------------------------------------------------------- /logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexshtf/torchcurves/78e2c4c193edc65aedfdf4804bd068afff3c5214/logo_small.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "torchcurves" 3 | version = "0.1.1" 4 | description = "PyTorch module for differentiable parametric curves with learnable coefficients" 5 | authors = [ 6 | { name = "Alex Shtoff", email = "alex.shtf@gmail.com" } 7 | ] 8 | readme = "README.md" 9 | license = { text = "Apache 2.0" } 10 | requires-python = ">=3.9" 11 | keywords = ["pytorch", "bspline", "curves", "differentiable", "deep-learning", "geometric-deep-learning"] 12 | classifiers = [ 13 | "Development Status :: 3 - Alpha", 14 | "Intended Audience :: Developers", 15 | "Intended Audience :: Science/Research", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 23 | "Topic :: Scientific/Engineering :: Mathematics", 24 | ] 25 | 26 | dependencies = [ 27 | "torch>=1.10.0", 28 | ] 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "numpy>=1.17.0", 33 | "pytest>=7.0.0", 34 | "pytest-cov>=4.0.0", 35 | "black>=23.0.0", 36 | "ruff>=0.1.0", 37 | "mypy>=1.0.0", 38 | "pre-commit>=3.0.0", 39 | ] 40 | doc = [ 41 | "sphinx>=7.4.7", 42 | "myst-parser>=3.0.1", 43 | "sphinx-autodoc-typehints>=2.3.0", 44 | "sphinx-copybutton>=0.5.2", 45 | "sphinx-autobuild>=2024.10.3", 46 | "sphinxext-opengraph>=0.10.0", 47 | "pydata-sphinx-theme>=0.16.1", 48 | "ipython>=8.18.1", 49 | "nbsphinx>=0.9.7", 50 | "pandoc>=2.4", 51 | ] 52 | examples = [ 53 | "ipykernel>=6.29.0", 54 | "matplotlib>=3.8.0", 55 | "ipywidgets>=8.1.7", 56 | "kagglehub[pandas-datasets]>=0.3.12", 57 | "pandas>=2.3.0", 58 | "scikit-learn>=1.6.1", 59 | ] 60 | 61 | [project.urls] 62 | Homepage = "https://github.com/alexshtf/torchcurves" 63 | Repository = "https://github.com/alexshtf/torchcurves" 64 | Issues = "https://github.com/alexshtf/torchcurves/issues" 65 | 66 | [build-system] 67 | requires = ["hatchling"] 68 | build-backend = "hatchling.build" 69 | 70 | [tool.hatch.build.targets.wheel] 71 | packages = ["src/torchcurves"] 72 | 73 | [tool.pytest.ini_options] 74 | testpaths = ["tests"] 75 | python_files = "test_*.py" 76 | python_classes = "Test*" 77 | python_functions = "test_*" 78 | addopts = "-v --cov=torch_bspline --cov-report=term-missing" 79 | 80 | [tool.ruff] 81 | line-length = 120 82 | 83 | [tool.ruff.lint] 84 | select = [ "E", "W", "F", "I", "B", "C4", "N", "D", "SIM",] 85 | extend-select = [ "RUF100",] 86 | ignore = [ "E101", "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "B024",] 87 | fixable = [ "ALL",] 88 | -------------------------------------------------------------------------------- /src/torchcurves/__init__.py: -------------------------------------------------------------------------------- 1 | """Differentiable parametric curves in arbitrary dimensions.""" 2 | 3 | from torchcurves import types as types 4 | 5 | from .modules import * # noqa: F403 6 | -------------------------------------------------------------------------------- /src/torchcurves/functional/__init__.py: -------------------------------------------------------------------------------- 1 | from ._bspline import bspline_curves, bspline_embeddings, uniform_augmented_knots 2 | from ._legendre import legendre_curves 3 | from ._normalization import clamp, rational 4 | 5 | __all__ = ["clamp", "rational", "uniform_augmented_knots", "bspline_curves", "bspline_embeddings", "legendre_curves"] 6 | -------------------------------------------------------------------------------- /src/torchcurves/functional/_bspline.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple, Union 2 | 3 | import torch 4 | import torch.nn.functional as F # noqa: N812 5 | 6 | 7 | def uniform_augmented_knots( 8 | n_control_points: int, degree: int, dtype=torch.float32, device: Union[torch.device, str, None] = None 9 | ) -> torch.Tensor: 10 | """Generate an augmented knot vector with uniform spacing in [-1, 1] for B-spline curves. 11 | 12 | This function returns a 1D tensor containing knot values. The internal knots are computed uniformly in the interval 13 | [-1, 1] for the given number of control points and degree. The head and tail, each containing (degree + 1) identical 14 | knots, conforming to the not-a-knot boundary conditions. 15 | 16 | Args: 17 | n_control_points (int): The total number of control points for the B-spline. 18 | Must be at least (degree + 1) to have a valid knot vector. 19 | degree (int): The degree of the B-spline. 20 | dtype (torch.dtype, optional): The desired data type of the output tensor. 21 | Defaults to torch.float32. 22 | device (torch.device or str): The device on which the knot vector will reside. 23 | 24 | Returns: 25 | torch.Tensor: A 1D tensor of knots consisting of head knots, uniformly spaced 26 | internal knots, and tail knots, all in the range [-1.0, 1.0]. 27 | 28 | Raises: 29 | ValueError: If the number of control points is less than (degree + 1), indicating 30 | that there are not enough points to form a valid knot vector. 31 | 32 | """ 33 | if n_control_points < 1 + degree: 34 | raise ValueError("Not enough control points for the given degree to form internal knots.") 35 | 36 | # Generates knots in [-1, 1] 37 | k_min, k_max = -1.0, 1.0 # Target range for normalized u 38 | 39 | head_knots = torch.full((degree + 1,), k_min, dtype=dtype, device=device) 40 | tail_knots = torch.full((degree + 1,), k_max, dtype=dtype, device=device) 41 | 42 | num_internal_knots = n_control_points - degree - 1 43 | if num_internal_knots == 0: 44 | internal_knots = torch.empty(0, dtype=dtype, device=device) 45 | else: 46 | internal_knots = torch.linspace(k_min, k_max, num_internal_knots + 2, dtype=dtype, device=device)[1:-1] 47 | 48 | return torch.cat([head_knots, internal_knots, tail_knots]) 49 | 50 | 51 | class _BSplineFunction(torch.autograd.Function): 52 | ZERO_TOLERANCE = 1e-12 53 | ONE_TOLERANCE = 1.0 - ZERO_TOLERANCE # Assuming u is normalized to [0,1] for these constants 54 | 55 | """Custom autograd function for B-spline evaluation and differentiation (Vectorized for multiple curves).""" 56 | 57 | @staticmethod 58 | def find_spans(u: torch.Tensor, knots: torch.Tensor, degree: int, n_control_points: int) -> torch.Tensor: 59 | """Find the knot span index for each parameter value (vectorized). 60 | 61 | Args: 62 | u: Parameter values, shape (N, M) or (N,). N samples, M curves. 63 | If u is (N,), it's treated as (N,1). 64 | Values are expected to be in the range defined by the knots (e.g., [0,1] or [-1,1]). 65 | knots: Knot vector, shape (num_total_knots,). Expected to be a clamped knot vector. 66 | degree: B-spline degree (p). 67 | n_control_points: Number of control points per curve (c). 68 | 69 | Returns: 70 | Span indices, shape (N, M) or (N,). Each span_idx `s` means u falls in [knots[s], knots[s+1]). 71 | 72 | """ 73 | # Note: The original ZERO_TOLERANCE and ONE_TOLERANCE assumed u in [0,1] and knots clamped to [0,1]. 74 | # If knots are e.g. [-1,1], this specific boundary handling might need adjustment 75 | # or u should be pre-normalized to [0,1] if this logic is to be kept strictly. 76 | # For now, we assume u is in the range [knots[degree], knots[n_control_points]]. 77 | # The torch.searchsorted and clamp largely handle this. 78 | 79 | spans = torch.searchsorted(knots, u, side="right") - 1 80 | 81 | # Handle boundaries based on the actual knot values for robustness 82 | # This assumes knots is sorted and clamped: knots[0]..knots[degree] are same, 83 | # and knots[n_control_points]..knots[n_control_points+degree] are same. 84 | min_knot_val = knots[degree] 85 | max_knot_val = knots[n_control_points] # This is the start of the last segment of p+1 knots 86 | 87 | # For u values at or slightly below the minimum parameter value 88 | spans[u <= min_knot_val + _BSplineFunction.ZERO_TOLERANCE] = degree 89 | # For u values at or slightly above the maximum parameter value 90 | spans[u >= max_knot_val - _BSplineFunction.ZERO_TOLERANCE] = n_control_points - 1 91 | 92 | spans = torch.clamp(spans, min=degree, max=n_control_points - 1) 93 | return spans 94 | 95 | @staticmethod 96 | def cox_de_boor(u: torch.Tensor, knots: torch.Tensor, spans: torch.Tensor, degree: int) -> torch.Tensor: 97 | """Compute B-spline basis functions using Cox-de Boor recursion. 98 | 99 | Args: 100 | u: Parameter values, shape (N, M). N samples, M curves. 101 | knots: Knot vector, shape (num_total_knots,). 102 | spans: Knot span indices, shape (N, M). `spans[n,m]` is `s`. 103 | degree: B-spline degree (p). 104 | 105 | Returns: 106 | Basis function values N_batch, shape (N, M, degree+1). 107 | N_batch[n, m, j] = B_{spans[n,m]-degree+j, degree}(u[n,m]). 108 | 109 | """ 110 | num_samples_n, num_curves_m = u.shape 111 | device, dtype = u.device, u.dtype 112 | 113 | # batch_nonzero_basis[n, m, k] will store B_{spans[n,m]-degree+k, degree}(u[n,m]) 114 | batch_nonzero_basis = torch.zeros(num_samples_n, num_curves_m, degree + 1, device=device, dtype=dtype) 115 | 116 | left_dist_all_p = torch.zeros(num_samples_n, num_curves_m, degree + 1, device=device, dtype=dtype) 117 | right_dist_all_p = torch.zeros(num_samples_n, num_curves_m, degree + 1, device=device, dtype=dtype) 118 | 119 | batch_nonzero_basis[..., 0] = 1.0 120 | 121 | for p_iter in range(1, degree + 1): # p_iter is 'j' in Piegl & Tiller A2.2 122 | # knots is 1D. We gather using indices derived from spans (N,M) 123 | # Resulting shapes for left_dist_all_p, etc. will be (N,M) 124 | idx_knot_left = (spans + 1 - p_iter).clamp(min=0, max=knots.shape[0] - 1) 125 | left_dist_all_p[..., p_iter] = u - knots[idx_knot_left] 126 | 127 | idx_knot_right = (spans + p_iter).clamp(min=0, max=knots.shape[0] - 1) 128 | right_dist_all_p[..., p_iter] = knots[idx_knot_right] - u 129 | 130 | saved_val = torch.zeros(num_samples_n, num_curves_m, device=device, dtype=dtype) 131 | 132 | for r_iter in range(p_iter): 133 | denominator_batch = right_dist_all_p[..., r_iter + 1] + left_dist_all_p[..., p_iter - r_iter] 134 | 135 | ratios = batch_nonzero_basis[..., r_iter] / denominator_batch 136 | ratios = torch.where(torch.isfinite(ratios), ratios, torch.zeros_like(ratios)) 137 | 138 | batch_nonzero_basis[..., r_iter] = saved_val + right_dist_all_p[..., r_iter + 1] * ratios 139 | saved_val = left_dist_all_p[..., p_iter - r_iter] * ratios 140 | 141 | batch_nonzero_basis[..., p_iter] = saved_val 142 | return batch_nonzero_basis 143 | 144 | @staticmethod 145 | def evaluate_curve( 146 | basis: torch.Tensor, # shape (N, M, degree+1) 147 | control_points: torch.Tensor, # shape (M, C, D) C=n_control_points 148 | spans: torch.Tensor, # shape (N, M) 149 | degree: int, 150 | ) -> torch.Tensor: 151 | """Evaluate B-spline curves (vectorized for multiple curves). 152 | 153 | Args: 154 | basis: Basis function values. basis[n,m,j] = N_{spans[n,m]-degree+j, degree}(u[n,m]). 155 | control_points: Control points for M curves. 156 | spans: Knot span indices. 157 | degree: B-spline degree. 158 | 159 | Returns: 160 | Points on curves, shape (N, M, D). 161 | 162 | """ 163 | num_samples_n, num_curves_m = spans.shape 164 | # C = num_control_points_per_curve, D = dim 165 | # M_cp, C_cp, D_cp = control_points.shape 166 | # Assert M_cp == num_curves_m 167 | 168 | # control_point_indices: indices into C dimension of control_points 169 | # Shape: (N, M, degree+1) 170 | degrees_range = torch.arange(degree + 1, device=spans.device).view(1, 1, -1) 171 | control_point_indices = spans.unsqueeze(-1) - degree + degrees_range 172 | 173 | # Clamp indices to be valid for control_points' C dimension 174 | clamped_cp_indices = torch.clamp(control_point_indices, 0, control_points.shape[1] - 1) 175 | 176 | # Gather control points: gathered_control_points[n, m, i, d] = control_points[m, clamped_cp_indices[n,m,i], d] 177 | # Need to create m_indices for gathering from control_points' M dimension 178 | # m_indices_for_gather shape: (N, M, degree+1) 179 | m_indices_for_gather = torch.arange(num_curves_m, device=control_points.device).view(1, -1, 1) 180 | m_indices_for_gather = m_indices_for_gather.expand(num_samples_n, -1, degree + 1) 181 | 182 | gathered_control_points = control_points[ 183 | m_indices_for_gather, # Selects the curve from M dimension of control_points 184 | clamped_cp_indices, # Selects the control points from C dimension 185 | :, # Selects all D dimensions 186 | ] # Shape (N, M, degree+1, D) 187 | 188 | # Compute points: points[n,m,d] = sum_i basis[n,m,i] * gathered_control_points[n,m,i,d] 189 | # basis.unsqueeze(-1) gives (N, M, degree+1, 1) 190 | return (basis.unsqueeze(-1) * gathered_control_points).sum(dim=2) # Sum over degree+1 dim 191 | 192 | @staticmethod 193 | def basis_derivative_coefficients( 194 | knots: torch.Tensor, spans: torch.Tensor, degree: int 195 | ) -> Tuple[torch.Tensor, torch.Tensor]: 196 | """Compute coefficients for basis function derivatives (vectorized for multiple curves). 197 | 198 | Args: 199 | knots: Knot vector. 200 | spans: Knot span indices, shape (N, M). 201 | degree: B-spline degree (p). 202 | 203 | Returns: 204 | alpha_coeffs_batch, beta_coeffs_batch: shape (N, M, degree+1). 205 | 206 | """ 207 | num_samples_n, num_curves_m = spans.shape 208 | device, dtype = spans.device, knots.dtype # Use knot's dtype for coeffs 209 | 210 | degrees_range = torch.arange(degree + 1, device=device).view(1, 1, -1) 211 | knots_idx = spans.unsqueeze(-1) - degree + degrees_range # (N, M, degree+1) 212 | 213 | # Gather knot values - knots[knots_idx] will broadcast correctly 214 | knots_k = knots[knots_idx.clamp(min=0, max=knots.shape[0] - 1)] 215 | knots_k_plus_deg = knots[(knots_idx + degree).clamp(min=0, max=knots.shape[0] - 1)] 216 | knots_k_plus_1 = knots[(knots_idx + 1).clamp(min=0, max=knots.shape[0] - 1)] 217 | knots_k_plus_deg_plus_1 = knots[(knots_idx + degree + 1).clamp(min=0, max=knots.shape[0] - 1)] 218 | 219 | alpha_coeffs_batch = torch.zeros(num_samples_n, num_curves_m, degree + 1, device=device, dtype=dtype) 220 | beta_coeffs_batch = torch.zeros(num_samples_n, num_curves_m, degree + 1, device=device, dtype=dtype) 221 | 222 | denom_alpha = knots_k_plus_deg - knots_k 223 | mask_alpha = torch.abs(denom_alpha) > _BSplineFunction.ZERO_TOLERANCE 224 | alpha_coeffs_batch[mask_alpha] = degree / denom_alpha[mask_alpha] 225 | 226 | denom_beta = knots_k_plus_deg_plus_1 - knots_k_plus_1 227 | mask_beta = torch.abs(denom_beta) > _BSplineFunction.ZERO_TOLERANCE 228 | beta_coeffs_batch[mask_beta] = degree / denom_beta[mask_beta] 229 | 230 | return alpha_coeffs_batch, beta_coeffs_batch 231 | 232 | @staticmethod 233 | def compute_basis_derivatives( 234 | u: torch.Tensor, knots: torch.Tensor, spans: torch.Tensor, degree: int 235 | ) -> torch.Tensor: 236 | """Compute derivatives of B-spline basis functions (vectorized for multiple curves). 237 | 238 | Output basis_deriv[n,m,i] = B'_{spans[n,m]-degree+i, degree}(u[n,m]). 239 | Shape: (N, M, degree+1) 240 | """ 241 | if degree == 0: 242 | return torch.zeros(*u.shape, 1, device=u.device, dtype=u.dtype) 243 | 244 | # lower_deg_basis shape: (N, M, degree) 245 | lower_deg_basis = _BSplineFunction.cox_de_boor(u, knots, spans, degree - 1) 246 | 247 | # alpha, beta have shape (N, M, degree+1) 248 | alpha, beta = _BSplineFunction.basis_derivative_coefficients(knots, spans, degree) 249 | 250 | # Pad lower_deg_basis's last dimension to (degree+1) 251 | # Pad (0,1) means add 1 zero to the right: [N0,...,N(deg-1), 0] 252 | lower_pad_right = F.pad(lower_deg_basis, (0, 1)) 253 | # Pad (1,0) means add 1 zero to the left: [0, N0,...,N(deg-1)] 254 | lower_pad_left = F.pad(lower_deg_basis, (1, 0)) 255 | 256 | basis_deriv = alpha * lower_pad_left - beta * lower_pad_right 257 | return basis_deriv 258 | 259 | @staticmethod 260 | def forward( 261 | ctx, 262 | u: torch.Tensor, # shape (N, M) 263 | control_points: torch.Tensor, # shape (M, C, D) 264 | knots: torch.Tensor, # shape (num_total_knots,) 265 | degree: int, 266 | ) -> torch.Tensor: 267 | # M_cp = control_points.shape[0] # Number of curves from control_points 268 | # N_u, M_u = u.shape # N samples, M curves from u 269 | # Assert M_cp == M_u 270 | 271 | n_control_points_per_curve = control_points.shape[1] # C 272 | 273 | spans = _BSplineFunction.find_spans(u, knots, degree, n_control_points_per_curve) # (N,M) 274 | basis_funcs = _BSplineFunction.cox_de_boor(u, knots, spans, degree) # (N,M,degree+1) 275 | points = _BSplineFunction.evaluate_curve(basis_funcs, control_points, spans, degree) # (N,M,D) 276 | 277 | ctx.save_for_backward(u, control_points, knots, spans, basis_funcs) 278 | ctx.degree = degree 279 | ctx.n_control_points_per_curve = n_control_points_per_curve # C 280 | 281 | # For re-computing control_point_indices in backward 282 | degrees_range = torch.arange(degree + 1, device=spans.device).view(1, 1, -1) 283 | ctx.control_point_indices = spans.unsqueeze(-1) - degree + degrees_range # (N,M,degree+1) 284 | 285 | return points 286 | 287 | @staticmethod 288 | def backward(ctx, grad_output: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, None, None]: # type: ignore 289 | # grad_output shape: (N, M, D) 290 | u, control_points, knots, spans, basis_funcs = ctx.saved_tensors 291 | # u: (N,M), control_points: (M,C,D), knots: (K,), spans: (N,M), basis_funcs: (N,M,deg+1) 292 | 293 | degree = ctx.degree 294 | n_control_points_per_curve = ctx.n_control_points_per_curve # C 295 | control_point_indices = ctx.control_point_indices # (N,M,deg+1) 296 | 297 | num_samples_n, num_curves_m = u.shape 298 | # _, _, dim_d = grad_output.shape 299 | 300 | # Gradient with respect to u 301 | # basis_deriv shape: (N, M, degree+1) 302 | basis_deriv = _BSplineFunction.compute_basis_derivatives(u, knots, spans, degree) 303 | 304 | clamped_cp_indices = torch.clamp(control_point_indices, 0, n_control_points_per_curve - 1) # (N,M,deg+1) 305 | 306 | # Gather control points for d_points_du calculation 307 | # m_indices_for_gather shape: (N, M, degree+1) 308 | m_indices_for_gather = torch.arange(num_curves_m, device=u.device).view(1, -1, 1) 309 | m_indices_for_gather = m_indices_for_gather.expand(num_samples_n, -1, degree + 1) 310 | 311 | # gathered_cps shape: (N, M, degree+1, D) 312 | gathered_cps = control_points[m_indices_for_gather, clamped_cp_indices, :] 313 | 314 | # d_points_du[n,m,d] = sum_i basis_deriv[n,m,i] * gathered_cps[n,m,i,d] 315 | d_points_du = torch.einsum("nmi,nmid->nmd", basis_deriv, gathered_cps) # Shape (N, M, D) 316 | 317 | # grad_u[n,m] = sum_d grad_output[n,m,d] * d_points_du[n,m,d] 318 | grad_u = (grad_output * d_points_du).sum(dim=-1) # Shape (N, M) 319 | 320 | # Gradient with respect to control_points 321 | # grad_control_points shape: (M, C, D) 322 | grad_control_points = torch.zeros_like(control_points) 323 | 324 | # update_values[n,m,i,d] = grad_output[n,m,d] * basis_funcs[n,m,i] 325 | # grad_output.unsqueeze(2): (N,M,1,D) 326 | # basis_funcs.unsqueeze(3): (N,M,deg+1,1) 327 | update_values = grad_output.unsqueeze(2) * basis_funcs.unsqueeze(3) # (N,M,deg+1,D) 328 | 329 | # Permute for scatter_add_: target grad_control_points[m_idx, c_idx, d_idx] 330 | # update_values: (N, M, deg+1, D) -> (M, N, deg+1, D) 331 | update_values_perm = update_values.permute(1, 0, 2, 3) 332 | # clamped_cp_indices: (N, M, deg+1) -> (M, N, deg+1) 333 | clamped_cp_indices_perm = clamped_cp_indices.permute(1, 0, 2) 334 | 335 | # Flatten N and deg+1 dimensions 336 | # uv_flat: (M, N*(deg+1), D) 337 | uv_flat = update_values_perm.reshape(num_curves_m, -1, grad_output.shape[-1]) 338 | # idx_flat: (M, N*(deg+1)) 339 | idx_flat = clamped_cp_indices_perm.reshape(num_curves_m, -1) 340 | 341 | # Expand idx_flat to match uv_flat for scatter_add_ 342 | # idx_expanded_for_scatter: (M, N*(deg+1), D) 343 | idx_expanded_for_scatter = idx_flat.unsqueeze(-1).expand_as(uv_flat) 344 | 345 | # Scatter add along dimension C (index 1) 346 | grad_control_points.scatter_add_(1, idx_expanded_for_scatter, uv_flat) 347 | 348 | return grad_u, grad_control_points, None, None 349 | 350 | 351 | def bspline_curves( 352 | u: torch.Tensor, control_points: torch.Tensor, knots: Optional[torch.Tensor] = None, degree: int = 3 353 | ): 354 | r"""Evaluate multiple B-Spline curves, each with its own control points, sharing the same knots and degree. 355 | 356 | This function allow back-propagating both through the control points and the argument. Useful as a layer in 357 | a neural network. 358 | 359 | Args: 360 | u: A tensor of size :math:`(B, C)` of values between ``knots.min()`` and ``knots.max()``, representing 361 | a mini-batch of :math:`B` arguments for sampling each of the :math:`C` curves. 362 | control_points: A tensor of size :math:`(M, C, D)` describing :math:`M` curves with :math:`C` control 363 | points each, embedded in :math:`\mathbb{R}^D`. 364 | knots: A 1D tensor of size :math:`M + P + 1` representing the spline function's 365 | knot vector, where :math:`P` is the degree of the piecewise polynomials defining the spline function. 366 | ``None`` means uniformly-spaced knots in :math:`[-1, 1]` with the not-a-knot boundary 367 | conditions. (default: ``None``) 368 | degree: The degree :math:`P` of the B-Spline function. (default: ``3`` meaning a cubic spline) 369 | 370 | Returns: 371 | A tensor of size :math:`(B, C, D)`, representing a mini-batch of size :math:`B`, corresponding to samples from 372 | :math:`C` curves in :math:`\mathbb{R}^D`. 373 | 374 | """ 375 | if knots is None: 376 | n_control_points = control_points.shape[1] 377 | knots = uniform_augmented_knots( 378 | n_control_points, degree, dtype=control_points.dtype, device=control_points.device 379 | ) 380 | 381 | return _BSplineFunction.apply( 382 | u, 383 | control_points, 384 | knots, 385 | degree, 386 | ) 387 | 388 | 389 | def bspline_embeddings( 390 | u: torch.Tensor, control_points: torch.Tensor, knots: Optional[torch.Tensor] = None, degree: int = 3 391 | ): 392 | r"""Evaluate multiple B-Spline curves, each with its own control points, sharing the same knots and degree. 393 | 394 | This function allow back-propagating only through the control points and the argument. Useful as the input layer 395 | in a neural network, whose arguments come from a data-set that requires no back-prop, while allowing a cheaper 396 | computation for this usecase than :func:`bspline_curves`. 397 | 398 | Args: 399 | u: A tensor of size :math:`(B, C)` of values between ``knots.min()`` and ``knots.max()``, representing 400 | a mini-batch of :math:`B` arguments for sampling each of the :math:`C` curves. 401 | control_points: A tensor of size :math:`(M, C, D)` describing :math:`M` curves with :math:`C` control 402 | points each, embedded in :math:`\mathbb{R}^D`. 403 | knots: A 1D tensor of size :math:`M + P + 1` representing the spline function's 404 | knot vector, where :math:`P` is the degree of the piecewise polynomials defining the spline function. 405 | ``None`` means uniformly-spaced knots in :math:`[-1, 1]` with the not-a-knot boundary 406 | conditions. (default: ``None``) 407 | degree: The degree :math:`P` of the B-Spline function. (default: ``3`` meaning a cubic spline) 408 | 409 | Returns: 410 | A tensor of size :math:`(B, C, D)`, representing a mini-batch of size :math:`B`, corresponding to samples from 411 | :math:`C` curves in :math:`\mathbb{R}^D`. 412 | 413 | """ 414 | n_control_points = control_points.shape[1] 415 | if knots is None: 416 | knots = uniform_augmented_knots( 417 | n_control_points, degree, dtype=control_points.dtype, device=control_points.device 418 | ) 419 | 420 | spans = _BSplineFunction.find_spans(u, knots, degree, n_control_points) # (N,M) 421 | basis_funcs = _BSplineFunction.cox_de_boor(u, knots, spans, degree) # (N,M,deg+1) 422 | return _BSplineFunction.evaluate_curve(basis_funcs, control_points, spans, degree) # (N,M,D) 423 | -------------------------------------------------------------------------------- /src/torchcurves/functional/_legendre.py: -------------------------------------------------------------------------------- 1 | import torch 2 | 3 | 4 | def legendre_curves(x: torch.Tensor, coefficients: torch.Tensor) -> torch.Tensor: 5 | r"""Evaluate curves parametrized by Legendre polynomials. 6 | 7 | Args: 8 | coefficients: A tensor of size :math:`(N, C, D)` of curve coefficients, of a set of :math:`C` polynomial curves 9 | in :math:`\mathbb{R}^D` of degree :math:`N-1`, represented in the Legendre basis. 10 | x: Batch of size :math:`(B, C)`, where ``x[:, j]`` is the batch of inputs for the j-th curve in the batch. 11 | 12 | Returns: 13 | Evaluated points on the curves, shape :math:`(B, C, D)`. 14 | 15 | Note: 16 | Uses the Clenshaw recursive algorithm, and thus requires :math:`O(N)` time. Implementation is vectorized along 17 | the :math:`B` and :math:`D` dimensions, but the algorithm requires a loop over the polynomial degree. 18 | 19 | """ 20 | n, c, m = coefficients.shape # n - number of coefficients, c - number of curves, m - curve dimension 21 | x = x.unsqueeze(-1).expand(-1, -1, m) # (b × c × m), b = batch size 22 | b2 = torch.zeros_like(x) # (b × c × m) 23 | b1 = torch.zeros_like(x) # (b × c × m) 24 | for k in reversed(range(n)): 25 | alpha = (2 * k + 1) / (k + 1) 26 | beta = (k + 1) / (k + 2) 27 | curr_coef = coefficients[k].unsqueeze(0) # (1 x c x m) 28 | bnext = torch.add(torch.addcmul(curr_coef, x, b1, value=alpha), b2, alpha=-beta) 29 | b2, b1 = b1, bnext 30 | return b1 31 | -------------------------------------------------------------------------------- /src/torchcurves/functional/_normalization.py: -------------------------------------------------------------------------------- 1 | import torch 2 | 3 | from ..types import TensorLike 4 | 5 | 6 | def rational(x: TensorLike, scale: float = 1, out_min: float = -1, out_max: float = 1) -> torch.Tensor: 7 | r"""Normalize values using the "Legendre Rational Functions" [1] normalization method. 8 | 9 | The normalization is performed with the formula 10 | 11 | .. math:: 12 | x_{\mathrm{norm}} = \frac{x}{\sqrt{\mathrm{scale}^2 + x^2}}, 13 | 14 | where `scale` is a scaling factor. 15 | 16 | Args: 17 | x: Input tensor to be normalized. 18 | scale: Scale factor for normalization. (default=1) 19 | out_min: Lower bound of the output interval (default=-1) 20 | out_max: Upper bound of the output interval (default=1) 21 | 22 | Returns: 23 | Normalized tensor. 24 | 25 | **References** 26 | 27 | [1] Wang, Z.Q. and Guo, B.Y., 2004. 28 | *Modified Legendre rational spectral method for the whole line.* 29 | Journal of Computational Mathematics, pp.457-474. 30 | 31 | """ 32 | x = torch.as_tensor(x) 33 | result = x / torch.sqrt(scale**2 + x.square()) 34 | out_scaled = ((out_max - out_min) * result + out_max + out_min) / 2 35 | return torch.clip(out_scaled, out_min, out_max) 36 | 37 | 38 | def clamp(x: TensorLike, scale: float = 1, out_min: float = -1, out_max: float = 1) -> torch.Tensor: 39 | r"""Clamp values in a tensor to a specified range. 40 | 41 | The function clamps the values of the input tensor `x` to be within the output range, after scaling by the 42 | `scale` factor, by the formula: 43 | 44 | .. math:: 45 | x_{\mathrm{norm}} = \min(1, \max(0, x / \mathrm{scale})) 46 | 47 | Args: 48 | x: Input tensor to be normalized. 49 | scale: Scale factor for normalization. (default=1) 50 | out_min: Lower bound of the output interval (default=-1) 51 | out_max: Upper bound of the output interval (default=1) 52 | 53 | Returns: 54 | Normalized tensor. 55 | 56 | """ 57 | x = torch.as_tensor(x) 58 | return torch.clip(x / scale, out_min, out_max) 59 | -------------------------------------------------------------------------------- /src/torchcurves/modules/__init__.py: -------------------------------------------------------------------------------- 1 | from ._bspline import BSplineCurve, BSplineEmbeddings 2 | from ._kan_tools import Sum 3 | from ._legendre import LegendreCurve 4 | 5 | __all__ = ["BSplineEmbeddings", "BSplineCurve", "LegendreCurve", "Sum"] 6 | -------------------------------------------------------------------------------- /src/torchcurves/modules/_bspline.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | 3 | import torch 4 | import torch.nn as nn 5 | 6 | from ..functional import bspline_curves, bspline_embeddings, uniform_augmented_knots 7 | from ..types import NormalizationFn 8 | from ._normalization import _normalization_catalogue 9 | 10 | 11 | class BSplineCurveBase(nn.Module): 12 | """Base PyTorch module for B-spline curves, supporting a batch of multiple curves.""" 13 | 14 | knots: torch.Tensor # explicit annotation for type-checking 15 | 16 | def __init__( 17 | self, 18 | num_curves: int, 19 | dim: int, 20 | degree: int = 3, 21 | knots_config: Union[int, torch.Tensor] = 10, # This is n_control_points_per_curve 22 | normalize_fn: Union[Literal["clamp", "rational"], NormalizationFn] = "rational", 23 | normalization_scale: float = 1.0, 24 | ): 25 | super().__init__() 26 | 27 | if not isinstance(num_curves, int) or num_curves <= 0: 28 | raise ValueError("num_curves must be a positive integer.") 29 | if not isinstance(dim, int) or dim <= 0: 30 | raise ValueError("dim must be a positive integer.") 31 | if not isinstance(degree, int) or degree < 0: 32 | raise ValueError("degree must be a non-negative integer.") 33 | 34 | self.num_curves = num_curves # m 35 | self.dim = dim # d 36 | self.degree = degree # p 37 | 38 | if isinstance(normalize_fn, str): 39 | normalize_fn_callable = _normalization_catalogue.get(normalize_fn) 40 | if normalize_fn_callable is None: 41 | raise ValueError(f"Unknown normalization {normalize_fn}") 42 | self.normalize_fn = normalize_fn_callable 43 | else: 44 | self.normalize_fn = normalize_fn 45 | 46 | self.normalization_scale = normalization_scale 47 | if self.normalization_scale <= 0: 48 | raise ValueError(f"Normalization scale must be positive, but {normalization_scale} was given.") 49 | 50 | if isinstance(knots_config, int): 51 | n_control_points_per_curve = knots_config # c 52 | elif isinstance(knots_config, torch.Tensor): 53 | if knots_config.ndim != 1: 54 | raise ValueError("Provided knots_config tensor must be 1D.") 55 | num_knots_from_tensor = knots_config.shape[0] 56 | n_control_points_per_curve = num_knots_from_tensor - self.degree - 1 57 | else: 58 | raise TypeError( 59 | "knots_config must be an int (number of control points per curve) or a torch.Tensor (knot vector)." 60 | ) 61 | 62 | if n_control_points_per_curve <= self.degree: 63 | raise ValueError( 64 | f"Number of control points per curve ({n_control_points_per_curve}) must be greater " 65 | f"than the degree ({self.degree})." 66 | ) 67 | self.n_control_points_per_curve = n_control_points_per_curve # c 68 | 69 | # Control points shape: (m, c, d) 70 | self.control_points = nn.Parameter(torch.empty(self.num_curves, self.n_control_points_per_curve, self.dim)) 71 | nn.init.xavier_uniform_(self.control_points) 72 | 73 | if isinstance(knots_config, int): 74 | # Knots are shared by all m curves 75 | knot_buffer = uniform_augmented_knots( 76 | self.n_control_points_per_curve, self.degree, dtype=self.control_points.dtype 77 | ) 78 | else: # knots_config is a torch.Tensor 79 | knot_buffer = knots_config.to(dtype=self.control_points.dtype, copy=True) 80 | 81 | self.register_buffer("knots", knot_buffer) 82 | # Determine knot range for normalization, assuming knots are sorted. 83 | # Effective parameter range for B-spline is [knots[degree], knots[n_control_points_per_curve]] 84 | self._knot_min = knot_buffer[self.degree].item() 85 | self._knot_max = knot_buffer[self.n_control_points_per_curve].item() 86 | 87 | def __repr__(self): 88 | return ( 89 | f"{self.__class__.__name__}(" 90 | f"num_curves={self.num_curves}, " 91 | f"n_control_points_per_curve={self.n_control_points_per_curve}, " 92 | f"dim={self.dim}, degree={self.degree}, " 93 | f"knots_shape={self.knots.shape if hasattr(self, 'knots') else None})" 94 | ) 95 | 96 | def _prepare_arg(self, u: torch.Tensor) -> torch.Tensor: 97 | return self.normalize_fn(u, self.normalization_scale, out_min=self._knot_min, out_max=self._knot_max) 98 | 99 | def forward(self, u: torch.Tensor): 100 | """Evaluate a batch of B-spline curves. 101 | 102 | Args: 103 | u: Parameter values of size :math:`(B, C)`, where :math:`B` is the mini-batch size, and `C` is the number 104 | of curves, and must be equal to `self.num_curves`. 105 | 106 | Returns: 107 | Points on the B-spline curves of shape :math:`(B, C, D)`. 108 | 109 | """ 110 | if u.ndim != 2 or u.shape[1] != self.num_curves: 111 | raise ValueError( 112 | f"Input u must be a 2D tensor of shape (N, num_curves={self.num_curves}). Got shape: {u.shape}" 113 | ) 114 | 115 | u_prepared = self._prepare_arg(u) 116 | return self._forward_core(u_prepared) 117 | 118 | def _forward_core(self, u_prepared: torch.Tensor) -> torch.Tensor: 119 | # u_prepared has shape (N, M) 120 | # self.control_points has shape (M, C, D) 121 | # Should return tensor of shape (N, M, D) 122 | raise NotImplementedError("This method should be implemented in derived classes") 123 | 124 | 125 | class BSplineEmbeddings(BSplineCurveBase): 126 | r"""Embeddings layer based on B-Spline curves. 127 | 128 | Useful as the first layer in a neural network, where the input comes from a data-set. 129 | 130 | The learnable parameters are the control points of :math:`M` curves in :math:`\mathbb{R}^D`. 131 | All curves share the same degree and knot configuration. 132 | 133 | The input of this layer normalized to the range :math:`[-1, 1]` (or the range of the knots if specified differently) 134 | using the specified normalization strategy. 135 | 136 | Args: 137 | num_curves: Number of B-spline curves to define in this module (:math:`M`). 138 | dim: Dimension of each curve's output points (:math:`D`). 139 | degree: Degree of the B-spline (default: 3). 140 | knots_config: 141 | If an int, it specifies the number of control points per curve (:math:`C`). 142 | A uniformly-spaced knot vector will be automatically generated in [-1, 1]. 143 | If a torch.Tensor, it explicitly specifies the knot values. The number 144 | of control points will be inferred. The tensor should be 1D. 145 | normalize_fn: Normalization method layer's input. (default: "rational") 146 | normalization_scale: Scale factor for normalization (default: 1.0). 147 | 148 | Note: 149 | Assumes the input of this layer is not learnable, and thus doesn't require computing gradients. 150 | 151 | """ 152 | 153 | def _forward_core(self, u_prepared: torch.Tensor) -> torch.Tensor: 154 | return bspline_embeddings(u_prepared, self.control_points, self.knots, self.degree) 155 | 156 | 157 | class BSplineCurve(BSplineCurveBase): 158 | r"""B-Spline curves layer that allows back-propagating through its input. 159 | 160 | The learnable parameters are the control points of :math:`M` curves in :math:`\mathbb{R}^D`. 161 | All curves share the same degree and knot configuration. 162 | 163 | The input of this layer normalized to the range :math:`[-1, 1]` (or the range of the knots if specified differently) 164 | using the specified normalization strategy. 165 | 166 | Args: 167 | num_curves: Number of B-spline curves to define in this module (:math:`M`). 168 | dim: Dimension of each curve's output points (:math:`D`). 169 | degree: Degree of the B-spline (default: 3). 170 | knots_config: 171 | If an int, it specifies the number of control points per curve (:math:`C`). 172 | A uniformly-spaced knot vector will be automatically generated in [-1, 1]. 173 | If a torch.Tensor, it explicitly specifies the knot values. The number 174 | of control points will be inferred. The tensor should be 1D. 175 | normalize_fn: Normalization method layer's input. (default: "rational") 176 | normalization_scale: Scale factor for normalization (default: 1.0). 177 | 178 | """ 179 | 180 | def _forward_core(self, u_prepared: torch.Tensor) -> torch.Tensor: 181 | return bspline_curves(u_prepared, self.control_points, self.knots, self.degree) 182 | -------------------------------------------------------------------------------- /src/torchcurves/modules/_kan_tools.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | 5 | class Sum(nn.Module): 6 | """A pooling layer that sums along the given dimension. 7 | 8 | Args: 9 | dim: The dimension along which to sum. 10 | 11 | """ 12 | 13 | def __init__(self, dim: int = -2): 14 | super().__init__() 15 | self.dim = dim 16 | 17 | def forward(self, x: torch.Tensor): 18 | return torch.sum(x, self.dim) 19 | -------------------------------------------------------------------------------- /src/torchcurves/modules/_legendre.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | 3 | import torch 4 | import torch.nn as nn 5 | 6 | from ..functional import legendre_curves 7 | from ..types import NormalizationFn 8 | from ._normalization import _normalization_catalogue 9 | 10 | 11 | class LegendreCurve(nn.Module): 12 | r"""PyTorch module for a batch of parametrized curves using Legendre polynomial basis. 13 | 14 | The learnable parameters are the control points (coefficients) of the 15 | `Legendre series `_ for each curve. 16 | All curves share the same degree. The input of this layer is normalized to :math:`[-1, 1]`. 17 | Each curve is: 18 | 19 | .. math:: 20 | 21 | \mathbf{C}_m(u) = \sum_{k=0}^{\mathrm{degree}} \mathbf{C}_{m,k} \cdot P_k(u), 22 | 23 | where :math:`P_k` is the :math:`k`-th Legendre polynomial. 24 | 25 | Args: 26 | num_curves: Number of Legendre curves to define (:math:`M`). 27 | dim: Dimension of each curve's output points (:math:`D`). 28 | degree: Degree of the Legendre polynomial basis (shared by all curves). 29 | The number of coefficients per curve will be `degree + 1`. 30 | normalize_fn: 31 | Normalization method this layer's input. (default: "rational") 32 | normalization_scale (float): 33 | Scale factor for normalization (default: 1.0). 34 | 35 | """ 36 | 37 | def __init__( 38 | self, 39 | num_curves: int, 40 | dim: int, 41 | degree: int, 42 | normalize_fn: Union[Literal["clamp", "rational"], NormalizationFn] = "rational", 43 | normalization_scale: float = 1.0, 44 | ): 45 | super().__init__() 46 | 47 | if not isinstance(num_curves, int) or num_curves <= 0: 48 | raise ValueError("num_curves must be a positive integer.") 49 | if not isinstance(dim, int) or dim <= 0: 50 | raise ValueError("dim must be a positive integer.") 51 | if not isinstance(degree, int) or degree < 0: 52 | raise ValueError("degree must be a non-negative integer.") 53 | 54 | self.num_curves = num_curves # M 55 | self.dim = dim # D 56 | self.degree = degree 57 | self.n_coefficients = self.degree + 1 # C (coefficients per curve) 58 | 59 | if isinstance(normalize_fn, str): 60 | normalize_fn_from_catalogue = _normalization_catalogue.get(normalize_fn) 61 | if normalize_fn_from_catalogue is None: 62 | raise ValueError(f"Unknown normalization {normalize_fn}") 63 | self.normalize_fn = normalize_fn_from_catalogue 64 | else: 65 | self.normalize_fn = normalize_fn 66 | 67 | self.normalization_scale = normalization_scale 68 | if self.normalization_scale <= 0: 69 | raise ValueError(f"Normalization scale must be positive, but {normalization_scale} was given.") 70 | 71 | # Coefficients shape: (M, C, D) 72 | self.coefficients = nn.Parameter(torch.empty(self.n_coefficients, self.num_curves, self.dim)) 73 | nn.init.xavier_uniform_(self.coefficients) 74 | 75 | def forward(self, u: torch.Tensor) -> torch.Tensor: 76 | """Evaluate the batch of Legendre curves. 77 | 78 | Args: 79 | u: Parameter values of size :math:`(B, C)`, where :math:`B` is the mini-batch size, and `C` is the number 80 | of curves, and must be equal to `self.num_curves`. 81 | 82 | Returns: 83 | Points on the Legendre curves of shape :math:`(B, C, D)`. 84 | 85 | """ 86 | if u.ndim != 2 or u.shape[1] != self.num_curves: 87 | raise ValueError( 88 | f"Input u must be a 2D tensor of shape (N, num_curves={self.num_curves}). Got shape: {u.shape}" 89 | ) 90 | 91 | u_normalized = self.normalize_fn(u, self.normalization_scale, out_min=-1.0, out_max=1.0) 92 | return legendre_curves(u_normalized, self.coefficients) 93 | 94 | def __repr__(self): 95 | return ( 96 | f"{self.__class__.__name__}(" 97 | f"num_curves={self.num_curves}, " 98 | f"dim={self.dim}, degree={self.degree}, " 99 | f"n_coefficients_per_curve={self.n_coefficients})" 100 | ) 101 | -------------------------------------------------------------------------------- /src/torchcurves/modules/_normalization.py: -------------------------------------------------------------------------------- 1 | from ..functional import clamp, rational 2 | from ..types import NormalizationFn 3 | 4 | _normalization_catalogue: dict[str, NormalizationFn] = { 5 | "rational": rational, 6 | "clamp": clamp, 7 | } 8 | -------------------------------------------------------------------------------- /src/torchcurves/types.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, Sequence, Union 2 | 3 | import torch 4 | 5 | Numeric = Union[int, float] 6 | """A number""" 7 | 8 | TensorLike = Union[torch.Tensor, Sequence[Numeric]] 9 | """A PyTorch tensor or a sequence of numbers""" 10 | 11 | 12 | class NormalizationFn(Protocol): 13 | """Protocol for normalization functions. 14 | 15 | A normalization function takes a tensor and normalizes it based on the provided parameters. 16 | 17 | Args: 18 | tensor: The input tensor to normalize. 19 | min_val: The minimum value for normalization. 20 | max_val: The maximum value for normalization. 21 | scale: Scale factor for normalization. 22 | 23 | Returns: 24 | The normalized tensor. 25 | 26 | """ 27 | 28 | def __call__(self, x: TensorLike, scale: float, out_min: float, out_max: float) -> torch.Tensor: ... 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexshtf/torchcurves/78e2c4c193edc65aedfdf4804bd068afff3c5214/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexshtf/torchcurves/78e2c4c193edc65aedfdf4804bd068afff3c5214/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_bspline.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import pytest 4 | import torch 5 | import torch.nn as nn 6 | 7 | from torchcurves import BSplineCurve 8 | from torchcurves.functional import bspline_curves 9 | 10 | 11 | class TestBSplineFunction(unittest.TestCase): 12 | def setUp(self): 13 | self.default_dtype = torch.float64 # For gradcheck 14 | self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 15 | # self.device = torch.device("cpu") # Force CPU for easier debugging if needed 16 | # print(f"Using device: {self.device}") 17 | 18 | @staticmethod 19 | def generate_clamped_knot_vector( 20 | n_control_points: int, degree: int, device="cpu", dtype=torch.float32 21 | ) -> torch.Tensor: 22 | """Generate a clamped knot vector in [-1, 1].""" 23 | if n_control_points <= degree: 24 | raise ValueError("Number of control points must be greater than degree.") 25 | 26 | # Total number of knots m = n_control_points + degree + 1. 27 | # Correct clamping: first p+1 knots are k_min, last p+1 knots are k_max 28 | k_min, k_max = -1.0, 1.0 29 | 30 | head_knots = torch.full((degree + 1,), k_min, dtype=dtype, device=device) 31 | tail_knots = torch.full((degree + 1,), k_max, dtype=dtype, device=device) 32 | 33 | num_internal_knots = n_control_points - degree - 1 34 | if num_internal_knots < 0: 35 | raise ValueError("Not enough control points for the given degree to form internal knots.") 36 | 37 | if num_internal_knots == 0: 38 | internal_knots = torch.empty(0, dtype=dtype, device=device) 39 | else: 40 | internal_knots = torch.linspace(k_min, k_max, num_internal_knots + 2, dtype=dtype, device=device)[1:-1] 41 | 42 | return torch.cat([head_knots, internal_knots, tail_knots]) 43 | 44 | def test_constant_function_degree0(self): 45 | degree = 0 46 | # control_points: (M, C, D) -> (1 curve, 1 CP, 1 Dim) 47 | control_points = torch.tensor([[[2.5]]], dtype=self.default_dtype, device=self.device) 48 | n_cp_c = control_points.shape[1] 49 | knots = self.generate_clamped_knot_vector(n_cp_c, degree, device=self.device, dtype=self.default_dtype) 50 | self.assertEqual(knots.shape[0], n_cp_c + degree + 1) 51 | 52 | u_values_scalar = torch.tensor([0.0, 0.5, 0.99], dtype=self.default_dtype, device=self.device) 53 | 54 | for u_val_scalar_item in u_values_scalar: 55 | # u: (N, M) -> (1 sample, 1 curve) 56 | u = u_val_scalar_item.view(1, 1) 57 | # points: (N, M, D) -> (1, 1, 1) 58 | points = bspline_curves(u, control_points, knots, degree) 59 | self.assertAlmostEqual( 60 | points.squeeze().item(), control_points.squeeze().item(), places=5, msg=f"Failed for u={u.item()}" 61 | ) 62 | 63 | u_gc = u.clone().requires_grad_(True) 64 | cp_gc = control_points.clone() 65 | 66 | # Output is (1,1,1), gradcheck handles this. 67 | self.assertTrue( 68 | torch.autograd.gradcheck( 69 | lambda x: bspline_curves(x, cp_gc, knots, degree), # noqa: B023 70 | u_gc, 71 | eps=1e-6, 72 | atol=1e-5, 73 | rtol=1e-3, 74 | nondet_tol=1e-7, 75 | ) 76 | ) 77 | 78 | points_gc = bspline_curves(u_gc, cp_gc, knots, degree) 79 | points_gc.sum().backward() # .sum() for scalar loss 80 | self.assertAlmostEqual(u_gc.grad.squeeze().item(), 0.0, places=5, msg=f"Grad_u non-zero for u={u.item()}") 81 | 82 | def test_constant_function_all_cps_same(self): 83 | degree = 2 84 | n_cp_c = 4 85 | const_val = 5.0 86 | # control_points: (M,C,D) -> (1, 4, 1) 87 | control_points = torch.full((1, n_cp_c, 1), const_val, dtype=self.default_dtype, device=self.device) 88 | knots = self.generate_clamped_knot_vector(n_cp_c, degree, device=self.device, dtype=self.default_dtype) 89 | 90 | # u_scalar: (N,) 91 | u_scalar = torch.tensor([0.0, 0.25, 0.5, 0.75, 1.0], dtype=self.default_dtype, device=self.device) 92 | # u: (N, M) -> (N, 1) 93 | u = u_scalar.unsqueeze(1) 94 | 95 | # points: (N, M, D) -> (N, 1, 1) 96 | points = bspline_curves(u, control_points, knots, degree) 97 | expected_points = torch.full((u.shape[0], 1, 1), const_val, dtype=self.default_dtype, device=self.device) 98 | torch.testing.assert_close(points, expected_points, atol=1e-5, rtol=1e-5) 99 | 100 | u_gc = u.clone().requires_grad_(True) 101 | cp_gc = control_points.clone().requires_grad_(True) 102 | 103 | output = bspline_curves(u_gc, cp_gc, knots, degree) 104 | output.sum().backward() 105 | 106 | # u_gc.grad: (N,1) 107 | torch.testing.assert_close(u_gc.grad, torch.zeros_like(u_gc), atol=1e-5, rtol=1e-5) 108 | # cp_gc.grad: (1, C, D). Sum of basis functions is 1. 109 | self.assertAlmostEqual(cp_gc.grad.sum().item(), u.shape[0], places=5) 110 | 111 | def test_linear_function_degree1(self): 112 | degree = 1 113 | # control_points: (M,C,D) -> (1, 2, 1) 114 | control_points = torch.tensor([[[0.0], [1.0]]], dtype=self.default_dtype, device=self.device) 115 | n_cp_c = control_points.shape[1] 116 | knots = self.generate_clamped_knot_vector(n_cp_c, degree, device=self.device, dtype=self.default_dtype) 117 | 118 | u_scalar = torch.tensor([-1.0, -0.5, 0.0, 0.5, 1.0], dtype=self.default_dtype, device=self.device) 119 | # u: (N,M) -> (N,1) 120 | u = u_scalar.unsqueeze(1) 121 | # For knots [-1,-1,1,1] and u in [-1,1], C(u) = ( (1-u)/2 * P0 + (1+u)/2 * P1 ) if knots are normalized to 122 | # [0,1] internally 123 | # If knots are [-1,-1,1,1] and u is directly used, then for u in [-1,1], it's linear interpolation. 124 | # The current BSplineFunction expects u to be in the knot range. 125 | # With knots [-1,-1,1,1], P0=[-1], P1=[1], then C(u)=u. 126 | # Here P0=[0], P1=[1]. Knots are [-1,-1,1,1]. 127 | # N01(u) = (knots[1+1]-u)/(knots[1+1]-knots[1]) = (1-u)/(1-(-1)) = (1-u)/2 for u in [-1,1) 128 | # N11(u) = (u-knots[1])/(knots[1+1]-knots[1]) = (u-(-1))/(1-(-1)) = (u+1)/2 for u in [-1,1) 129 | # C(u) = (1-u)/2 * 0 + (u+1)/2 * 1 = (u+1)/2 130 | # To get C(u)=u, we need u_norm = (u+1)/2. If input u is already in [-1,1], then we expect (u+1)/2. 131 | # Let's adjust CPs or expected points. If CPs are [0],[1] and knots are [-1,-1,1,1] 132 | # C(-1) = P0 = 0. C(1) = P1 = 1. (u+1)/2. 133 | # If we want C(u) = u for u in [-1,1], then P0=-1, P1=1. 134 | # Let's keep P0=0, P1=1. Then expected is (u_scalar+1)/2 135 | expected_points_scalar = (u_scalar + 1.0) / 2.0 136 | expected_points = expected_points_scalar.unsqueeze(1).unsqueeze(1) # (N,1,1) 137 | 138 | points = bspline_curves(u, control_points, knots, degree) 139 | torch.testing.assert_close(points, expected_points, atol=1e-6, rtol=1e-5) 140 | 141 | u_gc = u.clone().requires_grad_(True) 142 | cp_gc = control_points.clone().requires_grad_(True) 143 | 144 | self.assertTrue( 145 | torch.autograd.gradcheck( 146 | lambda x: bspline_curves(x, cp_gc.detach(), knots, degree).sum(), 147 | u_gc.detach().requires_grad_(True), 148 | eps=1e-6, 149 | atol=1e-5, 150 | rtol=1e-3, 151 | nondet_tol=1e-7, 152 | ) 153 | ) 154 | self.assertTrue( 155 | torch.autograd.gradcheck( 156 | lambda x: bspline_curves(u_gc.detach(), x, knots, degree).sum(), 157 | cp_gc.detach().requires_grad_(True), 158 | eps=1e-6, 159 | atol=1e-5, 160 | rtol=1e-3, 161 | nondet_tol=1e-7, 162 | ) 163 | ) 164 | 165 | output_an = bspline_curves(u_gc, cp_gc.detach(), knots, degree) 166 | output_an.sum().backward() 167 | # C'(u) = 0.5 168 | expected_grad_u = torch.full_like(u_gc, 0.5) 169 | torch.testing.assert_close(u_gc.grad, expected_grad_u, atol=1e-6, rtol=1e-5) 170 | 171 | def test_parabola_degree2(self): 172 | degree = 2 173 | # Knots [-1,-1,-1, 1,1,1]. u in [-1,1]. 174 | # N02=(1-u_norm)^2, N12=2*u_norm(1-u_norm), N22=u_norm^2 where u_norm = (u+1)/2 175 | # C(u) = N02*P0 + N12*P1 + N22*P2. 176 | # To get C(u) = u_norm^2 = ((u+1)/2)^2: P0=0, P1=0, P2=1. 177 | # control_points: (M,C,D) -> (1,3,1) 178 | control_points = torch.tensor([[[0.0], [0.0], [1.0]]], dtype=self.default_dtype, device=self.device) 179 | n_cp_c = control_points.shape[1] 180 | knots = self.generate_clamped_knot_vector(n_cp_c, degree, device=self.device, dtype=self.default_dtype) 181 | 182 | u_scalar = torch.tensor([-1.0, -0.6, -0.2, 0.2, 0.6, 1.0], dtype=self.default_dtype, device=self.device) 183 | u = u_scalar.unsqueeze(1) # (N,1) 184 | 185 | u_norm_scalar = (u_scalar + 1.0) / 2.0 186 | expected_points_scalar = u_norm_scalar.pow(2) 187 | expected_points = expected_points_scalar.unsqueeze(1).unsqueeze(1) # (N,1,1) 188 | 189 | points = bspline_curves(u, control_points, knots, degree) 190 | torch.testing.assert_close(points, expected_points, atol=1e-6, rtol=1e-5) 191 | 192 | u_gc = u.clone().requires_grad_(True) 193 | cp_gc = control_points.clone().requires_grad_(True) 194 | 195 | self.assertTrue( 196 | torch.autograd.gradcheck( 197 | lambda x_u: bspline_curves(x_u, cp_gc.detach(), knots, degree).sum(), 198 | u_gc.detach().requires_grad_(True), 199 | eps=1e-6, 200 | atol=1e-4, # Increased atol for parabola 201 | rtol=1e-3, 202 | nondet_tol=1e-7, 203 | ) 204 | ) 205 | self.assertTrue( 206 | torch.autograd.gradcheck( 207 | lambda x_cp: bspline_curves(u_gc.detach(), x_cp, knots, degree).sum(), 208 | cp_gc.detach().requires_grad_(True), 209 | eps=1e-6, 210 | atol=1e-5, 211 | rtol=1e-3, 212 | nondet_tol=1e-7, 213 | ) 214 | ) 215 | 216 | output_an = bspline_curves(u_gc, cp_gc.detach(), knots, degree) 217 | output_an.sum().backward() 218 | # C'(u) = d/du [((u+1)/2)^2] = 2 * ((u+1)/2) * (1/2) = (u+1)/2 219 | expected_grad_u_scalar = (u_gc.detach().squeeze(1) + 1.0) / 2.0 220 | expected_grad_u = expected_grad_u_scalar.unsqueeze(1) 221 | torch.testing.assert_close(u_gc.grad, expected_grad_u, atol=1e-6, rtol=1e-5) 222 | 223 | def test_boundary_values(self): 224 | degree = 3 225 | n_cp_c = 5 226 | # control_points: (M,C,D) -> (1,5,2) 227 | control_points = torch.randn(1, n_cp_c, 2, dtype=self.default_dtype, device=self.device) 228 | knots = self.generate_clamped_knot_vector(n_cp_c, degree, device=self.device, dtype=self.default_dtype) 229 | 230 | # u: (N,M) -> (1,1) 231 | u_start = torch.tensor([[-1.0]], dtype=self.default_dtype, device=self.device) # Min knot value 232 | u_end = torch.tensor([[1.0]], dtype=self.default_dtype, device=self.device) # Max knot value 233 | 234 | point_start = bspline_curves(u_start, control_points, knots, degree) # (1,1,2) 235 | point_end = bspline_curves(u_end, control_points, knots, degree) # (1,1,2) 236 | 237 | # control_points[:, 0, :] is (1,2). Need (1,1,2) 238 | torch.testing.assert_close(point_start, control_points[:, 0:1, :], atol=1e-6, rtol=1e-5) 239 | torch.testing.assert_close(point_end, control_points[:, -1:, :], atol=1e-6, rtol=1e-5) 240 | 241 | def test_multiple_dimensions(self): 242 | degree = 2 243 | # control_points: (M,C,D) -> (1,3,2) 244 | control_points_data = torch.tensor( 245 | [[[0.0, 0.0], [0.5, 1.0], [1.0, 0.0]]], dtype=self.default_dtype, device=self.device 246 | ) 247 | n_cp_c = control_points_data.shape[1] 248 | knots = self.generate_clamped_knot_vector(n_cp_c, degree, device=self.device, dtype=self.default_dtype) 249 | 250 | u_scalar = torch.tensor([-1.0, 0.0, 1.0], dtype=self.default_dtype, device=self.device) 251 | u = u_scalar.unsqueeze(1) # (N,1) 252 | 253 | # Expected points: (N,1,D) 254 | # u_norm = (u_scalar+1)/2 -> [0, 0.5, 1] 255 | # C(u_norm) = (1-u_norm)^2 P0 + 2u_norm(1-u_norm)P1 + u_norm^2 P2 256 | expected_points_calc = torch.empty((u_scalar.shape[0], 1, 2), dtype=self.default_dtype, device=self.device) 257 | P0, P1, P2 = control_points_data[0, 0], control_points_data[0, 1], control_points_data[0, 2] # noqa: N806 258 | 259 | expected_points_calc[0, 0, :] = P0 # u_norm = 0 260 | expected_points_calc[1, 0, :] = 0.25 * P0 + 0.5 * P1 + 0.25 * P2 # u_norm = 0.5 261 | expected_points_calc[2, 0, :] = P2 # u_norm = 1 262 | 263 | points = bspline_curves(u, control_points_data, knots, degree) 264 | torch.testing.assert_close(points, expected_points_calc, atol=1e-6, rtol=1e-5) 265 | 266 | u_gc = u.clone().requires_grad_(True) 267 | cp_gc = control_points_data.clone().requires_grad_(True) 268 | self.assertTrue( 269 | torch.autograd.gradcheck( 270 | lambda x_u: bspline_curves(x_u, cp_gc.detach(), knots, degree).sum(), 271 | u_gc.detach().requires_grad_(True), 272 | eps=1e-6, 273 | atol=1e-5, 274 | rtol=1e-3, 275 | nondet_tol=1e-7, 276 | ) 277 | ) 278 | self.assertTrue( 279 | torch.autograd.gradcheck( 280 | lambda x_cp: bspline_curves(u_gc.detach(), x_cp, knots, degree).sum(), 281 | cp_gc.detach().requires_grad_(True), 282 | eps=1e-6, 283 | atol=1e-5, 284 | rtol=1e-3, 285 | nondet_tol=1e-7, 286 | ) 287 | ) 288 | 289 | def test_batch_processing_u_values_single_curve(self): # Renamed for clarity 290 | degree = 1 291 | # control_points: (M,C,D) -> (1,2,2) 292 | control_points = torch.tensor([[[0.0, 1.0], [2.0, 3.0]]], dtype=self.default_dtype, device=self.device) 293 | n_cp_c = control_points.shape[1] 294 | knots = self.generate_clamped_knot_vector( 295 | n_cp_c, degree, device=self.device, dtype=self.default_dtype 296 | ) # Knots [-1,-1,1,1] 297 | 298 | u_scalar_batch = torch.tensor( 299 | [-1.0, 0.0, 1.0], dtype=self.default_dtype, device=self.device 300 | ) # Batch of N u-values 301 | u_batch = u_scalar_batch.unsqueeze(1) # (N,1) for 1 curve 302 | 303 | # Expected points (N,1,D) 304 | # u_norm = (u_scalar_batch+1)/2 305 | # C(u_norm) = (1-u_norm)P0 + u_norm*P1 306 | expected_points_batch = torch.empty( 307 | (u_batch.shape[0], 1, control_points.shape[2]), dtype=self.default_dtype, device=self.device 308 | ) 309 | P0, P1 = control_points[0, 0, :], control_points[0, 1, :] # noqa: N806 310 | u_norm_vals = (u_scalar_batch + 1.0) / 2.0 311 | for i, u_n_val in enumerate(u_norm_vals): 312 | expected_points_batch[i, 0, :] = (1 - u_n_val) * P0 + u_n_val * P1 313 | 314 | points_batch = bspline_curves(u_batch, control_points, knots, degree) 315 | torch.testing.assert_close(points_batch, expected_points_batch, atol=1e-6, rtol=1e-5) 316 | self.assertEqual(points_batch.shape, (u_batch.shape[0], 1, control_points.shape[2])) 317 | 318 | u_gc_batch = u_batch.clone().requires_grad_(True) 319 | cp_gc = control_points.clone().requires_grad_(True) 320 | 321 | self.assertTrue( 322 | torch.autograd.gradcheck( 323 | lambda x_u: bspline_curves(x_u, cp_gc.detach(), knots, degree).sum(), 324 | u_gc_batch.detach().requires_grad_(True), 325 | eps=1e-6, 326 | atol=1e-5, 327 | rtol=1e-3, 328 | nondet_tol=1e-7, 329 | ) 330 | ) 331 | self.assertTrue( 332 | torch.autograd.gradcheck( 333 | lambda x_cp: bspline_curves(u_gc_batch.detach(), x_cp, knots, degree).sum(), 334 | cp_gc.detach().requires_grad_(True), 335 | eps=1e-6, 336 | atol=1e-5, 337 | rtol=1e-3, 338 | nondet_tol=1e-7, 339 | ) 340 | ) 341 | 342 | def test_multiple_curves_equivalence(self): 343 | num_curves_m = 3 344 | n_samples_n = 5 345 | dim_d = 2 346 | degree = 2 347 | n_cp_c = 4 # Number of control points per curve 348 | 349 | knots = self.generate_clamped_knot_vector( 350 | n_cp_c, degree, device=self.device, dtype=self.default_dtype 351 | ) # Knots are in [-1,1] 352 | 353 | control_points_batched = torch.randn(num_curves_m, n_cp_c, dim_d, dtype=self.default_dtype, device=self.device) 354 | control_points_batched_clone_for_grad = control_points_batched.clone().requires_grad_(True) 355 | 356 | # u values for M curves: (N, M), in knot range [-1,1] 357 | u_batched_rand = torch.rand(n_samples_n, num_curves_m, dtype=self.default_dtype, device=self.device) 358 | # Scale u to be within the effective knot range [knots[degree], knots[n_cp_c]] 359 | # For default knots: knots[degree]=-1, knots[n_cp_c]=1 360 | knot_min_effective = knots[degree] 361 | knot_max_effective = knots[n_cp_c] # This is the start of the last span's p+1 knots. 362 | # For u, it should be knots[n_cp_c] which is the end of the domain. 363 | 364 | u_batched = u_batched_rand * (knot_max_effective - knot_min_effective) + knot_min_effective 365 | u_batched_clone_for_grad = u_batched.clone().requires_grad_(True) 366 | 367 | # 1. Evaluate all curves together 368 | points_batched = bspline_curves(u_batched_clone_for_grad, control_points_batched_clone_for_grad, knots, degree) 369 | 370 | # 2. Evaluate each curve individually 371 | points_individual_list = [] 372 | for i in range(num_curves_m): 373 | cp_single = control_points_batched[i : i + 1, :, :].clone() # Shape (1, C, D) 374 | u_single = u_batched[:, i : i + 1].clone() # Shape (N, 1) 375 | 376 | # For individual evaluation, BSplineFunction expects (M_cp=1, C, D) and (N, M_u=1) 377 | points_single = bspline_curves(u_single, cp_single, knots, degree) # Output (N, 1, D) 378 | points_individual_list.append(points_single) 379 | 380 | points_stacked = torch.cat(points_individual_list, dim=1) # (N,M,D) 381 | torch.testing.assert_close(points_batched.data, points_stacked.data, atol=1e-6, rtol=1e-5) 382 | 383 | # Compare backward pass 384 | grad_output = torch.randn_like(points_batched) 385 | 386 | points_batched.backward(grad_output) 387 | grad_u_batched_actual = u_batched_clone_for_grad.grad.clone() 388 | grad_cp_batched_actual = control_points_batched_clone_for_grad.grad.clone() 389 | 390 | # Zero grads for individual calculations 391 | # We need new tensors for individual grad accumulation if we want to compare to original batched grads 392 | 393 | expected_grad_u_from_individuals = torch.zeros_like(u_batched) 394 | expected_grad_cp_from_individuals = torch.zeros_like(control_points_batched) 395 | 396 | for i in range(num_curves_m): 397 | cp_single_grad_target = control_points_batched[i : i + 1, :, :].detach().clone().requires_grad_(True) 398 | u_single_grad_target = u_batched[:, i : i + 1].detach().clone().requires_grad_(True) 399 | 400 | points_single_eval = bspline_curves(u_single_grad_target, cp_single_grad_target, knots, degree) 401 | grad_output_single = grad_output[:, i : i + 1, :] 402 | points_single_eval.backward(grad_output_single) 403 | 404 | expected_grad_u_from_individuals[:, i : i + 1] = u_single_grad_target.grad 405 | expected_grad_cp_from_individuals[i : i + 1, :, :] = cp_single_grad_target.grad 406 | 407 | torch.testing.assert_close(grad_u_batched_actual, expected_grad_u_from_individuals, atol=1e-6, rtol=1e-5) 408 | torch.testing.assert_close(grad_cp_batched_actual, expected_grad_cp_from_individuals, atol=1e-6, rtol=1e-5) 409 | 410 | 411 | class TestBSplineCurveModule(unittest.TestCase): 412 | def setUp(self): 413 | self.default_dtype = torch.float64 414 | self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 415 | 416 | def test_init_with_int(self): 417 | num_curves = 1 418 | dim = 2 419 | degree = 3 420 | n_cps_per_curve = 5 421 | module = ( 422 | BSplineCurve(num_curves=num_curves, dim=dim, degree=degree, knots_config=n_cps_per_curve) 423 | .to(self.device) 424 | .to(self.default_dtype) 425 | ) 426 | 427 | self.assertEqual(module.num_curves, num_curves) 428 | self.assertEqual(module.n_control_points_per_curve, n_cps_per_curve) 429 | self.assertEqual(module.dim, dim) 430 | self.assertEqual(module.degree, degree) 431 | self.assertIsInstance(module.control_points, nn.Parameter) 432 | self.assertTrue(module.control_points.requires_grad) 433 | self.assertEqual(module.control_points.shape, (num_curves, n_cps_per_curve, dim)) 434 | self.assertIsInstance(module.knots, torch.Tensor) 435 | self.assertEqual(module.knots.shape[0], n_cps_per_curve + degree + 1) 436 | # Check if knots are clamped to [-1,1] 437 | self.assertTrue(torch.all(module.knots[0 : degree + 1] == -1.0)) 438 | self.assertTrue(torch.all(module.knots[n_cps_per_curve:] == 1.0)) 439 | 440 | def test_init_with_tensor(self): 441 | num_curves = 1 442 | dim = 3 443 | degree = 2 444 | # n_cp=4, deg=2 -> knots=7. Example knots in [0,1] 445 | knots_tensor = torch.tensor([0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0], dtype=self.default_dtype) 446 | expected_n_cps_per_curve = 4 # 7 - 2 - 1 = 4 447 | 448 | module = ( 449 | BSplineCurve(num_curves=num_curves, dim=dim, degree=degree, knots_config=knots_tensor) 450 | .to(self.device) 451 | .to(self.default_dtype) 452 | ) 453 | 454 | self.assertEqual(module.n_control_points_per_curve, expected_n_cps_per_curve) 455 | self.assertEqual(module.control_points.shape, (num_curves, expected_n_cps_per_curve, dim)) 456 | torch.testing.assert_close(module.knots, knots_tensor.to(self.device).to(self.default_dtype)) 457 | 458 | def test_init_errors(self): 459 | with self.assertRaisesRegex(ValueError, "must be greater than the degree"): 460 | BSplineCurve(num_curves=1, dim=2, degree=3, knots_config=3) # n_cp <= degree 461 | 462 | knots_tensor_short = torch.tensor([0.0, 0.0, 1.0, 1.0]) 463 | with self.assertRaisesRegex(ValueError, "must be greater than the degree"): 464 | BSplineCurve(num_curves=1, dim=2, degree=3, knots_config=knots_tensor_short) 465 | 466 | with self.assertRaisesRegex(TypeError, "knots_config must be an int .*or.*Tensor.*"): 467 | BSplineCurve(num_curves=1, dim=2, degree=3, knots_config="wrong_type") # type: ignore 468 | 469 | knots_tensor_2d = torch.tensor([[0.0, 1.0]]) 470 | with self.assertRaisesRegex(ValueError, "Provided knots_config tensor must be 1D"): 471 | BSplineCurve(num_curves=1, dim=2, degree=1, knots_config=knots_tensor_2d) 472 | 473 | def test_forward_pass_shape_and_device(self): 474 | num_curves = 1 475 | dim = 3 476 | degree = 2 477 | n_cps_per_curve = 4 478 | batch_size = 10 # Number of u-samples per curve 479 | module = ( 480 | BSplineCurve(num_curves=num_curves, dim=dim, degree=degree, knots_config=n_cps_per_curve) 481 | .to(self.device) 482 | .to(self.default_dtype) 483 | ) 484 | 485 | # u: (N, M) 486 | u_scalar = torch.linspace(-1, 1, batch_size, device=self.device, dtype=self.default_dtype) 487 | u = u_scalar.unsqueeze(1) # (N,1) for M=1 curve 488 | 489 | points = module(u) # Output (N,M,D) 490 | 491 | self.assertEqual(points.shape, (batch_size, num_curves, dim)) 492 | self.assertEqual(points.device, self.device) 493 | self.assertEqual(points.dtype, self.default_dtype) 494 | 495 | def test_boundary_interpolation_with_clamp_normalization(self): 496 | num_curves = 1 497 | dim = 2 498 | degree = 3 499 | n_cps_per_curve = 5 500 | module = ( 501 | BSplineCurve( 502 | num_curves=num_curves, dim=dim, degree=degree, knots_config=n_cps_per_curve, normalize_fn="clamp" 503 | ) 504 | .to(self.device) 505 | .to(self.default_dtype) 506 | ) # Knots are [-1,1] 507 | 508 | # u: (N,M) -> (1,1) 509 | u_start = torch.tensor([[-1.0]], device=self.device, dtype=self.default_dtype) 510 | u_end = torch.tensor([[1.0]], device=self.device, dtype=self.default_dtype) 511 | 512 | point_start = module(u_start) # (1,1,D) 513 | point_end = module(u_end) # (1,1,D) 514 | 515 | # module.control_points is (1,C,D) 516 | torch.testing.assert_close(point_start, module.control_points[:, 0:1, :]) 517 | torch.testing.assert_close(point_end, module.control_points[:, -1:, :]) 518 | 519 | def test_backward_pass(self): 520 | num_curves = 1 521 | dim = 2 522 | degree = 2 523 | n_cps_per_curve = 4 524 | module = ( 525 | BSplineCurve(num_curves=num_curves, dim=dim, degree=degree, knots_config=n_cps_per_curve) 526 | .to(self.device) 527 | .to(self.default_dtype) 528 | ) 529 | 530 | # u: (N,M) 531 | u = torch.tensor([[-0.7], [0.6]], device=self.device, dtype=self.default_dtype) # N=2, M=1 532 | 533 | self.assertIsNone(module.control_points.grad) 534 | 535 | points = module(u) # (2,1,D) 536 | loss = points.sum() 537 | loss.backward() 538 | 539 | self.assertIsNotNone(module.control_points.grad) 540 | self.assertEqual(module.control_points.grad.shape, module.control_points.shape) # (1,C,D) 541 | self.assertNotEqual(torch.sum(module.control_points.grad**2).item(), 0.0) 542 | 543 | def test_gradcheck_module(self): 544 | num_curves = 1 545 | dim = 2 546 | degree = 2 547 | n_cps_per_curve = 3 548 | module = ( 549 | BSplineCurve(num_curves=num_curves, dim=dim, degree=degree, knots_config=n_cps_per_curve) 550 | .to(self.device) 551 | .to(self.default_dtype) 552 | ) 553 | 554 | # u_gc: (N,M) 555 | u_gc = torch.tensor([[-0.75], [0.25]], device=self.device, dtype=self.default_dtype).requires_grad_(True) 556 | 557 | # Check BSplineFunction.apply part 558 | cp_gc = module.control_points.clone().requires_grad_(True) # (1,C,D) 559 | knots = module.knots 560 | current_degree = module.degree # Use module's degree 561 | 562 | # Output of apply is (N,M,D), sum for gradcheck 563 | self.assertTrue( 564 | torch.autograd.gradcheck( 565 | lambda u_in, cp_in: bspline_curves(u_in, cp_in, knots, current_degree).sum(), 566 | (u_gc, cp_gc), 567 | eps=1e-6, 568 | atol=1e-4, 569 | rtol=1e-3, 570 | nondet_tol=1e-7, 571 | ) 572 | ) 573 | 574 | # Check module call w.r.t 'u' 575 | module_clone = ( 576 | BSplineCurve(num_curves=num_curves, dim=dim, degree=degree, knots_config=n_cps_per_curve) 577 | .to(self.device) 578 | .to(self.default_dtype) 579 | ) 580 | module_clone.load_state_dict(module.state_dict()) 581 | 582 | u_gc_mod = torch.tensor([[-0.6]], device=self.device, dtype=self.default_dtype).requires_grad_(True) # (1,1) 583 | 584 | # Output of module is (N,M,D), sum for gradcheck 585 | self.assertTrue( 586 | torch.autograd.gradcheck( 587 | lambda u_in: module_clone(u_in).sum(), u_gc_mod, eps=1e-6, atol=1e-4, rtol=1e-3, nondet_tol=1e-7 588 | ) 589 | ) 590 | 591 | def test_device_movement(self): 592 | if not torch.cuda.is_available(): 593 | self.skipTest("CUDA not available, skipping device movement test.") 594 | 595 | num_curves = 1 596 | dim = 2 597 | degree = 2 598 | n_cps_per_curve = 4 599 | module_cpu = BSplineCurve(num_curves, dim, degree, n_cps_per_curve) 600 | 601 | self.assertEqual(module_cpu.control_points.device.type, "cpu") 602 | self.assertEqual(module_cpu.knots.device.type, "cpu") 603 | 604 | module_cuda = module_cpu.to("cuda").to(self.default_dtype) 605 | 606 | self.assertEqual(module_cuda.control_points.device.type, "cuda") 607 | self.assertEqual(module_cuda.knots.device.type, "cuda") 608 | 609 | u_cuda = torch.tensor([[-0.7], [0.6]], device="cuda", dtype=self.default_dtype) # (2,1) 610 | points = module_cuda(u_cuda) # (2,1,D) 611 | 612 | self.assertEqual(points.device.type, "cuda") 613 | self.assertEqual(points.shape, (2, num_curves, dim)) 614 | 615 | loss = points.sum() 616 | loss.backward() 617 | self.assertIsNotNone(module_cuda.control_points.grad) 618 | self.assertEqual(module_cuda.control_points.grad.device.type, "cuda") 619 | 620 | 621 | def test_bspline_curves_default_knots_device_dtype(): 622 | dtype = torch.float64 623 | device = torch.device("cpu") 624 | 625 | u = torch.tensor([[0.0]], dtype=dtype, device=device) 626 | control_points = torch.zeros((1, 4, 1), dtype=dtype, device=device) 627 | 628 | out = bspline_curves(u, control_points) 629 | 630 | assert out.dtype == control_points.dtype 631 | 632 | 633 | def test_bspline_curves_default_knots_cuda(): 634 | if not torch.cuda.is_available(): 635 | pytest.skip("CUDA not available, skipping test.") 636 | 637 | dtype = torch.float64 638 | device = torch.device("cuda") 639 | 640 | control_points = torch.randn(1, 4, 1, dtype=dtype, device=device) 641 | u = torch.linspace(-1, 1, 5, dtype=dtype, device=device).unsqueeze(1) 642 | 643 | result = bspline_curves(u, control_points, knots=None, degree=3) 644 | 645 | assert result.device == device 646 | assert result.dtype == dtype 647 | 648 | 649 | if __name__ == "__main__": 650 | pytest.main([__file__, "-v"]) 651 | -------------------------------------------------------------------------------- /tests/test_legendre.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | import pytest 5 | import torch 6 | import torch.nn as nn 7 | 8 | from torchcurves import LegendreCurve 9 | from torchcurves.functional import legendre_curves 10 | 11 | 12 | @pytest.mark.parametrize("num_curves", [1, 2, 5]) 13 | @pytest.mark.parametrize("dim", [1, 2, 3]) 14 | @pytest.mark.parametrize("degree", [0, 1, 2, 3]) 15 | @pytest.mark.parametrize("n_samples", [1, 10, 100]) 16 | @pytest.mark.parametrize("dtype", [torch.float32, torch.float64]) 17 | def test_legendre_curves(num_curves, dim, degree, n_samples, dtype): 18 | torch.random.manual_seed(42) # For reproducibility 19 | coefs = torch.randn(1 + degree, num_curves, dim, dtype=dtype) 20 | x = 2 * torch.rand(n_samples, num_curves, dtype=dtype) 21 | torch_eval = legendre_curves(x, coefs) 22 | for ci in range(num_curves): 23 | for mi in range(dim): 24 | coef_np = coefs[:, ci, mi].numpy() 25 | x_np = x[:, ci].numpy() 26 | np_vals = np.polynomial.legendre.legval(x_np, coef_np) 27 | torch_vals = torch_eval[:, ci, mi].numpy() 28 | np.testing.assert_allclose( 29 | np_vals, 30 | torch_vals, 31 | rtol=1e-3 if dtype == torch.float32 else 1e-10, 32 | err_msg=f"Mismatch for curve {ci}, dimension {mi} with degree {degree} and dtype {dtype}", 33 | ) 34 | 35 | 36 | class TestLegendreCurveModule(unittest.TestCase): 37 | def setUp(self): 38 | self.default_dtype = torch.float64 39 | self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 40 | 41 | def test_init(self): 42 | num_curves = 2 43 | dim = 3 44 | degree = 4 45 | module = LegendreCurve(num_curves, dim, degree).to(self.device).to(self.default_dtype) 46 | 47 | self.assertEqual(module.num_curves, num_curves) 48 | self.assertEqual(module.dim, dim) 49 | self.assertEqual(module.degree, degree) 50 | self.assertEqual(module.n_coefficients, degree + 1) 51 | self.assertIsInstance(module.coefficients, nn.Parameter) 52 | self.assertTrue(module.coefficients.requires_grad) 53 | self.assertEqual(module.coefficients.shape, (degree + 1, num_curves, dim)) 54 | 55 | def test_init_errors(self): 56 | with self.assertRaises(ValueError): 57 | LegendreCurve(num_curves=0, dim=1, degree=1) # num_curves <= 0 58 | with self.assertRaises(ValueError): 59 | LegendreCurve(num_curves=1, dim=0, degree=1) # dim <= 0 60 | with self.assertRaises(ValueError): 61 | LegendreCurve(num_curves=1, dim=1, degree=-1) # degree < 0 62 | with self.assertRaises(ValueError): # Unknown normalization 63 | LegendreCurve(num_curves=1, dim=1, degree=1, normalize_fn="unknown_norm") 64 | with self.assertRaises(ValueError): # Scale <=0 65 | LegendreCurve(num_curves=1, dim=1, degree=1, normalization_scale=0) 66 | 67 | def test_forward_pass_shape_and_device(self): 68 | num_curves = 2 69 | dim = 3 70 | degree = 2 71 | n_samples = 10 72 | 73 | module = LegendreCurve(num_curves, dim, degree).to(self.device).to(self.default_dtype) 74 | 75 | # u: (N, M) 76 | u_input = torch.rand(n_samples, num_curves, device=self.device, dtype=self.default_dtype) * 2 - 1 # in [-1,1] 77 | 78 | points = module(u_input) # Output (N, M, D) 79 | 80 | self.assertEqual(points.shape, (n_samples, num_curves, dim)) 81 | self.assertEqual(points.device, self.device) 82 | self.assertEqual(points.dtype, self.default_dtype) 83 | 84 | def test_backward_pass_module(self): 85 | num_curves = 2 86 | dim = 2 87 | degree = 3 88 | n_samples = 5 89 | module = LegendreCurve(num_curves, dim, degree).to(self.device).to(self.default_dtype) 90 | 91 | u_input = torch.rand(n_samples, num_curves, device=self.device, dtype=self.default_dtype).requires_grad_(True) 92 | 93 | self.assertIsNone(module.coefficients.grad) 94 | 95 | points = module(u_input) # (N,M,D) 96 | loss = points.sum() 97 | loss.backward() 98 | 99 | self.assertIsNotNone(module.coefficients.grad) 100 | self.assertEqual(module.coefficients.grad.shape, module.coefficients.shape) 101 | self.assertNotEqual(torch.sum(module.coefficients.grad**2).item(), 0.0) 102 | 103 | self.assertIsNotNone(u_input.grad) # Check grad w.r.t. u as well 104 | self.assertEqual(u_input.grad.shape, u_input.shape) 105 | 106 | def test_device_movement_module(self): 107 | if not torch.cuda.is_available(): 108 | self.skipTest("CUDA not available, skipping device movement test.") 109 | 110 | num_curves = 2 111 | dim = 1 112 | degree = 2 113 | module_cpu = LegendreCurve(num_curves, dim, degree) 114 | 115 | self.assertEqual(module_cpu.coefficients.device.type, "cpu") 116 | 117 | module_cuda = module_cpu.to("cuda").to(self.default_dtype) 118 | self.assertEqual(module_cuda.coefficients.device.type, "cuda") 119 | 120 | u_cuda = torch.rand(5, num_curves, device="cuda", dtype=self.default_dtype) * 2 - 1 121 | points = module_cuda(u_cuda) 122 | 123 | self.assertEqual(points.device.type, "cuda") 124 | self.assertEqual(points.shape, (5, num_curves, dim)) 125 | 126 | loss = points.sum() 127 | loss.backward() 128 | self.assertIsNotNone(module_cuda.coefficients.grad) 129 | self.assertEqual(module_cuda.coefficients.grad.device.type, "cuda") 130 | 131 | 132 | if __name__ == "__main__": 133 | pytest.main([__file__, "-v"]) 134 | --------------------------------------------------------------------------------