torchnd 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: torchnd
3
- Version: 0.1.2
4
- Summary: ND Convolution for PyTorch
3
+ Version: 0.2.0
4
+ Summary: ND operations for PyTorch
5
5
  Author-email: Felix Zimmermann <fzimmermann89@gmail.com>
6
6
  License: GPL-3.0-or-later
7
7
  Project-URL: Homepage, https://github.com/fzimmermann89/torch_conv_nd
@@ -31,7 +31,9 @@ Requires-Dist: types-setuptools; extra == "dev"
31
31
 
32
32
  # torchnd
33
33
 
34
- N-dimensional convolution for PyTorch that works with any number of spatial dimensions, supporting groups, dilation, transposed convolution, complex numbers, and arbitrary dimension layouts.
34
+ N-dimensional operations for PyTorch with flexible dimension specification.
35
+
36
+ `torchnd` provides convolution, transposed convolution, padding/cropping, linear interpolation, and corresponding adjoint operations for tensors with arbitrary dimension layouts. It supports complex tensors where applicable and uses native PyTorch 1D/2D/3D kernels whenever possible.
35
37
 
36
38
  ## Installation
37
39
 
@@ -112,6 +114,17 @@ cropped = pad_nd(x, pad=(-1, -1), dims=(0,)) # Negative values crop
112
114
  unpadded = adjoint_pad_nd(padded, pad=(1, 1, 2, 2), dims=(-2, -1))
113
115
  ```
114
116
 
117
+ **Linear interpolation:**
118
+ ```python
119
+ from torchnd import linear_interpolation_nd, adjoint_linear_interpolation_nd
120
+
121
+ x = torch.randn(2, 4, 64, 64)
122
+ low = linear_interpolation_nd(x, size=(32, 32), dims=(-2, -1))
123
+
124
+ # Adjoint of the interpolation operator
125
+ high_adjoint = adjoint_linear_interpolation_nd(low, input_size=(64, 64), dims=(-2, -1))
126
+ ```
127
+
115
128
  **Adjoint operators:**
116
129
  ```python
117
130
  from torchnd import ConvNd
@@ -149,6 +162,18 @@ Pad or crop a tensor to a target size, centering the original content. Combines
149
162
 
150
163
  Computes the adjoint of `pad_nd` via autograd. For a padding operator P, the adjoint P* satisfies the inner product identity: `<Px, y> = <x, P*y>` for all x, y. The adjoint unpads the tensor, summing contributions from padded regions back into the original shape. For constant/zeros mode, the adjoint is equivalent to cropping. For other modes, it correctly handles the adjoint of the boundary extension.
151
164
 
165
+ ### linear_interpolation_nd
166
+
167
+ Resizes selected tensor dimensions using linear interpolation. The `dims` parameter specifies which dimensions are interpolated; if `None`, the last `len(size)` dimensions are used. For more than three interpolated dimensions, the operation is split into chunks of up to three dimensions to use PyTorch's native interpolation kernels.
168
+
169
+ Complex tensors are handled by applying interpolation to real and imaginary parts separately.
170
+
171
+ ### adjoint_linear_interpolation_nd
172
+
173
+ Computes the adjoint of `linear_interpolation_nd`. The adjoint is evaluated with PyTorch's native interpolation backward kernels and satisfies the inner product identity `<Ax, y> = <x, A*y>` for the linear interpolation operator A.
174
+
175
+ The adjoint supports autograd, forward-mode AD, and `torch.func.vmap`.
176
+
152
177
  ### Adjoint convolution
153
178
 
154
179
  The `ConvNd` and `ConvTransposeNd` modules provide `adjoint()` methods. For `ConvNd`, the adjoint is the transposed convolution with conjugated weights (for complex) or identical weights (for real). For `ConvTransposeNd`, the adjoint is the forward convolution with conjugated weights.
@@ -159,6 +184,8 @@ The adjoint satisfies the inner product identity: `<Ax, y> = <x, A*y>` where A i
159
184
 
160
185
  For dimensions beyond 3D, `conv_nd` recursively decomposes the convolution into lower-dimensional operations. A 4D convolution becomes a sum of 3D convolutions applied to strided slices of the input.
161
186
 
187
+ For interpolation of more than three dimensions, `linear_interpolation_nd` applies native PyTorch interpolation to chunks of up to three dimensions. The adjoint applies the corresponding chunks in reverse order.
188
+
162
189
  Complex convolution is handled by decomposing into real operations: `(a+bi)*(c+di) = (ac-bd) + (ad+bc)i`.
163
190
 
164
191
  The implementation uses native PyTorch operations when possible (1D, 2D, 3D) and falls back to recursion only for higher dimensions, minimizing memory overhead with strided views.
@@ -1,6 +1,8 @@
1
1
  # torchnd
2
2
 
3
- N-dimensional convolution for PyTorch that works with any number of spatial dimensions, supporting groups, dilation, transposed convolution, complex numbers, and arbitrary dimension layouts.
3
+ N-dimensional operations for PyTorch with flexible dimension specification.
4
+
5
+ `torchnd` provides convolution, transposed convolution, padding/cropping, linear interpolation, and corresponding adjoint operations for tensors with arbitrary dimension layouts. It supports complex tensors where applicable and uses native PyTorch 1D/2D/3D kernels whenever possible.
4
6
 
5
7
  ## Installation
6
8
 
@@ -81,6 +83,17 @@ cropped = pad_nd(x, pad=(-1, -1), dims=(0,)) # Negative values crop
81
83
  unpadded = adjoint_pad_nd(padded, pad=(1, 1, 2, 2), dims=(-2, -1))
