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

130 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-07 11:26 +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 typing import Sequence, cast 

27 

28import numpy as np 

29 

30 

31class Box: 

32 """Bounding Box for an object 

33 

34 A Bounding box describes the location of a data unit in the 

35 global/model coordinate system, using the row-major 

36 (default numpy/C++) ordering convention. 

37 So, for example, a 2D image will have shape ``(height, width)``, 

38 however the bounding `Box` code is agnostic as to number of dimensions 

39 or the meaning of those dimensions. 

40 

41 Examples 

42 -------- 

43 

44 At a minimum a new `Box` can be initialized using the ``shape`` of the 

45 region it describes: 

46 

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

48 >>> bbox = Box((3, 4, 5, 6)) 

49 >>> print(bbox) 

50 Box(shape=(3, 4, 5, 6), origin=(0, 0, 0, 0)) 

51 

52 If the region described by the `Box` is offset from the zero origin, 

53 a new ``origin`` can be passed to the constructor 

54 

55 >>> bbox = Box((3, 4, 5, 6), (2, 4, 7, 9)) 

56 >>> print(bbox) 

57 Box(shape=(3, 4, 5, 6), origin=(2, 4, 7, 9)) 

58 

59 It is also possible to initialize a `Box` from a collection of tuples, 

60 where tuple is a pair of integers representing the 

61 first and last index in each dimension. For example: 

62 

63 >>> bbox = Box.from_bounds((3, 6), (11, 21)) 

64 >>> print(bbox) 

65 Box(shape=(3, 10), origin=(3, 11)) 

66 

67 It is also possible to initialize a `Box` by thresholding a numpy array 

68 and including only the region of the image above the threshold in the 

69 resulting `Box`. For example 

70 

71 >>> from lsst.scarlet.lite.utils import integrated_circular_gaussian 

72 >>> data = integrated_circular_gaussian(sigma=1.0) 

73 >>> bbox = Box.from_data(data, 1e-2) 

74 >>> print(bbox) 

75 Box(shape=(5, 5), origin=(5, 5)) 

76 

77 The `Box` class contains a number of convenience methods that can be used 

78 to extract subsets of an array, combine bounding boxes, etc. 

79 

80 For example, using the ``data`` and ``bbox`` from the end of the previous 

81 section, the portion of the data array that is contained in the bounding 

82 box can be extraced usng the `Box.slices` method: 

83 

84 >>> subset = data[bbox.slices] 

85 

86 The intersection of two boxes can be calcualted using the ``&`` operator, 

87 for example 

88 

89 >>> bbox = Box((5, 5)) & Box((5, 5), (2, 2)) 

90 >>> print(bbox) 

91 Box(shape=(3, 3), origin=(2, 2)) 

92 

93 Similarly, the union of two boxes can be calculated using the ``|`` 

94 operator: 

95 

96 >>> bbox = Box((5, 5)) | Box((5, 5), (2, 2)) 

97 >>> print(bbox) 

98 Box(shape=(7, 7), origin=(0, 0)) 

99 

100 To find out of a point is located in a `Box` use 

101 

102 >>> contains = bbox.contains((3, 3)) 

103 >>> print(contains) 

104 True 

105 

106 To find out if two boxes intersect (in other words ``box1 & box2`` has a 

107 non-zero size) use 

108 

109 >>> intersects = bbox.intersects(Box((10, 10), (100, 100))) 

110 >>> print(intersects) 

111 False 

112 

113 It is also possible to shift a box by a vector (sequence): 

114 

115 >>> bbox = bbox + (50, 60) 

116 >>> print(bbox) 

117 Box(shape=(7, 7), origin=(50, 60)) 

118 

119 which can also be negative 

120 

121 >>> bbox = bbox - (5, -5) 

122 >>> print(bbox) 

123 Box(shape=(7, 7), origin=(45, 65)) 

124 

125 Boxes can also be converted into higher dimensions using the 

126 ``@`` operator: 

127 

128 >>> bbox1 = Box((10,), (3, )) 

129 >>> bbox2 = Box((101, 201), (18, 21)) 

130 >>> bbox = bbox1 @ bbox2 

131 >>> print(bbox) 

132 Box(shape=(10, 101, 201), origin=(3, 18, 21)) 

133 

134 Boxes are equal when they have the same shape and the same origin, so 

135 

136 >>> print(Box((10, 10), (5, 5)) == Box((10, 10), (5, 5))) 

137 True 

138 

139 >>> print(Box((10, 10), (5, 5)) == Box((10, 10), (4, 4))) 

140 False 

141 

142 Finally, it is common to insert one array into another when their bounding 

143 boxes only partially overlap. 

144 In order to correctly insert the overlapping portion of the array it is 

145 convenient to calculate the slices from each array that overlap. 

146 For example: 

147 

148 >>> import numpy as np 

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

150 >>> y = np.arange(9).reshape(3, 3) 

151 >>> print(x) 

152 [[ 0 1 2 3] 

153 [ 4 5 6 7] 

154 [ 8 9 10 11]] 

155 >>> print(y) 

156 [[0 1 2] 

157 [3 4 5] 

158 [6 7 8]] 

159 >>> x_box = Box.from_data(x) + (3, 4) 

160 >>> y_box = Box.from_data(y) + (1, 3) 

161 >>> slices = x_box.overlapped_slices(y_box) 

162 >>> x[slices[0]] += y[slices[1]] 

163 >>> print(x) 

164 [[ 7 9 2 3] 

165 [ 4 5 6 7] 

166 [ 8 9 10 11]] 

167 

168 Parameters 

169 ---------- 

170 shape: 

171 Size of the box in each dimension. 

172 origin: 

173 Minimum corner coordinate of the box. 

174 This defaults to ``(0, ...)``. 

175 """ 

