Coverage for python/lsst/scarlet/lite/image.py: 22%

428 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-19 10:38 +0000

1# This file is part of 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/>. 

21 

22from __future__ import annotations 

23 

24import operator 

25from typing import Any, Callable, Sequence, cast 

26 

27import numpy as np 

28from numpy.typing import DTypeLike 

29 

30from .bbox import Box 

31from .utils import ScalarLike, ScalarTypes 

32 

33__all__ = ["Image", "MismatchedBoxError", "MismatchedBandsError"] 

34 

35 

36class MismatchedBandsError(Exception): 

37 """Attempt to compare images with different bands""" 

38 

39 

40class MismatchedBoxError(Exception): 

41 """Attempt to compare images in different bounding boxes""" 

42 

43 

44def get_dtypes(*data: np.ndarray | Image | ScalarLike) -> list[DTypeLike]: 

45 """Get a list of dtypes from a list of arrays, images, or scalars 

46 

47 Parameters 

48 ---------- 

49 data: 

50 The arrays to use for calculating the dtype 

51 

52 Returns 

53 ------- 

54 result: 

55 A list of datatypes. 

56 """ 

57 dtypes: list[DTypeLike] = [None] * len(data) 

58 for d, element in enumerate(data): 

59 if hasattr(element, "dtype"): 

60 dtypes[d] = cast(np.ndarray, element).dtype 

61 else: 

62 dtypes[d] = np.dtype(type(element)) 

63 return dtypes 

64 

65 

66def get_combined_dtype(*data: np.ndarray | Image | ScalarLike) -> DTypeLike: 

67 """Get the combined dtype for a collection of arrays to prevent loss 

68 of precision. 

69 

70 Parameters 

71 ---------- 

72 data: 

73 The arrays to use for calculating the dtype 

74 

75 Returns 

76 ------- 

77 result: np.dtype 

78 The resulting dtype. 

79 """ 

80 dtypes = get_dtypes(*data) 

81 return max(dtypes) # type: ignore 

82 

83 

84class Image: 