82
84
  ```
83
85
 
86
+ **Linear interpolation:**
87
+ ```python
88
+ from torchnd import linear_interpolation_nd, adjoint_linear_interpolation_nd
89
+
90
+ x = torch.randn(2, 4, 64, 64)
91
+ low = linear_interpolation_nd(x, size=(32, 32), dims=(-2, -1))
92
+
93
+ # Adjoint of the interpolation operator
94
+ high_adjoint = adjoint_linear_interpolation_nd(low, input_size=(64, 64), dims=(-2, -1))
95
+ ```
96
+
84
97
  **Adjoint operators:**
85
98
  ```python
86
99
  from torchnd import ConvNd
@@ -118,6 +131,18 @@ Pad or crop a tensor to a target size, centering the original content. Combines
118
131
 
119
132
  Computes the adjoint of `pad_nd` via autograd. For a padding operator P, the adjoint P* satisfies the inner product identity: `<Px, y> = <x, P*y>` for all x, y. The adjoint unpads the tensor, summing contributions from padded regions back into the original shape. For constant/zeros mode, the adjoint is equivalent to cropping. For other modes, it correctly handles the adjoint of the boundary extension.
120
133
 
134
+ ### linear_interpolation_nd
135
+
136
+ Resizes selected tensor dimensions using linear interpolation. The `dims` parameter specifies which dimensions are interpolated; if `None`, the last `len(size)` dimensions are used. For more than three interpolated dimensions, the operation is split into chunks of up to three dimensions to use PyTorch's native interpolation kernels.
137
+
138
+ Complex tensors are handled by applying interpolation to real and imaginary parts separately.
139
+
140
+ ### adjoint_linear_interpolation_nd
141
+
142
+ Computes the adjoint of `linear_interpolation_nd`. The adjoint is evaluated with PyTorch's native interpolation backward kernels and satisfies the inner product identity `<Ax, y> = <x, A*y>` for the linear interpolation operator A.
143
+
144
+ The adjoint supports autograd, forward-mode AD, and `torch.func.vmap`.
145
+
121
146
  ### Adjoint convolution
122
147
 
123
148
  The `ConvNd` and `ConvTransposeNd` modules provide `adjoint()` methods. For `ConvNd`, the adjoint is the transposed convolution with conjugated weights (for complex) or identical weights (for real). For `ConvTransposeNd`, the adjoint is the forward convolution with conjugated weights.
@@ -128,6 +153,8 @@ The adjoint satisfies the inner product identity: `<Ax, y> = <x, A*y>` where A i
128
153
 
129
154
  For dimensions beyond 3D, `conv_nd` recursively decomposes the convolution into lower-dimensional operations. A 4D convolution becomes a sum of 3D convolutions applied to strided slices of the input.
130
155
 
156
+ For interpolation of more than three dimensions, `linear_interpolation_nd` applies native PyTorch interpolation to chunks of up to three dimensions. The adjoint applies the corresponding chunks in reverse order.
157
+
131
158
  Complex convolution is handled by decomposing into real operations: `(a+bi)*(c+di) = (ac-bd) + (ad+bc)i`.
132
159
 
133
160
  The implementation uses native PyTorch operations when possible (1D, 2D, 3D) and falls back to recursion only for higher dimensions, minimizing memory overhead with strided views.
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "torchnd"
7
- version = "0.1.2"
8
- description = "ND Convolution for PyTorch"
7
+ version = "0.2.0"
8
+ description = "ND operations for PyTorch"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = {text = "GPL-3.0-or-later"}
@@ -0,0 +1,27 @@
1
+ __version__ = "0.2.0"
2
+
3
+ from torchnd.functional import (
4
+ adjoint_linear_interpolation_nd,
5
+ adjoint_nearest_interpolation_nd,
6
+ adjoint_pad_nd,
7
+ conv_nd,
8
+ linear_interpolation_nd,
9
+ nearest_interpolation_nd,
10
+ pad_nd,
11
+ pad_or_crop_to_size,
12
+ )
13
+ from torchnd.modules import ConvNd, ConvTransposeNd
14
+
15
+ __all__ = [
16
+ "adjoint_linear_interpolation_nd",
17
+ "adjoint_nearest_interpolation_nd",
18
+ "adjoint_pad_nd",
19
+ "conv_nd",
20
+ "linear_interpolation_nd",
21
+ "nearest_interpolation_nd",
22
+ "pad_nd",
23
+ "pad_or_crop_to_size",
24
+ "ConvNd",
25
+ "ConvTransposeNd",
26
+ "__version__",
27
+ ]
@@ -18,11 +18,318 @@ CONV_TRANSPOSE_REGISTRY: dict[int, Callable[..., Tensor]] = {
18
18
  3: torch.nn.functional.conv_transpose3d,
19
19
  }
20
20
 
21
+ LINEAR_INTERPOLATION_MODE: dict[int, str] = {
22
+ 1: "linear",
23
+ 2: "bilinear",
24
+ 3: "trilinear",
25
+ }
26
+
27
+ LINEAR_INTERPOLATION_ADJOINT_REGISTRY: dict[int, Callable[..., Tensor]] = {
28
+ 1: torch.ops.aten.upsample_linear1d_backward,
29
+ 2: torch.ops.aten.upsample_bilinear2d_backward,
30
+ 3: torch.ops.aten.upsample_trilinear3d_backward,
31
+ }
32
+
33
+ NEAREST_INTERPOLATION_ADJOINT_REGISTRY: dict[int, Callable[..., Tensor]] = {
34
+ 1: torch.ops.aten.upsample_nearest1d_backward,
35
+ 2: torch.ops.aten.upsample_nearest2d_backward,
36
+ 3: torch.ops.aten.upsample_nearest3d_backward,
37
+ }
38
+
21
39
 
22
40
  def _normalize_tuple(value: int | tuple[int, ...], length: int) -> tuple[int, ...]:
23
41
  return (value,) * length if isinstance(value, int) else tuple(value)
24
42
 
25
43
 
44
+ def _normalize_dims(ndim: int, dims: Sequence[int]) -> tuple[int, ...]:
45
+ normalized = tuple(d % ndim for d in dims)
46
+ if len(set(normalized)) != len(normalized):
47
+ raise ValueError("duplicate dimensions not supported")
48
+ return normalized
49
+
50
+
51
+ def _linear_interpolation_nd_real(
52
+ x: Tensor,
53
+ size: Sequence[int],
54
+ dims: Sequence[int] | None = None,
55
+ align_corners: bool = False,
56
+ ) -> Tensor:
57
+ size = tuple(size)
58
+ if dims is None:
59
+ if len(size) > x.ndim:
60
+ raise ValueError("len(size) must be <= x.ndim")
61
+ target_dims = tuple(range(x.ndim - len(size), x.ndim))
62
+ elif len(dims) != len(size):
63
+ raise ValueError("len(dims) must equal len(size)")
64
+ else:
65
+ target_dims = _normalize_dims(x.ndim, dims)
66
+
67
+ for i in range(0, len(target_dims), 3):
68
+ chunk_dims = target_dims[i : i + 3]
69
+ chunk_size = size[i : i + 3]
70
+
71
+ other_dims = tuple(dim for dim in range(x.ndim) if dim not in chunk_dims)
72
+ to_standard = (*other_dims, *chunk_dims)
73
+ from_standard = tuple(sorted(range(len(to_standard)), key=to_standard.__getitem__))
74
+ other_shape = tuple(x.shape[dim] for dim in other_dims)
75
+
76
+ x = x.permute(to_standard)
77
+ x = x.flatten(end_dim=len(other_dims) - 1).unsqueeze(0) if other_dims else x.unsqueeze(0).unsqueeze(0)
78
+
79
+ x = torch.nn.functional.interpolate(
80
+ x,
81
+ size=chunk_size,
82
+ mode=LINEAR_INTERPOLATION_MODE[len(chunk_dims)],
83
+ align_corners=align_corners,
84
+ )
85
+
86
+ x = x.squeeze(0).unflatten(0, other_shape).permute(from_standard) if other_dims else x.squeeze(0).squeeze(0)
87
+ return x
88
+
89
+
90
+ def linear_interpolation_nd(
91
+ x: Tensor,
92
+ size: Sequence[int],
93
+ dims: Sequence[int] | None = None,
94
+ align_corners: bool = False,
95
+ ) -> Tensor:
96
+ """Resize selected tensor dimensions using linear interpolation.
97
+
98
+ The selected dimensions are moved to PyTorch's interpolation layout and
99
+ processed in chunks of up to three dimensions. Complex tensors are
100
+ interpolated component-wise.
101
+
102
+ Parameters
103
+ ----------
104
+ x
105
+ Input tensor.
106
+ size
107
+ Target size for the interpolated dimensions.
108
+ dims
109
+ Dimensions to interpolate. If None, interpolates the last ``len(size)`` dimensions.
110
+ align_corners
111
+ Geometric convention passed to ``torch.nn.functional.interpolate``.
112
+
113
+ Returns
114
+ -------
115
+ Tensor with selected dimensions resized to ``size``.
116
+ """
117
+ if len(size) == 0:
118
+ return x
119
+ if x.is_complex():
120
+ return torch.complex(
121
+ _linear_interpolation_nd_real(x.real, size, dims, align_corners),
122
+ _linear_interpolation_nd_real(x.imag, size, dims, align_corners),
123
+ )
124
+ return _linear_interpolation_nd_real(x, size, dims, align_corners)
125
+
126
+
127
+ def _nearest_interpolation_nd_real(
128
+ x: Tensor,
129
+ size: Sequence[int],
130
+ dims: Sequence[int] | None = None,
131
+ ) -> Tensor:
132
+ size = tuple(size)
133
+ if dims is None:
134
+ if len(size) > x.ndim:
135
+ raise ValueError("len(size) must be <= x.ndim")
136
+ target_dims = tuple(range(x.ndim - len(size), x.ndim))
137
+ elif len(dims) != len(size):
138
+ raise ValueError("len(dims) must equal len(size)")
139
+ else:
140
+ target_dims = _normalize_dims(x.ndim, dims)
141
+
142
+ for i in range(0, len(target_dims), 3):
143
+ chunk_dims = target_dims[i : i + 3]
144
+ chunk_size = size[i : i + 3]
145
+
146
+ other_dims = tuple(dim for dim in range(x.ndim) if dim not in chunk_dims)
147
+ to_standard = (*other_dims, *chunk_dims)
148
+ from_standard = tuple(sorted(range(len(to_standard)), key=to_standard.__getitem__))
149
+ other_shape = tuple(x.shape[dim] for dim in other_dims)
150
+
151
+ x = x.permute(to_standard)
152
+ x = x.flatten(end_dim=len(other_dims) - 1).unsqueeze(0) if other_dims else x.unsqueeze(0).unsqueeze(0)
153
+ x = torch.nn.functional.interpolate(x, size=chunk_size, mode="nearest")
154
+ x = x.squeeze(0).unflatten(0, other_shape).permute(from_standard) if other_dims else x.squeeze(0).squeeze(0)
155
+ return x
156
+
157
+
158
+ def nearest_interpolation_nd(
159
+ x: Tensor,
160
+ size: Sequence[int],
161
+ dims: Sequence[int] | None = None,
162
+ ) -> Tensor:
163
+ """Resize selected tensor dimensions using nearest-neighbor interpolation.
164
+
165
+ Parameters
166
+ ----------
167
+ x
168
+ Input tensor.
169
+ size
170
+ Target size for the interpolated dimensions.
171
+ dims
172
+ Dimensions to interpolate. If None, interpolates the last ``len(size)`` dimensions.
173
+
174
+ Returns
175
+ -------
176
+ Tensor with selected dimensions resized to ``size``.
177
+ """
178
+ if len(size) == 0:
179
+ return x
180
+ if x.is_complex():
181
+ return torch.complex(
182
+ _nearest_interpolation_nd_real(x.real, size, dims),
183
+ _nearest_interpolation_nd_real(x.imag, size, dims),
184
+ )
185
+ return _nearest_interpolation_nd_real(x, size, dims)
186
+
187
+
188
+ def _adjoint_linear_interpolation_nd_real(
189
+ x: Tensor,
190
+ input_size: Sequence[int],
191
+ dims: Sequence[int] | None = None,
192
+ align_corners: bool = False,
193
+ ) -> Tensor:
194
+ input_size = tuple(input_size)
195
+ if dims is None:
196
+ if len(input_size) > x.ndim:
197
+ raise ValueError("len(input_size) must be <= x.ndim")
198
+ target_dims = tuple(range(x.ndim - len(input_size), x.ndim))
199
+ elif len(dims) != len(input_size):
200
+ raise ValueError("len(dims) must equal len(input_size)")
201
+ else:
202
+ target_dims = _normalize_dims(x.ndim, dims)
203
+
204
+ chunks = [(target_dims[i : i + 3], input_size[i : i + 3]) for i in range(0, len(target_dims), 3)]
205
+ for chunk_dims, chunk_input_size in reversed(chunks):
206
+ output_size = tuple(x.shape[dim] for dim in chunk_dims)
207
+
208
+ other_dims = tuple(dim for dim in range(x.ndim) if dim not in chunk_dims)
209
+ to_standard = (*other_dims, *chunk_dims)
210
+ from_standard = tuple(sorted(range(len(to_standard)), key=to_standard.__getitem__))
211
+ other_shape = tuple(x.shape[dim] for dim in other_dims)
212
+
213
+ x = x.permute(to_standard)
214
+ x = x.flatten(end_dim=len(other_dims) - 1).unsqueeze(0) if other_dims else x.unsqueeze(0).unsqueeze(0)
215
+
216
+ x = LINEAR_INTERPOLATION_ADJOINT_REGISTRY[len(chunk_input_size)](
217
+ x,
218
+ list(output_size),
219
+ [x.shape[0], x.shape[1], *chunk_input_size],
220
+ align_corners,
221
+ *([None] * len(chunk_input_size)),
222
+ )
223
+
224
+ x = x.squeeze(0).unflatten(0, other_shape).permute(from_standard) if other_dims else x.squeeze(0).squeeze(0)
225
+ return x
226
+
227
+
228
+ def adjoint_linear_interpolation_nd(
229
+ x: Tensor,
230
+ input_size: Sequence[int],
231
+ dims: Sequence[int] | None = None,
232
+ align_corners: bool = False,
233
+ ) -> Tensor:
234
+ """Apply the adjoint of ``linear_interpolation_nd``.
235
+
236
+ The adjoint is evaluated with PyTorch's native interpolation backward
237
+ kernels, not by running a dummy forward interpolation. Complex tensors are
238
+ handled component-wise.
239
+
240
+ Parameters
241
+ ----------
242
+ x
243
+ Tensor in the output space of ``linear_interpolation_nd``.
244
+ input_size
245
+ Size of the selected dimensions before interpolation.
246
+ dims
247
+ Dimensions that were interpolated. If None, uses the last ``len(input_size)`` dimensions.
248
+ align_corners
249
+ Geometric convention passed to ``torch.nn.functional.interpolate``.
250
+
251
+ Returns
252
+ -------
253
+ Tensor in the input space of ``linear_interpolation_nd``.
254
+ """
255
+ if len(input_size) == 0:
256
+ return x
257
+ if x.is_complex():
258
+ return torch.complex(
259
+ _adjoint_linear_interpolation_nd_real(x.real, input_size, dims, align_corners),
260
+ _adjoint_linear_interpolation_nd_real(x.imag, input_size, dims, align_corners),
261
+ )
262
+ return _adjoint_linear_interpolation_nd_real(x, input_size, dims, align_corners)
263
+
264
+
265
+ def _adjoint_nearest_interpolation_nd_real(
266
+ x: Tensor,
267
+ input_size: Sequence[int],
268
+ dims: Sequence[int] | None = None,
269
+ ) -> Tensor:
270
+ input_size = tuple(input_size)
271
+ if dims is None:
272
+ if len(input_size) > x.ndim:
273
+ raise ValueError("len(input_size) must be <= x.ndim")
274
+ target_dims = tuple(range(x.ndim - len(input_size), x.ndim))
275
+ elif len(dims) != len(input_size):
276
+ raise ValueError("len(dims) must equal len(input_size)")
277
+ else:
278
+ target_dims = _normalize_dims(x.ndim, dims)
279
+
280
+ chunks = [(target_dims[i : i + 3], input_size[i : i + 3]) for i in range(0, len(target_dims), 3)]
281
+ for chunk_dims, chunk_input_size in reversed(chunks):
282
+ output_size = tuple(x.shape[dim] for dim in chunk_dims)
283
+
284
+ other_dims = tuple(dim for dim in range(x.ndim) if dim not in chunk_dims)
285
+ to_standard = (*other_dims, *chunk_dims)
286
+ from_standard = tuple(sorted(range(len(to_standard)), key=to_standard.__getitem__))
287
+ other_shape = tuple(x.shape[dim] for dim in other_dims)
288
+
289
+ x = x.permute(to_standard)
290
+ x = x.flatten(end_dim=len(other_dims) - 1).unsqueeze(0) if other_dims else x.unsqueeze(0).unsqueeze(0)
291
+
292
+ x = NEAREST_INTERPOLATION_ADJOINT_REGISTRY[len(chunk_input_size)](
293
+ x,
294
+ list(output_size),
295
+ [x.shape[0], x.shape[1], *chunk_input_size],
296
+ *([None] * len(chunk_input_size)),
297
+ )
298
+
299
+ x = x.squeeze(0).unflatten(0, other_shape).permute(from_standard) if other_dims else x.squeeze(0).squeeze(0)
300
+ return x
301
+
302
+
303
+ def adjoint_nearest_interpolation_nd(
304
+ x: Tensor,
305
+ input_size: Sequence[int],
306
+ dims: Sequence[int] | None = None,
307
+ ) -> Tensor:
308
+ """Apply the adjoint of ``nearest_interpolation_nd``.
309
+
310
+ Parameters
311
+ ----------
312
+ x
313
+ Tensor in the output space of ``nearest_interpolation_nd``.
314
+ input_size
315
+ Size of the selected dimensions before interpolation.
316
+ dims
317
+ Dimensions that were interpolated. If None, uses the last ``len(input_size)`` dimensions.
318
+
319
+ Returns
320
+ -------
321
+ Tensor in the input space of ``nearest_interpolation_nd``.
322
+ """
323
+ if len(input_size) == 0:
324
+ return x
325
+ if x.is_complex():
326
+ return torch.complex(
327
+ _adjoint_nearest_interpolation_nd_real(x.real, input_size, dims),
328
+ _adjoint_nearest_interpolation_nd_real(x.imag, input_size, dims),
329
+ )
330
+ return _adjoint_nearest_interpolation_nd_real(x, input_size, dims)
331
+
332
+
26
333
  def conv_nd(
27
334
  x: Tensor,
28
335
  weight: Tensor,
@@ -295,10 +602,8 @@ def pad_nd(
295
602
  else:
296
603
  if len(dims) != n_dims:
297
604
  raise ValueError("len(dims) must equal len(pad) // 2")
298
- target_dims = [d % ndim for d in dims]
605
+ target_dims = list(_normalize_dims(ndim, dims))
299
606
  pad_pairs = [(pad[2 * i], pad[2 * i + 1]) for i in range(n_dims)]
300
- if len(set(target_dims)) != len(target_dims):
301
- raise ValueError("duplicate dimensions not supported")
302
607
 
303
608
  if mode == "constant":
304
609
  return _pad_constant(x, target_dims, pad_pairs, value)
@@ -340,7 +645,7 @@ def pad_or_crop_to_size(
340
645
  else:
341
646
  if len(dims) != len(size):
342
647
  raise ValueError("len(dims) must equal len(size)")
343
- target_dims = [d % ndim for d in dims]
648
+ target_dims = list(_normalize_dims(ndim, dims))
344
649
 
345
650
  pad_list: list[int] = []
346
651
  for dim, target in zip(target_dims, size, strict=True):
@@ -374,10 +679,6 @@ def _pad_non_constant(
374
679
  pad_pairs: list[tuple[int, int]],
375
680
  mode: str,
376
681
  ) -> Tensor:
377
- for left, right in pad_pairs:
378
- if left < 0 or right < 0:
379
- raise ValueError(f"Negative padding is not supported for mode '{mode}'. Use mode='constant' for cropping.")
380
-
381
682
  result = x
382
683
 
383
684
  for i in range(0, len(target_dims), 3):
@@ -464,7 +765,7 @@ def adjoint_pad_nd(
464
765
  else:
465
766
  if len(dims) != n_dims:
466
767
  raise ValueError("len(dims) must equal len(pad) // 2")
467
- target_dims = [d % ndim for d in dims]
768
+ target_dims = list(_normalize_dims(ndim, dims))
468
769
 
469
770
  if original_sizes is None:
470
771
  original_sizes = []
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: torchnd
3
- Version: 0.1.2
4
- Summary: ND Convolution for PyTorch
3
+ Version: 0.2.0
4
+ Summary: ND operations for PyTorch
5
5
  Author-email: Felix Zimmermann <fzimmermann89@gmail.com>
6
6
  License: GPL-3.0-or-later
7
7
  Project-URL: Homepage, https://github.com/fzimmermann89/torch_conv_nd
@@ -31,7 +31,9 @@ Requires-Dist: types-setuptools; extra == "dev"
31
31
 
32
32
  # torchnd
33
33
 
34
- N-dimensional convolution for PyTorch that works with any number of spatial dimensions, supporting groups, dilation, transposed convolution, complex numbers, and arbitrary dimension layouts.
34
+ N-dimensional operations for PyTorch with flexible dimension specification.
35
+
36
+ `torchnd` provides convolution, transposed convolution, padding/cropping, linear interpolation, and corresponding adjoint operations for tensors with arbitrary dimension layouts. It supports complex tensors where applicable and uses native PyTorch 1D/2D/3D kernels whenever possible.
35
37
 
36
38
  ## Installation
37
39
 
@@ -112,6 +114,17 @@ cropped = pad_nd(x, pad=(-1, -1), dims=(0,)) # Negative values crop
112
114
  unpadded = adjoint_pad_nd(padded, pad=(1, 1, 2, 2), dims=(-2, -1))
113
115
  ```
114
116
 
117
+ **Linear interpolation:**
118
+ ```python
119
+ from torchnd import linear_interpolation_nd, adjoint_linear_interpolation_nd
120
+
121
+ x = torch.randn(2, 4, 64, 64)
122
+ low = linear_interpolation_nd(x, size=(32, 32), dims=(-2, -1))
123
+
124
+ # Adjoint of the interpolation operator
125
+ high_adjoint = adjoint_linear_interpolation_nd(low, input_size=(64, 64), dims=(-2, -1))
126
+ ```
127
+
115
128
  **Adjoint operators:**
116
129
  ```python
117
130
  from torchnd import ConvNd
@@ -149,6 +162,18 @@ Pad or crop a tensor to a target size, centering the original content. Combines
149
162
 
150
163
  Computes the adjoint of `pad_nd` via autograd. For a padding operator P, the adjoint P* satisfies the inner product identity: `<Px, y> = <x, P*y>` for all x, y. The adjoint unpads the tensor, summing contributions from padded regions back into the original shape. For constant/zeros mode, the adjoint is equivalent to cropping. For other modes, it correctly handles the adjoint of the boundary extension.
151
164
 
165
+ ### linear_interpolation_nd
166
+
167
+ Resizes selected tensor dimensions using linear interpolation. The `dims` parameter specifies which dimensions are interpolated; if `None`, the last `len(size)` dimensions are used. For more than three interpolated dimensions, the operation is split into chunks of up to three dimensions to use PyTorch's native interpolation kernels.
168
+
169
+ Complex tensors are handled by applying interpolation to real and imaginary parts separately.
170
+
171
+ ### adjoint_linear_interpolation_nd
172
+
173
+ Computes the adjoint of `linear_interpolation_nd`. The adjoint is evaluated with PyTorch's native interpolation backward kernels and satisfies the inner product identity `<Ax, y> = <x, A*y>` for the linear interpolation operator A.
174
+
175
+ The adjoint supports autograd, forward-mode AD, and `torch.func.vmap`.
176
+
152
177
  ### Adjoint convolution
153
178
 
154
179
  The `ConvNd` and `ConvTransposeNd` modules provide `adjoint()` methods. For `ConvNd`, the adjoint is the transposed convolution with conjugated weights (for complex) or identical weights (for real). For `ConvTransposeNd`, the adjoint is the forward convolution with conjugated weights.
@@ -159,6 +184,8 @@ The adjoint satisfies the inner product identity: `<Ax, y> = <x, A*y>` where A i
159
184
 
160
185
  For dimensions beyond 3D, `conv_nd` recursively decomposes the convolution into lower-dimensional operations. A 4D convolution becomes a sum of 3D convolutions applied to strided slices of the input.
161
186
 
187
+ For interpolation of more than three dimensions, `linear_interpolation_nd` applies native PyTorch interpolation to chunks of up to three dimensions. The adjoint applies the corresponding chunks in reverse order.
188
+
162
189
  Complex convolution is handled by decomposing into real operations: `(a+bi)*(c+di) = (ac-bd) + (ad+bc)i`.
163
190
 
164
191
  The implementation uses native PyTorch operations when possible (1D, 2D, 3D) and falls back to recursion only for higher dimensions, minimizing memory overhead with strided views.
@@ -5,7 +5,16 @@ from collections.abc import Iterator
5
5
  import pytest
6
6
  import torch
7
7
 
8
- from torchnd import adjoint_pad_nd, conv_nd, pad_nd, pad_or_crop_to_size
8
+ from torchnd import (
9
+ adjoint_linear_interpolation_nd,
10
+ adjoint_nearest_interpolation_nd,
11
+ adjoint_pad_nd,
12
+ conv_nd,
13
+ linear_interpolation_nd,
14
+ nearest_interpolation_nd,
15
+ pad_nd,
16
+ pad_or_crop_to_size,
17
+ )
9
18
  from torchnd.functional import CONV_REGISTRY, CONV_TRANSPOSE_REGISTRY
10
19
 
11
20
 
@@ -373,10 +382,12 @@ class TestPadNd:
373
382
  pad_nd(torch.zeros(2, 2), **kwargs)
374
383
 
375
384
  @pytest.mark.parametrize("mode", ["reflect", "replicate", "circular"])
376
- def test_negative_padding_non_constant_raises(self, mode: str) -> None:
377
- """Verify negative padding with non-constant mode raises ValueError."""
378
- with pytest.raises(ValueError, match="Negative padding is not supported"):
379
- pad_nd(torch.zeros(5, 5), (-1, 0), dims=(0,), mode=mode)
385
+ def test_negative_padding_non_constant_crops(self, mode: str) -> None:
386
+ """Verify negative padding works for non-constant modes."""
387
+ x = torch.arange(5).float()[None, None, :]
388
+ out = pad_nd(x, (-1, 1), dims=(2,), mode=mode)
389
+ expected = torch.nn.functional.pad(x, (-1, 1), mode=mode)
390
+ torch.testing.assert_close(out, expected)
380
391
 
381
392
  def test_multi_dim_non_constant_chunking(self) -> None:
382
393
  """Verify correct handling of >3 dims requiring multiple pad calls."""
@@ -527,3 +538,219 @@ class TestAdjointPadNd:
527
538
  result = adjoint_pad_nd(padded, pad, mode="reflect", dims=dims)
528
539
 
529
540
  assert result.shape == (2, 4, 10, 10)
541
+
542
+
543
+ class TestLinearInterpolationNd:
544
+ """Tests for linear_interpolation_nd and adjoint_linear_interpolation_nd."""
545
+
546
+ @pytest.mark.parametrize(
547
+ "shape, size, dims",
548
+ [
549
+ ((2, 3, 16), (7,), (-1,)),
550
+ ((2, 3, 16, 18), (7, 9), (-2, -1)),
551
+ ((2, 3, 8, 10, 12), (4, 5, 6), (-3, -2, -1)),
552
+ ],
553
+ )
554
+ def test_matches_torch_interpolate(
555
+ self,
556
+ shape: tuple[int, ...],
557
+ size: tuple[int, ...],
558
+ dims: tuple[int, ...],
559
+ ) -> None:
560
+ """Verify standard layouts match torch.nn.functional.interpolate."""
561
+ x = torch.randn(shape)
562
+ expected = torch.nn.functional.interpolate(
563
+ x,
564
+ size=size,
565
+ mode={1: "linear", 2: "bilinear", 3: "trilinear"}[len(size)],
566
+ align_corners=False,
567
+ )
568
+ result = linear_interpolation_nd(x, size, dims=dims)
569
+ torch.testing.assert_close(result, expected)
570
+
571
+ def test_arbitrary_dims(self) -> None:
572
+ """Verify interpolation works when spatial dimensions are not trailing."""
573
+ x = torch.randn(2, 12, 3, 14)
574
+ result = linear_interpolation_nd(x, size=(6, 7), dims=(1, 3))
575
+ assert result.shape == (2, 6, 3, 7)
576
+
577
+ def test_complex(self) -> None:
578
+ """Verify complex tensors are interpolated component-wise."""
579
+ x = torch.randn(2, 3, 12, 14, dtype=torch.complex64)
580
+ result = linear_interpolation_nd(x, size=(6, 7), dims=(-2, -1))
581
+ expected = torch.complex(
582
+ linear_interpolation_nd(x.real, size=(6, 7), dims=(-2, -1)),
583
+ linear_interpolation_nd(x.imag, size=(6, 7), dims=(-2, -1)),
584
+ )
585
+ torch.testing.assert_close(result, expected)
586
+
587
+ def test_more_than_three_dims(self) -> None:
588
+ """Verify interpolation with more than three dimensions uses multiple backend calls."""
589
+ x = torch.randn(2, 3, 4, 5, 6)
590
+ result = linear_interpolation_nd(x, size=(3, 4, 5, 7), dims=(1, 2, 3, 4))
591
+ assert result.shape == (2, 3, 4, 5, 7)
592
+
593
+ @pytest.mark.parametrize(
594
+ "shape, size, dims, dtype",
595
+ [
596
+ ((2, 3, 16), (7,), (-1,), torch.float32),
597
+ ((2, 12, 3, 14), (6, 7), (1, 3), torch.float32),
598
+ ((2, 3, 8, 10, 12), (4, 5, 6), (-3, -2, -1), torch.complex64),
599
+ ((2, 3, 4, 5, 6), (4, 3, 4, 5), (1, 2, 3, 4), torch.float32),
600
+ ],
601
+ )
602
+ def test_adjointness(
603
+ self,
604
+ shape: tuple[int, ...],
605
+ size: tuple[int, ...],
606
+ dims: tuple[int, ...],
607
+ dtype: torch.dtype,
608
+ ) -> None:
609
+ """Verify <Ax, y> == <x, A^H y>."""
610
+ x = torch.randn(shape, dtype=dtype)
611
+ y_shape = list(shape)
612
+ normalized_dims = tuple(dim % len(shape) for dim in dims)
613
+ for dim, target in zip(normalized_dims, size, strict=True):
614
+ y_shape[dim] = target
615
+ y = torch.randn(y_shape, dtype=dtype)
616
+
617
+ ax = linear_interpolation_nd(x, size, dims=dims)
618
+ ahy = adjoint_linear_interpolation_nd(y, [shape[dim] for dim in normalized_dims], dims=dims)
619
+
620
+ lhs = torch.vdot(ax.flatten(), y.flatten())
621
+ rhs = torch.vdot(x.flatten(), ahy.flatten())
622
+ torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
623
+
624
+ def test_adjoint_is_differentiable(self) -> None:
625
+ """Verify the adjoint operation is differentiable."""
626
+ y = torch.randn(2, 3, 6, 7, requires_grad=True)
627
+ result = adjoint_linear_interpolation_nd(y, input_size=(12, 14), dims=(-2, -1))
628
+ result.square().sum().backward()
629
+ assert y.grad is not None
630
+
631
+ def test_gradcheck(self) -> None:
632
+ """Verify numerical gradients for interpolation and its adjoint."""
633
+ x = torch.randn(1, 2, 5, 6, dtype=torch.float64, requires_grad=True)
634
+ y = torch.randn(1, 2, 3, 4, dtype=torch.float64, requires_grad=True)
635
+
636
+ assert torch.autograd.gradcheck(lambda value: linear_interpolation_nd(value, size=(3, 4), dims=(-2, -1)), x)
637
+ assert torch.autograd.gradcheck(
638
+ lambda value: adjoint_linear_interpolation_nd(value, input_size=(5, 6), dims=(-2, -1)),
639
+ y,
640
+ )
641
+
642
+ def test_forward_jvp(self) -> None:
643
+ """Verify interpolation works with forward-mode AD."""
644
+ x = torch.randn(2, 3, 12, 14)
645
+ tangent = torch.randn_like(x)
646
+ primal, tangent_out = torch.func.jvp(
647
+ lambda y: linear_interpolation_nd(y, size=(6, 7), dims=(-2, -1)),
648
+ (x,),
649
+ (tangent,),
650
+ )
651
+ assert primal.shape == (2, 3, 6, 7)
652
+ torch.testing.assert_close(tangent_out, linear_interpolation_nd(tangent, size=(6, 7), dims=(-2, -1)))
653
+
654
+ def test_forward_vmap(self) -> None:
655
+ """Verify interpolation works with vmap."""
656
+ x = torch.randn(5, 2, 3, 12, 14)
657
+ result = torch.func.vmap(lambda y: linear_interpolation_nd(y, size=(6, 7), dims=(-2, -1)))(x)
658
+ assert result.shape == (5, 2, 3, 6, 7)
659
+
660
+ def test_adjoint_jvp(self) -> None:
661
+ """Verify the adjoint works with forward-mode AD."""
662
+ y = torch.randn(2, 3, 6, 7)
663
+ tangent = torch.randn_like(y)
664
+ primal, tangent_out = torch.func.jvp(
665
+ lambda x: adjoint_linear_interpolation_nd(x, input_size=(12, 14), dims=(-2, -1)),
666
+ (y,),
667
+ (tangent,),
668
+ )
669
+ assert primal.shape == (2, 3, 12, 14)
670
+ torch.testing.assert_close(
671
+ tangent_out,
672
+ adjoint_linear_interpolation_nd(tangent, input_size=(12, 14), dims=(-2, -1)),
673
+ )
674
+
675
+ def test_adjoint_vmap(self) -> None:
676
+ """Verify the adjoint works with vmap."""
677
+ y = torch.randn(5, 2, 3, 6, 7)
678
+ result = torch.func.vmap(lambda x: adjoint_linear_interpolation_nd(x, input_size=(12, 14), dims=(-2, -1)))(y)
679
+ assert result.shape == (5, 2, 3, 12, 14)
680
+
681
+
682
+ class TestNearestInterpolationNd:
683
+ """Tests for nearest_interpolation_nd and adjoint_nearest_interpolation_nd."""
684
+
685
+ @pytest.mark.parametrize(
686
+ "shape, size, dims",
687
+ [
688
+ ((2, 3, 16), (7,), (-1,)),
689
+ ((2, 3, 16, 18), (7, 9), (-2, -1)),
690
+ ((2, 3, 8, 10, 12), (4, 5, 6), (-3, -2, -1)),
691
+ ],
692
+ )
693
+ def test_matches_torch_interpolate(
694
+ self,
695
+ shape: tuple[int, ...],
696
+ size: tuple[int, ...],
697
+ dims: tuple[int, ...],
698
+ ) -> None:
699
+ """Verify standard layouts match torch.nn.functional.interpolate."""
700
+ x = torch.randn(shape)
701
+ expected = torch.nn.functional.interpolate(x, size=size, mode="nearest")
702
+ result = nearest_interpolation_nd(x, size, dims=dims)
703
+ torch.testing.assert_close(result, expected)
704
+
705
+ def test_arbitrary_dims(self) -> None:
706
+ """Verify interpolation works when spatial dimensions are not trailing."""
707
+ x = torch.randn(2, 12, 3, 14)
708
+ result = nearest_interpolation_nd(x, size=(6, 7), dims=(1, 3))
709
+ assert result.shape == (2, 6, 3, 7)
710
+
711
+ def test_complex(self) -> None:
712
+ """Verify complex tensors are interpolated component-wise."""
713
+ x = torch.randn(2, 3, 12, 14, dtype=torch.complex64)
714
+ result = nearest_interpolation_nd(x, size=(6, 7), dims=(-2, -1))
715
+ expected = torch.complex(
716
+ nearest_interpolation_nd(x.real, size=(6, 7), dims=(-2, -1)),
717
+ nearest_interpolation_nd(x.imag, size=(6, 7), dims=(-2, -1)),
718
+ )
719
+ torch.testing.assert_close(result, expected)
720
+
721
+ def test_more_than_three_dims(self) -> None:
722
+ """Verify interpolation with more than three dimensions uses multiple backend calls."""
723
+ x = torch.randn(2, 3, 4, 5, 6)
724
+ result = nearest_interpolation_nd(x, size=(3, 4, 5, 7), dims=(1, 2, 3, 4))
725
+ assert result.shape == (2, 3, 4, 5, 7)
726
+
727
+ @pytest.mark.parametrize(
728
+ "shape, size, dims, dtype",
729
+ [
730
+ ((2, 3, 16), (7,), (-1,), torch.float32),
731
+ ((2, 12, 3, 14), (6, 7), (1, 3), torch.float32),
732
+ ((2, 3, 8, 10, 12), (4, 5, 6), (-3, -2, -1), torch.complex64),
733
+ ((2, 3, 4, 5, 6), (4, 3, 4, 5), (1, 2, 3, 4), torch.float32),
734
+ ],
735
+ )
736
+ def test_adjointness(
737
+ self,
738
+ shape: tuple[int, ...],
739
+ size: tuple[int, ...],
740
+ dims: tuple[int, ...],
741
+ dtype: torch.dtype,
742
+ ) -> None:
743
+ """Verify <Ax, y> == <x, A^H y>."""
744
+ x = torch.randn(shape, dtype=dtype)
745
+ y_shape = list(shape)
746
+ normalized_dims = tuple(dim % len(shape) for dim in dims)
747
+ for dim, target in zip(normalized_dims, size, strict=True):
748
+ y_shape[dim] = target
749
+ y = torch.randn(y_shape, dtype=dtype)
750
+
751
+ ax = nearest_interpolation_nd(x, size, dims=dims)
752
+ ahy = adjoint_nearest_interpolation_nd(y, [shape[dim] for dim in normalized_dims], dims=dims)
753
+
754
+ lhs = torch.vdot(ax.flatten(), y.flatten())
755
+ rhs = torch.vdot(x.flatten(), ahy.flatten())
756
+ torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
@@ -1,14 +0,0 @@
1
- __version__ = "0.1.2"
2
-
3
- from torchnd.functional import adjoint_pad_nd, conv_nd, pad_nd, pad_or_crop_to_size
4
- from torchnd.modules import ConvNd, ConvTransposeNd
5
-
6
- __all__ = [
7
- "adjoint_pad_nd",
8
- "conv_nd",
9
- "pad_nd",
10
- "pad_or_crop_to_size",
11
- "ConvNd",
12
- "ConvTransposeNd",
13
- "__version__",
14
- ]
File without changes
File without changes
File without changes
File without changes