176 

177 def __init__(self, shape: tuple[int, ...], origin: tuple[int, ...] | None = None): 

178 self.shape = tuple(shape) 

179 if origin is None: 

180 origin = (0,) * len(shape) 

181 if len(origin) != len(shape): 

182 msg = "Mismatched origin and shape dimensions. " 

183 msg += f"Received {len(origin)} and {len(shape)}" 

184 raise ValueError(msg) 

185 self.origin = tuple(origin) 

186 

187 @staticmethod 

188 def from_bounds(*bounds: tuple[int, ...]) -> Box: 

189 """Initialize a box from its bounds 

190 

191 Parameters 

192 ---------- 

193 bounds: 

194 Min/Max coordinate for every dimension 

195 

196 Returns 

197 ------- 

198 bbox: 

199 A new box bounded by the input bounds. 

200 """ 

201 shape = tuple(max(0, cmax - cmin) for cmin, cmax in bounds) 

202 origin = tuple(cmin for cmin, cmax in bounds) 

203 return Box(shape, origin=origin) 

204 

205 @staticmethod 

206 def from_data(x: np.ndarray, threshold: float = 0) -> Box: 

207 """Define range of `x` above `min_value`. 

208 

209 This method creates the smallest `Box` that contains all of the 

210 elements in `x` that are above `min_value`. 

211 

212 Parameters 

213 ---------- 

214 x: 

215 Data to threshold to specify the shape/dimensionality of `x`. 

216 threshold: 

217 Threshold for the data. 

218 The box is trimmed so that all elements bordering `x` smaller than 

219 `min_value` are ignored. 

220 

221 Returns 

222 ------- 

223 bbox: 

224 Bounding box for the thresholded `x` 

225 """ 

226 sel = x > threshold 

227 if sel.any(): 

228 nonzero = np.where(sel) 

229 bounds = [] 

230 for dim in range(len(x.shape)): 

231 bounds.append((nonzero[dim].min(), nonzero[dim].max() + 1)) 

232 else: 

233 bounds = [(0, 0)] * len(x.shape) 

234 return Box.from_bounds(*bounds) 

235 

236 def contains(self, p: Sequence[int]) -> bool: 

237 """Whether the box contains a given coordinate `p`""" 

238 if len(p) != self.ndim: 

239 raise ValueError(f"Dimension mismatch in {p} and {self.ndim}") 

240 

241 for d in range(self.ndim): 

242 if not (p[d] >= self.origin[d] and (p[d] < (self.origin[d] + self.shape[d]))): 

243 return False 

244 return True 

245 

246 @property 

247 def ndim(self) -> int: 

248 """Dimensionality of this BBox""" 

249 return len(self.shape) 

250 

251 @property 

252 def start(self) -> tuple[int, ...]: 

253 """Tuple of start coordinates""" 

254 return self.origin 

255 

256 @property 

257 def stop(self) -> tuple[int, ...]: 

