dkist-processing-math 2.2.3__tar.gz → 3.0.0rc1__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.
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/PKG-INFO +1 -1
- dkist_processing_math-3.0.0rc1/changelog/27.feature.rst +1 -0
- dkist_processing_math-3.0.0rc1/changelog/27.removal.rst +1 -0
- dkist_processing_math-3.0.0rc1/dkist_processing_math/tests/test_transform.py +452 -0
- dkist_processing_math-3.0.0rc1/dkist_processing_math/transform/__init__.py +15 -0
- dkist_processing_math-3.0.0rc1/dkist_processing_math/transform/affine.py +186 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/PKG-INFO +1 -1
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/SOURCES.txt +4 -0
- dkist_processing_math-3.0.0rc1/dkist_processing_math.egg-info/scm_file_list.json +40 -0
- dkist_processing_math-3.0.0rc1/dkist_processing_math.egg-info/scm_version.json +8 -0
- dkist_processing_math-2.2.3/dkist_processing_math/tests/test_transform.py +0 -548
- dkist_processing_math-2.2.3/dkist_processing_math/transform/__init__.py +0 -25
- dkist_processing_math-2.2.3/dkist_processing_math/transform/affine.py +0 -261
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/.gitignore +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/.pre-commit-config.yaml +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/.readthedocs.yml +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/.snyk +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/CHANGELOG.rst +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/README.rst +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/bitbucket-pipelines.yml +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/changelog/.gitempty +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/__init__.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/arithmetic.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/feature.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/linear_algebra.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/statistics.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/__init__.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/conftest.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_arithmetic.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_feature.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_linear_algebra.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_statistics.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/transform/binary.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/transform/binning.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/transform/hough.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/dependency_links.txt +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/requires.txt +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/top_level.txt +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/docs/Makefile +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/docs/changelog.rst +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/docs/conf.py +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/docs/index.rst +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/docs/landing_page.rst +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/docs/make.bat +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/docs/requirements.txt +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/licenses/LICENSE.rst +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/pyproject.toml +0 -0
- {dkist_processing_math-2.2.3 → dkist_processing_math-3.0.0rc1}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Add ``apply_affine_transform`` which applies a combined center-preserving rotation and shift to an iterable of 2D arrays in a single interpolation pass. The rotation is specified as a scalar ``angle`` in radians. Also add ``affine_inverse_transform_matrix`` for callers that need the underlying homogeneous-coordinates matrix directly.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Remove ``affine_transform_arrays``, ``translate_arrays``, ``rotate_arrays_about_point``, ``scale_matrix``, ``rotation_matrix``, ``shear_matrix``, and ``translation_matrix`` from ``dkist_processing_math.transform``. These were superseded by ``apply_affine_transform`` (and, for callers that need the raw matrix, ``affine_inverse_transform_matrix``).
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from skimage import transform as transform
|
|
4
|
+
|
|
5
|
+
from dkist_processing_math.transform import affine_inverse_transform_matrix
|
|
6
|
+
from dkist_processing_math.transform import apply_affine_transform
|
|
7
|
+
from dkist_processing_math.transform import bin_arrays
|
|
8
|
+
from dkist_processing_math.transform import do_hough
|
|
9
|
+
from dkist_processing_math.transform import make_binary
|
|
10
|
+
from dkist_processing_math.transform.binning import resize_arrays
|
|
11
|
+
from dkist_processing_math.transform.binning import resize_arrays_local_mean
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture()
|
|
15
|
+
def binned_arrays(numpy_arrays):
|
|
16
|
+
"""
|
|
17
|
+
Create a list of binned arrays from numpy_arrays
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
numpy_arrays: List[np.ndarray]
|
|
22
|
+
A list of arrays to be binned
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
List[np.ndarray]
|
|
27
|
+
A list of 2x2 binned versions of the input arrays
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
temp_arrays = np.zeros((2, 2, 2), dtype=numpy_arrays[0].dtype)
|
|
32
|
+
for array, temp_array in zip(numpy_arrays, temp_arrays):
|
|
33
|
+
for row in range(temp_array.shape[1]):
|
|
34
|
+
for column in range(temp_array.shape[0]):
|
|
35
|
+
temp_array[column, row] = array[
|
|
36
|
+
column * 5 : (column + 1) * 5, row * 5 : (row + 1) * 5
|
|
37
|
+
].mean()
|
|
38
|
+
return temp_arrays
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture()
|
|
42
|
+
def binned_array(numpy_array, binned_arrays):
|
|
43
|
+
"""
|
|
44
|
+
Create a binned array from numpy_array
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
numpy_array: np.ndarray
|
|
49
|
+
The array to be binned
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
np.ndarray
|
|
54
|
+
A 2x2 binned version of the input array
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
return binned_arrays[0]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture()
|
|
61
|
+
def resized_arrays(numpy_arrays):
|
|
62
|
+
temp_arrays = np.zeros((2, 20, 100), dtype=numpy_arrays[0].dtype)
|
|
63
|
+
for array, temp_array in zip(numpy_arrays, temp_arrays):
|
|
64
|
+
temp_array[:, :] = transform.resize_local_mean(array, (20, 100), preserve_range=True)
|
|
65
|
+
return temp_arrays
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture()
|
|
69
|
+
def resized_array(resized_arrays):
|
|
70
|
+
return resized_arrays[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_bin_arrays(numpy_arrays, binned_arrays):
|
|
74
|
+
"""
|
|
75
|
+
Given: an iterable of numpy_arrays
|
|
76
|
+
When: binning an iterable of numpy_arrays
|
|
77
|
+
Then: an Iterator of binned versions of the input arrays is returned
|
|
78
|
+
"""
|
|
79
|
+
results = bin_arrays(numpy_arrays, (5, 5))
|
|
80
|
+
for result, binned_array in zip(results, binned_arrays):
|
|
81
|
+
np.testing.assert_allclose(result, binned_array)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_bin_arrays_single_array(numpy_array, binned_array):
|
|
85
|
+
"""
|
|
86
|
+
Given: an single numpy_array
|
|
87
|
+
When: binning an iterable of numpy_arrays
|
|
88
|
+
Then: an Iterator containing the binned version of the input arrays is returned
|
|
89
|
+
"""
|
|
90
|
+
result = next(bin_arrays(numpy_array, (5, 5)))
|
|
91
|
+
np.testing.assert_allclose(result, binned_array)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_bin_arrays_bad_bin_factor(numpy_array):
|
|
95
|
+
"""
|
|
96
|
+
Given: a single numpy array
|
|
97
|
+
When: binning an array using a bad binning factor
|
|
98
|
+
Then: an error is raised
|
|
99
|
+
"""
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
next(bin_arrays(numpy_array, (3, 3)))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_resize_arrays(numpy_arrays, resized_arrays):
|
|
105
|
+
"""
|
|
106
|
+
Given: an iterable of numpy_arrays
|
|
107
|
+
When: resizing an iterable of numpy_arrays
|
|
108
|
+
Then: an Iterator of resized versions of the input arrays is returned
|
|
109
|
+
"""
|
|
110
|
+
results = resize_arrays(numpy_arrays, (20, 100))
|
|
111
|
+
for result, resized_array in zip(results, resized_arrays):
|
|
112
|
+
np.testing.assert_allclose(result, resized_array)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_resize_arrays_single_array(numpy_array, resized_array):
|
|
116
|
+
"""
|
|
117
|
+
Given: a single numpy array
|
|
118
|
+
When: resizing that array
|
|
119
|
+
Then: an Iterator of resized versions of the input arrays is returned
|
|
120
|
+
"""
|
|
121
|
+
result = next(resize_arrays(numpy_array, (20, 100)))
|
|
122
|
+
np.testing.assert_allclose(result, resized_array)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_resize_arrays_local_mean(numpy_arrays, resized_arrays):
|
|
126
|
+
"""
|
|
127
|
+
Given: an iterable of numpy_arrays
|
|
128
|
+
When: resizing an iterable of numpy_arrays
|
|
129
|
+
Then: an Iterator of resized versions of the input arrays is returned
|
|
130
|
+
"""
|
|
131
|
+
results = resize_arrays_local_mean(numpy_arrays, (20, 100))
|
|
132
|
+
for result, resized_array in zip(results, resized_arrays):
|
|
133
|
+
np.testing.assert_allclose(result, resized_array)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_resize_arrays_local_mean_single_array(numpy_array, resized_array):
|
|
137
|
+
"""
|
|
138
|
+
Given: a single numpy array
|
|
139
|
+
When: resizing that array
|
|
140
|
+
Then: an Iterator of resized versions of the input arrays is returned
|
|
141
|
+
"""
|
|
142
|
+
result = next(resize_arrays_local_mean(numpy_array, (20, 100)))
|
|
143
|
+
np.testing.assert_allclose(result, resized_array)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.parametrize(
|
|
147
|
+
"array_shape, shift, angle, order, expected_matrix",
|
|
148
|
+
[
|
|
149
|
+
pytest.param(
|
|
150
|
+
(100, 100),
|
|
151
|
+
np.array([0.0, 0.0]),
|
|
152
|
+
0.0,
|
|
153
|
+
"shift_then_rotate",
|
|
154
|
+
np.eye(3),
|
|
155
|
+
id="identity",
|
|
156
|
+
),
|
|
157
|
+
pytest.param(
|
|
158
|
+
(100, 200),
|
|
159
|
+
np.array([0.0, 0.0]),
|
|
160
|
+
np.pi,
|
|
161
|
+
"shift_then_rotate",
|
|
162
|
+
np.array([[-1.0, 0.0, 99.0], [0.0, -1.0, 199.0], [0.0, 0.0, 1.0]]),
|
|
163
|
+
id="180_rotation_about_center",
|
|
164
|
+
),
|
|
165
|
+
pytest.param(
|
|
166
|
+
(100, 100),
|
|
167
|
+
np.array([5.0, -3.0]),
|
|
168
|
+
0.0,
|
|
169
|
+
"shift_then_rotate",
|
|
170
|
+
np.array([[1.0, 0.0, -5.0], [0.0, 1.0, 3.0], [0.0, 0.0, 1.0]]),
|
|
171
|
+
id="pure_translation",
|
|
172
|
+
),
|
|
173
|
+
pytest.param(
|
|
174
|
+
(100, 100),
|
|
175
|
+
np.array([0.0, 0.0]),
|
|
176
|
+
np.pi / 2,
|
|
177
|
+
"shift_then_rotate",
|
|
178
|
+
np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 99.0], [0.0, 0.0, 1.0]]),
|
|
179
|
+
id="90_deg_rotation",
|
|
180
|
+
),
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
def test_affine_inverse_transform_matrix_hand_derived(
|
|
184
|
+
array_shape, shift, angle, order, expected_matrix
|
|
185
|
+
):
|
|
186
|
+
"""
|
|
187
|
+
Given: 2D array shape / shift / angle / order combinations with easy-to-derive-by-hand expected matrices
|
|
188
|
+
When: building the homogeneous inverse transform matrix
|
|
189
|
+
Then: the result matches the hand-derived expectation
|
|
190
|
+
"""
|
|
191
|
+
result = affine_inverse_transform_matrix(array_shape, shift, angle, order=order)
|
|
192
|
+
np.testing.assert_allclose(result, expected_matrix, atol=1e-10)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_affine_inverse_transform_matrix_rejects_invalid_order():
|
|
196
|
+
"""
|
|
197
|
+
Given: an `order` value that isn't one of the two recognized options
|
|
198
|
+
When: building the homogeneous inverse transform matrix
|
|
199
|
+
Then: a ValueError is raised, rather than silently falling back to some default behavior
|
|
200
|
+
"""
|
|
201
|
+
with pytest.raises(ValueError, match="order must be"):
|
|
202
|
+
affine_inverse_transform_matrix(
|
|
203
|
+
(10, 10), np.array([0.0, 0.0]), 0.0, order="not_a_real_order"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_affine_inverse_transform_matrix_requires_2d():
|
|
208
|
+
"""
|
|
209
|
+
Given: an array_shape with more than 2 dimensions
|
|
210
|
+
When: building the homogeneous inverse transform matrix
|
|
211
|
+
Then: a ValueError is raised
|
|
212
|
+
"""
|
|
213
|
+
with pytest.raises(ValueError, match="array_shape must be 2D"):
|
|
214
|
+
affine_inverse_transform_matrix((10, 10, 10), np.zeros(3), 0.3, order="shift_then_rotate")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.mark.parametrize(
|
|
218
|
+
"shift",
|
|
219
|
+
[
|
|
220
|
+
pytest.param(5.0, id="scalar_shift"),
|
|
221
|
+
pytest.param(np.array([5.0]), id="length_one_shift"),
|
|
222
|
+
pytest.param(np.array([1.0, 2.0, 3.0]), id="wrong_length_shift"),
|
|
223
|
+
],
|
|
224
|
+
)
|
|
225
|
+
def test_affine_inverse_transform_matrix_rejects_mismatched_shift(shift):
|
|
226
|
+
"""
|
|
227
|
+
Given: a `shift` whose shape doesn't match `array_shape`'s dimensionality -- including scalar
|
|
228
|
+
and length-1 shifts, which would otherwise silently broadcast rather than raising
|
|
229
|
+
When: building the homogeneous inverse transform matrix for a 2D array_shape
|
|
230
|
+
Then: a ValueError is raised
|
|
231
|
+
"""
|
|
232
|
+
with pytest.raises(ValueError, match="shift must have shape"):
|
|
233
|
+
affine_inverse_transform_matrix((10, 10), shift, 0.0, order="shift_then_rotate")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_apply_affine_transform_identity():
|
|
237
|
+
"""
|
|
238
|
+
Given: a zero shift and zero rotation
|
|
239
|
+
When: applying the correction
|
|
240
|
+
Then: the output array is numerically identical to the input
|
|
241
|
+
"""
|
|
242
|
+
array = np.arange(100.0).reshape(10, 10)
|
|
243
|
+
result = next(
|
|
244
|
+
apply_affine_transform(array, np.array([0.0, 0.0]), 0.0, order="shift_then_rotate")
|
|
245
|
+
)
|
|
246
|
+
np.testing.assert_allclose(result, array, atol=1e-10)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_apply_affine_transform_single_array_wrapped():
|
|
250
|
+
"""
|
|
251
|
+
Given: a single ndarray (not wrapped in a list)
|
|
252
|
+
When: applying the correction
|
|
253
|
+
Then: it is accepted and the generator yields exactly one result
|
|
254
|
+
"""
|
|
255
|
+
array = np.zeros((5, 5))
|
|
256
|
+
results = list(
|
|
257
|
+
apply_affine_transform(array, np.array([0.0, 0.0]), 0.0, order="shift_then_rotate")
|
|
258
|
+
)
|
|
259
|
+
assert len(results) == 1
|
|
260
|
+
assert results[0].shape == (5, 5)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_apply_affine_transform_nonfinite_values_replaced():
|
|
264
|
+
"""
|
|
265
|
+
Given: an input array containing NaN and ±inf
|
|
266
|
+
When: applying the correction
|
|
267
|
+
Then: the output is fully finite (NaN/inf do not survive interpolation)
|
|
268
|
+
"""
|
|
269
|
+
array = np.ones((10, 10))
|
|
270
|
+
array[0, 0] = np.nan
|
|
271
|
+
array[0, 1] = np.inf
|
|
272
|
+
array[0, 2] = -np.inf
|
|
273
|
+
result = next(
|
|
274
|
+
apply_affine_transform(array, np.array([0.0, 0.0]), 0.0, order="shift_then_rotate")
|
|
275
|
+
)
|
|
276
|
+
assert np.isfinite(result).all()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_apply_affine_transform_hand_derived_shift():
|
|
280
|
+
"""
|
|
281
|
+
Given: a distinctive single-pixel array and a pure integer shift (no rotation)
|
|
282
|
+
When: applying the correction with shift_then_rotate
|
|
283
|
+
Then: the bright pixel lands exactly where hand-derived arithmetic says it should
|
|
284
|
+
|
|
285
|
+
With shift=[2, 3] and no rotation, a pixel at (row=1, col=1) moves to (row=3, col=4).
|
|
286
|
+
This directly tests that the sign convention is correct end-to-end -- that a positive
|
|
287
|
+
shift moves content in the positive direction -- with no ambiguity from interpolation.
|
|
288
|
+
"""
|
|
289
|
+
array = np.zeros((10, 10))
|
|
290
|
+
array[1, 1] = 1.0
|
|
291
|
+
shift = np.array([2.0, 3.0])
|
|
292
|
+
|
|
293
|
+
result = next(apply_affine_transform(array, shift, 0.0, order="shift_then_rotate"))
|
|
294
|
+
|
|
295
|
+
assert result[3, 4] == pytest.approx(1.0)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_apply_affine_transform_yields_one_per_input():
|
|
299
|
+
"""
|
|
300
|
+
Given: a list of N arrays
|
|
301
|
+
When: applying the correction
|
|
302
|
+
Then: the generator yields exactly N arrays, each with the correct shape
|
|
303
|
+
"""
|
|
304
|
+
arrays = [np.ones((8, 8)) * i for i in range(3)]
|
|
305
|
+
results = list(
|
|
306
|
+
apply_affine_transform(arrays, np.array([0.0, 0.0]), 0.0, order="shift_then_rotate")
|
|
307
|
+
)
|
|
308
|
+
assert len(results) == 3
|
|
309
|
+
assert all(r.shape == (8, 8) for r in results)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_apply_affine_transform_hand_derived_rotation():
|
|
313
|
+
"""
|
|
314
|
+
Given: a distinctive single-pixel array and a 90° CCW rotation (no shift)
|
|
315
|
+
When: applying the transform with shift_then_rotate
|
|
316
|
+
Then: the bright pixel lands exactly where hand-derived arithmetic says it should
|
|
317
|
+
|
|
318
|
+
With angle=π/2 and no shift, a pixel at (row=1, col=7) in a 10×10 array (center=(4.5, 4.5))
|
|
319
|
+
rotates CCW to (row=2, col=1). Derivation: the pull-back maps output (r', c') to input
|
|
320
|
+
(c', 9−r'), so output[2, 1] samples input[1, 7].
|
|
321
|
+
"""
|
|
322
|
+
array = np.zeros((10, 10))
|
|
323
|
+
array[1, 7] = 1.0
|
|
324
|
+
|
|
325
|
+
result = next(
|
|
326
|
+
apply_affine_transform(array, np.array([0.0, 0.0]), np.pi / 2, order="shift_then_rotate")
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
assert result[2, 1] == pytest.approx(1.0)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_apply_affine_transform_hand_derived_shift_and_rotate():
|
|
333
|
+
"""
|
|
334
|
+
Given: a distinctive single-pixel array, a shift of [1, 0], and a 90° CCW rotation
|
|
335
|
+
When: applying shift_then_rotate
|
|
336
|
+
Then: the bright pixel lands exactly where hand-derived arithmetic says it should
|
|
337
|
+
|
|
338
|
+
Shift [1, 0] moves (1, 7) → (2, 7); 90° CCW about center (4.5, 4.5) then moves
|
|
339
|
+
(2, 7) → (2, 2). The pull-back for output (2, 2): after un-centering, applying CW
|
|
340
|
+
rotation, re-centering, and un-shifting gives input (1, 7).
|
|
341
|
+
"""
|
|
342
|
+
array = np.zeros((10, 10))
|
|
343
|
+
array[1, 7] = 1.0
|
|
344
|
+
|
|
345
|
+
result = next(
|
|
346
|
+
apply_affine_transform(array, np.array([1.0, 0.0]), np.pi / 2, order="shift_then_rotate")
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
assert result[2, 2] == pytest.approx(1.0)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_affine_inverse_transform_matrix_matches_numpy_inverse():
|
|
353
|
+
"""
|
|
354
|
+
Given: a known shift and rotation angle
|
|
355
|
+
When: computing the inverse transform matrix
|
|
356
|
+
Then: the result matches np.linalg.inv of the hand-composed forward matrix
|
|
357
|
+
|
|
358
|
+
This verifies the matrix algebra is correct end-to-end, independently of any
|
|
359
|
+
hand-derived expected values.
|
|
360
|
+
"""
|
|
361
|
+
shape = (11, 21)
|
|
362
|
+
shift = np.array([3.0, -5.0])
|
|
363
|
+
angle = np.pi / 4
|
|
364
|
+
|
|
365
|
+
cx, cy = (shape[0] - 1) / 2.0, (shape[1] - 1) / 2.0 # 5.0, 10.0
|
|
366
|
+
cos_a, sin_a = np.cos(angle), np.sin(angle)
|
|
367
|
+
|
|
368
|
+
# Hand-composed forward matrix for shift_then_rotate:
|
|
369
|
+
# T(center) @ R_fwd @ T(-center) @ T(shift)
|
|
370
|
+
R_fwd = np.array([[cos_a, -sin_a, 0.0], [sin_a, cos_a, 0.0], [0.0, 0.0, 1.0]])
|
|
371
|
+
T_cen = np.array([[1.0, 0.0, cx], [0.0, 1.0, cy], [0.0, 0.0, 1.0]])
|
|
372
|
+
T_neg_cen = np.array([[1.0, 0.0, -cx], [0.0, 1.0, -cy], [0.0, 0.0, 1.0]])
|
|
373
|
+
T_s = np.array([[1.0, 0.0, shift[0]], [0.0, 1.0, shift[1]], [0.0, 0.0, 1.0]])
|
|
374
|
+
forward = T_cen @ R_fwd @ T_neg_cen @ T_s
|
|
375
|
+
|
|
376
|
+
result = affine_inverse_transform_matrix(shape, shift, angle, order="shift_then_rotate")
|
|
377
|
+
np.testing.assert_allclose(result, np.linalg.inv(forward), atol=1e-10)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_make_binary_image():
|
|
381
|
+
"""
|
|
382
|
+
Given: a single numpy array
|
|
383
|
+
When: generating a binary image for values in the array below a threshold
|
|
384
|
+
Then: a correct binary array results from the generation
|
|
385
|
+
"""
|
|
386
|
+
input_array = np.ones((10, 10), dtype=float)
|
|
387
|
+
input_array[:5, :] += 1.0
|
|
388
|
+
result = make_binary(input_array) # Something ridiculous
|
|
389
|
+
assert isinstance(result, np.ndarray)
|
|
390
|
+
|
|
391
|
+
expected = np.ones((10, 10), dtype=int)
|
|
392
|
+
expected[:5, :] = 0
|
|
393
|
+
np.testing.assert_equal(result, expected)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_make_binary_trigger_cutoff():
|
|
397
|
+
"""
|
|
398
|
+
Given: a single numpy array
|
|
399
|
+
When: calling make_binary in such a way that the result will converge to all 0s or all 1s
|
|
400
|
+
Then: make_binary returns before a single value array is created
|
|
401
|
+
"""
|
|
402
|
+
rng = np.random.default_rng()
|
|
403
|
+
input_array = rng.standard_normal((100, 100))
|
|
404
|
+
result = make_binary(input_array, numotsu=100)
|
|
405
|
+
frac = np.sum(result) / result.size
|
|
406
|
+
assert frac >= 1e-3
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_do_hough(numpy_array):
|
|
410
|
+
"""
|
|
411
|
+
Given: a single numpy array
|
|
412
|
+
When: performing a hough line transformation
|
|
413
|
+
Then: return the correct hough space, array of angle values, and array of distance values
|
|
414
|
+
"""
|
|
415
|
+
binary = make_binary(numpy_array)
|
|
416
|
+
|
|
417
|
+
# Rho and Theta ranges
|
|
418
|
+
thetas = np.linspace(-np.pi / 4, np.pi / 4, 1500)
|
|
419
|
+
width, height = binary.shape
|
|
420
|
+
diag_len = np.ceil(np.sqrt(width * width + height * height)) # max_dist
|
|
421
|
+
diag_len = int(diag_len)
|
|
422
|
+
num_rhos = 2 * diag_len + 1
|
|
423
|
+
rhos = np.linspace(-diag_len, diag_len, num_rhos)
|
|
424
|
+
# Cache some reusable values
|
|
425
|
+
cos_t = np.cos(thetas)
|
|
426
|
+
sin_t = np.sin(thetas)
|
|
427
|
+
num_thetas = len(thetas)
|
|
428
|
+
# Hough accumulator array of theta vs rho
|
|
429
|
+
accumulator = np.zeros((num_rhos, num_thetas), dtype=np.uint64)
|
|
430
|
+
y_idxs, x_idxs = np.nonzero(binary) # (row, col) indexes to edges
|
|
431
|
+
for i in range(len(x_idxs)):
|
|
432
|
+
x = x_idxs[i]
|
|
433
|
+
y = y_idxs[i]
|
|
434
|
+
for t_idx in range(num_thetas):
|
|
435
|
+
# Calculate rho. diag_len is added for a positive index
|
|
436
|
+
rho = round(x * cos_t[t_idx] + y * sin_t[t_idx]) + diag_len
|
|
437
|
+
accumulator[rho, t_idx] += 1
|
|
438
|
+
desired_H = accumulator
|
|
439
|
+
desired_thetas = thetas
|
|
440
|
+
desired_rhos = rhos
|
|
441
|
+
assert isinstance(desired_H, np.ndarray)
|
|
442
|
+
assert isinstance(desired_thetas, np.ndarray)
|
|
443
|
+
assert isinstance(desired_rhos, np.ndarray)
|
|
444
|
+
|
|
445
|
+
result_H, result_theta, result_rho = do_hough(binary, theta_min=-np.pi / 4, theta_max=np.pi / 4)
|
|
446
|
+
assert isinstance(result_H, np.ndarray)
|
|
447
|
+
assert isinstance(result_theta, np.ndarray)
|
|
448
|
+
assert isinstance(result_rho, np.ndarray)
|
|
449
|
+
|
|
450
|
+
np.testing.assert_allclose(result_H, desired_H)
|
|
451
|
+
np.testing.assert_allclose(result_theta, desired_thetas)
|
|
452
|
+
np.testing.assert_allclose(result_rho, desired_rhos)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""init."""
|
|
2
|
+
|
|
3
|
+
from .affine import affine_inverse_transform_matrix
|
|
4
|
+
from .affine import apply_affine_transform
|
|
5
|
+
from .binary import make_binary
|
|
6
|
+
from .binning import bin_arrays
|
|
7
|
+
from .hough import do_hough
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"affine_inverse_transform_matrix",
|
|
11
|
+
"apply_affine_transform",
|
|
12
|
+
"make_binary",
|
|
13
|
+
"bin_arrays",
|
|
14
|
+
"do_hough",
|
|
15
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Affine transforms."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import scipy.ndimage as spnd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def affine_inverse_transform_matrix(
|
|
12
|
+
array_shape: tuple[int, ...],
|
|
13
|
+
shift: np.ndarray,
|
|
14
|
+
angle: float,
|
|
15
|
+
order: Literal["rotate_then_shift", "shift_then_rotate"],
|
|
16
|
+
) -> np.ndarray:
|
|
17
|
+
"""
|
|
18
|
+
Build the homogeneous-coordinates inverse transform matrix for a center-preserving rotation and shift.
|
|
19
|
+
|
|
20
|
+
This is the matrix form needed by `scipy.ndimage.affine_transform`'s `matrix` kwarg
|
|
21
|
+
to apply a rotation about the array's center and a shift in a single pass. A positive
|
|
22
|
+
angle rotates content counter-clockwise (the conventional mathematical direction).
|
|
23
|
+
The `order` argument controls which forward operation is being modeled (see below).
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
array_shape
|
|
28
|
+
The shape of the 2D array the transform will be applied to.
|
|
29
|
+
|
|
30
|
+
shift
|
|
31
|
+
The shift to apply, in pixels, with length `ndim` (matching `array_shape`). A positive
|
|
32
|
+
`shift` moves the array's content in the positive direction along each axis (e.g.
|
|
33
|
+
``shift=[0, 1]`` moves content one pixel toward higher column indices).
|
|
34
|
+
|
|
35
|
+
angle
|
|
36
|
+
The rotation angle, in radians.
|
|
37
|
+
|
|
38
|
+
order
|
|
39
|
+
Whether the modeled forward operation shifts first and then rotates
|
|
40
|
+
(``"shift_then_rotate"``) or rotates first and then shifts (``"rotate_then_shift"``).
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
A ``(3, 3)`` homogeneous transform matrix encoding the rotation and shift together.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
ndim = len(array_shape)
|
|
48
|
+
|
|
49
|
+
shift = np.asarray(shift, dtype=np.float64)
|
|
50
|
+
if shift.shape != (ndim,):
|
|
51
|
+
raise ValueError(f"shift must have shape ({ndim},) to match array_shape, got {shift.shape}")
|
|
52
|
+
|
|
53
|
+
if order not in ("rotate_then_shift", "shift_then_rotate"):
|
|
54
|
+
raise ValueError(f"order must be 'rotate_then_shift' or 'shift_then_rotate', got {order!r}")
|
|
55
|
+
|
|
56
|
+
if ndim != 2:
|
|
57
|
+
raise ValueError(f"array_shape must be 2D; got {ndim = }.")
|
|
58
|
+
|
|
59
|
+
center = 0.5 * (np.asarray(array_shape, dtype=np.float64) - 1.0)
|
|
60
|
+
|
|
61
|
+
# This function returns the pull-back (inverse) of the requested forward transform, because
|
|
62
|
+
# `scipy.ndimage.affine_transform` maps each *output* pixel coordinate back to the *input*
|
|
63
|
+
# coordinate to sample. The inverse of T(v) is T(-v); the inverse of rotation R is R(-angle).
|
|
64
|
+
# Each matrix below uses its inverse form directly, so the negations are visible at the
|
|
65
|
+
# construction site rather than applied to the variables earlier in the function.
|
|
66
|
+
R = _rotation_matrix(-angle) # R_fwd^-1
|
|
67
|
+
T_uncenter = _translation_matrix(center) # T_fwd(-center)^-1 = T(+center)
|
|
68
|
+
T_center = _translation_matrix(-center) # T_fwd(+center)^-1 = T(-center)
|
|
69
|
+
T_shift = _translation_matrix(-shift) # T_fwd(+shift)^-1 = T(-shift)
|
|
70
|
+
|
|
71
|
+
if order == "shift_then_rotate":
|
|
72
|
+
# Inverse of: T(+center) @ R_fwd @ T(-center) @ T(+shift)
|
|
73
|
+
return T_shift @ T_uncenter @ R @ T_center
|
|
74
|
+
else: # order == "rotate_then_shift"
|
|
75
|
+
# Inverse of: T(+shift) @ T(+center) @ R_fwd @ T(-center)
|
|
76
|
+
return T_uncenter @ R @ T_center @ T_shift
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def apply_affine_transform(
|
|
80
|
+
arrays: Iterable[np.ndarray] | np.ndarray,
|
|
81
|
+
shift: np.ndarray,
|
|
82
|
+
angle: float,
|
|
83
|
+
order: Literal["rotate_then_shift", "shift_then_rotate"],
|
|
84
|
+
interp_order: int = 1,
|
|
85
|
+
mode: str = "nearest",
|
|
86
|
+
cval: float = np.nan,
|
|
87
|
+
) -> Generator[np.ndarray, None, None]:
|
|
88
|
+
"""
|
|
89
|
+
Apply a center-preserving rotation and shift to each input array.
|
|
90
|
+
|
|
91
|
+
Builds a single homogeneous-coordinates inverse transform matrix (via
|
|
92
|
+
`affine_inverse_transform_matrix`) and applies it with one
|
|
93
|
+
`scipy.ndimage.affine_transform` call per array, resolving both the rotation and shift in a
|
|
94
|
+
single interpolation pass. Non-finite values (NaN, ±inf) are replaced before interpolation so
|
|
95
|
+
that they don't spread during resampling.
|
|
96
|
+
|
|
97
|
+
The transform matrix is recomputed only when the array shape changes, so passing a sequence of
|
|
98
|
+
same-shaped arrays (the typical case) incurs only one matrix build.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
arrays
|
|
103
|
+
One or more arrays to correct. A single `np.ndarray` is wrapped automatically.
|
|
104
|
+
|
|
105
|
+
shift
|
|
106
|
+
The shift to apply, in pixels. See `affine_inverse_transform_matrix` for convention.
|
|
107
|
+
|
|
108
|
+
angle
|
|
109
|
+
The rotation angle, in radians. See `affine_inverse_transform_matrix` for details.
|
|
110
|
+
|
|
111
|
+
order
|
|
112
|
+
Whether the modeled forward operation shifts first then rotates (``"shift_then_rotate"``)
|
|
113
|
+
or rotates first then shifts (``"rotate_then_shift"``).
|
|
114
|
+
|
|
115
|
+
interp_order
|
|
116
|
+
Spline interpolation order for `scipy.ndimage.affine_transform`. Must be 0–5.
|
|
117
|
+
0 = nearest-neighbor, 1 = bilinear (default), …, 5 = quintic.
|
|
118
|
+
|
|
119
|
+
mode
|
|
120
|
+
How points outside the array boundary are handled. Passed directly to
|
|
121
|
+
`scipy.ndimage.affine_transform`. Default ``"nearest"``.
|
|
122
|
+
|
|
123
|
+
cval
|
|
124
|
+
Fill value used when ``mode="constant"``. Default `np.nan`.
|
|
125
|
+
|
|
126
|
+
Yields
|
|
127
|
+
------
|
|
128
|
+
np.ndarray
|
|
129
|
+
Corrected arrays, one per input, each with the same shape as its input.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
arrays = [arrays] if isinstance(arrays, np.ndarray) else arrays
|
|
133
|
+
|
|
134
|
+
previous_shape = None
|
|
135
|
+
matrix = None
|
|
136
|
+
|
|
137
|
+
for array in arrays:
|
|
138
|
+
array = np.asarray(array, dtype=np.float64)
|
|
139
|
+
|
|
140
|
+
# Replace non-finite values before interpolation; affine_transform spreads NaN/inf.
|
|
141
|
+
finite_mask = np.isfinite(array)
|
|
142
|
+
if not finite_mask.all():
|
|
143
|
+
finite_values = array[finite_mask]
|
|
144
|
+
if finite_values.size == 0:
|
|
145
|
+
# Entire array is non-finite -- fill with a safe scalar.
|
|
146
|
+
array.fill(cval if np.isfinite(cval) else 0.0)
|
|
147
|
+
else:
|
|
148
|
+
pos_inf = np.isposinf(array)
|
|
149
|
+
if pos_inf.any():
|
|
150
|
+
array[pos_inf] = finite_values.max()
|
|
151
|
+
neg_inf = np.isneginf(array)
|
|
152
|
+
if neg_inf.any():
|
|
153
|
+
array[neg_inf] = finite_values.min()
|
|
154
|
+
nan_mask = np.isnan(array)
|
|
155
|
+
if nan_mask.any():
|
|
156
|
+
array[nan_mask] = np.nanmedian(array)
|
|
157
|
+
|
|
158
|
+
if array.shape != previous_shape:
|
|
159
|
+
matrix = affine_inverse_transform_matrix(array.shape, shift, angle, order=order)
|
|
160
|
+
previous_shape = array.shape
|
|
161
|
+
|
|
162
|
+
yield spnd.affine_transform(
|
|
163
|
+
array,
|
|
164
|
+
matrix=matrix,
|
|
165
|
+
order=interp_order,
|
|
166
|
+
mode=mode,
|
|
167
|
+
cval=cval,
|
|
168
|
+
prefilter=(interp_order > 1),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _rotation_matrix(angle: float) -> np.ndarray:
|
|
173
|
+
"""Build a (3, 3) homogeneous rotation matrix for the given angle in radians."""
|
|
174
|
+
cosine_angle, sine_angle = np.cos(angle), np.sin(angle)
|
|
175
|
+
return np.array(
|
|
176
|
+
[[cosine_angle, -sine_angle, 0.0], [sine_angle, cosine_angle, 0.0], [0.0, 0.0, 1.0]],
|
|
177
|
+
dtype=np.float64,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _translation_matrix(shift: np.ndarray) -> np.ndarray:
|
|
182
|
+
"""Build an (ndim+1, ndim+1) homogeneous pure-translation matrix."""
|
|
183
|
+
ndim = len(shift)
|
|
184
|
+
m = np.eye(ndim + 1, dtype=np.float64)
|
|
185
|
+
m[:ndim, ndim] = shift
|
|
186
|
+
return m
|
|
@@ -7,6 +7,8 @@ README.rst
|
|
|
7
7
|
bitbucket-pipelines.yml
|
|
8
8
|
pyproject.toml
|
|
9
9
|
changelog/.gitempty
|
|
10
|
+
changelog/27.feature.rst
|
|
11
|
+
changelog/27.removal.rst
|
|
10
12
|
dkist_processing_math/__init__.py
|
|
11
13
|
dkist_processing_math/arithmetic.py
|
|
12
14
|
dkist_processing_math/feature.py
|
|
@@ -16,6 +18,8 @@ dkist_processing_math.egg-info/PKG-INFO
|
|
|
16
18
|
dkist_processing_math.egg-info/SOURCES.txt
|
|
17
19
|
dkist_processing_math.egg-info/dependency_links.txt
|
|
18
20
|
dkist_processing_math.egg-info/requires.txt
|
|
21
|
+
dkist_processing_math.egg-info/scm_file_list.json
|
|
22
|
+
dkist_processing_math.egg-info/scm_version.json
|
|
19
23
|
dkist_processing_math.egg-info/top_level.txt
|
|
20
24
|
dkist_processing_math/tests/__init__.py
|
|
21
25
|
dkist_processing_math/tests/conftest.py
|