torchwavelets 0.1.0__py3-none-any.whl

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.
Files changed (47) hide show
  1. torchwavelets/__init__.py +62 -0
  2. torchwavelets/_version.py +1 -0
  3. torchwavelets/compat.py +136 -0
  4. torchwavelets/core/__init__.py +13 -0
  5. torchwavelets/core/filters.py +211 -0
  6. torchwavelets/core/modes.py +109 -0
  7. torchwavelets/dtcwt/__init__.py +24 -0
  8. torchwavelets/dtcwt/_autocast.py +24 -0
  9. torchwavelets/dtcwt/coeffs.py +76 -0
  10. torchwavelets/dtcwt/data/__init__.py +5 -0
  11. torchwavelets/dtcwt/data/antonini.npz +0 -0
  12. torchwavelets/dtcwt/data/farras.npz +0 -0
  13. torchwavelets/dtcwt/data/legall.npz +0 -0
  14. torchwavelets/dtcwt/data/near_sym_a.npz +0 -0
  15. torchwavelets/dtcwt/data/near_sym_a2.npz +0 -0
  16. torchwavelets/dtcwt/data/near_sym_b.npz +0 -0
  17. torchwavelets/dtcwt/data/near_sym_b_bp.npz +0 -0
  18. torchwavelets/dtcwt/data/qshift_06.npz +0 -0
  19. torchwavelets/dtcwt/data/qshift_32.npz +0 -0
  20. torchwavelets/dtcwt/data/qshift_a.npz +0 -0
  21. torchwavelets/dtcwt/data/qshift_b.npz +0 -0
  22. torchwavelets/dtcwt/data/qshift_b_bp.npz +0 -0
  23. torchwavelets/dtcwt/data/qshift_c.npz +0 -0
  24. torchwavelets/dtcwt/data/qshift_d.npz +0 -0
  25. torchwavelets/dtcwt/lowlevel.py +296 -0
  26. torchwavelets/dtcwt/transform1d.py +253 -0
  27. torchwavelets/dtcwt/transform2d.py +298 -0
  28. torchwavelets/dtcwt/transform3d.py +420 -0
  29. torchwavelets/dtcwt/transform_funcs.py +93 -0
  30. torchwavelets/dwt/__init__.py +6 -0
  31. torchwavelets/dwt/_validation.py +59 -0
  32. torchwavelets/dwt/lowlevel.py +153 -0
  33. torchwavelets/dwt/transform1d.py +175 -0
  34. torchwavelets/dwt/transform2d.py +195 -0
  35. torchwavelets/packets.py +439 -0
  36. torchwavelets/py.typed +0 -0
  37. torchwavelets/scatter/__init__.py +6 -0
  38. torchwavelets/scatter/lowlevel.py +221 -0
  39. torchwavelets/scatter/network.py +204 -0
  40. torchwavelets/swt/__init__.py +14 -0
  41. torchwavelets/swt/lowlevel.py +53 -0
  42. torchwavelets/swt/transform.py +386 -0
  43. torchwavelets-0.1.0.dist-info/METADATA +303 -0
  44. torchwavelets-0.1.0.dist-info/RECORD +47 -0
  45. torchwavelets-0.1.0.dist-info/WHEEL +5 -0
  46. torchwavelets-0.1.0.dist-info/licenses/LICENSE +29 -0
  47. torchwavelets-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,439 @@