258 """Tuple of stop coordinates""" 

259 return tuple(o + s for o, s in zip(self.origin, self.shape)) 

260 

261 @property 

262 def center(self) -> tuple[float, ...]: 

263 """Tuple of center coordinates""" 

264 return tuple(o + s / 2 for o, s in zip(self.origin, self.shape)) 

265 

266 @property 

267 def bounds(self) -> tuple[tuple[int, int], ...]: 

268 """Bounds of the box""" 

269 return tuple((o, o + s) for o, s in zip(self.origin, self.shape)) 

270 

271 @property 

272 def slices(self) -> tuple[slice, ...]: 

273 """Bounds of the box as slices""" 

274 if np.any(self.origin) < 0: 

275 raise ValueError("Cannot get slices for a box with negative indices") 

276 return tuple([slice(o, o + s) for o, s in zip(self.origin, self.shape)]) 

277 

278 def grow(self, radius: int | tuple[int, ...]) -> Box: 

279 """Grow the Box by the given radius in each direction""" 

280 if isinstance(radius, int): 

281 radius = tuple([radius] * self.ndim) 

282 origin = tuple([self.origin[d] - radius[d] for d in range(self.ndim)]) 

283 shape = tuple([self.shape[d] + 2 * radius[d] for d in range(self.ndim)]) 

284 return Box(shape, origin=origin) 

285 

286 def shifted_by(self, shift: Sequence[int]) -> Box: 

287 """Generate a shifted copy of this box 

288 

289 Parameters 

290 ---------- 

291 shift: 

292 The amount to shift each axis to create the new box 

293 

294 Returns 

295 ------- 

296 result: 

297 The resulting bounding box. 

298 """ 

299 origin = tuple(o + shift[i] for i, o in enumerate(self.origin)) 

300 return Box(self.shape, origin=origin) 

301 

302 def intersects(self, other: Box) -> bool: 

303 """Check if two boxes overlap 

304 

305 Parameters 

306 ---------- 

307 other: 

308 The boxes to check for overlap 

309 

310 Returns 

311 ------- 

312 result: 

313 True when the two boxes overlap. 

314 """ 

315 overlap = self & other 

316 return np.all(np.array(overlap.shape) != 0) # type: ignore 

317 

318 def overlapped_slices(self, other: Box) -> tuple[tuple[slice, ...], tuple[slice, ...]]: 

319 """Return `slice` for the box that contains the overlap of this and 

320 another `Box` 

321 

322 Parameters 

323 ---------- 

324 other: 

325 

326 Returns 

327 ------- 

328 slices: 

329 The slice of an array bounded by `self` and 

330 the slice of an array bounded by `other` in the 

331 overlapping region. 

332 """ 

333 return overlapped_slices(self, other) 

334 

335 def __or__(self, other: Box) -> Box: 

336 """Union of two bounding boxes 

337 

338 Parameters 

339 ---------- 

340 other: 

341 The other bounding box in the union 

342 

343 Returns 

344 ------- 

345 result: 

346 The smallest rectangular box that contains *both* boxes. 

347 """ 

348 if other.ndim != self.ndim: 

349 raise ValueError(f"Dimension mismatch in the boxes {other} and {self}") 

350 bounds = [] 

351 for d in range(self.ndim): 

352 bounds.append((min(self.start[d], other.start[d]), max(self.stop[d], other.stop[d]))) 

353 return Box.from_bounds(*bounds) 

354 

355 def __and__(self, other: Box) -> Box: 

356 """Intersection of two bounding boxes 

357 

358 If there is no intersection between the two bounding 

359 boxes then an empty bounding box is returned. 

360 

361 Parameters 

362 ---------- 

363 other: 

364 The other bounding box in the intersection 

365 

366 Returns 

367 ------- 

368 result: 

369 The rectangular box that is in the overlap region 

370 of both boxes. 

371 """ 

372 if other.ndim != self.ndim: 

373 raise ValueError(f"Dimension mismatch in the boxes {other=} and {self=}") 

374 

375 bounds = [] 

376 for d in range(self.ndim): 

377 bounds.append((max(self.start[d], other.start[d]), min(self.stop[d], other.stop[d]))) 

378 return Box.from_bounds(*bounds) 

379 

380 def __getitem__(self, index: int | slice | tuple[int, ...]) -> Box: 

