Coverage for python / lsst / scarlet / lite / initialization.py: 11%

212 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 

22import logging 

23from typing import Sequence, cast 

24 

25import numpy as np 

26from deprecated.sphinx import deprecated # type: ignore 

27 

28from .bbox import Box 

29from .component import FactorizedComponent 

30from .detect import bounds_to_bbox, get_detect_wavelets 

31from .image import Image 

32from .measure import calculate_snr 

33from .observation import Observation 

34from .operators import Monotonicity, prox_monotonic_mask, prox_uncentered_symmetry 

35from .source import Source 

36 

37logger = logging.getLogger("scarlet.lite.initialization") 

38 

39 

40def trim_morphology( 

41 morph: np.ndarray, 

42 threshold: float = 0, 

43 padding: int = 5, 

44 bg_thresh: float | None = None, 

45) -> tuple[np.ndarray, Box]: 

46 """Trim the morphology up to pixels above a threshold 

47 

48 Parameters 

49 ---------- 

50 morph: 

51 The morphology to be trimmed. 

52 thresh: 

53 The morphology is trimmed to pixels above the threshold. 

54 bg_thresh: 

55 Deprecated in favor of `thresh`. 

56 padding: 

57 The amount to pad each side to allow the source to grow. 

58 

59 Returns 

60 ------- 

61 morph: 

62 The trimmed morphology 

63 box: 

64 The box that contains the morphology. 

65 """ 

66 # Temporarily support bg_thresh 

67 if bg_thresh is not None: 

68 logger.warning("bg_thresh is deprecated and will be after v29.0. " "Use threshold instead.") 

69 threshold = bg_thresh 

70 

71 # trim morph to pixels above threshold 

72 mask = morph > threshold 

73 morph[~mask] = 0 

74 bbox = Box.from_data(morph, threshold=0).grow(padding) 

75 return morph, bbox 

76 

77 

78def init_monotonic_morph( 

79 detect: np.ndarray, 

80 center: tuple[int, int], 

81 full_box: Box, 

82 padding: int = 5, 

83 normalize: bool = True, 

84 monotonicity: Monotonicity | None = None, 

85 threshold: float = 0, 

86 max_iter: int = 0, 

87 center_radius: int = 1, 

88 variance_factor: float = 0, 

89) -> tuple[Box, np.ndarray | None]: 

90 """Initialize a morphology for a monotonic source 

91 

92 Parameters 

93 ---------- 

94 detect: 

95 The 2D detection image contained in `full_box`. 

96 center: 

97 The center of the monotonic source. 

98 full_box: 

99 The bounding box of `detect`. 

100 padding: 

101 The number of pixels to grow the morphology in each direction. 

102 This can be useful if initializing a source with a kernel that 

103 is known to be narrower than the expected value of the source. 

104 normalize: 

105 Whether or not to normalize the morphology. 

106 monotonicity: 

107 When `monotonicity` is `None`, 

108 the component is initialized with only the 

109 monotonic pixels, otherwise the monotonicity operator is used to 

110 project the morphology to a monotonic solution. 

111 threshold: 

112 The minimum value to use for trimming the 

113 morphology. 

114 max_iter: 

115 The maximum number of iterations to use in the monotonicity operator. 

116 Only used if `monotonicity` is `None`. 

117 center_radius: 

118 The amount that the center can be shifted to a local maximum. 

119 Only used if `monotonicity` is `None`. 

120 variance_factor: 

121 The average variance in the image. 

122 This is used to allow pixels to be non-monotonic up to 

123 `variance` * `noise_rms`, so setting `variance = 0` 

124 will force strict monotonicity in the mask. 

125 Only used if `monotonicity` is `None`. 

126 

127 Returns 

128 ------- 

129 bbox: 

130 The bounding box of the morphology. 

131 morph: 

132 The initialized morphology. 

133 """ 

134 center: tuple[int, int] = tuple(center[i] - full_box.origin[i] for i in range(2)) # type: ignore 

135 

136 if monotonicity is None: 

137 _, morph, bounds = prox_monotonic_mask( 

138 x=detect, 

139 center=center, 

140 center_radius=center_radius, 

141 variance=variance_factor, 

142 max_iter=max_iter, 

143 ) 

