Coverage for python / lsst / scarlet / lite / bbox.py: 24%

138 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 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/>. 

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