381 if isinstance(index, int) or isinstance(index, slice): 

382 s_ = self.shape[index] 

383 o_ = self.origin[index] 

384 if isinstance(s_, int): 

385 s_ = (s_,) 

386 o_ = (cast(int, o_),) # type: ignore 

387 else: 

388 iter(index) 

389 # If I is a Sequence then select the indices in `index`, in order 

390 s_ = tuple(self.shape[i] for i in index) 

391 o_ = tuple(self.origin[i] for i in index) 

392 return Box(s_, origin=cast(tuple[int, ...], o_)) 

393 

394 def __repr__(self) -> str: 

395 return f"Box(shape={self.shape}, origin={self.origin})" 

396 

397 def _offset_to_tuple(self, offset: int | Sequence[int]) -> tuple[int, ...]: 

398 """Expand an integer offset into a tuple 

399 

400 Parameters 

401 ---------- 

402 offset: 

403 The offset to (potentially) convert into a tuple. 

404 

405 Returns 

406 ------- 

407 offset: 

408 The offset as a tuple. 

409 """ 

410 if isinstance(offset, int): 

411 _offset = (offset,) * self.ndim 

412 else: 

413 _offset = tuple(offset) 

414 return _offset 

415 

416 def __add__(self, offset: int | Sequence[int]) -> Box: 

417 """Generate a new Box with a shifted offset 

418 

419 Parameters 

420 ---------- 

421 offset: 

422 The amount to shift the current offset 

423 

424 Returns 

425 ------- 

426 result: 

427 The shifted box. 

428 """ 

429 return self.shifted_by(self._offset_to_tuple(offset)) 

430 

431 def __sub__(self, offset: int | Sequence[int]) -> Box: 

432 """Generate a new Box with a shifted offset in the negative direction 

433 

434 Parameters 

435 ---------- 

436 offset: 

437 The amount to shift the current offset 

438 

439 Returns 

440 ------- 

441 result: 

442 The shifted box. 

443 """ 

444 offset = self._offset_to_tuple(offset) 

445 offset = tuple(-o for o in offset) 

446 return self.shifted_by(offset) 

447 

448 def __matmul__(self, bbox: Box) -> Box: 

449 """Combine two Boxes into a higher dimensional box 

450 

451 Parameters 

452 ---------- 

453 bbox: 

454 The box to append to this box. 

455 

456 Returns 

457 ------- 

458 result: 

459 The combined Box. 

460 """ 

461 bounds = self.bounds + bbox.bounds 

462 result = Box.from_bounds(*bounds) 

463 return result 

464 

465 def __copy__(self) -> Box: 

466 """Copy of the box""" 

467 return Box(self.shape, origin=self.origin) 

468 

469 def copy(self) -> Box: 

470 """Copy of the box""" 

471 return self.__copy__() 

472 

473 def __eq__(self, other: object) -> bool: 

474 """Check for equality. 

475 

476 Two boxes are equal when they have the same shape and origin. 

477 """ 

478 if not hasattr(other, "shape") and not hasattr(other, "origin"): 

479 return False 

480 return self.shape == other.shape and self.origin == other.origin # type: ignore 

481 

482 def __hash__(self) -> int: 

483 return hash((self.shape, self.origin)) 

484 

485 

486def overlapped_slices(bbox1: Box, bbox2: Box) -> tuple[tuple[slice, ...], tuple[slice, ...]]: 

487 """Slices of bbox1 and bbox2 that overlap 

488 

489 Parameters 

490 ---------- 

491 bbox1: 

492 The first box. 

493 bbox2: 

494 The second box. 

495 

496 Returns 

497 ------- 

498 slices: tuple[Sequence[slice], Sequence[slice]] 

499 The slice of an array bounded by `bbox1` and 

500 the slice of an array bounded by `bbox2` in the 

501 overlapping region. 

502 """ 

503 overlap = bbox1 & bbox2 

504 if np.all(np.array(overlap.shape) == 0): 

505 # There was no overlap, so return empty slices 

506 return (slice(0, 0),) * len(overlap.shape), (slice(0, 0),) * len(overlap.shape) 

507 _bbox1 = overlap - bbox1.origin 

508 _bbox2 = overlap - bbox2.origin 

509 slices = ( 

510 _bbox1.slices, 

511 _bbox2.slices, 

512 ) 

513 return slices