144 bbox = bounds_to_bbox(bounds) 

145 if bbox.shape == (1, 1) and morph[bbox.slices][0, 0] == 0: 

146 return Box((0, 0)), None 

147 

148 if threshold > 0: 

149 morph, bbox = trim_morphology(morph, threshold=threshold, padding=padding) 

150 

151 else: 

152 morph = monotonicity(detect, center) 

153 

154 # truncate morph at thresh * bg_rms 

155 morph, bbox = trim_morphology(morph, threshold=threshold, padding=padding) 

156 

157 # Shift the bounding box to account for the non-zero origin 

158 bbox += full_box.origin 

159 

160 if np.max(morph) == 0: 

161 return Box((0, 0), origin=full_box.origin), None 

162 

163 if normalize: 

164 morph /= np.max(morph) 

165 

166 if padding is not None and padding > 0: 

167 # Pad the morphology to allow it to grow 

168 bbox = bbox.grow(padding) 

169 

170 # Ensure that the bounding box is inside the full box, 

171 # even after padding. 

172 bbox = bbox & full_box 

173 return bbox, morph 

174 

175 

176def multifit_spectra( 

177 observation: Observation, 

178 morphs: Sequence[Image], 

179 model: Image | None = None, 

180) -> np.ndarray: 

181 """Fit the spectra of multiple components simultaneously 

182 

183 Parameters 

184 ---------- 

185 observation: 

186 The class containing the observation data. 

187 morphs: 

188 The morphology of each component. 

189 model: 

190 An optional model for sources that are not factorized, 

191 and thus will not have their spectra fit. 

192 This model is subtracted from the data before fitting the other 

193 spectra. 

194 

195 Returns 

196 ------- 

197 spectra: 

198 The spectrum for each component, in the same order as `morphs`. 

199 """ 

200 _bands = observation.bands 

201 n_bands = len(_bands) 

202 dtype = observation.images.dtype 

203 

204 if model is not None: 

205 image = observation.images - model 

206 else: 

207 image = observation.images.copy() 

208 

209 morph_images = np.zeros((n_bands, len(morphs), image.data[0].size), dtype=dtype) 

210 for idx, morph in enumerate(morphs): 

211 _image = morph.repeat(observation.bands) 

212 _image = Image.from_box(image.bbox, bands=image.bands).insert(_image) 

213 morph_images[:, idx] = observation.convolve(_image).data.reshape(n_bands, -1) 

214 

215 spectra = np.zeros((len(morphs), n_bands), dtype=dtype) 

216 

217 for b in range(n_bands): 

218 a = np.vstack(morph_images[b]).T 

219 spectra[:, b] = np.linalg.lstsq(a, image[observation.bands[b]].data.flatten(), rcond=None)[0] 

220 spectra[spectra < 0] = 0 

221 return spectra 

222 

223 

224class FactorizedInitialization: 

225 """Common variables and methods for both Factorized Component schemes 

226 

227 There are a large number of parameters that are universal for all of the 

228 sources being initialized from the same set of observed images. 

229 To simplify the API those parameters are all initialized by this class 

230 and passed to `init_source` for each source. 

231 It also creates temporary objects that only need to be created once for 

232 all of the sources in a blend. 

233 

234 Parameters 

235 ---------- 

236 observation: 

237 The observation containing the blend 

238 centers: 

239 The center of each source to initialize. 

240 detect: 

241 The array that contains a 2D image used for detection. 

242 min_snr: 

243 The minimum SNR required per component. 

244 So a 2-component source requires at least `2*min_snr` while sources 

245 with SNR < `min_snr` will be initialized with the PSF. 

246 monotonicity: 

247 When `monotonicity` is `None`, 

248 the component is initialized with only the 

249 monotonic pixels, otherwise the monotonicity operator is used to 

250 project the morphology to a monotonic solution. 

251 disk_percentile: 

252 The percentage of the overall flux to attribute to the disk. 

253 bg_thresh: 

254 The fraction of the background RMS to use as a threshold for the 

255 morphology (in other words the threshold is set at 

256 `bg_thresh * noise_rms`). 

257 initital_bg_thresh: 

258 The same as bg_thresh, but this allows a different background 

259 threshold to be used for initialization. 

260 thresh: 

261 Deprecated. Use `initial_bg_thresh` instead. 

262 padding: 

263 The amount to pad the morphology to allow for extra flux 

264 in the first few iterations before resizing. 

265 use_sparse_init: 

266 Use a monotonic mask to prevent initial source models from growing 

267 too large. 

268 max_components: 

269 The maximum number of components in the source. 

270 This should be one or two. 

271 convolved: 

272 Deprecated. This is now calculated in __init__, but the 

273 old API is supported until v29.0. 

274 is_symmetric: 

275 Whether or not the sources are symmetric. 

276 This is used to determine whether to use the symmetry operator 

277 for initialization. 

278 """ 