1
+ """Wavelet packets (full binary decomposition tree) for 1D and 2D.
2
+
3
+ A wavelet packet decomposes both the approximation AND the detail bands at
4
+ every level, producing a full binary (1D) or quaternary (2D) tree. The API
5
+ mirrors ``pywt.WaveletPacket`` / ``pywt.WaveletPacket2D``: nodes are accessed
6
+ by path (``"aad"``, ``"av"``, ...), the tree is built with ``decompose()``,
7
+ reconstructed with ``reconstruct()``, and coefficients match PyWavelets
8
+ bit-for-bit (verified by the parity tests).
9
+
10
+ All node data are torch tensors of shape ``(N, C, L)`` (1D) or
11
+ ``(N, C, H, W)`` (2D); the precision follows the input dtype.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+
18
+ from .core.filters import filter_bank
19
+ from .dwt._validation import dwt_max_level
20
+ from .dwt.transform1d import dwt1d, idwt1d
21
+ from .dwt.transform2d import dwt2d, idwt2d
22
+
23
+ __all__ = ["WaveletPacket", "WaveletPacket2D"]
24
+
25
+
26
+ def get_graycode_order(level: int, x: str = "a", y: str = "d") -> list[str]:
27
+ """Sequency (frequency) ordering of the paths at ``level``."""
28
+ order = [x, y]
29
+ for _ in range(level - 1):
30
+ order = [x + p for p in order] + [y + p for p in order[::-1]]
31
+ return order
32
+
33
+
34
+ class BaseNode:
35
+ """A node of a wavelet packet tree."""
36
+
37
+ PART_LEN = 1
38
+ PARTS: tuple[str, ...] = ()
39
+
40
+ def __init__(self, parent, data, node_name):
41
+ self.parent = parent
42
+ if parent is not None:
43
+ self.wavelet = parent.wavelet
44
+ self.mode = parent.mode
45
+ self.level = parent.level + 1
46
+ self._maxlevel = parent.maxlevel
47
+ self.path = parent.path + node_name
48
+ else:
49
+ self.wavelet = None
50
+ self.mode = None
51
+ self._maxlevel = None
52
+ self.path = ""
53
+ self.level = 0
54
+ self.axes = getattr(parent, "axes", None) if parent is not None else None
55
+ self._data = data
56
+ for part in self.PARTS:
57
+ setattr(self, part, None)
58
+
59
+ # -- data ------------------------------------------------------------
60
+ @property
61
+ def data(self):
62
+ return self._data
63
+
64
+ @data.setter
65
+ def data(self, value):
66
+ self._data = value
67
+
68
+ @property
69
+ def is_empty(self) -> bool:
70
+ return self._data is None
71
+
72
+ @property
73
+ def maxlevel(self) -> int:
74
+ return self._maxlevel
75
+
76
+ @property
77
+ def is_leaf(self) -> bool:
78
+ return not any(getattr(self, part) is not None for part in self.PARTS)
79
+
80
+ @property
81
+ def has_any_subnode(self) -> bool:
82
+ return any(getattr(self, part) is not None for part in self.PARTS)
83
+
84
+ # -- tree structure ---------------------------------------------------
85
+ def _get_node(self, part):
86
+ return getattr(self, part)
87
+
88
+ def _set_node(self, part, node):
89
+ setattr(self, part, node)
90
+
91
+ def _validate_node_name(self, part):
92
+ if part not in self.PARTS:
93
+ raise ValueError(f"Subnode name must be in {self.PARTS}, not {part!r}.")
94
+
95
+ def _create_subnode(self, part, data=None, overwrite=True):
96
+ self._validate_node_name(part)
97
+ if not overwrite and self._get_node(part) is not None:
98
+ return self._get_node(part)
99
+ node = type(self)(self, data, part)
100
+ self._set_node(part, node)
101
+ return node
102
+
103
+ def get_subnode(self, part, decompose=True):
104
+ """Return the direct subnode ``part``, decomposing if needed."""
105
+ node = self._get_node(part)
106
+ if node is None:
107
+ if decompose:
108
+ self._decompose()
109
+ node = self._get_node(part)
110
+ if node is None:
111
+ raise ValueError(f"Invalid subnode name: {part!r}")
112
+ else:
113
+ raise ValueError(f"Subnode {part!r} does not exist.")
114
+ return node
115
+
116
+ def decompose(self):
117
+ """Decompose this node one level (children are (re)created)."""
118
+ if self.level >= self.maxlevel:
119
+ raise ValueError("Maximum decomposition level reached.")
120
+ return self._decompose()
121
+
122
+ def _reconstruct(self) -> torch.Tensor:
123
+ """Rebuild this node's data from its subnodes (implemented by the
124
+ 1-D and 2-D trees)."""
125
+ raise NotImplementedError
126
+
127
+ def _order_freq(self, nodes, level):
128
+ """Sequency ordering of the nodes at ``level`` (implemented by the
129
+ 1-D and 2-D trees)."""
130
+ raise NotImplementedError
131
+
132
+ def reconstruct(self, update: bool = False) -> torch.Tensor:
133
+ """Reconstruct data from subnodes (IDWT), or return leaf data.
134
+
135
+ With ``update=True`` the reconstructed data replaces the node data.
136
+ """
137
+ if not self.has_any_subnode:
138
+ return self.data
139
+ data = self._reconstruct()
140
+ if update:
141
+ self.data = data
142
+ return data
143
+
144
+ def get_path(self, path: str):
145
+ """Return the node at ``path``, decomposing as needed."""
146
+ if not path:
147
+ return self
148
+ return self.get_subnode(path[: self.PART_LEN], True).get_path(path[self.PART_LEN :])
149
+
150
+ def walk(self, func, decompose=True):
151
+ """Depth-first walk; ``func(node)`` returns False to stop descent."""
152
+ if func(self) is False:
153
+ return
154
+ if not self.has_any_subnode and decompose and self.level < self.maxlevel:
155
+ self._decompose()
156
+ for part in self.PARTS:
157
+ node = self._get_node(part)
158
+ if node is not None:
159
+ node.walk(func, decompose)
160
+
161
+ def get_leaf_nodes(self, decompose=False):
162
+ """Return all leaf nodes (with ``decompose=True``, fill the tree)."""
163
+ result = []
164
+
165
+ def collect(node):
166
+ if node.is_leaf:
167
+ result.append(node)
168
+ return False
169
+ return True
170
+
171
+ self.walk(collect, decompose=decompose)
172
+ return result
173
+
174
+ def get_level(self, level: int, order: str = "natural", decompose=True):
175
+ """Return all nodes at ``level`` (``order`` = 'natural' or 'freq')."""
176
+ if order not in ("natural", "freq"):
177
+ raise ValueError(f"Invalid order: {order!r}")
178
+ if level > self.maxlevel:
179
+ raise ValueError(
180
+ "The level cannot be greater than the maximum "
181
+ f"decomposition level value ({self.maxlevel})"
182
+ )
183
+ result = []
184
+
185
+ def collect(node):
186
+ if node.level == level:
187
+ result.append(node)
188
+ return False
189
+ return True
190
+
191
+ self.walk(collect, decompose=decompose)
192
+ if order == "natural":
193
+ return result
194
+ return self._order_freq(result, level)
195
+
196
+ # -- python accessors -------------------------------------------------
197
+ def __getitem__(self, path):
198
+ if isinstance(path, tuple):
199
+ path = "".join(path)
200
+ if not isinstance(path, str):
201
+ raise TypeError(
202
+ f"Invalid path parameter type - expected string or tuple of "
203
+ f"strings but got {type(path)}."
204
+ )
205
+ if self.maxlevel is not None and len(path) > self.maxlevel * self.PART_LEN:
206
+ raise IndexError("Path length is out of range.")
207
+ if not path:
208
+ return self
209
+ return self.get_subnode(path[: self.PART_LEN], True)[path[self.PART_LEN :]]
210
+
211
+ def __setitem__(self, path, data):
212
+ if isinstance(path, tuple):
213
+ path = "".join(path)
214
+ if not isinstance(path, str):
215
+ raise TypeError(f"Invalid path parameter type - expected string but got {type(path)}.")
216
+ if self.maxlevel is not None and len(self.path) + len(path) > self.maxlevel * self.PART_LEN:
217
+ raise IndexError("Path length out of range.")
218
+ if path:
219
+ subnode = self._get_node(path[: self.PART_LEN])
220
+ if subnode is None:
221
+ self._create_subnode(path[: self.PART_LEN], None)
222
+ subnode = self._get_node(path[: self.PART_LEN])
223
+ subnode[path[self.PART_LEN :]] = data
224
+ else:
225
+ if isinstance(data, BaseNode):
226
+ self.data = data.data
227
+ else:
228
+ self.data = torch.as_tensor(data)
229
+
230
+ def __repr__(self):
231
+ return f"{type(self).__name__}(path={self.path!r}, level={self.level})"
232
+
233
+
234
+ class WaveletPacket(BaseNode):
235
+ """1-D wavelet packet decomposition tree.
236
+
237
+ Args:
238
+ data (torch.Tensor): ``(N, C, L)`` input (a 1-D tensor is promoted to
239
+ ``(1, 1, L)``).
240
+ wavelet (str or pywt.Wavelet or tuple): as in :func:`torchwavelets.dwt1d`.
241
+ mode (str): as in :func:`torchwavelets.dwt1d`.
242
+ maxlevel (int): maximum decomposition depth (default: pywt's rule).
243
+ axis (int): the signal axis (default ``-1``, any of the three dims).
244
+
245
+ Children are named ``'a'`` (approximation) and ``'d'`` (detail).
246
+ """
247
+
248
+ PARTS = ("a", "d")
249
+ CHILD: type[BaseNode] | None = None # set after the Node class is defined
250
+
251
+ def __init__(self, data, wavelet, mode="symmetric", maxlevel=None, axis=-1):
252
+ super().__init__(None, data, "")
253
+ self.wavelet = wavelet
254
+ self.mode = mode
255
+ self.axes = axis
256
+ if data is not None:
257
+ if data.ndim == 1:
258
+ data = data.unsqueeze(0).unsqueeze(0)
259
+ if data.ndim != 3:
260
+ raise ValueError(f"WaveletPacket expects (N, C, L) data; got {tuple(data.shape)}")
261
+ self._data = data
262
+ self.data_size = data.shape
263
+ fl = filter_bank(wavelet).length
264
+ if maxlevel is None:
265
+ maxlevel = dwt_max_level(data.shape[self._axis()], fl)
266
+ self._maxlevel = maxlevel
267
+
268
+ def _create_subnode(self, part, data=None, overwrite=True):
269
+ self._validate_node_name(part)
270
+ if not overwrite and self._get_node(part) is not None:
271
+ return self._get_node(part)
272
+ node = self.CHILD(self, data, part)
273
+ self._set_node(part, node)
274
+ return node
275
+
276
+ def _axis(self) -> int:
277
+ """Normalise the (already validated) transform axis to ``-1``-style."""
278
+ axis = self.axes
279
+ if axis not in (0, 1, 2, -1, -2, -3):
280
+ raise ValueError(f"invalid axis for a (N, C, L) tensor: {axis!r}")
281
+ return axis if axis >= 0 else axis + 3
282
+
283
+ def _to_last(self, x: torch.Tensor) -> torch.Tensor:
284
+ ax = self._axis()
285
+ return x if ax == 2 else x.movedim(ax, -1)
286
+
287
+ def _from_last(self, x: torch.Tensor) -> torch.Tensor:
288
+ ax = self._axis()
289
+ return x if ax == 2 else x.movedim(-1, ax)
290
+
291
+ def _decompose(self):
292
+ yl, (yh,) = dwt1d(self._to_last(self.data), self.wavelet, self.mode, level=1)
293
+ self._data_shape = tuple(self.data.shape)
294
+ self._create_subnode("a", self._from_last(yl))
295
+ self._create_subnode("d", self._from_last(yh))
296
+ return self.a, self.d
297
+
298
+ def _reconstruct(self):
299
+ rec = idwt1d(
300
+ (self._to_last(self.a.reconstruct()), [self._to_last(self.d.reconstruct())]),
301
+ self.wavelet,
302
+ self.mode,
303
+ )
304
+ rec = self._from_last(rec)
305
+ if self._data_shape is not None and tuple(rec.shape) != self._data_shape:
306
+ rec = rec[tuple(slice(sz) for sz in self._data_shape)]
307
+ return rec
308
+
309
+ def _order_freq(self, nodes, level):
310
+ by_path = {n.path: n for n in nodes}
311
+ return [by_path[p] for p in get_graycode_order(level, "a", "d") if p in by_path]
312
+
313
+
314
+ class Node(WaveletPacket):
315
+ """A 1-D wavelet packet tree node (internal; created by the tree)."""
316
+
317
+ def __init__(self, parent, data, node_name):
318
+ BaseNode.__init__(self, parent, data, node_name)
319
+
320
+
321
+ WaveletPacket.CHILD = Node
322
+
323
+
324
+ class WaveletPacket2D(BaseNode):
325
+ """2-D wavelet packet decomposition tree.
326
+
327
+ Args:
328
+ data (torch.Tensor): ``(N, C, H, W)`` input (a 2-D tensor is promoted
329
+ to ``(1, 1, H, W)``).
330
+ wavelet (str or pywt.Wavelet or tuple): as in :func:`torchwavelets.dwt2d`.
331
+ mode (str): as in :func:`torchwavelets.dwt2d`.
332
+ maxlevel (int): maximum decomposition depth (default: pywt's rule).
333
+ axes (tuple[int, int]): the transformed axes (``(-2, -1)`` only).
334
+
335
+ Children are named ``'a'`` (LL), ``'h'`` (LH), ``'v'`` (HL), ``'d'`` (HH),
336
+ following PyWavelets' convention (``'h'``/``'v'`` match ``cH``/``cV``).
337
+ """
338
+
339
+ PARTS = ("a", "h", "v", "d")
340
+ CHILD: type[BaseNode] | None = None
341
+
342
+ def __init__(self, data, wavelet, mode="smooth", maxlevel=None, axes=(-2, -1)):
343
+ super().__init__(None, data, "")
344
+ self.wavelet = wavelet
345
+ self.mode = mode
346
+ self.axes = tuple(axes)
347
+ self._axes() # validate at construction (children inherit)
348
+ if data is not None:
349
+ if data.ndim == 2:
350
+ data = data.unsqueeze(0).unsqueeze(0)
351
+ if data.ndim != 4:
352
+ raise ValueError(
353
+ f"WaveletPacket2D expects (N, C, H, W) data; got {tuple(data.shape)}"
354
+ )
355
+ self._data = data
356
+ self.data_size = data.shape
357
+ fl = filter_bank(wavelet).length
358
+ if maxlevel is None:
359
+ maxlevel = dwt_max_level(min(data.shape[-2:]), fl)
360
+ self._maxlevel = maxlevel
361
+
362
+ def _create_subnode(self, part, data=None, overwrite=True):
363
+ self._validate_node_name(part)
364
+ if not overwrite and self._get_node(part) is not None:
365
+ return self._get_node(part)
366
+ node = self.CHILD(self, data, part)
367
+ self._set_node(part, node)
368
+ return node
369
+
370
+ def _axes(self) -> tuple[int, int]:
371
+ """Normalise and validate the transformed axes."""
372
+ a = tuple(axes % 4 for axes in self.axes)
373
+ if a != (2, 3):
374
+ raise ValueError(
375
+ "WaveletPacket2D supports axes=(-2, -1) only (the last two "
376
+ f"dims of (N, C, H, W)); got {tuple(self.axes)}"
377
+ )
378
+ return a
379
+
380
+ def _decompose(self):
381
+ self._axes() # validate the transform axes
382
+ yl, (h,) = dwt2d(self.data, self.wavelet, self.mode, level=1)
383
+ self._data_shape = tuple(self.data.shape)
384
+ cH, cV, cD = torch.unbind(h, dim=2)
385
+ self._create_subnode("a", yl)
386
+ self._create_subnode("h", cH)
387
+ self._create_subnode("v", cV)
388
+ self._create_subnode("d", cD)
389
+ return self.a, self.h, self.v, self.d
390
+
391
+ def _reconstruct(self):
392
+ rec = idwt2d(
393
+ (
394
+ self.a.reconstruct(),
395
+ [
396
+ torch.stack(
397
+ (
398
+ self.h.reconstruct(),
399
+ self.v.reconstruct(),
400
+ self.d.reconstruct(),
401
+ ),
402
+ dim=2,
403
+ )
404
+ ],
405
+ ),
406
+ self.wavelet,
407
+ self.mode,
408
+ )
409
+ if self._data_shape is not None and tuple(rec.shape) != self._data_shape:
410
+ rec = rec[tuple(slice(sz) for sz in self._data_shape)]
411
+ return rec
412
+
413
+ def _order_freq(self, nodes, level):
414
+ # Observed PyWavelets convention: 'a'->(l,l), 'h'->(h,l), 'v'->(l,h),
415
+ # 'd'->(h,h) (first char = row band, second = column band).
416
+ def expand(path):
417
+ table = {"a": "ll", "h": "hl", "v": "lh", "d": "hh"}
418
+ return (
419
+ "".join(table[c][0] for c in path),
420
+ "".join(table[c][1] for c in path),
421
+ )
422
+
423
+ grid = {}
424
+ for node in nodes:
425
+ row, col = expand(node.path)
426
+ grid.setdefault(row, {})[col] = node
427
+ gray = get_graycode_order(level, "l", "h")
428
+ rows = [grid[p] for p in gray if p in grid]
429
+ return [[row[p] for p in gray if p in row] for row in rows]
430
+
431
+
432
+ class Node2D(WaveletPacket2D):
433
+ """A 2-D wavelet packet tree node (internal; created by the tree)."""
434
+
435
+ def __init__(self, parent, data, node_name):
436
+ BaseNode.__init__(self, parent, data, node_name)
437
+
438
+
439
+ WaveletPacket2D.CHILD = Node2D
torchwavelets/py.typed ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Dual-tree complex wavelet scattering."""
2
+
3
+ from .lowlevel import ScatLayer, ScatLayerj2
4
+ from .network import DTCWTScattering, dtcwt_scattering
5
+
6
+ __all__ = ["ScatLayer", "ScatLayerj2", "DTCWTScattering", "dtcwt_scattering"]
@@ -0,0 +1,221 @@
1
+ """Dual-tree complex wavelet scattering layers.
2
+
3
+ A scattering layer applies one (or two) DTCWT scale(s) with the smooth
4
+ modulus ``sqrt(|Wx|^2 + magbias^2) - magbias`` and stacks the results along
5
+ the channel dimension. With the DTCWT's near shift-invariance this gives
6
+ locally shift-invariant feature maps for a single, cheap layer — the classic
7
+ DTCWT scattering front-end.
8
+
9
+ The implementation is built directly on the complex-native DTCWT, so no
10
+ custom ``autograd.Function`` is needed: gradients flow through the complex
11
+ ops, autocast works, and the module is ``torch.compile``-compatible.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+
20
+ from ..dtcwt._autocast import autocast_off
21
+ from ..dtcwt.coeffs import biort as _biort
22
+ from ..dtcwt.coeffs import qshift as _qshift
23
+ from ..dtcwt.transform2d import _compute_dtype, _prep_filt, _validate_names
24
+ from ..dtcwt.transform_funcs import fwd_j1, fwd_j2plus
25
+
26
+ __all__ = ["ScatLayer", "ScatLayerj2"]
27
+
28
+
29
+ def _smooth_mag(highs: torch.Tensor, magbias: float) -> torch.Tensor:
30
+ """Smooth modulus ``sqrt(|z|^2 + b^2) - b`` of complex bandpasses."""
31
+ return torch.sqrt(highs.abs().pow(2) + magbias**2) - magbias
32
+
33
+
34
+ def _pad_to(x: torch.Tensor, mult: int) -> torch.Tensor:
35
+ """Reflectively pad ``x`` so H and W are multiples of ``mult``."""
36
+ _, _, r, c = x.shape
37
+ rem = r % mult
38
+ if rem != 0:
39
+ before = (mult - rem) // 2
40
+ after = mult - rem - before
41
+ if before > 0:
42
+ x = torch.cat((x[:, :, :before], x), dim=2)
43
+ if after > 0:
44
+ x = torch.cat((x, x[:, :, -after:]), dim=2)
45
+ rem = c % mult
46
+ if rem != 0:
47
+ before = (mult - rem) // 2
48
+ after = mult - rem - before
49
+ if before > 0:
50
+ x = torch.cat((x[:, :, :, :before], x), dim=3)
51
+ if after > 0:
52
+ x = torch.cat((x, x[:, :, :, -after:]), dim=3)
53
+ return x
54
+
55
+
56
+ def _check_mode(mode: str) -> None:
57
+ if mode != "symmetric":
58
+ raise ValueError(
59
+ f"scattering supports only mode='symmetric' (Kingsbury boundary handling); got {mode!r}"
60
+ )
61
+
62
+
63
+ def _scatter_j1_impl(x, f, magbias: float, combine_colour: bool) -> torch.Tensor:
64
+ """One scale of scattering: (lowpass, |bandpass|) stacked in channels."""
65
+ ll, highs = fwd_j1(x, f["h0o"], f["h1o"], False, 2)
66
+ ll = F.avg_pool2d(ll, 2)
67
+ if combine_colour:
68
+ # combine the colour channels of the magnitude
69
+ mag = torch.sqrt(highs.abs().pow(2).sum(dim=1) + magbias**2) - magbias
70
+ return torch.cat((ll, mag), dim=1)
71
+ mag = _smooth_mag(highs, magbias)
72
+ b, ch, _, h, w = mag.shape
73
+ return torch.cat((ll, mag.reshape(b, ch * 6, h, w)), dim=1)
74
+
75
+
76
+ def _scatter_j2_impl(x, f, magbias: float, combine_colour: bool) -> torch.Tensor:
77
+ """Two scales of scattering with second-order terms."""
78
+ # ---- first order, scale 1 ----
79
+ s0, h1 = fwd_j1(x, f["h0o"], f["h1o"], False, 2)
80
+ if combine_colour:
81
+ s1_j1 = torch.sqrt(h1.abs().pow(2).sum(dim=1) + magbias**2) - magbias # (N,6,H,W)
82
+ else:
83
+ s1_j1 = _smooth_mag(h1, magbias) # (N,C,6,H,W)
84
+
85
+ # ---- first order, scale 2 ----
86
+ s0, h2 = fwd_j2plus(s0, f["h0a"], f["h1a"], f["h0b"], f["h1b"], False, 2)
87
+ if combine_colour:
88
+ s1_j2 = torch.sqrt(h2.abs().pow(2).sum(dim=1) + magbias**2) - magbias
89
+ else:
90
+ s1_j2 = _smooth_mag(h2, magbias)
91
+ s0 = F.avg_pool2d(s0, 2)
92
+
93
+ if combine_colour:
94
+ # second order on the 6 combined-orientation channels
95
+ s1_j1_flat = s1_j1
96
+ s0_2, h2nd = fwd_j1(s1_j1_flat, f["h0o"], f["h1o"], False, 2)
97
+ s1_j1_pool = F.avg_pool2d(s0_2, 2) # smoothed first-order map
98
+ s2_j1 = torch.sqrt(h2nd.abs().pow(2) + magbias**2) - magbias # (N,6,6,H,W)
99
+ b, _, _, h, w = s2_j1.shape
100
+ # upstream orders second-order channels (o2nd, o1st)
101
+ s2_j1 = s2_j1.permute(0, 2, 1, 3, 4).reshape(b, 36, h, w)
102
+ return torch.cat((s0, s1_j1_pool, s1_j2, s2_j1), dim=1)
103
+
104
+ # ---- second order on the 6*C orientation channels ----
105
+ b, ch, _, h, w = s1_j1.shape
106
+ s1_j1_flat = s1_j1.reshape(b, ch * 6, h, w) # c-major channel order
107
+ s0_2, h2nd = fwd_j1(s1_j1_flat, f["h0o"], f["h1o"], False, 2)
108
+ s1_j1_pool = F.avg_pool2d(s0_2, 2) # smoothed first-order map
109
+ s2_j1 = _smooth_mag(h2nd, magbias) # (N, 6C, 6, H', W') dims: (C, o1st, o2nd)
110
+ b, ch6, _, h2_, w2_ = s2_j1.shape
111
+ # upstream orders second-order channels (o2nd, o1st); keep C outer
112
+ s2_j1 = s2_j1.reshape(b, ch, 6, 6, h2_, w2_).permute(0, 1, 3, 2, 4, 5)
113
+ s2_j1 = s2_j1.reshape(b, ch * 36, h2_, w2_)
114
+ # pool s1_j1 and flatten s1_j2 for stacking
115
+ b2, ch2, _, h3, w3 = s1_j2.shape
116
+ s1_j2_flat = s1_j2.reshape(b2, ch2 * 6, h3, w3)
117
+ return torch.cat((s0, s1_j1_pool, s1_j2_flat, s2_j1), dim=1)
118
+
119
+
120
+ class ScatLayer(nn.Module):
121
+ """One order of scattering at a single scale (level-1 DTCWT).
122
+
123
+ Args:
124
+ biort (str): level-1 biorthogonal filter bank name.
125
+ mode (str): boundary handling (only ``'symmetric'`` is supported).
126
+ magbias (float): smoothing bias of the modulus.
127
+ combine_colour (bool): for 3-channel input, combine colour channels
128
+ into a single set of 6 orientation magnitudes (output has 3+6
129
+ channels instead of 1+6*C).
130
+
131
+ Returns a feature map ``(N, 1 + 6*C, H/2, W/2)`` (or ``(N, 9, H/2, W/2)``
132
+ with ``combine_colour``), the lowpass followed by the invariant terms.
133
+ """
134
+
135
+ def __init__(self, biort="near_sym_a", mode="symmetric", magbias=1e-2, combine_colour=False):
136
+ super().__init__()
137
+ _validate_names(biort, "qshift_a")
138
+ _check_mode(mode)
139
+ self.biort = biort
140
+ self.mode = mode
141
+ self.magbias = float(magbias)
142
+ self.combine_colour = combine_colour
143
+ h0o, _, h1o, _ = _biort(biort)
144
+ self.register_buffer("h0o", _prep_filt(h0o, torch.float64, torch.device("cpu")))
145
+ self.register_buffer("h1o", _prep_filt(h1o, torch.float64, torch.device("cpu")))
146
+
147
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
148
+ if x.ndim != 4:
149
+ raise ValueError(f"ScatLayer expects (N, C, H, W); got {tuple(x.shape)}")
150
+ if self.combine_colour and x.shape[1] != 3:
151
+ raise ValueError(f"combine_colour requires 3 input channels; got {x.shape[1]}")
152
+ dtype = _compute_dtype(x)
153
+ f = {
154
+ "h0o": self.h0o.to(dtype=dtype, device=x.device),
155
+ "h1o": self.h1o.to(dtype=dtype, device=x.device),
156
+ }
157
+ with autocast_off(x.device):
158
+ return _scatter_j1_impl(_pad_to(x.to(dtype), 2), f, self.magbias, self.combine_colour)
159
+
160
+ def extra_repr(self) -> str:
161
+ return f"biort={self.biort!r}, mode={self.mode!r}, magbias={self.magbias}"
162
+
163
+
164
+ class ScatLayerj2(nn.Module):
165
+ """Two scales of scattering with second-order terms.
166
+
167
+ Applies the level-1 biorthogonal and level-2 qshift DTCWT filters (the
168
+ correct two-scale filter set, unlike stacking two :class:`ScatLayer`).
169
+
170
+ Returns ``(N, 49*C, H/4, W/4)``: ``C`` lowpass + ``12*C`` first-order
171
+ invariants (scales 1 and 2) + ``36*C`` second-order invariants (or
172
+ ``(N, 3 + 12 + 36, ...)`` with ``combine_colour``).
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ biort="near_sym_a",
178
+ qshift="qshift_a",
179
+ mode="symmetric",
180
+ magbias=1e-2,
181
+ combine_colour=False,
182
+ ):
183
+ super().__init__()
184
+ _validate_names(biort, qshift)
185
+ _check_mode(mode)
186
+ self.biort = biort
187
+ self.qshift = qshift
188
+ self.mode = mode
189
+ self.magbias = float(magbias)
190
+ self.combine_colour = combine_colour
191
+ h0o, _, h1o, _ = _biort(biort)
192
+ h0a, h0b, _, _, h1a, h1b, _, _ = _qshift(qshift)
193
+ dev = torch.device("cpu")
194
+ for name, arr in (
195
+ ("h0o", h0o),
196
+ ("h1o", h1o),
197
+ ("h0a", h0a),
198
+ ("h1a", h1a),
199
+ ("h0b", h0b),
200
+ ("h1b", h1b),
201
+ ):
202
+ self.register_buffer(name, _prep_filt(arr, torch.float64, dev))
203
+
204
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
205
+ if x.ndim != 4:
206
+ raise ValueError(f"ScatLayerj2 expects (N, C, H, W); got {tuple(x.shape)}")
207
+ if self.combine_colour and x.shape[1] != 3:
208
+ raise ValueError(f"combine_colour requires 3 input channels; got {x.shape[1]}")
209
+ dtype = _compute_dtype(x)
210
+ f = {
211
+ name: getattr(self, name).to(dtype=dtype, device=x.device)
212
+ for name in ("h0o", "h1o", "h0a", "h1a", "h0b", "h1b")
213
+ }
214
+ with autocast_off(x.device):
215
+ return _scatter_j2_impl(_pad_to(x.to(dtype), 8), f, self.magbias, self.combine_colour)
216
+
217
+ def extra_repr(self) -> str:
218
+ return (
219
+ f"biort={self.biort!r}, qshift={self.qshift!r}, "
220
+ f"mode={self.mode!r}, magbias={self.magbias}"
221
+ )