85 """A numpy array with an origin and (optional) bands 

86 

87 This class contains a 2D numpy array with the addition of an 

88 origin (``yx0``) and an optional first index (``bands``) that 

89 allows an immutable named index to be used. 

90 

91 Notes 

92 ----- 

93 One of the main limitations of using numpy arrays to store image data 

94 is the lack of an ``origin`` attribute that allows an array to retain 

95 knowledge of it's location in a larger scene. 

96 For example, if a numpy array ``x`` is sliced, eg. ``x[10:20, 30:40]`` 

97 the result will be a new ``10x10`` numpy array that has no meta 

98 data to inform the user that it was sliced from a larger image. 

99 In addition, astrophysical images are also multi-band data cubes, 

100 with a 2D image in each band (in fact this is the simplifying 

101 assumption that distinguishes scarlet lite from scarlet main). 

102 However, the ordering of the bands during processing might differ from 

103 the ordering of the bands to display multiband data. 

104 So a mechanism was also desired to simplify the sorting and index of 

105 an image by band name. 

106 

107 Thus, scarlet lite creates a numpy-array like class with the additional 

108 ``bands`` and ``yx0`` attributes to keep track of the bands contained 

109 in an array and the origin of that array (we specify ``yx0`` as opposed 

110 to ``xy0`` to be consistent with the default numpy/C++ ``(y, x)`` 

111 ordering of arrays as opposed to the traditional cartesian ``(x, y)`` 

112 ordering used in astronomy and other modules in the science pipelines. 

113 While this may be a small source of confusion for the user, 

114 it is consistent with the ordering in the original scarlet package and 

115 ensures the consistency of scarlet lite images and python index slicing. 

116 

117 Examples 

118 -------- 

119 

120 The easiest way to create a new image is to use ``Image(numpy_array)``, 

121 for example 

122 

123 >>> import numpy as np 

124 >>> from lsst.scarlet.lite import Image 

125 >>> 

126 >>> x = np.arange(12).reshape(3, 4) 

127 >>> image = Image(x) 

128 >>> print(image) 

129 Image: 

130 [[ 0 1 2 3] 

131 [ 4 5 6 7] 

132 [ 8 9 10 11]] 

133 bands=() 

134 bbox=Box(shape=(3, 4), origin=(0, 0)) 

135 

136 This will create a single band :py:class:`~lsst.scarlet.lite.Image` with 

137 origin ``(0, 0)``. 

138 To create a multi-band image the input array must have 3 dimensions and 

139 the ``bands`` property must be specified: 

140 

141 >>> x = np.arange(24).reshape(2, 3, 4) 

142 >>> image = Image(x, bands=("i", "z")) 

143 >>> print(image) 

144 Image: 

145 [[[ 0 1 2 3] 

146 [ 4 5 6 7] 

147 [ 8 9 10 11]] 

148 <BLANKLINE> 

149 [[12 13 14 15] 

150 [16 17 18 19] 

151 [20 21 22 23]]] 

152 bands=('i', 'z') 

153 bbox=Box(shape=(3, 4), origin=(0, 0)) 

154 

155 It is also possible to create an empty single-band image using the 

156 ``from_box`` static method: 

157 

158 >>> from lsst.scarlet.lite import Box 

159 >>> image = Image.from_box(Box((3, 4), (100, 120))) 

160 >>> print(image) 

161 Image: 

162 [[0. 0. 0. 0.] 

163 [0. 0. 0. 0.] 

164 [0. 0. 0. 0.]] 

165 bands=() 

166 bbox=Box(shape=(3, 4), origin=(100, 120)) 

167 

168 Similarly, an empty multi-band image can be created by passing a tuple 

169 of ``bands``: 

170 

171 >>> image = Image.from_box(Box((3, 4)), bands=("r", "i")) 

172 >>> print(image) 

173 Image: 

174 [[[0. 0. 0. 0.] 

175 [0. 0. 0. 0.] 

176 [0. 0. 0. 0.]] 

177 <BLANKLINE> 

178 [[0. 0. 0. 0.] 

179 [0. 0. 0. 0.] 

180 [0. 0. 0. 0.]]] 

181 bands=('r', 'i') 

182 bbox=Box(shape=(3, 4), origin=(0, 0)) 

183 

184 To select a sub-image use a ``Box`` to select a spatial region in either a 

185 single-band or multi-band image: 

186 

187 >>> x = np.arange(60).reshape(3, 4, 5) 

188 >>> image = Image(x, bands=("g", "r", "i"), yx0=(20, 30)) 

189 >>> bbox = Box((2, 2), (21, 32)) 

190 >>> print(image[bbox]) 

191 Image: 

192 [[[ 7 8] 

193 [12 13]] 

194 <BLANKLINE> 

195 [[27 28] 

196 [32 33]] 

197 <BLANKLINE> 

198 [[47 48] 

199 [52 53]]] 

200 bands=('g', 'r', 'i') 

201 bbox=Box(shape=(2, 2), origin=(21, 32)) 

202 

203 

204 To select a single-band image from a multi-band image, 

205 pass the name of the band as an index: 

206 

207 >>> print(image["r"]) 

208 Image: 

209 [[20 21 22 23 24] 

210 [25 26 27 28 29] 

211 [30 31 32 33 34] 

212 [35 36 37 38 39]] 

213 bands=() 

214 bbox=Box(shape=(4, 5), origin=(20, 30)) 

215 

216 Multi-band images can also be sliced in the spatial dimension, for example 

217 

218 >>> print(image["g":"r"]) 

219 Image: 

220 [[[ 0 1 2 3 4] 

221 [ 5 6 7 8 9] 

222 [10 11 12 13 14] 

223 [15 16 17 18 19]] 

224 <BLANKLINE> 

225 [[20 21 22 23 24] 

226 [25 26 27 28 29] 

227 [30 31 32 33 34] 

228 [35 36 37 38 39]]] 

229 bands=('g', 'r') 

230 bbox=Box(shape=(4, 5), origin=(20, 30)) 

231 

232 and 

233 

234 >>> print(image["r":"r"]) 

235 Image: 

236 [[[20 21 22 23 24] 

237 [25 26 27 28 29] 

238 [30 31 32 33 34] 

239 [35 36 37 38 39]]] 

240 bands=('r',) 

241 bbox=Box(shape=(4, 5), origin=(20, 30)) 

242 

243 both extract a slice of a multi-band image. 

244 

245 .. warning:: 

246 Unlike numerical indices, where ``slice(x, y)`` will select the 

247 subset of an array from ``x`` to ``y-1`` (excluding ``y``), 

248 a spectral slice of an ``Image`` will return the image slice 

249 including band ``y``. 

250 

251 It is also possible to change the order or index a subset of bands 

252 in an image. For example: 

253 

254 >>> print(image[("r", "g", "i")]) 

255 Image: 

256 [[[20 21 22 23 24] 

257 [25 26 27 28 29] 

258 [30 31 32 33 34] 

259 [35 36 37 38 39]] 

260 <BLANKLINE> 

261 [[ 0 1 2 3 4] 

262 [ 5 6 7 8 9] 

263 [10 11 12 13 14] 

264 [15 16 17 18 19]] 

265 <BLANKLINE> 

266 [[40 41 42 43 44] 

267 [45 46 47 48 49] 

268 [50 51 52 53 54] 

269 [55 56 57 58 59]]] 

270 bands=('r', 'g', 'i') 

271 bbox=Box(shape=(4, 5), origin=(20, 30)) 

272 

273 

274 will return a new image with the bands re-ordered. 

275 

276 Images can be combined using the standard arithmetic operations similar to 

277 numpy arrays, including ``+, -, *, /, **`` etc, however, if two images are 

278 combined with different bounding boxes, the _union_ of the two 

279 boxes is used for the result. For example: 

280 

281 >>> image1 = Image(np.ones((2, 3, 4)), bands=tuple("gr")) 

282 >>> image2 = Image(np.ones((2, 3, 4)), bands=tuple("gr"), yx0=(2, 3)) 

283 >>> result = image1 + image2 

284 >>> print(result) 

285 Image: 

286 [[[1. 1. 1. 1. 0. 0. 0.] 

287 [1. 1. 1. 1. 0. 0. 0.] 

288 [1. 1. 1. 2. 1. 1. 1.] 

289 [0. 0. 0. 1. 1. 1. 1.] 

290 [0. 0. 0. 1. 1. 1. 1.]] 

291 <BLANKLINE> 

292 [[1. 1. 1. 1. 0. 0. 0.] 

293 [1. 1. 1. 1. 0. 0. 0.] 

294 [1. 1. 1. 2. 1. 1. 1.] 

295 [0. 0. 0. 1. 1. 1. 1.] 

296 [0. 0. 0. 1. 1. 1. 1.]]] 

297 bands=('g', 'r') 

298 bbox=Box(shape=(5, 7), origin=(0, 0)) 

299 

300 If instead you want to additively ``insert`` image 1 into image 2, 

301 so that they have the same bounding box as image 2, use 

302 

303 >>> _ = image2.insert(image1) 

304 >>> print(image2) 

305 Image: 

306 [[[2. 1. 1. 1.] 

307 [1. 1. 1. 1.] 

308 [1. 1. 1. 1.]] 

309 <BLANKLINE> 

310 [[2. 1. 1. 1.] 

311 [1. 1. 1. 1.] 

312 [1. 1. 1. 1.]]] 

313 bands=('g', 'r') 

314 bbox=Box(shape=(3, 4), origin=(2, 3)) 

315 

316 To insert an image using a different operation use 

317 

318 >>> from operator import truediv 

319 >>> _ = image2.insert(image1, truediv) 

320 >>> print(image2) 

321 Image: 

322 [[[2. 1. 1. 1.] 

323 [1. 1. 1. 1.] 

324 [1. 1. 1. 1.]] 

325 <BLANKLINE> 

326 [[2. 1. 1. 1.] 

327 [1. 1. 1. 1.] 

328 [1. 1. 1. 1.]]] 

329 bands=('g', 'r') 

330 bbox=Box(shape=(3, 4), origin=(2, 3)) 

331 

332 

333 However, depending on the operation you may get unexpected results 

334 since now there could be ``NaN`` and ``inf`` values due to the zeros 

335 in the non-overlapping regions. 

336 Instead, to select only the overlap region one can use 

337 

338 >>> result = image1 / image2 

339 >>> print(result[image1.bbox & image2.bbox]) 

340 Image: 

341 [[[0.5]] 

342 <BLANKLINE> 

343 [[0.5]]] 

344 bands=('g', 'r') 

345 bbox=Box(shape=(1, 1), origin=(2, 3)) 

346 

347 

348 Parameters 

349 ---------- 

350 data: 

351 The array data for the image. 

352 bands: 

353 The bands coving the image. 

354 yx0: 

355 The (y, x) offset for the lower left of the image. 

356 """ 