279 

280 def __init__( 

281 self, 

282 observation: Observation, 

283 centers: Sequence[tuple[int, int]], 

284 detect: np.ndarray | None = None, 

285 min_snr: float = 50, 

286 monotonicity: Monotonicity | None = None, 

287 disk_percentile: float = 25, 

288 initial_bg_thresh: float = 0.5, 

289 bg_thresh: float = 0.25, 

290 thresh: float | None = None, 

291 padding: int = 2, 

292 use_sparse_init: bool = True, 

293 max_components: int = 2, 

294 convolved: Image | None = None, 

295 is_symmetric: bool = False, 

296 ): 

297 if detect is None: 

298 # Build the morphology detection image 

299 detect = np.sum( 

300 observation.images.data / (observation.noise_rms**2)[:, None, None], 

301 axis=0, 

302 ) 

303 self.detect = detect 

304 

305 if convolved is None: 

306 _detect = Image(detect) 

307 convolved = observation.convolve(_detect.repeat(observation.bands), mode="real") 

308 else: 

309 logger.warning( 

310 "convolved is deprecated and will be removed after v29.0. " 

311 "The convolved image is now calculated in __init__ and does " 

312 "not need to be specified." 

313 ) 

314 

315 if thresh is not None: 

316 initial_bg_thresh = thresh 

317 

318 self.observation = observation 

319 self.convolved = convolved 

320 # Ensure that each center is a tuple of integers 

321 centers = [(int(round(c[0])), int(round(c[1]))) for c in centers] 

322 self.centers = centers 

323 self.min_snr = min_snr 

324 self.monotonicity = monotonicity 

325 self.use_sparse_init = use_sparse_init 

326 self.is_symmetric = is_symmetric 

327 

328 # Get the model PSF 

329 # Convolve the PSF in order to set the spectrum 

330 # of a point source correctly. 

331 model_psf = Image(cast(np.ndarray, observation.model_psf)[0]) 

332 convolved_psf = model_psf.repeat(observation.bands) 

333 self.convolved_psf = observation.convolve(convolved_psf, mode="real").data 

334 # Get the "spectrum" of the PSF 

335 self.py = model_psf.shape[0] // 2 

336 self.px = model_psf.shape[1] // 2 

337 self.psf_spectrum = self.convolved_psf[:, self.py, self.px] 

338 

339 # Set the maximum number of components for any source in the blend 

340 if max_components < 0 or max_components > 2: 

341 raise ValueError(f"max_components must be 0, 1 or 2, got {max_components}") 

342 self.max_components = max_components 

343 self.initial_bg_thresh = initial_bg_thresh 

344 self.bg_thresh = bg_thresh 

345 self.padding = padding 

346 self.disk_percentile = disk_percentile 

347 

348 # Initalize all of the sources 

349 sources = [] 

350 for center in centers: 

351 if max_components == 0: 

352 source = Source([self.get_psf_component(center)]) 

353 else: 

354 source = self.init_source(center) 

355 sources.append(source) 

356 self.sources = sources 

357 

358 @property 

359 def thresh(self): 

360 logger.warning( 

361 "thresh is deprecated and will be removed after v29.0. " "Use initial_bg_thresh instead." 

362 ) 

363 return self.initial_bg_thresh 

