258 inputs = butlerQC.get(inputRefs)
259 ref_obj_loader = ReferenceObjectLoader(
260 dataIds=[ref.datasetRef.dataId
for ref
in inputRefs.ref_cat],
261 refCats=inputs.pop(
"ref_cat"),
262 name=self.config.connections.ref_cat,
263 config=self.config.load_reference_objects_config,
265 output = self.
run(ref_obj_loader=ref_obj_loader, **inputs)
267 butlerQC.put(output, outputRefs)
272 preliminary_visit_image: ExposureF,
273 extended_psf: ExtendedPsfImage,
274 ref_obj_loader: ReferenceObjectLoader,
275 preliminary_visit_image_background: BackgroundList |
None =
None,
277 """Subtract fitted extended-PSF models from selected stars.
279 This method clones the input exposure, optionally restores the
280 associated background model for fitting, selects subtraction stars from
281 the reference catalog, and fits/subtracts the warped extended-PSF
282 model for each star in sequence.
283 Stars are always processed in magnitude order from brightest to
284 faintest, and ``max_stars_per_detector`` (if non-zero) is applied after
285 sorting so only the brightest stars are retained.
286 After per-star subtraction, the method optionally re-estimates the
287 background model. When re-estimation is enabled, both updated
288 exposure and background outputs are returned.
292 preliminary_visit_image : `lsst.afw.image.ExposureF`
293 Background-subtracted image.
294 extended_psf : `ExtendedPsfImage`
295 Extended PSF model to be warped and fit per star.
296 ref_obj_loader : `lsst.meas.algorithms.ReferenceObjectLoader`
297 Reference object loader used for star selection.
298 preliminary_visit_image_background :
299 `lsst.afw.math.BackgroundList`, optional
300 Supplied input background model associated with the input image.
301 This is restored onto the working exposure when
302 ``do_restore_background`` is True.
304 output_exposure = preliminary_visit_image.clone()
305 if self.config.do_restore_background:
306 if preliminary_visit_image_background
is None:
307 raise RuntimeError(
"do_restore_background=True requires preliminary_visit_image_background.")
308 output_exposure.maskedImage += preliminary_visit_image_background.getImage()
311 order = np.argsort(star_table[
"mag"])
312 star_table = star_table[order]
313 if self.config.max_stars_per_detector > 0
and len(star_table) > self.config.max_stars_per_detector:
314 star_table = star_table[: self.config.max_stars_per_detector]
316 model_image_legacy = extended_psf.image.to_legacy(copy=
False)
321 amplitude_values = []
323 for star
in star_table:
331 if np.isfinite(amplitude):
333 amplitude_values.append(amplitude)
335 if self.config.do_rerun_background_subtraction:
337 bg_result = self.subtract_background.run(
338 exposure=output_exposure,
340 backgroundToPhotometricRatio=
None,
343 output_background = bg_result.background
345 metadata = output_exposure.getMetadata()
346 metadata.set(
"EPSFSUB_ATTEMPTED", int(n_attempted))
347 metadata.set(
"EPSFSUB_SUBTRACTED", int(n_subtracted))
349 metadata.set(
"EPSFSUB_AMP_MIN", float(np.min(amplitude_values)))
350 metadata.set(
"EPSFSUB_AMP_MAX", float(np.max(amplitude_values)))
351 metadata.set(
"EPSFSUB_AMP_MED", float(np.median(amplitude_values)))
354 "Extended-PSF subtraction attempted on %d stars; successfully subtracted %d.",
359 if self.config.do_rerun_background_subtraction:
361 preliminary_visit_image_extended_psf_subtracted=output_exposure,
362 preliminary_visit_image_extended_psf_subtracted_background=output_background,
364 return Struct(preliminary_visit_image_extended_psf_subtracted=output_exposure)
368 ref_obj_loader: ReferenceObjectLoader,
371 """Build a table of subtraction stars from the reference catalog.
375 ref_obj_loader : `~lsst.meas.algorithms.ReferenceObjectLoader`
376 Loader used to query reference objects in the detector footprint.
377 exposure : `~lsst.afw.image.ExposureF`
378 Exposure used to define the detector bounding box, WCS, and focal
379 plane geometry for star selection.
383 star_table : `~astropy.table.Table`
384 Table of selected subtraction stars after magnitude, isolation,
385 detector-footprint, and focal-plane-radius filtering.
386 Includes per-star pixel and focal-plane coordinates.
388 bbox = exposure.getBBox()
389 wcs = exposure.getWcs()
390 detector = exposure.detector
392 within_region = ref_obj_loader.loadPixelBox(bbox, wcs, self.config.ref_cat_filter_name)
393 ref_cat_full = within_region.refCat
394 flux_field: str = within_region.fluxField
395 exclude_arcsec_radius = self.config.exclude_arcsec_radius * u.arcsec
397 flux_range_candidate = sorted(((self.config.mag_range * u.ABmag).to(u.nJy)).to_value())
398 flux_range_neighbor = sorted(((self.config.exclude_mag_range * u.ABmag).to(u.nJy)).to_value())
400 flux_min = np.min((flux_range_candidate[0], flux_range_neighbor[0]))
401 flux_max = np.max((flux_range_candidate[1], flux_range_neighbor[1]))
402 maximal_subset = (ref_cat_full[flux_field] >= flux_min) & (ref_cat_full[flux_field] <= flux_max)
403 ref_cat_subset = Table(
404 ref_cat_full.extract(
409 where=maximal_subset,
412 flux_subset = ref_cat_subset[flux_field]
414 is_candidate = (flux_subset >= flux_range_candidate[0]) & (flux_subset <= flux_range_candidate[1])
415 is_neighbor = (flux_subset >= flux_range_neighbor[0]) & (flux_subset <= flux_range_neighbor[1])
417 coords = SkyCoord(ref_cat_subset[
"coord_ra"], ref_cat_subset[
"coord_dec"], unit=
"rad")
418 coords_candidate = coords[is_candidate]
419 coords_neighbor = coords[is_neighbor]
421 is_candidate_isolated = np.ones(len(coords_candidate), dtype=bool)
422 if len(coords_neighbor) > 0:
423 _, indices_candidate, angular_separation, _ = coords_candidate.search_around_sky(
424 coords_neighbor, exclude_arcsec_radius
426 indices_candidate = indices_candidate[angular_separation > 0 * u.arcsec]
427 is_candidate_isolated[indices_candidate] =
False
429 star_table = ref_cat_subset[is_candidate][is_candidate_isolated]
430 flux_njy = star_table[flux_field][:] * u.nJy
431 star_table[
"mag"] = flux_njy.to(u.ABmag).to_value()
435 for ra, dec
in zip(star_table[
"coord_ra"], star_table[
"coord_dec"])
437 pixel_coords = wcs.skyToPixel(sphere_points)
438 star_table[
"pixel_x"] = [pixel_coord.x
for pixel_coord
in pixel_coords]
439 star_table[
"pixel_y"] = [pixel_coord.y
for pixel_coord
in pixel_coords]
441 mm_coords = detector.transform(pixel_coords, PIXELS, FOCAL_PLANE)
442 mm_x = np.array([mm_coord.x
for mm_coord
in mm_coords])
443 mm_y = np.array([mm_coord.y
for mm_coord
in mm_coords])
444 radius_mm = np.sqrt(mm_x**2 + mm_y**2)
445 star_table[
"radius_mm"] = radius_mm
446 star_table[
"angle_radians"] = np.arctan2(mm_y, mm_x)
448 within_bbox = star_table[
"pixel_x"] >= bbox.getMinX()
449 within_bbox &= star_table[
"pixel_x"] <= bbox.getMaxX()
450 within_bbox &= star_table[
"pixel_y"] >= bbox.getMinY()
451 within_bbox &= star_table[
"pixel_y"] <= bbox.getMaxY()
452 within_radii = star_table[
"radius_mm"] >= self.config.min_focal_plane_radius
453 within_radii &= star_table[
"radius_mm"] <= self.config.max_focal_plane_radius
454 star_table = star_table[within_bbox & within_radii]
461 model_image_legacy: ImageF,
462 warp_control: WarpingControl,
465 """Warp, fit, and subtract one star model in detector coordinates.
469 exposure : `~lsst.afw.image.ExposureF`
470 Exposure to update in place by subtracting the fitted PSF model.
471 model_image_legacy : `~lsst.afw.image.ImageF`
472 Empirical extended-PSF model image in the model frame.
473 warp_control : `~lsst.afw.math.WarpingControl`
474 Warping configuration used to map the model into detector
476 star : `~astropy.table.Row`
477 Row containing per-star subtraction metadata, including pixel
478 coordinates and focal-plane angle.
483 Fitted PSF amplitude if subtraction succeeds. Returns `numpy.nan`
484 when no valid fit is obtained or subtraction is rejected.
488 This method modifies ``exposure`` in place for the selected local
489 bounding box by subtracting the fitted PSF model from the image plane.
491 detector_bbox = exposure.getBBox()
492 pix_coord =
Point2D(star[
"pixel_x"], star[
"pixel_y"])
494 pixel_scale = exposure.getWcs().getPixelScale(detector_bbox.getCenter()).asArcseconds() * arcseconds
495 pixels_to_boresight_pseudopixels = exposure.detector.getTransform(PIXELS, FIELD_ANGLE).then(
496 makeTransform(AffineTransform.makeScaling(1 / pixel_scale.asRadians()))
499 boresight_pseudopixel_coord = pixels_to_boresight_pseudopixels.applyForward(pix_coord)
501 rotation = makeTransform(AffineTransform.makeRotation(-star[
"angle_radians"] * radians))
502 pixels_to_cutout_frame = pixels_to_boresight_pseudopixels.then(shift).then(rotation)
503 cutout_to_pixels = pixels_to_cutout_frame.inverted()
505 model_bbox = model_image_legacy.getBBox()
506 radius_x = model_bbox.getWidth() // 2
507 radius_y = model_bbox.getHeight() // 2
508 x_center = int(np.round(pix_coord.x))
509 y_center = int(np.round(pix_coord.y))
511 Point2I(x_center - radius_x, y_center - radius_y),
512 Extent2I(model_bbox.getWidth(), model_bbox.getHeight()),
514 local_bbox.clip(detector_bbox)
515 if local_bbox.getArea() == 0:
518 model_local = ImageF(local_bbox)
519 warpImage(model_local, model_image_legacy, cutout_to_pixels, warp_control)
521 invalid_model = ~np.isfinite(model_local.array)
522 if np.any(invalid_model):
523 model_local.array[invalid_model] = 0.0
525 local_mi = exposure.maskedImage[local_bbox]
526 data = local_mi.image.array.astype(np.float64)
527 variance = local_mi.variance.array.astype(np.float64)
528 model = model_local.array.astype(np.float64)
529 grid_y, grid_x = np.mgrid[
530 local_bbox.getMinY() : local_bbox.getMaxY() + 1,
531 local_bbox.getMinX() : local_bbox.getMaxX() + 1,
533 background_terms = np.vstack(
534 [(grid_x**i * grid_y**j).astype(np.float64).ravel()
for i, j
in self.
_bg_powers]
537 bitmask = local_mi.mask.getPlaneBitMask(self.config.bad_mask_planes)
538 good = (local_mi.mask.array & bitmask) == 0
539 good &= np.isfinite(data) & np.isfinite(variance) & np.isfinite(model)
541 good &= model > self.config.min_model_value
543 if np.count_nonzero(good) < self.config.min_fit_pixels:
546 good_flat = good.ravel()
547 sigma = np.sqrt(variance[good])
548 normalized_data = data[good] / sigma
549 normalized_model = model[good] / sigma
550 normalized_background_terms = background_terms[good_flat] / sigma[:,
None]
551 fit_matrix = np.hstack([normalized_model[:,
None], normalized_background_terms])
554 solution, _, _, _ = np.linalg.lstsq(fit_matrix, normalized_data, rcond=
None)
555 except np.linalg.LinAlgError:
558 amplitude = float(solution[0])
562 local_mi.image.array -= amplitude * model