357 

358 def __init__( 

359 self, 

360 data: np.ndarray, 

361 bands: Sequence | None = None, 

362 yx0: tuple[int, int] | None = None, 

363 ): 

364 if bands is None or len(bands) == 0: 

365 # Using an empty tuple for the bands will result in a 2D image 

366 bands = () 

367 assert len(data.shape) == 2 

368 else: 

369 bands = tuple(bands) 

370 assert len(data.shape) == 3 

371 if data.shape[0] != len(bands): 

372 raise ValueError(f"Array has spectral size {data.shape[0]}, but {bands} bands") 

373 if yx0 is None: 

374 yx0 = (0, 0) 

375 self._data = data 

376 self._yx0 = yx0 

377 self._bands = bands 

378 

379 @staticmethod 

380 def from_box(bbox: Box, bands: tuple | None = None, dtype: DTypeLike = float) -> Image: 

381 """Initialize an empty image from a bounding Box and optional bands 

382 

383 Parameters 

384 ---------- 

385 bbox: 

386 The bounding box that contains the image. 

387 bands: 

388 The bands for the image. 

389 If bands is `None` then a 2D image is created. 

390 dtype: 

391 The numpy dtype of the image. 

392 

393 Returns 

394 ------- 

395 image: 

396 An empty image contained in ``bbox`` with ``bands`` bands. 

397 """ 

398 if bands is not None and len(bands) > 0: 

399 shape = (len(bands),) + bbox.shape 

400 else: 

401 shape = bbox.shape 

402 data = np.zeros(shape, dtype=dtype) 

403 return Image(data, bands=bands, yx0=cast(tuple[int, int], bbox.origin)) 

404 

405 @property 

406 def shape(self) -> tuple[int, ...]: 

407 """The shape of the image. 

408 

409 This includes the spectral dimension, if there is one. 

410 """ 

411 return self._data.shape 

412 

413 @property 

414 def dtype(self) -> DTypeLike: 

415 """The numpy dtype of the image.""" 

416 return self._data.dtype 

417 

418 @property 

419 def bands(self) -> tuple: 

420 """The bands used in the image.""" 

421 return self._bands 

422 

423 @property 

424 def n_bands(self) -> int: 

425 """Number of bands in the image. 

426 

427 If `n_bands == 0` then the image is 2D and does not have a spectral 

428 dimension. 

429 """ 

430 return len(self._bands) 

431 

432 @property 

433 def is_multiband(self) -> bool: 

434 """Whether or not the image has a spectral dimension.""" 

435 return self.n_bands > 0 

436 

437 @property 

438 def height(self) -> int: 

439 """Height of the image.""" 

440 return self.shape[-2] 

441 

442 @property 

443 def width(self) -> int: 

444 """Width of the image.""" 

445 return self.shape[-1] 

446 

447 @property 

448 def yx0(self) -> tuple[int, int]: 

449 """Origin of the image, in numpy/C++ y,x ordering.""" 

450 return self._yx0 

451 

452 @property 

453 def y0(self) -> int: 

454 """location of the y-offset.""" 

455 return self._yx0[0] 

456 

457 @property 

458 def x0(self) -> int: 

459 """Location of the x-offset.""" 

460 return self._yx0[1] 

461 

462 @property 

463 def bbox(self) -> Box: 

464 """Bounding box for the special dimensions in the image.""" 

465 return Box(self.shape[-2:], self._yx0) 

466 

467 @property 

468 def data(self) -> np.ndarray: 

469 """The image viewed as a numpy array.""" 

470 return self._data 

471 

472 def spectral_indices(self, bands: Sequence | slice) -> tuple[int, ...] | slice: 

473 """The indices to extract each band in `bands` in order from the image 

474 

475 This converts a band name, or list of band names, 

476 into numerical indices that can be used to slice the internal numpy 

477 `data` array. 

478 

479 Parameters 

480 --------- 

481 bands: 

482 If `bands` is a list of band names, then the result will be an 

483 index corresponding to each band, in order. 

484 If `bands` is a slice, then the ``start`` and ``stop`` properties 

485 should be band names, and the result will be a slice with the 

486 appropriate indices to start at `bands.start` and end at 

487 `bands.stop`. 

488 

489 Returns 

490 ------- 

491 band_indices: 

492 Tuple of indices for each band in this image. 

493 """ 

494 if isinstance(bands, slice): 

495 # Convert a slice of band names into a slice of array indices 

496 # to select the appropriate slice. 

497 if bands.start is None: 

498 start = None 

499 else: 

500 start = self.bands.index(bands.start) 

501 if bands.stop is None: 

502 stop = None 

503 else: 

504 stop = self.bands.index(bands.stop) + 1 

505 return slice(start, stop, bands.step) 

506 

507 if isinstance(bands, str): 

508 return (self.bands.index(bands),) 

509 

510 band_indices = tuple(self.bands.index(band) for band in bands if band in self.bands) 

511 return band_indices 

512 

513 def matched_spectral_indices( 

514 self, 

515 other: Image, 

516 ) -> tuple[tuple[int, ...] | slice, tuple[int, ...] | slice]: 

517 """Match bands between two images 

518 

519 Parameters 

520 ---------- 

521 other: 

522 The other image to match spectral indices to. 

523 

524 Returns 

525 ------- 

526 result: 

527 A tuple with a tuple of indices/slices for each dimension, 

528 including the spectral dimension. 

529 """ 