364 

365 def get_snr(self, center: tuple[int, int]) -> float: 

366 """Get the SNR at the center of a component 

367 

368 Parameters 

369 ---------- 

370 center: 

371 The location of the center of the source. 

372 

373 Returns 

374 ------- 

375 result: 

376 The SNR at the center of the component. 

377 """ 

378 snr = np.floor( 

379 calculate_snr( 

380 self.observation.images, 

381 self.observation.variance, 

382 self.observation.psfs, 

383 center, 

384 ) 

385 ) 

386 return snr / self.min_snr 

387 

388 def get_psf_component(self, center: tuple[int, int]) -> FactorizedComponent: 

389 """Create a factorized component with a PSF morphology 

390 

391 Parameters 

392 ---------- 

393 center: 

394 The center of the component. 

395 

396 Returns 

397 ------- 

398 component: 

399 A `FactorizedComponent` with a PSF-like morphology. 

400 """ 

401 local_center = ( 

402 center[0] - self.observation.bbox.origin[0], 

403 center[1] - self.observation.bbox.origin[1], 

404 ) 

405 # There wasn't sufficient flux for an extended source, 

406 # so create a PSF source. 

407 spectrum_center = (slice(None), local_center[0], local_center[1]) 

408 spectrum = self.observation.images.data[spectrum_center] / self.psf_spectrum 

409 spectrum[spectrum < 0] = 0 

410 

411 psf = cast(np.ndarray, self.observation.model_psf)[0].copy() 

412 py = psf.shape[0] // 2 

413 px = psf.shape[1] // 2 

414 bbox = Box(psf.shape, origin=(-py + center[0], -px + center[1])) 

415 bbox = self.observation.bbox & bbox 

416 morph = Image(psf, yx0=cast(tuple[int, int], bbox.origin))[bbox].data 

417 component = FactorizedComponent( 

418 self.observation.bands, 

419 spectrum, 

420 morph, 

421 bbox, 

422 center, 

423 self.observation.noise_rms, 

424 monotonicity=self.monotonicity, 

425 is_symmetric=self.is_symmetric, 

426 ) 

427 return component 

428 

429 def get_single_component( 

430 self, 

431 center: tuple[int, int], 

432 detect: np.ndarray, 

433 thresh: float, 

434 padding: int, 

435 ) -> FactorizedComponent | None: 

436 """Initialize parameters for a `FactorizedComponent` 

437 

438 Parameters 

439 ---------- 

440 center: 

441 The location of the center of the source to detect in the 

442 full image. 

443 detect: 

444 The image used for detection of the morphology. 

445 thresh: 

446 The lower cutoff threshold to use for the morphology. 

447 padding: 

448 The amount to pad the morphology to allow for extra flux 

449 in the first few iterations before resizing. 

450 

451 Returns 

452 ------- 

453 component: 

454 A `FactorizedComponent` created from the detection image. 

455 

456 """ 

457 local_center = ( 

458 center[0] - self.observation.bbox.origin[0], 

459 center[1] - self.observation.bbox.origin[1], 

460 ) 

461 

462 if self.use_sparse_init: 

463 monotonicity = None 

464 else: 

465 monotonicity = self.monotonicity 

466 bbox, morph = init_monotonic_morph( 

467 detect, 

468 center, 

469 self.observation.bbox, 

470 padding=padding, 

471 normalize=False, 

472 monotonicity=monotonicity, 

473 threshold=thresh, 

474 ) 

475 

476 if morph is None: 

477 return None 

478 morph = morph[(bbox - self.observation.bbox.origin).slices] 

479 

480 spectrum_center = (slice(None), local_center[0], local_center[1]) 

481 images = self.observation.images 

482 

483 convolved = self.convolved 

484 spectrum = images.data[spectrum_center] / convolved.data[spectrum_center] 

485 spectrum[spectrum < 0] = 0 

486 morph_max = np.max(morph) 

487 spectrum *= morph_max 

488 morph /= morph_max 

489 

