dkist-processing-math 2.2.2__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.
Files changed (48) hide show
  1. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/CHANGELOG.rst +9 -0
  2. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/PKG-INFO +1 -1
  3. dkist_processing_math-3.0.0rc1/changelog/27.feature.rst +1 -0
  4. dkist_processing_math-3.0.0rc1/changelog/27.removal.rst +1 -0
  5. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/arithmetic.py +19 -0
  6. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_arithmetic.py +54 -1
  7. dkist_processing_math-3.0.0rc1/dkist_processing_math/tests/test_transform.py +452 -0
  8. dkist_processing_math-3.0.0rc1/dkist_processing_math/transform/__init__.py +15 -0
  9. dkist_processing_math-3.0.0rc1/dkist_processing_math/transform/affine.py +186 -0
  10. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/PKG-INFO +1 -1
  11. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/SOURCES.txt +4 -0
  12. dkist_processing_math-3.0.0rc1/dkist_processing_math.egg-info/scm_file_list.json +40 -0
  13. dkist_processing_math-3.0.0rc1/dkist_processing_math.egg-info/scm_version.json +8 -0
  14. dkist_processing_math-2.2.2/dkist_processing_math/tests/test_transform.py +0 -548
  15. dkist_processing_math-2.2.2/dkist_processing_math/transform/__init__.py +0 -25
  16. dkist_processing_math-2.2.2/dkist_processing_math/transform/affine.py +0 -261
  17. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/.gitignore +0 -0
  18. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/.pre-commit-config.yaml +0 -0
  19. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/.readthedocs.yml +0 -0
  20. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/.snyk +0 -0
  21. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/README.rst +0 -0
  22. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/bitbucket-pipelines.yml +0 -0
  23. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/changelog/.gitempty +0 -0
  24. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/__init__.py +0 -0
  25. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/feature.py +0 -0
  26. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/linear_algebra.py +0 -0
  27. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/statistics.py +0 -0
  28. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/__init__.py +0 -0
  29. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/conftest.py +0 -0
  30. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_feature.py +0 -0
  31. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_linear_algebra.py +0 -0
  32. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/tests/test_statistics.py +0 -0
  33. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/transform/binary.py +0 -0
  34. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/transform/binning.py +0 -0
  35. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math/transform/hough.py +0 -0
  36. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/dependency_links.txt +0 -0
  37. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/requires.txt +0 -0
  38. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/dkist_processing_math.egg-info/top_level.txt +0 -0
  39. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/docs/Makefile +0 -0
  40. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/docs/changelog.rst +0 -0
  41. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/docs/conf.py +0 -0
  42. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/docs/index.rst +0 -0
  43. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/docs/landing_page.rst +0 -0
  44. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/docs/make.bat +0 -0
  45. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/docs/requirements.txt +0 -0
  46. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/licenses/LICENSE.rst +0 -0
  47. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/pyproject.toml +0 -0
  48. {dkist_processing_math-2.2.2 → dkist_processing_math-3.0.0rc1}/setup.cfg +0 -0
@@ -1,3 +1,12 @@
1
+ v2.2.3 (2026-06-15)
2
+ ===================
3
+
4
+ Features
5
+ --------
6
+
7
+ - Add new method to find the closest datetime in a list to a target datetime. (`#26 <https://bitbucket.org/dkistdc/dkist-processing-math/pull-requests/26>`__)
8
+
9
+
1
10
  v2.2.2 (2026-04-16)
2
11
  ===================
3
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dkist-processing-math
3
- Version: 2.2.2
3
+ Version: 3.0.0rc1
4
4
  Summary: Common math functionality used in the DKIST calibrations suite
5
5
  Author-email: NSO / AURA <dkistdc@nso.edu>
6
6
  License: BSD-3-Clause
@@ -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``).
@@ -1,5 +1,6 @@
1
1
  """Arithmetic."""
2
2
 
3
+ from datetime import datetime
3
4
  from typing import Generator
4
5
  from typing import Iterable
5
6
  from typing import Union