530 if self.bands == other.bands and self.n_bands != 0: 

531 # The bands match 

532 return slice(None), slice(None) 

533 if self.n_bands == 0 and other.n_bands == 0: 

534 # The images are 2D, so no spectral slicing is necessary 

535 return (), () 

536 if self.n_bands == 0 and other.n_bands > 1: 

537 err = "Attempted to insert a monochromatic image into a mutli-band image" 

538 raise ValueError(err) 

539 if other.n_bands == 0: 

540 err = "Attempted to insert a multi-band image into a monochromatic image" 

541 raise ValueError(err) 

542 

543 self_indices = cast(tuple[int, ...], self.spectral_indices(other.bands)) 

544 matched_bands = tuple(self.bands[bidx] for bidx in self_indices) 

545 other_indices = cast(tuple[int, ...], other.spectral_indices(matched_bands)) 

546 return other_indices, self_indices 

547 

548 def matched_slices(self, bbox: Box) -> tuple[tuple[slice, ...], tuple[slice, ...]]: 

549 """Get the slices to match this image to a given bounding box 

550 

551 Parameters 

552 ---------- 

553 bbox: 

554 The bounding box to match this image to. 

555 

556 Returns 

557 ------- 

558 result: 

559 Tuple of indices/slices to match this image to the given bbox. 

560 """ 

561 if self.bbox == bbox: 

562 # No need to slice, since the boxes match 

563 _slice = (slice(None),) * bbox.ndim 

564 return _slice, _slice 

565 

566 slices = self.bbox.overlapped_slices(bbox) 

567 return slices 

568 

569 def project( 

570 self, 

571 bands: object | tuple[object] | None = None, 

572 bbox: Box | None = None, 

573 ) -> Image: 

574 """Project this image into a different set of bands 

575 

576 Parameters 

577 ---------- 

578 bands: 

579 Spectral bands to project this image into. 

580 Not all bands have to be contained in the image, and not all 

581 bands contained in the image have to be used in the projection. 

582 bbox: 

583 A bounding box to project the image into. 

584 

585 Results 

586 ------- 

587 image: 

588 A new image creating by projecting this image into 

589 `bbox` and `bands`. 

590 """ 

591 if bands is None: 

592 bands = self.bands 

593 if not isinstance(bands, tuple): 

594 bands = (bands,) 

595 if self.is_multiband: 

596 indices = self.spectral_indices(bands) 

597 data = self.data[indices, :] 

598 else: 

599 data = self.data 

600 

601 if bbox is None: 

602 return Image(data, bands=bands, yx0=self.yx0) 

603 

604 if self.is_multiband: 

605 image = np.zeros((len(bands),) + bbox.shape, dtype=data.dtype) 

606 slices = bbox.overlapped_slices(self.bbox) 

607 # Insert a slice for the spectral dimension 

608 image[(slice(None),) + slices[0]] = data[(slice(None),) + slices[1]] 

609 return Image(image, bands=bands, yx0=cast(tuple[int, int], bbox.origin)) 

610 

611 image = np.zeros(bbox.shape, dtype=data.dtype) 

612 slices = bbox.overlapped_slices(self.bbox) 

613 image[slices[0]] = data[slices[1]] 

614 return Image(image, bands=bands, yx0=cast(tuple[int, int], bbox.origin)) 

615 

616 @property 

617 def multiband_slices(self) -> tuple[tuple[int, ...] | slice, slice, slice]: 

618 """Return the slices required to slice a multiband image""" 

619 return (self.spectral_indices(self.bands),) + self.bbox.slices # type: ignore 

620 

621 def insert_into( 

622 self, 

623 image: Image, 

624 op: Callable = operator.add, 

625 ) -> Image: 

626 """Insert this image into another image in place. 

627 

628 Parameters 

629 ---------- 

630 image: 

631 The image to insert this image into. 

632 op: 

633 The operator to use when combining the images. 

634 

635 Returns 

636 ------- 

637 result: 

638 `image` updated by inserting this instance. 

639 """ 

640 return insert_image(image, self, op) 

641 

642 def insert(self, image: Image, op: Callable = operator.add) -> Image: 

643 """Insert another image into this image in place. 

644 

645 Parameters 

646 ---------- 

647 image: 

648 The image to insert this image into. 

649 op: 

650 The operator to use when combining the images. 

651 

652 Returns 

653 ------- 

654 result: 

655 This instance with `image` inserted. 

656 """ 

657 return insert_image(self, image, op) 

658 

659 def repeat(self, bands: tuple) -> Image: 

660 """Project a 2D image into the spectral dimension 

661 

662 Parameters 

663 ---------- 

664 bands: 

665 The bands in the projected image. 

666 

667 Returns 

668 ------- 

669 result: Image 

670 The 2D image repeated in each band in the spectral dimension. 

671 """ 

672 if self.is_multiband: 

673 raise ValueError("Image.repeat only works with 2D images") 

674 return self.copy_with( 

675 np.repeat(self.data[None, :, :], len(bands), axis=0), 

676 bands=bands, 

677 yx0=self.yx0, 

678 ) 

679 

680 def copy(self, order=None) -> Image: 

681 """Make a copy of this image. 

682 

683 Parameters 

684 ---------- 

685 order: 

686 The ordering to use for storing the bytes. 

687 This is unlikely to be needed, and just defaults to 

688 the numpy behavior (C) ordering. 

689 

690 Returns 

691 ------- 

692 image: Image 

693 The copy of this image. 

694 """ 

695 return self.copy_with(order=order) 

696 

697 def copy_with( 

698 self, 

699 data: np.ndarray | None = None, 

700 order: str | None = None, 

701 bands: tuple[str, ...] | None = None, 

702 yx0: tuple[int, int] | None = None, 

703 ): 

704 """Copy of this image with some parameters updated. 

705 

706 Any parameters not specified by the user will be copied from the 

707 current image. 

708 

709 Parameters 

710 ---------- 

711 data: 

712 An update for the data in the image. 

713 order: 

714 The ordering for stored bytes, from numpy.copy. 

715 bands: 

716 The bands that the resulting image will have. 

717 The number of bands must be the same as the first dimension 

718 in the data array. 

719 yx0: 

720 The lower-left of the image bounding box. 

721 

722 Returns 

723 ------- 

724 image: Image 

725 The copied image. 

726 """ 

