268 ref_obj_loader: ReferenceObjectLoader,
269 input_exposure: ExposureF,
271 """Get a table of extended PSF candidates from the reference catalog.
273 Trim the reference catalog to only those objects within the exposure
275 Then, select stars based on the specified magnitude range,
276 isolation criteria, and optionally focal plane radius criteria.
277 Finally, add columns with pixel coordinates and focal plane coordinates
278 for each extended PSF candidate.
282 ref_obj_loader : `~lsst.meas.algorithms.ReferenceObjectLoader`
283 Loader to find objects within a reference catalog.
284 input_exposure : `~lsst.afw.image.ExposureF`
285 The exposure for which extended PSF candidates are being selected.
289 extended_psf_candidate_table : `~astropy.table.Table`
290 Table of extended PSF candidates within the exposure.
292 bbox = input_exposure.getBBox()
293 wcs = input_exposure.getWcs()
294 detector = input_exposure.detector
297 within_region = ref_obj_loader.loadPixelBox(bbox, wcs, self.config.ref_cat_filter_name)
298 ref_cat_full = within_region.refCat
299 flux_field: str = within_region.fluxField
300 exclude_arcsec_radius = self.config.exclude_arcsec_radius * u.arcsec
303 flux_range_candidate = sorted(((self.config.mag_range * u.ABmag).to(u.nJy)).to_value())
304 flux_range_neighbor = sorted(((self.config.exclude_mag_range * u.ABmag).to(u.nJy)).to_value())
308 flux_min = np.min((flux_range_candidate[0], flux_range_neighbor[0]))
309 flux_max = np.max((flux_range_candidate[1], flux_range_neighbor[1]))
310 maximal_subset = (ref_cat_full[flux_field] >= flux_min) & (ref_cat_full[flux_field] <= flux_max)
311 ref_cat_subset_columns = (
"id",
"coord_ra",
"coord_dec", flux_field)
312 ref_cat_subset = Table(ref_cat_full.extract(*ref_cat_subset_columns, where=maximal_subset))
313 flux_subset = ref_cat_subset[flux_field]
316 is_candidate = (flux_subset >= flux_range_candidate[0]) & (flux_subset <= flux_range_candidate[1])
317 is_neighbor = (flux_subset >= flux_range_neighbor[0]) & (flux_subset <= flux_range_neighbor[1])
320 coords = SkyCoord(ref_cat_subset[
"coord_ra"], ref_cat_subset[
"coord_dec"], unit=
"rad")
321 coords_candidate = coords[is_candidate]
322 coords_neighbor = coords[is_neighbor]
325 is_candidate_isolated = np.ones(len(coords_candidate), dtype=bool)
326 if len(coords_neighbor) > 0:
327 _, indices_candidate, angular_separation, _ = coords_candidate.search_around_sky(
328 coords_neighbor, exclude_arcsec_radius
330 indices_candidate = indices_candidate[angular_separation > 0 * u.arcsec]
331 is_candidate_isolated[indices_candidate] =
False
334 extended_psf_candidate_table = ref_cat_subset[is_candidate][is_candidate_isolated]
336 flux_nanojansky = extended_psf_candidate_table[flux_field][:] * u.nJy
337 extended_psf_candidate_table[
"mag"] = flux_nanojansky.to(u.ABmag).to_value()
340 extended_psf_candidate_table[
"coord_ra"] * radians,
341 extended_psf_candidate_table[
"coord_dec"] * radians,
343 sphere_points = [
SpherePoint(ra, dec)
for ra, dec
in zip_ra_dec]
344 pixel_coords = wcs.skyToPixel(sphere_points)
345 extended_psf_candidate_table[
"pixel_x"] = [pixel_coord.x
for pixel_coord
in pixel_coords]
346 extended_psf_candidate_table[
"pixel_y"] = [pixel_coord.y
for pixel_coord
in pixel_coords]
348 mm_coords = detector.transform(pixel_coords, PIXELS, FOCAL_PLANE)
349 mm_coords_x = np.array([mm_coord.x
for mm_coord
in mm_coords])
350 mm_coords_y = np.array([mm_coord.y
for mm_coord
in mm_coords])
351 radius_mm = np.sqrt(mm_coords_x**2 + mm_coords_y**2)
352 angle_radians = np.arctan2(mm_coords_y, mm_coords_x)
353 extended_psf_candidate_table[
"radius_mm"] = radius_mm
354 extended_psf_candidate_table[
"angle_radians"] = angle_radians
358 within_bbox = extended_psf_candidate_table[
"pixel_x"] >= bbox.getMinX()
359 within_bbox &= extended_psf_candidate_table[
"pixel_x"] <= bbox.getMaxX()
360 within_bbox &= extended_psf_candidate_table[
"pixel_y"] >= bbox.getMinY()
361 within_bbox &= extended_psf_candidate_table[
"pixel_y"] <= bbox.getMaxY()
362 within_radii = extended_psf_candidate_table[
"radius_mm"] >= self.config.min_focal_plane_radius
363 within_radii &= extended_psf_candidate_table[
"radius_mm"] <= self.config.max_focal_plane_radius
364 extended_psf_candidate_table = extended_psf_candidate_table[within_bbox & within_radii]
367 "Identified %i reference star%s in the field of view after applying magnitude and isolation "
369 len(extended_psf_candidate_table),
370 "s" if len(extended_psf_candidate_table) != 1
else "",
373 return extended_psf_candidate_table
377 input_exposure: ExposureF,
378 input_background: BackgroundList |
None,
379 footprints: SourceCatalog | np.ndarray,
380 extended_psf_candidate_table: Table,
381 ) -> ExtendedPsfCandidates |
None:
382 """Extract and warp extended PSF candidate cutouts.
384 For each extended PSF candidate, extract a cutout from the input
385 exposure centered on the candidate's pixel coordinates.
386 Then, shift and warp the cutout to recenter on the candidate and align
387 each to the same orientation.
388 Finally, check the fraction of the cutout area that is masked
389 (e.g. due to neighboring sources or bad pixels), and only retain those
390 with sufficient unmasked area.
394 input_exposure : `~lsst.afw.image.ExposureF`
395 The science image to extract extended PSF cutouts.
396 input_background : `~lsst.afw.math.BackgroundList` | None
397 The background model associated with the input exposure.
398 If provided, this will be added back on to the input image.
399 footprints : `~lsst.afw.table.SourceCatalog` | `numpy.ndarray`
400 The source catalog containing footprints on the input exposure, or
401 a 2D numpy array with the same dimensions as the input exposure
402 where each pixel value corresponds to the source footprint ID.
403 extended_psf_candidate_table : `~astropy.table.Table`
404 Table of extended PSF candidates for which to extract cutouts.
408 extended_psf_candidates :
409 `~lsst.pipe.tasks.extendedPsf.ExtendedPsfCandidates` | None
410 A set of cutouts, each centered on an extended PSF candidate.
411 If no cutouts are retained post-masking, returns `None`.
413 warp_control =
WarpingControl(self.config.warping_kernel_name, self.config.mask_warping_kernel_name)
414 bbox = input_exposure.getBBox()
417 input_MI = input_exposure.getMaskedImage()
418 if input_background
is not None:
419 input_MI += input_background.getImage()
420 input_MI = input_exposure.photoCalib.calibrateImage(input_MI)
423 input_MI.mask.addMaskPlane(NEIGHBOR_MASK_PLANE)
424 if isinstance(footprints, SourceCatalog):
425 footprints = footprintsToNumpy(footprints, bbox, asBool=
False)
428 pixel_scale = input_exposure.wcs.getPixelScale(bbox.getCenter()).asArcseconds() * arcseconds
429 pixels_to_boresight_pseudopixels = input_exposure.detector.getTransform(PIXELS, FIELD_ANGLE).then(
430 makeTransform(AffineTransform.makeScaling(1 / pixel_scale.asRadians()))
434 cutout_radius = floor(
Extent2D(*self.config.cutout_size) / 2)
438 cutout_radius_padded = floor((
Extent2D(*self.config.cutout_size) * 1.42) / 2)
442 focal_plane_radii_mm = []
443 for candidate
in extended_psf_candidate_table:
444 pix_coord =
Point2D(candidate[
"pixel_x"], candidate[
"pixel_y"])
447 neighbor_bit_mask = input_MI.mask.getPlaneBitMask(NEIGHBOR_MASK_PLANE)
448 input_MI.mask.clearMaskPlane(input_MI.mask.getMaskPlaneDict()[NEIGHBOR_MASK_PLANE])
449 candidate_id = footprints[int(pix_coord.y), int(pix_coord.x)]
450 neighbor_mask = (footprints != 0) & (footprints != candidate_id)
451 input_MI.mask.array[neighbor_mask] |= neighbor_bit_mask
454 boresight_pseudopixel_coord = pixels_to_boresight_pseudopixels.applyForward(pix_coord)
456 rotation = makeTransform(AffineTransform.makeRotation(-candidate[
"angle_radians"] * radians))
457 pixels_to_cutout_frame = pixels_to_boresight_pseudopixels.then(shift).then(rotation)
460 cutout_MI = MaskedImageF(cutout_bbox_padded)
461 warpImage(cutout_MI, input_MI, pixels_to_cutout_frame, warp_control)
462 cutout_MI = cutout_MI[cutout_bbox]
465 bad_bit_mask = cutout_MI.mask.getPlaneBitMask(self.config.bad_mask_planes)
466 good = (cutout_MI.mask.array & bad_bit_mask) == 0
467 good_frac = np.sum(good) / cutout_MI.mask.array.size
468 if good_frac < self.config.min_area_fraction:
472 cutout_wcs = makeModifiedWcs(pixels_to_cutout_frame, input_exposure.wcs,
False)
473 sky_projection = SkyProjection.from_legacy(cutout_wcs, GeneralFrame(unit=u.pixel))
476 psf_warped =
WarpedPsf(input_exposure.getPsf(), pixels_to_cutout_frame, warp_control)
477 psf_kernel_image = Image.from_legacy(psf_warped.computeKernelImage(
Point2D(0, 0)))
481 visit=input_exposure.visitInfo.getId(),
482 detector=input_exposure.detector.getId(),
483 ref_id=candidate[
"id"],
484 ref_mag=candidate[
"mag"],
485 position_x=pix_coord.x,
486 position_y=pix_coord.y,
487 focal_plane_radius=candidate[
"radius_mm"] * u.mm,
488 focal_plane_angle=candidate[
"angle_radians"] * u.rad,
491 plane_map = get_legacy_visit_image_mask_planes()
492 plane_map[
"NEIGHBOR"] = MaskPlane(
493 "NEIGHBOR",
"Flux in pixel is attributed to a neighboring source detection footprint."
498 image=Image.from_legacy(cutout_MI.image),
499 mask=Mask.from_legacy(cutout_MI.mask, plane_map=plane_map),
500 variance=Image.from_legacy(cutout_MI.variance),
501 sky_projection=sky_projection,
502 psf_kernel_image=psf_kernel_image,
505 cutouts.append(cutout)
506 focal_plane_radii_mm.append(candidate[
"radius_mm"])
508 num_stars = len(extended_psf_candidate_table)
509 num_excluded = num_stars - len(cutouts)
510 percent_excluded = 100.0 * num_excluded / num_stars
if num_stars > 0
else 0.0
512 "Extracted %i extended PSF candidate%s. "
513 "Excluded %i star%s (%.1f%%) with an unmasked area fraction below %s.",
515 "" if len(cutouts) == 1
else "s",
517 "" if num_excluded == 1
else "s",
519 self.config.min_area_fraction,
524 "No extended PSF candidates were retained from %i selected reference star%s.",
526 "" if num_stars == 1
else "s",
531 "FOCAL_PLANE_RADIUS_MM_MIN": np.min(focal_plane_radii_mm),
532 "FOCAL_PLANE_RADIUS_MM_MAX": np.max(focal_plane_radii_mm),