490 return FactorizedComponent( 

491 bands=self.observation.bands, 

492 spectrum=spectrum, 

493 morph=morph, 

494 bbox=bbox, 

495 peak=center, 

496 bg_rms=self.observation.noise_rms, 

497 bg_thresh=self.bg_thresh, 

498 monotonicity=self.monotonicity, 

499 is_symmetric=self.is_symmetric, 

500 ) 

501 

502 def init_source(self, center: tuple[int, int]) -> Source: 

503 """Initialize a source from a chi^2 detection. 

504 

505 Parameter 

506 --------- 

507 center: 

508 The center of the source. 

509 init: 

510 The initialization parameters common to all of the sources. 

511 max_components: 

512 The maximum number of components in the source. 

513 """ 

514 # Some operators need the local center, not center in the full image 

515 local_center = ( 

516 center[0] - self.observation.bbox.origin[0], 

517 center[1] - self.observation.bbox.origin[1], 

518 ) 

519 

520 # Calculate the signal to noise at the center of this source 

521 component_snr = self.get_snr(center) 

522 

523 # Initialize the bbox, morph, and spectrum 

524 # for a single component source 

525 detect = prox_uncentered_symmetry(self.detect.copy(), local_center, fill=0) 

526 thresh = np.mean(self.observation.noise_rms) * self.initial_bg_thresh 

527 component = self.get_single_component(center, detect, thresh, self.padding) 

528 

529 if component is None: 

530 # There wasn't enough flux to initialize the source as 

531 # as single component, so initialize it with the model PSF. 

532 components = [self.get_psf_component(center)] 

533 elif component_snr < 2 or self.max_components < 2: 

534 # There isn't sufficient flux to add a second component. 

535 components = [component] 

536 else: 

537 # There was enough flux for a 2-component source, 

538 # so split the single component model into two components, 

539 # using the same algorithm as scarlet main. 

540 bulge_morph = component.morph.copy() 

541 disk_morph = component.morph 

542 # Set the threshold for the bulge. 

543 # Since the morphology is monotonic, this selects the inner 

544 # of the single component morphology and assigns it to the bulge. 

545 flux_thresh = self.disk_percentile / 100 

546 mask = disk_morph > flux_thresh 

547 # Remove the flux above the threshold so that the disk will have 

548 # a flat center. 

549 disk_morph[mask] = flux_thresh 

550 # Subtract off the thresholded flux (since we're normalizing the 

551 # morphology anyway) so that it does not have a sharp 

552 # discontinuity at the edge. 

553 bulge_morph -= flux_thresh 

554 bulge_morph[bulge_morph < 0] = 0 

555 

556 bulge_morph /= np.max(bulge_morph) 

557 disk_morph /= np.max(disk_morph) 

558 

559 # Fit the spectra assuming that all of the flux in the image 

560 # is due to both components. This is not true, but for the 

561 # vast majority of sources this is a good approximation. 

562 try: 

563 bulge_spectrum, disk_spectrum = multifit_spectra( 

564 self.observation, 

565 [ 

566 Image(bulge_morph, yx0=cast(tuple[int, int], component.bbox.origin)), 

567 Image(disk_morph, yx0=cast(tuple[int, int], component.bbox.origin)), 

568 ], 

569 ) 

570 

571 components = [ 

572 FactorizedComponent( 

573 bands=self.observation.bands, 

574 spectrum=bulge_spectrum, 

575 morph=bulge_morph, 

576 bbox=component.bbox.copy(), 

577 peak=center, 

578 bg_rms=self.observation.noise_rms, 

579 bg_thresh=self.bg_thresh, 

580 monotonicity=self.monotonicity, 

581 is_symmetric=self.is_symmetric, 

582 ), 

583 FactorizedComponent( 

584 bands=self.observation.bands, 

585 spectrum=disk_spectrum, 

586 morph=disk_morph, 

587 bbox=component.bbox.copy(), 

588 peak=center, 

589 bg_rms=self.observation.noise_rms, 

590 bg_thresh=self.bg_thresh, 

591 monotonicity=self.monotonicity, 

592 is_symmetric=self.is_symmetric, 

593 ), 

594 ] 

595 except np.linalg.LinAlgError: 

596 components = [component] 

597 

598 return Source(components) # type: ignore 