727 if order is None: 

728 order = "C" 

729 if data is None: 

730 data = self.data.copy(order) # type: ignore 

731 if bands is None: 

732 bands = self.bands 

733 if yx0 is None: 

734 yx0 = self.yx0 

735 return Image(data, bands, yx0) 

736 

737 def _i_update(self, op: Callable, other: Image | ScalarLike) -> Image: 

738 """Update the data array in place. 

739 

740 This is typically implemented by `__i<op>__` methods, 

741 like `__iadd__`, to apply an operator and update this image 

742 with the data in place. 

743 

744 Parameters 

745 ---------- 

746 op: 

747 Operator used to combine this image with the `other` image. 

748 other: 

749 The other image that is combined with this one using the operator 

750 `op`. 

751 

752 Returns 

753 ------- 

754 image: Image 

755 This image, after being updated by the operator 

756 """ 

757 dtype = get_combined_dtype(self.data, other) 

758 if self.dtype != dtype: 

759 if hasattr(other, "dtype"): 

760 _dtype = cast(np.ndarray, other).dtype 

761 else: 

762 _dtype = type(other) 

763 msg = f"Cannot update an array with type {self.dtype} with {_dtype}" 

764 raise ValueError(msg) 

765 result = op(other) 

766 self._data[:] = result.data 

767 self._bands = result.bands 

768 self._yx0 = result.yx0 

769 return self 

770 

771 def _check_equality(self, other: Image | ScalarLike, op: Callable) -> Image: 

772 """Compare this array to another. 

773 

774 This performs an element by element equality check. 

775 

776 Parameters 

777 ---------- 

778 other: 

779 The image to compare this image to. 

780 op: 

781 The operator used for the comparision (==, !=, >=, <=). 

782 

783 Returns 

784 ------- 

785 image: Image 

786 An image made by checking all of the elements in this array with 

787 another. 

788 

789 Raises 

790 ------ 

791 TypeError: 

792 If `other` is not an `Image`. 

793 MismatchedBandsError: 

794 If `other` has different bands. 

795 MismatchedBoxError: 

796 if `other` exists in a different bounding box. 

797 """ 

798 if isinstance(other, Image) and other.bands == self.bands and other.bbox == self.bbox: 

799 return self.copy_with(data=op(self.data, other.data)) 

800 

801 if not isinstance(other, Image): 

802 if type(other) in ScalarTypes: 

803 return self.copy_with(data=op(self.data, other)) 

804 raise TypeError(f"Cannot compare images to {type(other)}") 

805 

806 if other.bands != self.bands: 

807 msg = f"Cannot compare images with mismatched bands: {self.bands} vs {other.bands}" 

808 raise MismatchedBandsError(msg) 

809 

810 raise MismatchedBoxError( 

811 f"Cannot compare images with different bounds boxes: {self.bbox} vs. {other.bbox}" 

812 ) 

813 

814 def __eq__(self, other: object) -> Image: # type: ignore 

815 """Check if this image is equal to another.""" 

816 if not isinstance(other, Image) and not isinstance(other, ScalarTypes): 

817 raise TypeError(f"Cannot compare an Image to {type(other)}.") 

818 return self._check_equality(other, operator.eq) # type: ignore 

819 

820 def __ne__(self, other: object) -> Image: # type: ignore 

821 """Check if this image is not equal to another.""" 

822 return ~self.__eq__(other) 

823 

824 def __ge__(self, other: Image | ScalarLike) -> Image: 

825 """Check if this image is greater than or equal to another.""" 

826 if type(other) in ScalarTypes: 

827 return self.copy_with(data=self.data >= other) 

828 return self._check_equality(other, operator.ge) 

829 

830 def __le__(self, other: Image | ScalarLike) -> Image: 

831 """Check if this image is less than or equal to another.""" 

832 if type(other) in ScalarTypes: 

833 return self.copy_with(data=self.data <= other) 

834 return self._check_equality(other, operator.le) 

835 

836 def __gt__(self, other: Image | ScalarLike) -> Image: 

837 """Check if this image is greater than or equal to another.""" 

838 if type(other) in ScalarTypes: 

839 return self.copy_with(data=self.data > other) 

840 return self._check_equality(other, operator.ge) 

841 

842 def __lt__(self, other: Image | ScalarLike) -> Image: 

843 """Check if this image is less than or equal to another.""" 

844 if type(other) in ScalarTypes: 

845 return self.copy_with(data=self.data < other) 

846 return self._check_equality(other, operator.le) 

847 

848 def __neg__(self): 

849 """Take the negative of the image.""" 

850 return self.copy_with(data=-self._data) 

851 

852 def __pos__(self): 

853 """Make a copy using of the image.""" 

854 return self.copy() 

855 

856 def __invert__(self): 

857 """Take the inverse (~) of the image.""" 

858 return self.copy_with(data=~self._data) 

859 

860 def __add__(self, other: Image | ScalarLike) -> Image: 

861 """Combine this image and another image using addition.""" 

862 return _operate_on_images(self, other, operator.add) 

863 

864 def __iadd__(self, other: Image | ScalarLike) -> Image: 

865 """Combine this image and another image using addition and update 

866 in place. 

867 """ 

868 return self._i_update(self.__add__, other) 

869 

870 def __radd__(self, other: Image | ScalarLike) -> Image: 

871 """Combine this image and another image using addition, 

872 with this image on the right. 

873 """ 

874 if type(other) in ScalarTypes: 

875 return self.copy_with(data=other + self.data) 

876 return cast(Image, other).__add__(self) 

877 

878 def __sub__(self, other: Image | ScalarLike) -> Image: 

879 """Combine this image and another image using subtraction.""" 

880 return _operate_on_images(self, other, operator.sub) 

881 

882 def __isub__(self, other: Image | ScalarLike) -> Image: 

