Coverage for tests/test_image.py: 6%
375 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 01:23 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 01:23 -0700
1# This file is part of lsst.scarlet.lite.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import operator
24import numpy as np
25from lsst.scarlet.lite import Box, Image
26from lsst.scarlet.lite.image import MismatchedBandsError, MismatchedBoxError
27from numpy.testing import assert_almost_equal, assert_array_equal
28from utils import ScarletTestCase
31class TestImage(ScarletTestCase):
32 def test_constructors(self):
33 # Default constructor
34 data = np.arange(12).reshape(3, 4) # type: ignore
35 image = Image(data)
36 self.assertEqual(image.dtype, int)
37 self.assertTupleEqual(image.bands, ())
38 self.assertEqual(image.n_bands, 0)
39 assert_array_equal(image.shape, (3, 4))
40 self.assertEqual(image.height, 3)
41 self.assertEqual(image.width, 4)
42 assert_array_equal(image.yx0, (0, 0))
43 self.assertEqual(image.y0, 0)
44 self.assertEqual(image.x0, 0)
45 self.assertBoxEqual(image.bbox, Box((3, 4), (0, 0)))
46 assert_array_equal(image.data, data)
47 self.assertIsInstance(image.data, np.ndarray)
48 self.assertNotIsInstance(image.data, Image)
50 # Test constructor with all parameters
51 data = np.arange(24, dtype=float).reshape(2, 3, 4) # type: ignore
52 bands = ("g", "i")
53 y0, x0 = 10, 15
54 image = Image(
55 data,
56 bands=bands,
57 yx0=(y0, x0),
58 )
59 self.assertEqual(image.dtype, float)
60 assert_array_equal(image.bands, bands)
61 self.assertEqual(image.n_bands, 2)
62 assert_array_equal(image.shape, (2, 3, 4))
63 self.assertEqual(image.height, 3)
64 self.assertEqual(image.width, 4)
65 assert_array_equal(image.yx0, (10, 15))
66 self.assertEqual(image.y0, 10)
67 self.assertEqual(image.x0, 15)
68 self.assertBoxEqual(image.bbox, Box((3, 4), (10, 15)))
69 assert_array_equal(image.data, data)
70 self.assertIsInstance(image.data, np.ndarray)
71 self.assertNotIsInstance(image.data, Image)
73 # test initializing an empty image from a bounding box
74 image = Image.from_box(Box((10, 10), (13, 50)))
75 self.assertImageEqual(image, Image(np.zeros((10, 10), dtype=float), bands=(), yx0=(13, 50)))
76 bands = ("g", "r", "i")
77 image = Image.from_box(Box((10, 10), (13, 50)), bands=bands)
78 self.assertImageEqual(image, Image(np.zeros((3, 10, 10), dtype=float), bands=bands, yx0=(13, 50)))
80 with self.assertRaises(ValueError):
81 Image(np.zeros((3, 4, 5)), bands=tuple("gr"))
83 truth = "Image:\n [[[0 1 2]\n [3 4 5]]]\n bands=('g',)\n bbox=Box(shape=(2, 3), origin=(3, 2))"
84 data = np.arange(6).reshape(1, 2, 3)
85 bands = tuple("g")
86 yx0 = (3, 2)
87 image = Image(data, bands=bands, yx0=yx0)
88 self.assertEqual(str(image), truth)
90 def _binary_operation_test(
91 self,
92 lower_data: np.ndarray,
93 higher_data: np.ndarray,
94 lower_image: Image,
95 higher_image: Image,
96 op_name: str,
97 ) -> None:
98 lower = lower_image.copy()
99 higher = higher_image.copy()
100 op = getattr(operator, op_name)
102 # Test operation with constants
103 for constant in (3, 3.14, 3.14 + 3j):
104 if op_name in ("floordiv", "mod", "rshift", "lshift") and constant != 3:
105 # Cannot use floats or complex numbers for some operations,
106 # so skip them
107 continue
108 truth = op(lower_data, constant)
109 truth_image = Image(truth, bands=lower.bands)
110 result = op(lower, constant)
111 assert_array_equal(result.data, truth)
112 self.assertImageEqual(result, truth_image)
114 if op_name not in ("eq", "ne", "ge", "le", "lt", "gt") and (op_name != "pow" or constant == 3.14):
115 truth = op(constant, lower_data)
116 truth_image = Image(truth, bands=lower.bands)
117 result = getattr(lower, f"__r{op_name}__")(constant)
118 assert_array_equal(result.data, truth)
119 self.assertImageEqual(result, truth_image)
121 if op_name in ["rshift", "lshift"]:
122 # Shifting cannot be done with non-integer arrays
123 return
125 # Test lower * higher
126 truth = op(lower_data, higher_data)
127 truth_image = Image(truth, bands=higher_image.bands)
128 result = op(lower, higher)
129 assert_array_equal(result.data, truth)
130 self.assertImageEqual(result, truth_image)
132 if op_name not in ("eq", "ne", "ge", "le", "gt", "lt"):
133 result = getattr(higher, f"__r{op_name}__")(lower)
134 assert_array_equal(result.data, truth)
135 self.assertImageEqual(result, truth_image)
137 truth = op(higher_data, lower_data)
138 truth_image = Image(truth, bands=higher_image.bands)
139 iop = getattr(operator, "i" + op_name)
140 iop(higher, lower)
141 assert_array_equal(higher.data, truth)
142 self.assertImageEqual(higher, truth_image)
144 with self.assertRaises(ValueError):
145 iop(lower_image, higher_image)
147 def check_simple_arithmetic(self, data_bool, data_int, data_float, bands):
148 image_bool = Image(data_bool, bands=bands)
149 image_int = Image(data_int, bands=bands)
150 image_float = Image(data_float, bands=bands)
152 self.assertEqual(data_bool.dtype, bool)
153 self.assertEqual(data_int.dtype, int)
154 self.assertEqual(data_float.dtype, float)
156 # test casting for bool + int
157 self._binary_operation_test(
158 data_bool,
159 data_int,
160 image_bool,
161 image_int,
162 "add",
163 )
165 # Test binary operations
166 binary_operations = (
167 "add",
168 "sub",
169 "mul",
170 "truediv",
171 "floordiv",
172 "pow",
173 "mod",
174 "eq",
175 "ne",
176 "ge",
177 "le",
178 "gt",
179 "lt",
180 "rshift",
181 "lshift",
182 )
183 for op_name in binary_operations:
184 if op_name == "pow":
185 _data_int = np.abs(data_int)
186 _data_int[_data_int == 0] = 1
187 _image_int = image_int.copy()
188 _image_int.data[:] = _data_int
189 _data_float = np.abs(data_float)
190 _data_float[_data_float == 0] = 1
191 _image_float = image_float.copy()
192 _image_float.data[:] = _data_float
193 else:
194 _data_float = data_float
195 _image_float = image_float
196 _data_int = data_int
197 _image_int = image_int
198 self._binary_operation_test(
199 _data_int,
200 _data_float,
201 _image_int,
202 _image_float,
203 op_name,
204 )
206 # Test negation
207 self.assertImageEqual(-image_float, Image(-data_float, bands=bands)) # type: ignore
208 # Test unary positive operator
209 self.assertImageEqual(+image_float, image_float)
211 # Test that matrix multiplication is not supported
212 with self.assertRaises(TypeError):
213 image_int @ image_float
215 with self.assertRaises(TypeError):
216 image_int @= image_float
218 def test_simple_3d_arithmetic(self):
219 np.random.seed(1)
220 data_bool = np.random.choice((True, False), size=(2, 3, 4))
221 data_int = np.random.randint(-10, 10, (2, 3, 4))
222 data_int[data_int == 0] = 1
223 data_float = (np.random.random((2, 3, 4)) - 0.5) * 10
224 # Force a few pixels to be exactly equal so the comparison ops
225 # (eq/ne, gt/ge, lt/le) actually differentiate strict from
226 # non-strict comparisons (audit C-4).
227 data_float[0, 0, 0] = data_int[0, 0, 0]
228 data_float[1, 2, 3] = data_int[1, 2, 3]
229 self.check_simple_arithmetic(data_bool, data_int, data_float, bands=("g", "r"))
231 def test_simple_2d_arithmetic(self):
232 np.random.seed(1)
233 data_bool = np.random.choice((True, False), size=(3, 4))
234 data_int = np.random.randint(-10, 10, (3, 4))
235 data_int[data_int == 0] = 1
236 data_float = (np.random.random((3, 4)) - 0.5) * 10
237 # See test_simple_3d_arithmetic for rationale.
238 data_float[0, 0] = data_int[0, 0]
239 data_float[2, 3] = data_int[2, 3]
240 self.check_simple_arithmetic(data_bool, data_int, data_float, bands=None)
242 def test_3d_image_equality(self):
243 # Note: equality of the arrays is tested in other tests.
244 # This just checks that comparing non-images to images,
245 # or images with different bounding boxes or bands raises
246 # the appropriate exception.
247 np.random.seed(1)
248 bands = ("g", "r")
249 data1 = np.random.randint(-10, 10, (2, 3, 4))
250 data2 = data1.astype(float)
251 data3 = np.random.randint(-10, 10, (2, 3, 4)).astype(float)
253 image1 = Image(data1, bands=bands)
254 image2 = Image(data2, bands=bands)
255 image3 = Image(data3, bands=bands)
257 for op in (operator.eq, operator.ne):
258 with self.assertRaises(TypeError):
259 op(image1, data1)
260 with self.assertRaises(MismatchedBandsError):
261 op(image1, image2.copy_with(bands=("g", "i")))
262 with self.assertRaises(MismatchedBandsError):
263 op(image1, image3.copy_with(bands=("g", "i")))
264 with self.assertRaises(MismatchedBoxError):
265 op(image1, image2.copy_with(yx0=(30, 35)))
266 with self.assertRaises(MismatchedBoxError):
267 op(image1, image3.copy_with(yx0=(30, 35)))
269 def test_2d_image_equality(self):
270 # Note: equality of the arrays is tested in other tests.
271 # This just checks that comparing non-images to images,
272 # or images with different bounding boxes or bands raises
273 # the appropriate exception.
274 np.random.seed(1)
275 data1 = np.random.randint(-10, 10, (3, 4))
276 data2 = data1.astype(float)
277 data3 = np.random.randint(-10, 10, (3, 4)).astype(float)
279 image1 = Image(data1)
280 image2 = Image(data2)
281 image3 = Image(data3)
283 for op in (operator.eq, operator.ne):
284 with self.assertRaises(TypeError):
285 op(image1, data1)
286 with self.assertRaises(MismatchedBoxError):
287 op(image1, image2.copy_with(yx0=(30, 35)))
288 with self.assertRaises(MismatchedBoxError):
289 op(image1, image3.copy_with(yx0=(30, 35)))
291 def test_simple_boolean_arithmetic(self):
292 np.random.seed(1)
293 # Test boolean operations
294 boolean_operations = (
295 "and_",
296 "or_",
297 "xor",
298 )
299 data1 = np.random.choice((True, False), size=(2, 3, 4))
300 data2 = np.random.choice((True, False), size=(2, 3, 4))
301 _image1 = Image(data1, bands=("g", "i"))
302 _image2 = Image(data2, bands=("g", "i"))
303 for op_name in boolean_operations:
304 image1 = _image1.copy()
305 image2 = _image2.copy()
306 op = getattr(operator, op_name)
307 result = op(image1, image2)
308 data_result = op(data1, data2)
309 self.assertImageEqual(result, Image(data_result, bands=("g", "i")))
311 if op_name[-1] == "_":
312 # Trim the underscore after `or` and `and` in operator
313 op_name = op_name[:-1]
315 result = getattr(image2, f"__r{op_name}__")(image1)
316 self.assertImageEqual(result, Image(data_result, bands=("g", "i")))
318 iop = getattr(operator, "i" + op_name)
319 iop(image1, image2)
320 self.assertImageEqual(image1, Image(data_result, bands=("g", "i")))
322 # Test inversion
323 self.assertImageEqual(~_image1, Image(~data1, bands=("g", "i")))
325 def _3d_mismatched_images_test(
326 self,
327 op_name: str,
328 ):
329 np.random.seed(1)
330 op = getattr(operator, op_name)
331 grizy = ("g", "r", "i", "z", "y")
332 gir = ("g", "i", "r")
333 igy = ("i", "g", "y")
335 # Test band insert
336 if op_name == "add" or op_name == "subtract":
337 data1 = (np.random.random((5, 3, 4)) - 0.5) * 10
338 data2 = (np.random.random((3, 3, 4)) - 0.5) * 10
339 image1 = Image(data1, bands=grizy)
340 image2 = Image(data2, bands=gir)
341 result = op(image1, image2)
342 truth = np.zeros((5, 3, 4), dtype=float)
343 truth += data1
344 truth[(0, 2, 1), :, :] = op(truth[(0, 2, 1), :, :], data2)
345 assert_almost_equal(result.data, truth)
346 self.assertImageEqual(result, Image(truth, bands=grizy))
348 # Test band mixture
349 if op_name == "pow":
350 data1 = np.random.random((3, 3, 4)) + 1
351 data2 = np.random.random((3, 3, 4)) + 1
352 else:
353 data1 = (np.random.random((3, 3, 4)) - 0.5) * 10
354 data2 = (np.random.random((3, 3, 4)) - 0.5) * 10
355 image1 = Image(data1, bands=gir)
356 image2 = Image(data2, bands=igy)
357 result = op(image1, image2)
358 truth = np.zeros((4, 3, 4), dtype=float)
359 truth[(0, 1, 2), :, :] = data1
360 truth[(1, 0, 3), :, :] = op(truth[(1, 0, 3), :, :], data2)
361 assert_almost_equal(result.data, truth)
362 self.assertImageEqual(result, Image(truth, bands=("g", "i", "r", "y")))
364 # Test spatial offsets
365 if op_name == "pow":
366 data1 = np.random.random((3, 3, 4)) + 1
367 data2 = np.random.random((3, 3, 4)) + 1
368 else:
369 data1 = (np.random.random((3, 3, 4)) - 0.5) * 10
370 data2 = (np.random.random((3, 3, 4)) - 0.5) * 10
371 image1 = Image(data1, bands=gir, yx0=(10, 20))
372 image2 = Image(data2, bands=gir, yx0=(11, 17))
373 result = op(image1, image2)
375 _data1 = np.zeros((3, 4, 7), dtype=float)
376 _data2 = np.zeros((3, 4, 7), dtype=float)
377 _data1[:, :3, 3:] = data1
378 _data2[:, 1:, :4] = data2
379 with np.errstate(divide="ignore", invalid="ignore"):
380 truth = op(_data1, _data2)
381 assert_almost_equal(result.data, truth)
382 self.assertImageEqual(result, Image(truth, bands=gir, yx0=(10, 17)))
384 def _2d_mismatched_images_test(
385 self,
386 op_name: str,
387 ):
388 np.random.seed(1)
389 op = getattr(operator, op_name)
391 # Test spatial offsets
392 if op_name == "pow":
393 data1 = np.random.random((3, 4)) + 1
394 data2 = np.random.random((3, 4)) + 1
395 else:
396 data1 = (np.random.random((3, 4)) - 0.5) * 10
397 data2 = (np.random.random((3, 4)) - 0.5) * 10
398 image1 = Image(data1, yx0=(10, 20))
399 image2 = Image(data2, yx0=(11, 17))
400 result = op(image1, image2)
402 _data1 = np.zeros((4, 7), dtype=float)
403 _data2 = np.zeros((4, 7), dtype=float)
404 _data1[:3, 3:] = data1
405 _data2[1:, :4] = data2
406 with np.errstate(divide="ignore", invalid="ignore"):
407 truth = op(_data1, _data2)
408 assert_almost_equal(result.data, truth)
409 self.assertImageEqual(result, Image(truth, yx0=(10, 17)))
411 def test_mismatchd_arithmetic(self):
412 binary_operations = (
413 "add",
414 "sub",
415 "mul",
416 "truediv",
417 "floordiv",
418 "pow",
419 "mod",
420 )
422 for op_name in binary_operations:
423 self._3d_mismatched_images_test(op_name)
424 self._2d_mismatched_images_test(op_name)
426 def test_scalar_arithmetic(self):
427 data = np.arange(6).reshape(1, 2, 3)
428 bands = tuple("g")
429 yx0 = (3, 2)
430 image = Image(data, bands=bands, yx0=yx0)
431 self.assertImageEqual(2 & image, Image(2 & data, bands=bands, yx0=yx0))
432 self.assertImageEqual(2 | image, Image(2 | data, bands=bands, yx0=yx0))
433 self.assertImageEqual(2 ^ image, Image(2 ^ data, bands=bands, yx0=yx0))
435 with self.assertRaises(TypeError):
436 image << 2.0
437 with self.assertRaises(TypeError):
438 image >> 2.0
440 image2 = image.copy()
441 image2 <<= 2
442 self.assertImageEqual(image2, Image(data << 2, bands=bands, yx0=yx0))
444 image2 = image.copy()
445 image2 >>= 2
446 self.assertImageEqual(image2, Image(data >> 2, bands=bands, yx0=yx0))
448 def test_slicing(self):
449 bands = ("g", "r", "i", "z", "y")
450 yx0 = (27, 82)
451 data = (np.random.random((5, 30, 40)) - 0.5) * 10
452 image = Image(data, bands=bands, yx0=yx0)
453 image_2d = Image(data[0], yx0=yx0)
455 # test band slicing
456 sub_img = image["g"]
457 self.assertImageEqual(sub_img, Image(data[0], yx0=yx0))
459 sub_img = image[:"g"]
460 self.assertImageEqual(sub_img, Image(data[:1], bands=("g",), yx0=yx0))
462 sub_img = image["g":"r"]
463 self.assertImageEqual(sub_img, Image(data[0:2], bands=("g", "r"), yx0=yx0))
465 sub_img = image["r":"z"]
466 self.assertImageEqual(sub_img, Image(data[1:4], bands=("r", "i", "z"), yx0=yx0))
468 sub_img = image["z":]
469 self.assertImageEqual(sub_img, Image(data[-2:], bands=("z", "y"), yx0=yx0))
471 sub_img = image[("z", "i", "y")]
472 self.assertImageEqual(sub_img, Image(data[(3, 2, 4), :, :], bands=("z", "i", "y"), yx0=yx0))
474 self.assertImageEqual(image[:], image)
476 # Test bounding box slicing
477 sub_img = image[:, Box((10, 5), (37, 87))]
478 self.assertImageEqual(sub_img, Image(data[:, 10:20, 5:10], bands=bands, yx0=(37, 87)))
480 sub_img = image[Box((10, 5), (37, 87))]
481 self.assertImageEqual(sub_img, Image(data[:, 10:20, 5:10], bands=bands, yx0=(37, 87)))
483 sub_img = image_2d[Box((10, 5), (37, 87))]
484 self.assertImageEqual(sub_img, Image(data[0, 10:20, 5:10], yx0=(37, 87)))
486 with self.assertRaises(IndexError):
487 # Cannot index a single row, since it would not return an image
488 _ = image["g", 0]
490 with self.assertRaises(IndexError):
491 # Cannot index a single column, since it would not return an image
492 _ = image[:, :, 0]
494 with self.assertRaises(IndexError):
495 # Cannot use a tuple to select rows/columns
496 _ = image[("r", "i"), (1, 2)]
498 with self.assertRaises(IndexError):
499 # Cannot use a bounding box outside of the image
500 _ = image[:, Box((10, 10), (0, 0))]
502 with self.assertRaises(IndexError):
503 # Cannot use a bounding box partially outside of the image
504 _ = image[:, Box((40, 40), (20, 80))]
506 with self.assertRaises(IndexError):
507 # Too many spatial indices
508 _ = image[:, :, :, :]
510 truth = (
511 (0, 1, 2, 3, 4),
512 slice(27, 57),
513 slice(82, 122),
514 )
515 self.assertTupleEqual(image.multiband_slices, truth)
517 def test_overlap_detection(self):
518 # Test 2D image
519 image = Image(np.zeros((5, 6)), yx0=(10, 15))
520 slices = image.overlapped_slices(Box((8, 9), (7, 18)))
521 truth = ((slice(0, 5), slice(3, 6)), (slice(3, 8), slice(0, 3)))
522 self.assertTupleEqual(slices, truth)
524 # Test 3D image
525 image = Image(np.zeros((3, 10, 12)), bands=("g", "r", "i"), yx0=(13, 21))
526 slices = image.overlapped_slices(Box((8, 9), (15, 18)))
527 truth = (
528 (slice(None), slice(2, 10), slice(0, 6)),
529 (slice(None), slice(0, 8), slice(3, 9)),
530 )
531 self.assertTupleEqual(slices, truth)
533 # Test no overlap
534 slices = image.overlapped_slices(Box((8, 9), (115, 118)))
535 truth = (
536 (slice(None), slice(0, 0), slice(0, 0)),
537 (slice(None), slice(0, 0), slice(0, 0)),
538 )
539 self.assertTupleEqual(slices, truth)
541 def test_insertion(self):
542 img1 = Image.from_box(Box((20, 20)), bands=tuple("gri"))
543 img2 = Image.from_box(Box((5, 5), (11, 12)), bands=tuple("gi"))
544 img2.data[:] = np.arange(1, 3)[:, None, None]
545 img1.insert(img2)
547 truth = img1.copy()
548 truth.data[0, 11:16, 12:17] = 1
549 truth.data[2, 11:16, 12:17] = 2
550 self.assertImageEqual(img1, truth)
552 def test_matched_spectral_indices(self):
553 img1 = Image.from_box(Box((5, 5)))
554 img2 = Image.from_box(Box((5, 5)))
555 indices = img1.matched_spectral_indices(img2)
556 self.assertTupleEqual(indices, ((), ()))
558 img3 = Image.from_box(Box((5, 5)), bands=tuple("gri"))
559 with self.assertRaises(ValueError):
560 img1.matched_spectral_indices(img3)
562 with self.assertRaises(ValueError):
563 img3.matched_spectral_indices(img1)
565 def test_project(self):
566 data = np.arange(30).reshape(5, 6)
567 img = Image(data, yx0=(11, 15))
569 result = img.project(bbox=Box((20, 20), (2, 3)))
570 truth = np.zeros((20, 20))
571 truth[9:14, 12:18] = data
572 truth = Image(truth, yx0=(2, 3))
573 self.assertImageEqual(result, truth)
575 data = np.arange(60).reshape(3, 4, 5)
576 img = Image(data, bands=tuple("gri"))
577 result = img.project(tuple("gi"))
578 truth = data[(0, 2), :]
579 self.assertImageEqual(result, Image(truth, bands=tuple("gi")))
581 def test_repeat(self):
582 data = np.arange(18).reshape(3, 6)
583 image = Image(data, yx0=(15, 32))
584 result = image.repeat(tuple("grizy"))
585 truth = np.array([data, data, data, data, data])
586 truth = Image(truth, bands=tuple("grizy"), yx0=(15, 32))
587 self.assertImageEqual(result, truth)
589 with self.assertRaises(ValueError):
590 result.repeat(tuple("ubv"))