599 

600 

601@deprecated( 

602 reason="This class is replaced by FactorizedInitialization and will be removed after v29.0", 

603 version="v29.0", 

604 category=FutureWarning, 

605) 

606class FactorizedChi2Initialization(FactorizedInitialization): 

607 """Initialize all sources with chi^2 detections 

608 

609 There are a large number of parameters that are universal for all of the 

610 sources being initialized from the same set of observed images. 

611 To simplify the API those parameters are all initialized by this class 

612 and passed to `init_main_source` for each source. 

613 It also creates temporary objects that only need to be created once for 

614 all of the sources in a blend. 

615 

616 Parameters 

617 ---------- 

618 observation: 

619 The observation containing the blend 

620 centers: 

621 The center of each source to initialize. 

622 detect: 

623 The array that contains a 2D image used for detection. 

624 min_snr: 

625 The minimum SNR required per component. 

626 So a 2-component source requires at least `2*min_snr` while sources 

627 with SNR < `min_snr` will be initialized with the PSF. 

628 monotonicity: 

629 When `monotonicity` is `None`, 

630 the component is initialized with only the 

631 monotonic pixels, otherwise the monotonicity operator is used to 

632 project the morphology to a monotonic solution. 

633 disk_percentile: 

634 The percentage of the overall flux to attribute to the disk. 

635 thresh: 

636 The threshold used to trim the morphology, 

637 so all pixels below `thresh * bg_rms` are set to zero. 

638 padding: 

639 The amount to pad the morphology to allow for extra flux 

640 in the first few iterations before resizing. 

641 """ 

642 

643 pass 

644 

645 

646@deprecated( 

647 reason="FactorizedWaveletInitialization will be removed after v29.0 " 

648 "since it does not appear to offer any advantages over " 

649 "FactorizedChi2Initialization. Consider switching to " 

650 "FactorizedInitialization now.", 

651 version="v29.0", 

652 category=FutureWarning, 

653) 

654class FactorizedWaveletInitialization(FactorizedInitialization): 

655 """Parameters used to initialize all sources with wavelet detections 

656 

657 There are a large number of parameters that are universal for all of the 

658 sources being initialized from the same set of wavelet coefficients. 

659 To simplify the API those parameters are all initialized by this class 

660 and passed to `init_wavelet_source` for each source. 

661 

662 Parameters 

663 ---------- 

664 observation: 

665 The multiband observation of the blend. 

666 centers: 

667 The center of each source to initialize. 

668 bulge_slice, disk_slice: 

669 The slice used to select the wavelet scales used for the 

670 bulge/disk. 

671 bulge_padding, disk_padding: 

672 The number of pixels to grow the bounding box of the bulge/disk 

673 to leave extra room for growth in the first few iterations. 

674 use_psf: 

675 Whether or not to use the PSF for single component sources. 

676 If `use_psf` is `False` then only sources with low signal 

677 at all scales are initialized with the PSF morphology. 

678 scales: 

679 Number of wavelet scales to use. 

680 wavelets: 

681 The array of wavelet coefficients `(scale, y, x)` 

682 used for detection. 

683 monotonicity: 

684 When `monotonicity` is `None`, 

685 the component is initialized with only the 

686 monotonic pixels, otherwise the monotonicity operator is used to 

687 project the morphology to a monotonic solution. 

688 min_snr: 

689 The minimum SNR required per component. 

690 So a 2-component source requires at least `2*min_snr` while sources 

691 with SNR < `min_snr` will be initialized with the PSF. 

692 """ 

693 

694 def __init__( 

695 self, 

696 observation: Observation, 

697 centers: Sequence[tuple[int, int]], 

698 bulge_slice: slice = slice(None, 2), 

699 disk_slice: slice = slice(2, -1), 

700 bulge_padding: int = 5, 

701 disk_padding: int = 5, 

702 use_psf: bool = True, 

703 scales: int = 5, 

704 wavelets: np.ndarray | None = None, 

705 monotonicity: Monotonicity | None = None, 

706 min_snr: float = 50, 

707 use_sparse_init: bool = True, 

708 ): 