883 """Combine this image and another image using subtraction, 

884 with this image on the right. 

885 """ 

886 return self._i_update(self.__sub__, other) 

887 

888 def __rsub__(self, other: Image | ScalarLike) -> Image: 

889 """Combine this image and another image using subtraction, 

890 with this image on the right. 

891 """ 

892 if type(other) in ScalarTypes: 

893 return self.copy_with(data=other - self.data) 

894 return cast(Image, other).__sub__(self) 

895 

896 def __mul__(self, other: Image | ScalarLike) -> Image: 

897 """Combine this image and another image using multiplication.""" 

898 return _operate_on_images(self, other, operator.mul) 

899 

900 def __imul__(self, other: Image | ScalarLike) -> Image: 

901 """Combine this image and another image using multiplication, 

902 with this image on the right. 

903 """ 

904 return self._i_update(self.__mul__, other) 

905 

906 def __rmul__(self, other: Image | ScalarLike) -> Image: 

907 """Combine this image and another image using multiplication, 

908 with this image on the right. 

909 """ 

910 if type(other) in ScalarTypes: 

911 return self.copy_with(data=other * self.data) 

912 return cast(Image, other).__mul__(self) 

913 

914 def __truediv__(self, other: Image | ScalarLike) -> Image: 

915 """Divide this image by `other`.""" 

916 return _operate_on_images(self, other, operator.truediv) 

917 

918 def __itruediv__(self, other: Image | ScalarLike) -> Image: 

919 """Divide this image by `other` in place.""" 

920 return self._i_update(self.__truediv__, other) 

921 

922 def __rtruediv__(self, other: Image | ScalarLike) -> Image: 

923 """Divide this image by `other` with this on the right.""" 

924 if type(other) in ScalarTypes: 

925 return self.copy_with(data=other / self.data) 

926 return cast(Image, other).__truediv__(self) 

927 

928 def __floordiv__(self, other: Image | ScalarLike) -> Image: 

929 """Floor divide this image by `other` in place.""" 

930 return _operate_on_images(self, other, operator.floordiv) 

931 

932 def __ifloordiv__(self, other: Image | ScalarLike) -> Image: 

933 """Floor divide this image by `other` in place.""" 

934 return self._i_update(self.__floordiv__, other) 

935 

936 def __rfloordiv__(self, other: Image | ScalarLike) -> Image: 

937 """Floor divide this image by `other` with this on the right.""" 

938 if type(other) in ScalarTypes: 