@@ -124,3 +125,21 @@ def divide_fits_access_by_array(
124
125
  for obj in access_objs:
125
126
  obj.data = next(divide_arrays_by_array(obj.data, array_to_divide_by))
126
127
  yield obj
128
+
129
+
130
+ def closest_datetime(
131
+ target_datetime: str | datetime, datetimes: list[str | datetime] | str | datetime
132
+ ) -> str | datetime:
133
+ """Find the closest datetime to a target datetime. Return type is of the same type as the datetimes list."""
134
+ if isinstance(target_datetime, str):
135
+ target_datetime = datetime.fromisoformat(target_datetime)
136
+ if isinstance(datetimes, str) or isinstance(datetimes, datetime):
137
+ datetimes = [datetimes]
138
+ converted_datetimes = [
139
+ datetime.fromisoformat(d) if isinstance(d, str) else d for d in datetimes
140
+ ]
141
+ closest_index, _ = min(
142
+ enumerate(converted_datetimes),
143
+ key=lambda dt: abs(dt[1] - target_datetime),
144
+ )
145
+ return datetimes[closest_index]
@@ -1,3 +1,4 @@
1
+ from datetime import datetime
1
2
  from typing import Generator
2
3
 
3
4
  import numpy as np
@@ -5,6 +6,7 @@ import pytest
5
6
  from astropy.io import fits
6
7
  from dkist_processing_common.models.fits_access import FitsAccessBase
7
8
 
9
+ from dkist_processing_math.arithmetic import closest_datetime
8
10
  from dkist_processing_math.arithmetic import divide_arrays_by_array
9
11
  from dkist_processing_math.arithmetic import divide_fits_access_by_array
10
12
  from dkist_processing_math.arithmetic import subtract_array_from_arrays
@@ -18,7 +20,7 @@ def multiple_test_fits_access(request, numpy_arrays):
18
20
  for i, array in enumerate(numpy_arrays):
19
21
  hdu = fits.PrimaryHDU(data=array)
20
22
  hdu.header["TEST"] = i
21
- access_list.append(FitsAccessBase(hdu=hdu))
23
+ access_list.append(FitsAccessBase(header=hdu.header, data=array))
22
24
  if request.param == "multiple":
23
25
  return access_list
24
26
  return access_list[0]
@@ -126,3 +128,54 @@ def test_divide_arrays_by_array_wrong_shape(numpy_arrays_wrong_shape, numpy_arra
126
128
  """
127
129
  with pytest.raises(ValueError):
128
130
  list(divide_arrays_by_array(numpy_arrays_wrong_shape, numpy_array))
131
+
132
+
133
+ @pytest.mark.parametrize(
134
+ "datetimes, expected_result",
135
+ [
136
+ pytest.param(
137
+ ["2025-12-31T00:00:00", "2026-01-07T00:00:00"],
138
+ "2025-12-31T00:00:00",
139
+ id="multiple solar gain ops",
140
+ ),
141
+ pytest.param("2025-12-31T00:00:00", "2025-12-31T00:00:00", id="single solar gain op"),
142
+ ],
143
+ )
144
+ def test_closest_datetime_with_strings(datetimes, expected_result):
145
+ """
146
+ Given: a target datetime and a list of string datetimes
147
+ When: running the closest_datetime method
148
+ Then: the closest datetime from the list of datetimes is returned
149
+ """
150
+ result = closest_datetime(
151
+ target_datetime="2026-01-01T00:00:00",
152
+ datetimes=datetimes,
153
+ )
154
+ assert result == expected_result
155
+
156
+
157
+ @pytest.mark.parametrize(
158
+ "datetimes, expected_result",
159
+ [
160
+ pytest.param(
161
+ [
162
+ datetime(2025, 12, 31),
163
+ datetime(2026, 1, 7),
164
+ ],
165
+ datetime(2025, 12, 31),
166
+ id="multiple solar gain ops",
167
+ ),
168
+ pytest.param(datetime(2025, 12, 31), datetime(2025, 12, 31), id="single solar gain op"),
169
+ ],
170
+ )
171
+ def test_closest_datetime_with_datetimes(datetimes, expected_result):
172
+ """
173
+ Given: a target datetime and a list of datetimes
174
+ When: running the closest_datetime method
175
+ Then: the closest datetime from the list of datetimes is returned
176
+ """
177
+ result = closest_datetime(
178
+ target_datetime=datetime(2026, 1, 1),
179
+ datetimes=datetimes,
180
+ )
181
+ assert result == expected_result
@@ -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
+ ]