709 if wavelets is None: 

710 wavelets = get_detect_wavelets( 

711 observation.images.data, 

712 observation.variance.data, 

713 scales=scales, 

714 ) 

715 wavelets[wavelets < 0] = 0 

716 # The detection coadd for single component sources 

717 detectlets = np.sum(wavelets[:-1], axis=0) 

718 # The detection coadd for the bulge 

719 bulgelets = np.sum(wavelets[bulge_slice], axis=0) 

720 # The detection coadd for the disk 

721 disklets = np.sum(wavelets[disk_slice], axis=0) 

722 

723 self.detectlets = detectlets 

724 self.bulgelets = bulgelets 

725 self.disklets = disklets 

726 self.bulge_grow = bulge_padding 

727 self.disk_grow = disk_padding 

728 self.use_psf = use_psf 

729 

730 # Initialize the sources 

731 super().__init__( 

732 observation=observation, 

733 centers=centers, 

734 detect=detectlets, 

735 min_snr=min_snr, 

736 monotonicity=monotonicity, 

737 use_sparse_init=use_sparse_init, 

738 ) 

739 

740 def init_source(self, center: tuple[int, int]) -> Source: 

741 """Initialize a source from a chi^2 detection. 

742 

743 Parameter 

744 --------- 

745 center: 

746 The center of the source. 

747 """ 

748 local_center = ( 

749 center[0] - self.observation.bbox.origin[0], 

750 center[1] - self.observation.bbox.origin[1], 

751 ) 

752 nbr_components = self.get_snr(center) 

753 observation = self.observation 

754 

755 if (nbr_components < 1 and self.use_psf) or self.detectlets[local_center[0], local_center[1]] <= 0: 

756 # Initialize the source as an PSF source 

757 components = [self.get_psf_component(center)] 

758 elif nbr_components < 2: 

759 # Inititialize with a single component 

760 component = self.get_single_component(center, self.detectlets, 0, self.disk_grow) 

761 if component is not None: 

762 components = [component] 

763 else: 

764 # Initialize with a 2 component model 

765 bulge_box, bulge_morph = init_monotonic_morph( 

766 self.bulgelets, center, observation.bbox, self.bulge_grow 

767 ) 

768 disk_box, disk_morph = init_monotonic_morph( 

769 self.disklets, center, observation.bbox, self.disk_grow 

770 ) 

771 if bulge_morph is None or disk_morph is None: 

772 if bulge_morph is None: 

773 if disk_morph is None: 

774 raise RuntimeError("Both components are None") 

775 # One of the components was null, 

776 # so initialize as a single component 

777 component = self.get_single_component(center, self.detectlets, 0, self.disk_grow) 

778 if component is not None: 

779 components = [component] 

780 else: 

781 local_bulge_box = bulge_box - self.observation.bbox.origin 

782 local_disk_box = disk_box - self.observation.bbox.origin 

783 bulge_morph = bulge_morph[local_bulge_box.slices] 

784 disk_morph = disk_morph[local_disk_box.slices] 

785 

786 bulge_spectrum, disk_spectrum = multifit_spectra( 

787 observation, 

788 [ 

789 Image(bulge_morph, yx0=cast(tuple[int, int], bulge_box.origin)), 

790 Image(disk_morph, yx0=cast(tuple[int, int], disk_box.origin)), 

791 ], 

792 ) 

793 

794 components = [] 

795 if np.sum(bulge_spectrum != 0): 

796 components.append( 

797 FactorizedComponent( 

798 observation.bands, 

799 bulge_spectrum, 

800 bulge_morph, 

801 bulge_box, 

802 center, 

803 monotonicity=self.monotonicity, 

804 ) 

805 ) 

806 else: 

807 logger.debug("cut bulge") 

808 if np.sum(disk_spectrum) != 0: 

809 components.append( 

810 FactorizedComponent( 

811 observation.bands, 

812 disk_spectrum, 

813 disk_morph, 

814 disk_box, 

815 center, 

816 monotonicity=self.monotonicity, 

817 ) 

818 ) 

819 else: 

820 logger.debug("cut disk") 

821 return Source(components) # type: ignore