939 return self.copy_with(data=other // self.data) 

940 return cast(Image, other).__floordiv__(self) 

941 

942 def __pow__(self, other: Image | ScalarLike) -> Image: 

943 """Raise this image to the `other` power.""" 

944 return _operate_on_images(self, other, operator.pow) 

945 

946 def __ipow__(self, other: Image | ScalarLike) -> Image: 

947 """Raise this image to the `other` power in place.""" 

948 return self._i_update(self.__pow__, other) 

949 

950 def __rpow__(self, other: Image | ScalarLike) -> Image: 

951 """Raise this other to the power of this image.""" 

952 if type(other) in ScalarTypes: 

953 return self.copy_with(data=other**self.data) 

954 return cast(Image, other).__pow__(self) 

955 

956 def __mod__(self, other: Image | ScalarLike) -> Image: 

957 """Take the modulus of this % other.""" 

958 return _operate_on_images(self, other, operator.mod) 

959 

960 def __imod__(self, other: Image | ScalarLike) -> Image: 

961 """Take the modulus of this % other in place.""" 

962 return self._i_update(self.__mod__, other) 

963 

964 def __rmod__(self, other: Image | ScalarLike) -> Image: 

965 """Take the modulus of other % this.""" 

966 if type(other) in ScalarTypes: 

967 return self.copy_with(data=other % self.data) 

968 return cast(Image, other).__mod__(self) 

969 

970 def __and__(self, other: Image | ScalarLike) -> Image: 

971 """Take the bitwise and of this and other.""" 

972 return _operate_on_images(self, other, operator.and_) 

973 

974 def __iand__(self, other: Image | ScalarLike) -> Image: 

975 """Take the bitwise and of this and other in place.""" 

976 return self._i_update(self.__and__, other) 

977 

978 def __rand__(self, other: Image | ScalarLike) -> Image: 

979 """Take the bitwise and of other and this.""" 

980 if type(other) in ScalarTypes: 

981 return self.copy_with(data=other & self.data) 

982 return cast(Image, other).__and__(self) 

983 

984 def __or__(self, other: Image | ScalarLike) -> Image: 

985 """Take the binary or of this or other.""" 

986 return _operate_on_images(self, other, operator.or_) 

987 

988 def __ior__(self, other: Image | ScalarLike) -> Image: 

989 """Take the binary or of this or other in place.""" 

990 return self._i_update(self.__or__, other) 

991 

992 def __ror__(self, other: Image | ScalarLike) -> Image: 

993 """Take the binary or of other or this.""" 

994 if type(other) in ScalarTypes: 

995 return self.copy_with(data=other | self.data) 

996 return cast(Image, other).__or__(self) 

997 

998 def __xor__(self, other: Image | ScalarLike) -> Image: 

999 """Take the binary xor of this xor other.""" 

1000 return _operate_on_images(self, other, operator.xor) 

1001 

1002 def __ixor__(self, other: Image | ScalarLike) -> Image: 

1003 """Take the binary xor of this xor other in place.""" 

1004 return self._i_update(self.__xor__, other) 

1005 

1006 def __rxor__(self, other: Image | ScalarLike) -> Image: 

1007 """Take the binary xor of other xor this.""" 

1008 if type(other) in ScalarTypes: 

1009 return self.copy_with(data=other ^ self.data) 

1010 return cast(Image, other).__xor__(self) 

1011 

1012 def __lshift__(self, other: ScalarLike) -> Image: 

1013 """Shift this image to the left by other bits.""" 

1014 if not issubclass(np.dtype(type(other)).type, np.integer): 

1015 raise TypeError("Bit shifting an image can only be done with integers") 

1016 return self.copy_with(data=self.data << other) 

1017 

1018 def __ilshift__(self, other: ScalarLike) -> Image: 

1019 """Shift this image to the left by other bits in place.""" 

1020 self[:] = self.__lshift__(other) 

1021 return self 

1022 

1023 def __rlshift__(self, other: ScalarLike) -> Image: 

1024 """Shift other to the left by this image bits.""" 

1025 return self.copy_with(data=other << self.data) 

1026 

1027 def __rshift__(self, other: ScalarLike) -> Image: 

1028 """Shift this image to the right by other bits.""" 

1029 if not issubclass(np.dtype(type(other)).type, np.integer): 

1030 raise TypeError("Bit shifting an image can only be done with integers") 

1031 return self.copy_with(data=self.data >> other) 

1032 

1033 def __irshift__(self, other: ScalarLike) -> Image: 

1034 """Shift this image to the right by other bits in place.""" 

1035 self[:] = self.__rshift__(other) 

1036 return self 

1037 

1038 def __rrshift__(self, other: ScalarLike) -> Image: 

1039 """Shift other to the right by this image bits.""" 

1040 return self.copy_with(data=other >> self.data) 

1041 

1042 def __str__(self): 

1043 """Display the image array, bands, and bounding box.""" 

1044 return f"Image:\n {str(self.data)}\n bands={self.bands}\n bbox={self.bbox}" 

1045 

1046 def _is_spectral_index(self, index: Any) -> bool: 

1047 """Check to see if an index is a spectral index. 

1048 

1049 Parameters 

1050 ---------- 

1051 index: 

1052 Either a slice, a tuple, or an element in `Image.bands`. 

1053 

1054 Returns 

1055 ------- 

1056 result: 

1057 ``True`` if `index` is band or tuple of bands. 

1058 """ 

1059 bands = self.bands 

1060 if isinstance(index, slice): 

1061 if index.start in bands or index.stop in bands or (index.start is None and index.stop is None): 

1062 return True 

1063 return False 

1064 if index in self.bands: 

1065 return True 

1066 if isinstance(index, tuple) and index[0] in self.bands: 

1067 return True 

1068 return False 

1069 

1070 def _get_box_slices(self, bbox: Box) -> tuple[slice, slice]: 

1071 """Get the slices of the image to insert it into the overlapping 

1072 region with `bbox`.""" 

1073 overlap = self.bbox & bbox 

1074 if overlap != bbox: 

1075 raise IndexError("Bounding box is outside of the image") 

1076 origin = bbox.origin 

1077 shape = bbox.shape 

1078 y_start = origin[0] - self.yx0[0] 

1079 y_stop = origin[0] + shape[0] - self.yx0[0] 

1080 x_start = origin[1] - self.yx0[1] 

1081 x_stop = origin[1] + shape[1] - self.yx0[1] 

1082 y_index = slice(y_start, y_stop) 

1083 x_index = slice(x_start, x_stop) 

1084 return y_index, x_index 

1085 

1086 def _get_sliced(self, indices: Any, value: Image | None = None) -> Image: 

1087 """Select a subset of an image 

1088 

1089 Parameters 

1090 ---------- 

1091 indices: 

1092 The indices to select a subsection of the image. 

1093 The spectral index can either be a tuple of indices, 

1094 a slice of indices, or a single index used to select a 

1095 single-band 2D image. 

1096 The spatial index (if present) is a `Box`. 

1097 

1098 value: 

1099 The value used to set this slice of the image. 

1100 This allows the single `_get_sliced` method to be used for 

1101 both getting a slice of an image and setting it. 

1102 

1103 Returns 

1104 ------- 

1105 result: Image | np.ndarray 

1106 The resulting image obtained by selecting subsets of the iamge 

1107 based on the `indices`. 

1108 """ 

1109 if not isinstance(indices, tuple): 

1110 indices = (indices,) 

1111 

1112 if self.is_multiband: 

1113 if self._is_spectral_index(indices[0]): 

1114 if len(indices) > 1 and indices[1] in self.bands: 

1115 # The indices are all band names, 

1116 # so use them all as a spectral indices 

1117 bands = indices 

1118 spectral_index = self.spectral_indices(bands) 

1119 y_index = x_index = slice(None) 

1120 elif self._is_spectral_index(indices[0]): 

1121 # The first index is a spectral index 

1122 spectral_index = self.spectral_indices(indices[0]) 

1123 if isinstance(spectral_index, slice): 

1124 bands = self.bands[spectral_index] 

1125 elif len(spectral_index) == 1: 

1126 bands = () 

1127 spectral_index = spectral_index[0] # type: ignore 

1128 else: 

1129 bands = tuple(self.bands[idx] for idx in spectral_index) 

1130 indices = indices[1:] 

1131 if len(indices) == 1: 

1132 # The spatial index must be a bounding box 

1133 if not isinstance(indices[0], Box): 

1134 raise IndexError(f"Expected a Box for the spatial index but got {indices[1]}") 

1135 y_index, x_index = self._get_box_slices(indices[0]) 

1136 elif len(indices) == 0: 

1137 y_index = x_index = slice(None) 

1138 else: 

1139 raise IndexError(f"Too many spatial indices, expeected a Box bot got {indices}") 

1140 full_index = (spectral_index, y_index, x_index) 

1141 elif isinstance(indices[0], Box): 

1142 bands = self.bands 

1143 y_index, x_index = self._get_box_slices(indices[0]) 

1144 full_index = (slice(None), y_index, x_index) 

1145 else: 

1146 error = f"3D images can only be indexed by spectral indices or bounding boxes, got {indices}" 

1147 raise IndexError(error) 

1148 else: 

1149 if len(indices) != 1 or not isinstance(indices[0], Box): 

1150 raise IndexError(f"2D images can only be sliced by bounding box, got {indices}") 

1151 bands = () 

1152 y_index, x_index = self._get_box_slices(indices[0]) 

1153 full_index = (y_index, x_index) # type: ignore 

1154 

1155 y0 = y_index.start 

1156 if y0 is None: 

1157 y0 = 0 

1158 

1159 x0 = x_index.start 

1160 if x0 is None: 

1161 x0 = 0 

1162 

1163 if value is None: 

1164 # This is a getter, 

1165 # so return an image with the data sliced properly 

1166 yx0 = (y0 + self.yx0[0], x0 + self.yx0[1]) 

1167 

1168 data = self.data[full_index] 

1169 

1170 if len(data.shape) == 2: 

1171 # Only a single band was selected, so return that band 

1172 return Image(data, yx0=yx0) 

1173 return Image(data, bands=bands, yx0=yx0) 

1174 

1175 # Set the data 

1176 self._data[full_index] = value.data 

1177 return self 

1178 

1179 def overlapped_slices(self, bbox: Box) -> tuple[tuple[slice, ...], tuple[slice, ...]]: 

1180 """Get the slices needed to insert this image into a bounding box. 

1181 

1182 Parameters 

1183 ---------- 

1184 bbox: 

1185 The region to insert this image into. 

1186 

1187 Returns 

1188 ------- 

1189 overlap: 

1190 The slice of this image and the slice of the `bbox` required to 

1191 insert the overlapping portion of this image. 

1192 

1193 """ 

1194 overlap = self.bbox.overlapped_slices(bbox) 

1195 if self.is_multiband: 

1196 overlap = (slice(None),) + overlap[0], (slice(None),) + overlap[1] 

1197 return overlap 

1198 

1199 def __getitem__(self, indices: Any) -> Image: 

1200 """Get the subset of an image 

1201 

1202 Parameters 

1203 ---------- 

1204 indices: 

1205 The indices to select a subsection of the image. 

1206 

1207 Returns 

1208 ------- 

1209 result: 

1210 The resulting image obtained by selecting subsets of the iamge 

1211 based on the `indices`. 

1212 """ 

1213 return self._get_sliced(indices) 

1214 

1215 def __setitem__(self, indices, value: Image) -> Image: 

1216 """Set a subset of an image to a given value 

1217 

1218 Parameters 

1219 ---------- 

1220 indices: 

1221 The indices to select a subsection of the image. 

1222 value: 

1223 The value to use for the subset of the image. 

1224 

1225 Returns 

1226 ------- 

1227 result: 

1228 The resulting image obtained by selecting subsets of the image 

1229 based on the `indices`. 

1230 """ 

1231 return self._get_sliced(indices, value) 

1232 

1233 

1234def _operate_on_images(image1: Image, image2: Image | ScalarLike, op: Callable) -> Image: 

1235 """Perform an operation on two images, that may or may not be spectrally 

1236 and spatially aligned. 

1237 

1238 Parameters 

1239 ---------- 

1240 image1: 

1241 The image on the LHS of the operation 

1242 image2: 

1243 The image on the RHS of the operation 

1244 op: 

1245 The operation used to combine the images. 

1246 

1247 Returns 

1248 ------- 

1249 image: 

1250 The resulting combined image. 

1251 """ 

1252 if type(image2) in ScalarTypes: 

1253 return image1.copy_with(data=op(image1.data, image2)) 

1254 image2 = cast(Image, image2) 

1255 if image1.bands == image2.bands and image1.bbox == image2.bbox: 

1256 # The images perfectly overlap, so just combine their results 

1257 with np.errstate(divide="ignore", invalid="ignore"): 

1258 result = op(image1.data, image2.data) 

1259 return Image(result, bands=image1.bands, yx0=image1.yx0) 

1260 

1261 if op != operator.add and op != operator.sub and image1.bands != image2.bands: 

1262 msg = "Images with different bands can only be combined using addition and subtraction, " 

1263 msg += f"got {op}, with bands {image1.bands}, {image2.bands}" 

1264 raise ValueError(msg) 

1265 

1266 # Use all of the bands in the first image 

1267 bands = image1.bands 

1268 # Add on any bands from the second image not contained in the first image 

1269 bands = bands + tuple(band for band in image2.bands if band not in bands) 

1270 # Create a box that contains both images 

1271 bbox = image1.bbox | image2.bbox 

1272 # Create an image that will contain both images 

1273 if len(bands) > 0: 

1274 shape = (len(bands),) + bbox.shape 

1275 else: 

1276 shape = bbox.shape 

1277 

1278 if op == operator.add or op == operator.sub: 

1279 dtype = get_combined_dtype(image1, image2) 

1280 result = Image(np.zeros(shape, dtype=dtype), bands=bands, yx0=cast(tuple[int, int], bbox.origin)) 

1281 # Add the first image in place 

1282 image1.insert_into(result, operator.add) 

1283 # Use the operator to insert the second image 

1284 image2.insert_into(result, op) 

1285 else: 

1286 # Project both images into the full bbox 

1287 image1 = image1.project(bbox=bbox) 

1288 image2 = image2.project(bbox=bbox) 

1289 result = op(image1, image2) 

1290 return result 

1291 

1292 

1293def insert_image( 

1294 main_image: Image, 

1295 sub_image: Image, 

1296 op: Callable = operator.add, 

1297) -> Image: 

1298 """Insert one image into another image 

1299 

1300 Parameters 

1301 ---------- 

1302 main_image: 

1303 The image that will have `sub_image` insertd. 

1304 sub_image: 

1305 The image that is inserted into `main_image`. 

1306 op: 

1307 The operator to use for insertion 

1308 (addition, subtraction, multiplication, etc.). 

1309 

1310 Returns 

1311 ------- 

1312 main_image: Image 

1313 The `main_image`, with the `sub_image` inserted in place. 

1314 """ 

1315 if len(main_image.bands) == 0 and len(sub_image.bands) == 0: 

1316 slices = sub_image.matched_slices(main_image.bbox) 

1317 image_slices = slices[1] 

1318 self_slices = slices[0] 

1319 else: 

1320 band_indices = sub_image.matched_spectral_indices(main_image) 

1321 slices = sub_image.matched_slices(main_image.bbox) 

1322 image_slices = (band_indices[0],) + slices[1] # type: ignore 

1323 self_slices = (band_indices[1],) + slices[0] # type: ignore 

1324 

1325 main_image._data[image_slices] = op(main_image.data[image_slices], sub_image.data[self_slices]) 

1326 return main_image