261 ref_obj_loader: ReferenceObjectLoader,
262 input_exposure: ExposureF,
264 """Get a table of extended PSF candidates from the reference catalog.
266 Trim the reference catalog to only those objects within the exposure
268 Then, select stars based on the specified magnitude range,
269 isolation criteria, and optionally focal plane radius criteria.
270 Finally, add columns with pixel coordinates and focal plane coordinates
271 for each extended PSF candidate.
275 ref_obj_loader : `~lsst.meas.algorithms.ReferenceObjectLoader`
276 Loader to find objects within a reference catalog.
277 input_exposure : `~lsst.afw.image.ExposureF`
278 The exposure for which extended PSF candidates are being selected.
282 extended_psf_candidate_table : `~astropy.table.Table`
283 Table of extended PSF candidates within the exposure.
285 bbox = input_exposure.getBBox()
286 wcs = input_exposure.getWcs()
287 detector = input_exposure.detector
290 within_region = ref_obj_loader.loadPixelBox(bbox, wcs, self.config.ref_cat_filter_name)
291 ref_cat_full = within_region.refCat
292 flux_field: str = within_region.fluxField
293 exclude_arcsec_radius = self.config.exclude_arcsec_radius * u.arcsec
296 flux_range_candidate = sorted(((self.config.mag_range * u.ABmag).to(u.nJy)).to_value())
297 flux_range_neighbor = sorted(((self.config.exclude_mag_range * u.ABmag).to(u.nJy)).to_value())
301 flux_min = np.min((flux_range_candidate[0], flux_range_neighbor[0]))
302 flux_max = np.max((flux_range_candidate[1], flux_range_neighbor[1]))
303 maximal_subset = (ref_cat_full[flux_field] >= flux_min) & (ref_cat_full[flux_field] <= flux_max)
304 ref_cat_subset_columns = (
"id",
"coord_ra",
"coord_dec", flux_field)
305 ref_cat_subset = Table(ref_cat_full.extract(*ref_cat_subset_columns, where=maximal_subset))
306 flux_subset = ref_cat_subset[flux_field]
309 is_candidate = (flux_subset >= flux_range_candidate[0]) & (flux_subset <= flux_range_candidate[1])
310 is_neighbor = (flux_subset >= flux_range_neighbor[0]) & (flux_subset <= flux_range_neighbor[1])
313 coords = SkyCoord(ref_cat_subset[
"coord_ra"], ref_cat_subset[
"coord_dec"], unit=
"rad")
314 coords_candidate = coords[is_candidate]
315 coords_neighbor = coords[is_neighbor]
318 is_candidate_isolated = np.ones(len(coords_candidate), dtype=bool)
319 if len(coords_neighbor) > 0:
320 _, indices_candidate, angular_separation, _ = coords_candidate.search_around_sky(
321 coords_neighbor, exclude_arcsec_radius
323 indices_candidate = indices_candidate[angular_separation > 0 * u.arcsec]
324 is_candidate_isolated[indices_candidate] =
False
327 extended_psf_candidate_table = ref_cat_subset[is_candidate][is_candidate_isolated]
329 flux_nanojansky = extended_psf_candidate_table[flux_field][:] * u.nJy
330 extended_psf_candidate_table[
"mag"] = flux_nanojansky.to(u.ABmag).to_value()
333 extended_psf_candidate_table[
"coord_ra"] * radians,
334 extended_psf_candidate_table[
"coord_dec"] * radians,
336 sphere_points = [
SpherePoint(ra, dec)
for ra, dec
in zip_ra_dec]
337 pixel_coords = wcs.skyToPixel(sphere_points)
338 extended_psf_candidate_table[
"pixel_x"] = [pixel_coord.x
for pixel_coord
in pixel_coords]
339 extended_psf_candidate_table[
"pixel_y"] = [pixel_coord.y
for pixel_coord
in pixel_coords]
341 mm_coords = detector.transform(pixel_coords, PIXELS, FOCAL_PLANE)
342 mm_coords_x = np.array([mm_coord.x
for mm_coord
in mm_coords])
343 mm_coords_y = np.array([mm_coord.y
for mm_coord
in mm_coords])
344 radius_mm = np.sqrt(mm_coords_x**2 + mm_coords_y**2)
345 angle_radians = np.arctan2(mm_coords_y, mm_coords_x)
346 extended_psf_candidate_table[
"radius_mm"] = radius_mm
347 extended_psf_candidate_table[
"angle_radians"] = angle_radians
351 within_bbox = extended_psf_candidate_table[
"pixel_x"] >= bbox.getMinX()
352 within_bbox &= extended_psf_candidate_table[
"pixel_x"] <= bbox.getMaxX()
353 within_bbox &= extended_psf_candidate_table[
"pixel_y"] >= bbox.getMinY()
354 within_bbox &= extended_psf_candidate_table[
"pixel_y"] <= bbox.getMaxY()
355 within_radii = extended_psf_candidate_table[
"radius_mm"] >= self.config.min_focal_plane_radius
356 within_radii &= extended_psf_candidate_table[
"radius_mm"] <= self.config.max_focal_plane_radius
357 extended_psf_candidate_table = extended_psf_candidate_table[within_bbox & within_radii]
360 "Identified %i reference star%s in the field of view after applying magnitude and isolation "
362 len(extended_psf_candidate_table),
363 "s" if len(extended_psf_candidate_table) != 1
else "",
366 return extended_psf_candidate_table
370 input_exposure: ExposureF,
371 input_background: BackgroundList |
None,
372 footprints: SourceCatalog | np.ndarray,
373 extended_psf_candidate_table: Table,
374 ) -> ExtendedPsfCandidates |
None:
375 """Extract and warp extended PSF candidate cutouts.
377 For each extended PSF candidate, extract a cutout from the input
378 exposure centered on the candidate's pixel coordinates.
379 Then, shift and warp the cutout to recenter on the candidate and align
380 each to the same orientation.
381 Finally, check the fraction of the cutout area that is masked
382 (e.g. due to neighboring sources or bad pixels), and only retain those
383 with sufficient unmasked area.
387 input_exposure : `~lsst.afw.image.ExposureF`
388 The science image to extract extended PSF cutouts.
389 input_background : `~lsst.afw.math.BackgroundList` | None
390 The background model associated with the input exposure.
391 If provided, this will be added back on to the input image.
392 footprints : `~lsst.afw.table.SourceCatalog` | `numpy.ndarray`
393 The source catalog containing footprints on the input exposure, or
394 a 2D numpy array with the same dimensions as the input exposure
395 where each pixel value corresponds to the source footprint ID.
396 extended_psf_candidate_table : `~astropy.table.Table`
397 Table of extended PSF candidates for which to extract cutouts.
401 extended_psf_candidates :
402 `~lsst.pipe.tasks.extendedPsf.ExtendedPsfCandidates` | None
403 A set of cutouts, each centered on an extended PSF candidate.
404 If no cutouts are retained post-masking, returns `None`.
406 warp_control =
WarpingControl(self.config.warping_kernel_name, self.config.mask_warping_kernel_name)
407 bbox = input_exposure.getBBox()
410 input_MI = input_exposure.getMaskedImage()
411 if input_background
is not None:
412 input_MI += input_background.getImage()
413 input_MI = input_exposure.photoCalib.calibrateImage(input_MI)
416 input_MI.mask.addMaskPlane(NEIGHBOR_MASK_PLANE)
417 if isinstance(footprints, SourceCatalog):
418 footprints = footprintsToNumpy(footprints, bbox, asBool=
False)
421 pixel_scale = input_exposure.wcs.getPixelScale(bbox.getCenter()).asArcseconds() * arcseconds
422 pixels_to_boresight_pseudopixels = input_exposure.detector.getTransform(PIXELS, FIELD_ANGLE).then(
423 makeTransform(AffineTransform.makeScaling(1 / pixel_scale.asRadians()))
427 cutout_radius = floor(
Extent2D(*self.config.cutout_size) / 2)
431 cutout_radius_padded = floor((
Extent2D(*self.config.cutout_size) * 1.42) / 2)
435 focal_plane_radii_mm = []
436 for candidate
in extended_psf_candidate_table:
437 pix_coord =
Point2D(candidate[
"pixel_x"], candidate[
"pixel_y"])
440 neighbor_bit_mask = input_MI.mask.getPlaneBitMask(NEIGHBOR_MASK_PLANE)
441 input_MI.mask.clearMaskPlane(input_MI.mask.getMaskPlaneDict()[NEIGHBOR_MASK_PLANE])
442 candidate_id = footprints[int(pix_coord.y), int(pix_coord.x)]
443 neighbor_mask = (footprints != 0) & (footprints != candidate_id)
444 input_MI.mask.array[neighbor_mask] |= neighbor_bit_mask
447 boresight_pseudopixel_coord = pixels_to_boresight_pseudopixels.applyForward(pix_coord)
449 rotation = makeTransform(AffineTransform.makeRotation(-candidate[
"angle_radians"] * radians))
450 pixels_to_cutout_frame = pixels_to_boresight_pseudopixels.then(shift).then(rotation)
453 cutout_MI = MaskedImageF(cutout_bbox_padded)
454 warpImage(cutout_MI, input_MI, pixels_to_cutout_frame, warp_control)
455 cutout_MI = cutout_MI[cutout_bbox]
458 bad_bit_mask = cutout_MI.mask.getPlaneBitMask(self.config.bad_mask_planes)
459 good = (cutout_MI.mask.array & bad_bit_mask) == 0
460 good_frac = np.sum(good) / cutout_MI.mask.array.size
461 if good_frac < self.config.min_area_fraction:
465 cutout_wcs = makeModifiedWcs(pixels_to_cutout_frame, input_exposure.wcs,
False)
466 projection = Projection.from_legacy(cutout_wcs, GeneralFrame(unit=u.pixel))
469 psf_warped =
WarpedPsf(input_exposure.getPsf(), pixels_to_cutout_frame, warp_control)
470 psf_kernel_image = Image.from_legacy(psf_warped.computeKernelImage(
Point2D(0, 0)))
474 visit=input_exposure.visitInfo.getId(),
475 detector=input_exposure.detector.getId(),
476 ref_id=candidate[
"id"],
477 ref_mag=candidate[
"mag"],
478 position_x=pix_coord.x,
479 position_y=pix_coord.y,
480 focal_plane_radius=candidate[
"radius_mm"] * u.mm,
481 focal_plane_angle=candidate[
"angle_radians"] * u.rad,
486 image=Image.from_legacy(cutout_MI.image),
487 mask=Mask.from_legacy(cutout_MI.mask),
488 variance=Image.from_legacy(cutout_MI.variance),
489 projection=projection,
490 psf_kernel_image=psf_kernel_image,
493 cutouts.append(cutout)
494 focal_plane_radii_mm.append(candidate[
"radius_mm"])
496 num_stars = len(extended_psf_candidate_table)
497 num_excluded = num_stars - len(cutouts)
498 percent_excluded = 100.0 * num_excluded / num_stars
if num_stars > 0
else 0.0
500 "Extracted %i extended PSF candidate%s. "
501 "Excluded %i star%s (%.1f%%) with an unmasked area fraction below %s.",
503 "" if len(cutouts) == 1
else "s",
505 "" if num_excluded == 1
else "s",
507 self.config.min_area_fraction,
512 "No extended PSF candidates were retained from %i selected reference star%s.",
514 "" if num_stars == 1
else "s",
519 "FOCAL_PLANE_RADIUS_MM_MIN": np.min(focal_plane_radii_mm),
520 "FOCAL_PLANE_RADIUS_MM_MAX": np.max(focal_plane_radii_mm),