LSST Applications 30.0.7,g0e76e35be5+e8e946ae08,g19811a7679+138f7293ba,g199a45376c+5e234f8357,g1fd858c14a+2f48dbc4c4,g262e1987ae+fb36cac54d,g29ae962dfc+d9108a0941,g2c21b0017a+4f59a27f16,g31e44d4a5c+b0138be388,g33ac35c1f1+28b9f72785,g35bb328faa+b0138be388,g40c9b15c53+823ad735c1,g47891489e3+bcc48a0b46,g53246c7159+b0138be388,g64539dfbff+e8e946ae08,g67b6fd64d1+bcc48a0b46,g74acd417e5+422380537a,g76965917b2+a5ca99c4d9,g786e29fd12+796b79145d,g7aefaa3e3d+dc0c200193,g86b635cae8+734fe384f0,g87389fa792+d8b5378923,g89139ef638+bcc48a0b46,g8bbb235e95+3f4f7f9447,g8ea07a8fe4+78a4c88802,g9290983e33+ffdc83c6f7,g92c671f44c+e8e946ae08,gaa753fd333+03f406da14,gbf99507273+b0138be388,gc49b57b85e+8df26ee1f0,gca7fc764a6+bcc48a0b46,gd7ef33dd92+bcc48a0b46,gdab6d2f7ff+422380537a,ge1c02a5578+b0138be388,ge410e46f29+bcc48a0b46,ge80df9fc40+e6db5413d1,geaed405ab2+1de65a85c6,gf5dcc679e7+35a0ce2edd,gf5f1c85443+e8e946ae08
LSST Data Management Base Package
Loading...
Searching...
No Matches
bbox.py
Go to the documentation of this file.
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
24__all__ = ["Box", "overlapped_slices"]
25
26from copy import deepcopy
27from typing import Any, Sequence, cast
28
29import numpy as np
30
31
32class Box:
33 """Bounding Box for an object
34
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.
41
42 Examples
43 --------
44
45 At a minimum a new `Box` can be initialized using the ``shape`` of the
46 region it describes:
47
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))
52
53 If the region described by the `Box` is offset from the zero origin,
54 a new ``origin`` can be passed to the constructor
55
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))
59
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:
63
64 >>> bbox = Box.from_bounds((3, 6), (11, 21))
65 >>> print(bbox)
66 Box(shape=(3, 10), origin=(3, 11))
67
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
71
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))
77
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.
80
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:
84
85 >>> subset = data[bbox.slices]
86
87 The intersection of two boxes can be calcualted using the ``&`` operator,
88 for example
89
90 >>> bbox = Box((5, 5)) & Box((5, 5), (2, 2))
91 >>> print(bbox)
92 Box(shape=(3, 3), origin=(2, 2))
93
94 Similarly, the union of two boxes can be calculated using the ``|``
95 operator:
96
97 >>> bbox = Box((5, 5)) | Box((5, 5), (2, 2))
98 >>> print(bbox)
99 Box(shape=(7, 7), origin=(0, 0))
100
101 To find out of a point is located in a `Box` use
102
103 >>> contains = bbox.contains((3, 3))
104 >>> print(contains)
105 True
106
107 To find out if two boxes intersect (in other words ``box1 & box2`` has a
108 non-zero size) use
109
110 >>> intersects = bbox.intersects(Box((10, 10), (100, 100)))
111 >>> print(intersects)
112 False
113
114 It is also possible to shift a box by a vector (sequence):
115
116 >>> bbox = bbox + (50, 60)
117 >>> print(bbox)
118 Box(shape=(7, 7), origin=(50, 60))
119
120 which can also be negative
121
122 >>> bbox = bbox - (5, -5)
123 >>> print(bbox)
124 Box(shape=(7, 7), origin=(45, 65))
125
126 Boxes can also be converted into higher dimensions using the
127 ``@`` operator:
128
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))
134
135 Boxes are equal when they have the same shape and the same origin, so
136
137 >>> print(Box((10, 10), (5, 5)) == Box((10, 10), (5, 5)))
138 True
139
140 >>> print(Box((10, 10), (5, 5)) == Box((10, 10), (4, 4)))
141 False
142
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:
148
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]]
168
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 """
177
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)
187
188 @staticmethod
189 def from_bounds(*bounds: tuple[int, ...]) -> Box:
190 """Initialize a box from its bounds
191
192 Parameters
193 ----------
194 bounds:
195 Min/Max coordinate for every dimension
196
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)
205
206 @staticmethod
207 def from_data(x: np.ndarray, threshold: float = 0) -> Box:
208 """Define range of `x` above `min_value`.
209
210 This method creates the smallest `Box` that contains all of the
211 elements in `x` that are above `min_value`.
212
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.
221
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)
236
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}")
241
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
246
247 @property
248 def ndim(self) -> int:
249 """Dimensionality of this BBox"""
250 return len(self.shape)
251
252 @property
253 def start(self) -> tuple[int, ...]:
254 """Tuple of start coordinates"""
255 return self.origin
256
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))
261
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))
266
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))
271
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)])
278
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)
286
287 def shifted_by(self, shift: Sequence[int]) -> Box:
288 """Generate a shifted copy of this box
289
290 Parameters
291 ----------
292 shift:
293 The amount to shift each axis to create the new box
294
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)
302
303 def intersects(self, other: Box) -> bool:
304 """Check if two boxes overlap
305
306 Parameters
307 ----------
308 other:
309 The boxes to check for overlap
310
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
318
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`
322
323 Parameters
324 ----------
325 other:
326
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)
335
336 def __or__(self, other: Box) -> Box:
337 """Union of two bounding boxes
338
339 Parameters
340 ----------
341 other:
342 The other bounding box in the union
343
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)
355
356 def __and__(self, other: Box) -> Box:
357 """Intersection of two bounding boxes
358
359 If there is no intersection between the two bounding
360 boxes then an empty bounding box is returned.
361
362 Parameters
363 ----------
364 other:
365 The other bounding box in the intersection
366
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=}")
375
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)
380
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_))
394
395 def __repr__(self) -> str:
396 return f"Box(shape={self.shape}, origin={self.origin})"
397
398 def _offset_to_tuple(self, offset: int | Sequence[int]) -> tuple[int, ...]:
399 """Expand an integer offset into a tuple
400
401 Parameters
402 ----------
403 offset:
404 The offset to (potentially) convert into a tuple.
405
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
416
417 def __add__(self, offset: int | Sequence[int]) -> Box:
418 """Generate a new Box with a shifted offset
419
420 Parameters
421 ----------
422 offset:
423 The amount to shift the current offset
424
425 Returns
426 -------
427 result:
428 The shifted box.
429 """
430 return self.shifted_by(self._offset_to_tuple(offset))
431
432 def __sub__(self, offset: int | Sequence[int]) -> Box:
433 """Generate a new Box with a shifted offset in the negative direction
434
435 Parameters
436 ----------
437 offset:
438 The amount to shift the current offset
439
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)
448
449 def __matmul__(self, bbox: Box) -> Box:
450 """Combine two Boxes into a higher dimensional box
451
452 Parameters
453 ----------
454 bbox:
455 The box to append to this box.
456
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
465
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
474
475 def __copy__(self) -> Box:
476 """Copy of the box"""
477 return Box(self.shape, origin=self.origin)
478
479 def copy(self) -> Box:
480 """Copy of the box"""
481 return self.__copy__()
482
483 def __eq__(self, other: object) -> bool:
484 """Check for equality.
485
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
491
492 def __hash__(self) -> int:
493 return hash((self.shape, self.origin))
494
495
496def overlapped_slices(bbox1: Box, bbox2: Box) -> tuple[tuple[slice, ...], tuple[slice, ...]]:
497 """Slices of bbox1 and bbox2 that overlap
498
499 Parameters
500 ----------
501 bbox1:
502 The first box.
503 bbox2:
504 The second box.
505
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
Box __sub__(self, int|Sequence[int] offset)
Definition bbox.py:432
tuple[tuple[int, int],...] bounds(self)
Definition bbox.py:268
Box __getitem__(self, int|slice|tuple[int,...] index)
Definition bbox.py:381
Box __add__(self, int|Sequence[int] offset)
Definition bbox.py:417
bool contains(self, Sequence[int] p)
Definition bbox.py:237
tuple[int,...] stop(self)
Definition bbox.py:258
Box shifted_by(self, Sequence[int] shift)
Definition bbox.py:287
tuple[slice,...] slices(self)
Definition bbox.py:273
Box __or__(self, Box other)
Definition bbox.py:336
tuple[int,...] _offset_to_tuple(self, int|Sequence[int] offset)
Definition bbox.py:398
Box from_bounds(*tuple[int,...] bounds)
Definition bbox.py:189
__init__(self, tuple[int,...] shape, tuple[int,...]|None origin=None)
Definition bbox.py:178
Box __matmul__(self, Box bbox)
Definition bbox.py:449
tuple[float,...] center(self)
Definition bbox.py:263
bool __eq__(self, object other)
Definition bbox.py:483
Box __deepcopy__(self, dict[int, Any] memo)
Definition bbox.py:466
Box from_data(np.ndarray x, float threshold=0)
Definition bbox.py:207
tuple[int,...] start(self)
Definition bbox.py:253
tuple[tuple[slice,...], tuple[slice,...]] overlapped_slices(self, Box other)
Definition bbox.py:319
Box grow(self, int|tuple[int,...] radius)
Definition bbox.py:279
bool intersects(self, Box other)
Definition bbox.py:303
Box __and__(self, Box other)
Definition bbox.py:356
tuple[tuple[slice,...], tuple[slice,...]] overlapped_slices(Box bbox1, Box bbox2)
Definition bbox.py:496