Coverage for python / lsst / scarlet / lite / bbox.py: 24%
138 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:40 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:40 +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/>.
22from __future__ import annotations
24__all__ = ["Box", "overlapped_slices"]
26from copy import deepcopy
27from typing import Any, Sequence, cast
29import numpy as np
32class Box:
33 """Bounding Box for an object
35 A Bounding box describes the location of a data unit in the
36 global/model coordinate system, using the row-major
37 (default numpy/C++) ordering convention.
38 So, for example, a 2D image will have shape ``(height, width)``,
39 however the bounding `Box` code is agnostic as to number of dimensions
40 or the meaning of those dimensions.
42 Examples
43 --------
45 At a minimum a new `Box` can be initialized using the ``shape`` of the
46 region it describes:
48 >>> from lsst.scarlet.lite import Box
49 >>> bbox = Box((3, 4, 5, 6))
50 >>> print(bbox)
51 Box(shape=(3, 4, 5, 6), origin=(0, 0, 0, 0))
53 If the region described by the `Box` is offset from the zero origin,
54 a new ``origin`` can be passed to the constructor
56 >>> bbox = Box((3, 4, 5, 6), (2, 4, 7, 9))
57 >>> print(bbox)
58 Box(shape=(3, 4, 5, 6), origin=(2, 4, 7, 9))
60 It is also possible to initialize a `Box` from a collection of tuples,
61 where tuple is a pair of integers representing the
62 first and last index in each dimension. For example:
64 >>> bbox = Box.from_bounds((3, 6), (11, 21))
65 >>> print(bbox)
66 Box(shape=(3, 10), origin=(3, 11))
68 It is also possible to initialize a `Box` by thresholding a numpy array
69 and including only the region of the image above the threshold in the
70 resulting `Box`. For example
72 >>> from lsst.scarlet.lite.utils import integrated_circular_gaussian
73 >>> data = integrated_circular_gaussian(sigma=1.0)
74 >>> bbox = Box.from_data(data, 1e-2)
75 >>> print(bbox)
76 Box(shape=(5, 5), origin=(5, 5))
78 The `Box` class contains a number of convenience methods that can be used
79 to extract subsets of an array, combine bounding boxes, etc.
81 For example, using the ``data`` and ``bbox`` from the end of the previous
82 section, the portion of the data array that is contained in the bounding
83 box can be extraced usng the `Box.slices` method:
85 >>> subset = data[bbox.slices]
87 The intersection of two boxes can be calcualted using the ``&`` operator,
88 for example
90 >>> bbox = Box((5, 5)) & Box((5, 5), (2, 2))
91 >>> print(bbox)
92 Box(shape=(3, 3), origin=(2, 2))
94 Similarly, the union of two boxes can be calculated using the ``|``
95 operator:
97 >>> bbox = Box((5, 5)) | Box((5, 5), (2, 2))
98 >>> print(bbox)
99 Box(shape=(7, 7), origin=(0, 0))
101 To find out of a point is located in a `Box` use
103 >>> contains = bbox.contains((3, 3))
104 >>> print(contains)
105 True
107 To find out if two boxes intersect (in other words ``box1 & box2`` has a
108 non-zero size) use
110 >>> intersects = bbox.intersects(Box((10, 10), (100, 100)))
111 >>> print(intersects)
112 False
114 It is also possible to shift a box by a vector (sequence):
116 >>> bbox = bbox + (50, 60)
117 >>> print(bbox)
118 Box(shape=(7, 7), origin=(50, 60))
120 which can also be negative
122 >>> bbox = bbox - (5, -5)
123 >>> print(bbox)
124 Box(shape=(7, 7), origin=(45, 65))
126 Boxes can also be converted into higher dimensions using the
127 ``@`` operator:
129 >>> bbox1 = Box((10,), (3, ))
130 >>> bbox2 = Box((101, 201), (18, 21))
131 >>> bbox = bbox1 @ bbox2
132 >>> print(bbox)
133 Box(shape=(10, 101, 201), origin=(3, 18, 21))
135 Boxes are equal when they have the same shape and the same origin, so
137 >>> print(Box((10, 10), (5, 5)) == Box((10, 10), (5, 5)))
138 True
140 >>> print(Box((10, 10), (5, 5)) == Box((10, 10), (4, 4)))
141 False
143 Finally, it is common to insert one array into another when their bounding
144 boxes only partially overlap.
145 In order to correctly insert the overlapping portion of the array it is
146 convenient to calculate the slices from each array that overlap.
147 For example:
149 >>> import numpy as np
150 >>> x = np.arange(12).reshape(3, 4)
151 >>> y = np.arange(9).reshape(3, 3)
152 >>> print(x)
153 [[ 0 1 2 3]
154 [ 4 5 6 7]
155 [ 8 9 10 11]]
156 >>> print(y)
157 [[0 1 2]
158 [3 4 5]
159 [6 7 8]]
160 >>> x_box = Box.from_data(x) + (3, 4)
161 >>> y_box = Box.from_data(y) + (1, 3)
162 >>> slices = x_box.overlapped_slices(y_box)
163 >>> x[slices[0]] += y[slices[1]]
164 >>> print(x)
165 [[ 7 9 2 3]
166 [ 4 5 6 7]
167 [ 8 9 10 11]]
169 Parameters
170 ----------
171 shape:
172 Size of the box in each dimension.
173 origin:
174 Minimum corner coordinate of the box.
175 This defaults to ``(0, ...)``.
176 """
178 def __init__(self, shape: tuple[int, ...], origin: tuple[int, ...] | None = None):
179 self.shape = tuple(shape)
180 if origin is None:
181 origin = (0,) * len(shape)
182 if len(origin) != len(shape):
183 msg = "Mismatched origin and shape dimensions. "
184 msg += f"Received {len(origin)} and {len(shape)}"
185 raise ValueError(msg)
186 self.origin = tuple(origin)
188 @staticmethod
189 def from_bounds(*bounds: tuple[int, ...]) -> Box:
190 """Initialize a box from its bounds
192 Parameters
193 ----------
194 bounds:
195 Min/Max coordinate for every dimension
197 Returns
198 -------
199 bbox:
200 A new box bounded by the input bounds.
201 """
202 shape = tuple(max(0, cmax - cmin) for cmin, cmax in bounds)
203 origin = tuple(cmin for cmin, cmax in bounds)
204 return Box(shape, origin=origin)
206 @staticmethod
207 def from_data(x: np.ndarray, threshold: float = 0) -> Box:
208 """Define range of `x` above `min_value`.
210 This method creates the smallest `Box` that contains all of the
211 elements in `x` that are above `min_value`.
213 Parameters
214 ----------
215 x:
216 Data to threshold to specify the shape/dimensionality of `x`.
217 threshold:
218 Threshold for the data.
219 The box is trimmed so that all elements bordering `x` smaller than
220 `min_value` are ignored.
222 Returns
223 -------
224 bbox:
225 Bounding box for the thresholded `x`
226 """
227 sel = x > threshold
228 if sel.any():
229 nonzero = np.where(sel)
230 bounds = []
231 for dim in range(len(x.shape)):
232 bounds.append((int(nonzero[dim].min()), int(nonzero[dim].max() + 1)))
233 else:
234 bounds = [(0, 0)] * len(x.shape)
235 return Box.from_bounds(*bounds)
237 def contains(self, p: Sequence[int]) -> bool:
238 """Whether the box contains a given coordinate `p`"""
239 if len(p) != self.ndim:
240 raise ValueError(f"Dimension mismatch in {p} and {self.ndim}")
242 for d in range(self.ndim):
243 if not (p[d] >= self.origin[d] and (p[d] < (self.origin[d] + self.shape[d]))):
244 return False
245 return True
247 @property
248 def ndim(self) -> int:
249 """Dimensionality of this BBox"""
250 return len(self.shape)
252 @property
253 def start(self) -> tuple[int, ...]:
254 """Tuple of start coordinates"""
255 return self.origin
257 @property
258 def stop(self) -> tuple[int, ...]:
259 """Tuple of stop coordinates"""
260 return tuple(o + s for o, s in zip(self.origin, self.shape))
262 @property
263 def center(self) -> tuple[float, ...]:
264 """Tuple of center coordinates"""
265 return tuple(o + s / 2 for o, s in zip(self.origin, self.shape))
267 @property
268 def bounds(self) -> tuple[tuple[int, int], ...]:
269 """Bounds of the box"""
270 return tuple((o, o + s) for o, s in zip(self.origin, self.shape))
272 @property
273 def slices(self) -> tuple[slice, ...]:
274 """Bounds of the box as slices"""
275 if np.any(self.origin) < 0:
276 raise ValueError("Cannot get slices for a box with negative indices")
277 return tuple([slice(o, o + s) for o, s in zip(self.origin, self.shape)])
279 def grow(self, radius: int | tuple[int, ...]) -> Box:
280 """Grow the Box by the given radius in each direction"""
281 if isinstance(radius, int):
282 radius = tuple([radius] * self.ndim)
283 origin = tuple([self.origin[d] - radius[d] for d in range(self.ndim)])
284 shape = tuple([self.shape[d] + 2 * radius[d] for d in range(self.ndim)])
285 return Box(shape, origin=origin)
287 def shifted_by(self, shift: Sequence[int]) -> Box:
288 """Generate a shifted copy of this box
290 Parameters
291 ----------
292 shift:
293 The amount to shift each axis to create the new box
295 Returns
296 -------
297 result:
298 The resulting bounding box.
299 """
300 origin = tuple(o + shift[i] for i, o in enumerate(self.origin))
301 return Box(self.shape, origin=origin)
303 def intersects(self, other: Box) -> bool:
304 """Check if two boxes overlap
306 Parameters
307 ----------
308 other:
309 The boxes to check for overlap
311 Returns
312 -------
313 result:
314 True when the two boxes overlap.
315 """
316 overlap = self & other
317 return np.all(np.array(overlap.shape) != 0) # type: ignore
319 def overlapped_slices(self, other: Box) -> tuple[tuple[slice, ...], tuple[slice, ...]]:
320 """Return `slice` for the box that contains the overlap of this and
321 another `Box`
323 Parameters
324 ----------
325 other:
327 Returns
328 -------
329 slices:
330 The slice of an array bounded by `self` and
331 the slice of an array bounded by `other` in the
332 overlapping region.
333 """
334 return overlapped_slices(self, other)
336 def __or__(self, other: Box) -> Box:
337 """Union of two bounding boxes
339 Parameters
340 ----------
341 other:
342 The other bounding box in the union
344 Returns
345 -------
346 result:
347 The smallest rectangular box that contains *both* boxes.
348 """
349 if other.ndim != self.ndim:
350 raise ValueError(f"Dimension mismatch in the boxes {other} and {self}")
351 bounds = []
352 for d in range(self.ndim):
353 bounds.append((min(self.start[d], other.start[d]), max(self.stop[d], other.stop[d])))
354 return Box.from_bounds(*bounds)
356 def __and__(self, other: Box) -> Box:
357 """Intersection of two bounding boxes
359 If there is no intersection between the two bounding
360 boxes then an empty bounding box is returned.
362 Parameters
363 ----------
364 other:
365 The other bounding box in the intersection
367 Returns
368 -------
369 result:
370 The rectangular box that is in the overlap region
371 of both boxes.
372 """
373 if other.ndim != self.ndim:
374 raise ValueError(f"Dimension mismatch in the boxes {other=} and {self=}")
376 bounds = []
377 for d in range(self.ndim):
378 bounds.append((max(self.start[d], other.start[d]), min(self.stop[d], other.stop[d])))
379 return Box.from_bounds(*bounds)
381 def __getitem__(self, index: int | slice | tuple[int, ...]) -> Box:
382 if isinstance(index, int) or isinstance(index, slice):
383 s_ = self.shape[index]
384 o_ = self.origin[index]
385 if isinstance(s_, int):
386 s_ = (s_,)
387 o_ = (cast(int, o_),) # type: ignore
388 else:
389 iter(index)
390 # If I is a Sequence then select the indices in `index`, in order
391 s_ = tuple(self.shape[i] for i in index)
392 o_ = tuple(self.origin[i] for i in index)
393 return Box(s_, origin=cast(tuple[int, ...], o_))
395 def __repr__(self) -> str:
396 return f"Box(shape={self.shape}, origin={self.origin})"
398 def _offset_to_tuple(self, offset: int | Sequence[int]) -> tuple[int, ...]:
399 """Expand an integer offset into a tuple
401 Parameters
402 ----------
403 offset:
404 The offset to (potentially) convert into a tuple.
406 Returns
407 -------
408 offset:
409 The offset as a tuple.
410 """
411 if isinstance(offset, int):
412 _offset = (offset,) * self.ndim
413 else:
414 _offset = tuple(offset)
415 return _offset
417 def __add__(self, offset: int | Sequence[int]) -> Box:
418 """Generate a new Box with a shifted offset
420 Parameters
421 ----------
422 offset:
423 The amount to shift the current offset
425 Returns
426 -------
427 result:
428 The shifted box.
429 """
430 return self.shifted_by(self._offset_to_tuple(offset))
432 def __sub__(self, offset: int | Sequence[int]) -> Box:
433 """Generate a new Box with a shifted offset in the negative direction
435 Parameters
436 ----------
437 offset:
438 The amount to shift the current offset
440 Returns
441 -------
442 result:
443 The shifted box.
444 """
445 offset = self._offset_to_tuple(offset)
446 offset = tuple(-o for o in offset)
447 return self.shifted_by(offset)
449 def __matmul__(self, bbox: Box) -> Box:
450 """Combine two Boxes into a higher dimensional box
452 Parameters
453 ----------
454 bbox:
455 The box to append to this box.
457 Returns
458 -------
459 result:
460 The combined Box.
461 """
462 bounds = self.bounds + bbox.bounds
463 result = Box.from_bounds(*bounds)
464 return result
466 def __deepcopy__(self, memo: dict[int, Any]) -> Box:
467 """Deep copy of the box"""
468 my_id = id(self)
469 if my_id in memo:
470 return memo[my_id]
471 result = Box(deepcopy(self.shape), origin=deepcopy(self.origin))
472 memo[my_id] = result
473 return result
475 def __copy__(self) -> Box:
476 """Copy of the box"""
477 return Box(self.shape, origin=self.origin)
479 def copy(self) -> Box:
480 """Copy of the box"""
481 return self.__copy__()
483 def __eq__(self, other: object) -> bool:
484 """Check for equality.
486 Two boxes are equal when they have the same shape and origin.
487 """
488 if not hasattr(other, "shape") and not hasattr(other, "origin"):
489 return False
490 return self.shape == other.shape and self.origin == other.origin # type: ignore
492 def __hash__(self) -> int:
493 return hash((self.shape, self.origin))
496def overlapped_slices(bbox1: Box, bbox2: Box) -> tuple[tuple[slice, ...], tuple[slice, ...]]:
497 """Slices of bbox1 and bbox2 that overlap
499 Parameters
500 ----------
501 bbox1:
502 The first box.
503 bbox2:
504 The second box.
506 Returns
507 -------
508 slices: tuple[Sequence[slice], Sequence[slice]]
509 The slice of an array bounded by `bbox1` and
510 the slice of an array bounded by `bbox2` in the
511 overlapping region.
512 """
513 overlap = bbox1 & bbox2
514 if np.all(np.array(overlap.shape) == 0):
515 # There was no overlap, so return empty slices
516 return (slice(0, 0),) * len(overlap.shape), (slice(0, 0),) * len(overlap.shape)
517 _bbox1 = overlap - bbox1.origin
518 _bbox2 = overlap - bbox2.origin
519 slices = (
520 _bbox1.slices,
521 _bbox2.slices,
522 )
523 return slices