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.
- torchwavelets/__init__.py +62 -0
- torchwavelets/_version.py +1 -0
- torchwavelets/compat.py +136 -0
- torchwavelets/core/__init__.py +13 -0
- torchwavelets/core/filters.py +211 -0
- torchwavelets/core/modes.py +109 -0
- torchwavelets/dtcwt/__init__.py +24 -0
- torchwavelets/dtcwt/_autocast.py +24 -0
- torchwavelets/dtcwt/coeffs.py +76 -0
- torchwavelets/dtcwt/data/__init__.py +5 -0
- torchwavelets/dtcwt/data/antonini.npz +0 -0
- torchwavelets/dtcwt/data/farras.npz +0 -0
- torchwavelets/dtcwt/data/legall.npz +0 -0
- torchwavelets/dtcwt/data/near_sym_a.npz +0 -0
- torchwavelets/dtcwt/data/near_sym_a2.npz +0 -0
- torchwavelets/dtcwt/data/near_sym_b.npz +0 -0
- torchwavelets/dtcwt/data/near_sym_b_bp.npz +0 -0
- torchwavelets/dtcwt/data/qshift_06.npz +0 -0
- torchwavelets/dtcwt/data/qshift_32.npz +0 -0
- torchwavelets/dtcwt/data/qshift_a.npz +0 -0
- torchwavelets/dtcwt/data/qshift_b.npz +0 -0
- torchwavelets/dtcwt/data/qshift_b_bp.npz +0 -0
- torchwavelets/dtcwt/data/qshift_c.npz +0 -0
- torchwavelets/dtcwt/data/qshift_d.npz +0 -0
- torchwavelets/dtcwt/lowlevel.py +296 -0
- torchwavelets/dtcwt/transform1d.py +253 -0
- torchwavelets/dtcwt/transform2d.py +298 -0
- torchwavelets/dtcwt/transform3d.py +420 -0
- torchwavelets/dtcwt/transform_funcs.py +93 -0
- torchwavelets/dwt/__init__.py +6 -0
- torchwavelets/dwt/_validation.py +59 -0
- torchwavelets/dwt/lowlevel.py +153 -0
- torchwavelets/dwt/transform1d.py +175 -0
- torchwavelets/dwt/transform2d.py +195 -0
- torchwavelets/packets.py +439 -0
- torchwavelets/py.typed +0 -0
- torchwavelets/scatter/__init__.py +6 -0
- torchwavelets/scatter/lowlevel.py +221 -0
- torchwavelets/scatter/network.py +204 -0
- torchwavelets/swt/__init__.py +14 -0
- torchwavelets/swt/lowlevel.py +53 -0
- torchwavelets/swt/transform.py +386 -0
- torchwavelets-0.1.0.dist-info/METADATA +303 -0
- torchwavelets-0.1.0.dist-info/RECORD +47 -0
- torchwavelets-0.1.0.dist-info/WHEEL +5 -0
- torchwavelets-0.1.0.dist-info/licenses/LICENSE +29 -0
- torchwavelets-0.1.0.dist-info/top_level.txt +1 -0
torchwavelets/packets.py
ADDED
|
@@ -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,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
|
+
)
|