3 from scipy.spatial
import cKDTree
4 from scipy.stats
import sigmaclip
7 import lsst.pipe.base
as pipeBase
10 from lsst.meas.algorithms.sourceSelector
import sourceSelectorRegistry
12 from .matchOptimisticB
import MatchTolerance
14 from .pessimistic_pattern_matcher_b_3D
import PessimisticPatternMatcherB
16 __all__ = [
"MatchPessimisticBTask",
"MatchPessimisticBConfig",
17 "MatchTolerancePessimistic"]
21 """Stores match tolerances for use in AstrometryTask and later 22 iterations of the matcher. 24 MatchPessimisticBTask relies on several state variables to be 25 preserved over different iterations in the 26 AstrometryTask.matchAndFitWcs loop of AstrometryTask. 30 maxMatchDist : `lsst.geom.Angle` 31 Maximum distance to consider a match from the previous match/fit 33 autoMaxMatchDist : `lsst.geom.Angle` 34 Automated estimation of the maxMatchDist from the sky statistics of the 35 source and reference catalogs. 36 maxShift : `lsst.geom.Angle` 37 Maximum shift found in the previous match/fit cycle. 38 lastMatchedPattern : `int` 39 Index of the last source pattern that was matched into the reference 41 failedPatternList : `list` of `int` 42 Previous matches were found to be false positives. 45 def __init__(self, maxMatchDist=None, autoMaxMatchDist=None,
46 maxShift=None, lastMatchedPattern=None,
47 failedPatternList=None):
52 if failedPatternList
is None:
57 """Configuration for MatchPessimisticBTask 59 numBrightStars = pexConfig.RangeField(
60 doc=
"Number of bright stars to use. Sets the max number of patterns " 61 "that can be tested.",
66 minMatchedPairs = pexConfig.RangeField(
67 doc=
"Minimum number of matched pairs; see also minFracMatchedPairs.",
72 minFracMatchedPairs = pexConfig.RangeField(
73 doc=
"Minimum number of matched pairs as a fraction of the smaller of " 74 "the number of reference stars or the number of good sources; " 75 "the actual minimum is the smaller of this value or " 82 matcherIterations = pexConfig.RangeField(
83 doc=
"Number of softening iterations in matcher.",
88 maxOffsetPix = pexConfig.RangeField(
89 doc=
"Maximum allowed shift of WCS, due to matching (pixel). " 90 "When changing this value, the " 91 "LoadReferenceObjectsConfig.pixelMargin should also be updated.",
96 maxRotationDeg = pexConfig.RangeField(
97 doc=
"Rotation angle allowed between sources and position reference " 103 numPointsForShape = pexConfig.Field(
104 doc=
"Number of points to define a shape for matching.",
108 numPointsForShapeAttempt = pexConfig.Field(
109 doc=
"Number of points to try for creating a shape. This value should " 110 "be greater than or equal to numPointsForShape. Besides " 111 "loosening the signal to noise cut in the matcherSourceSelector, " 112 "increasing this number will solve CCDs where no match was found.",
116 minMatchDistPixels = pexConfig.RangeField(
117 doc=
"Distance in units of pixels to always consider a source-" 118 "reference pair a match. This prevents the astrometric fitter " 119 "from over-fitting and removing stars that should be matched and " 120 "allows for inclusion of new matches as the wcs improves.",
126 numPatternConsensus = pexConfig.Field(
127 doc=
"Number of implied shift/rotations from patterns that must agree " 128 "before it a given shift/rotation is accepted. This is only used " 129 "after the first softening iteration fails and if both the " 130 "number of reference and source objects is greater than " 135 sourceSelector = sourceSelectorRegistry.makeField(
136 doc=
"How to select sources for cross-matching. The default " 137 "matcherSourceSelector removes objects with low S/N, bad " 138 "saturated objects, edge objects, and interpolated objects.",
139 default=
"matcherPessimistic" 144 sourceSelector.setDefaults()
147 pexConfig.Config.validate(self)
149 raise ValueError(
"numPointsForShapeAttempt must be greater than " 150 "or equal to numPointsForShape.")
163 """Match sources to reference objects. 166 ConfigClass = MatchPessimisticBConfig
167 _DefaultName =
"matchObjectsToSources" 170 pipeBase.Task.__init__(self, **kwargs)
171 self.makeSubtask(
"sourceSelector")
175 match_tolerance=None):
176 """Match sources to position reference stars 178 refCat : `lsst.afw.table.SimpleCatalog` 179 catalog of reference objects that overlap the exposure; reads 183 - the specified flux field 185 sourceCat : `lsst.afw.table.SourceCatalog` 186 catalog of sources found on an exposure; Please check the required 187 fields of your specified source selector that the correct flags are present. 188 wcs : `lsst.afw.geom.SkyWcs` 191 field of refCat to use for flux 192 match_tolerance : `lsst.meas.astrom.MatchTolerancePessimistic` 193 is a MatchTolerance class object or `None`. This this class is used 194 to communicate state between AstrometryTask and MatcherTask. 195 AstrometryTask will also set the MatchTolerance class variable 196 maxMatchDist based on the scatter AstrometryTask has found after 201 result : `lsst.pipe.base.Struct` 202 Result struct with components: 204 - ``matches`` : source to reference matches found (`list` of 205 `lsst.afw.table.ReferenceMatch`) 206 - ``usableSourcCat`` : a catalog of sources potentially usable for 207 matching and WCS fitting (`lsst.afw.table.SourceCatalog`). 208 - ``match_tolerance`` : a MatchTolerance object containing the 209 resulting state variables from the match 210 (`lsst.meas.astrom.MatchTolerancePessimistic`). 217 if match_tolerance
is None:
221 numSources = len(sourceCat)
222 selectedSources = self.sourceSelector.run(sourceCat)
223 goodSourceCat = selectedSources.sourceCat
224 numUsableSources = len(goodSourceCat)
225 self.log.info(
"Purged %d sources, leaving %d good sources" %
226 (numSources - numUsableSources, numUsableSources))
228 if len(goodSourceCat) == 0:
229 raise pipeBase.TaskError(
"No sources are good")
234 minMatchedPairs = min(self.config.minMatchedPairs,
235 int(self.config.minFracMatchedPairs *
236 min([len(refCat), len(goodSourceCat)])))
240 sourceCat=goodSourceCat,
242 refFluxField=refFluxField,
243 numUsableSources=numUsableSources,
244 minMatchedPairs=minMatchedPairs,
245 match_tolerance=match_tolerance,
246 sourceFluxField=self.sourceSelector.fluxField,
247 verbose=debug.verbose,
249 matches = doMatchReturn.matches
250 match_tolerance = doMatchReturn.match_tolerance
252 if len(matches) == 0:
253 raise RuntimeError(
"Unable to match sources")
255 self.log.info(
"Matched %d sources" % len(matches))
256 if len(matches) < minMatchedPairs:
257 self.log.warn(
"Number of matches is smaller than request")
259 return pipeBase.Struct(
261 usableSourceCat=goodSourceCat,
262 match_tolerance=match_tolerance,
266 def _doMatch(self, refCat, sourceCat, wcs, refFluxField, numUsableSources,
267 minMatchedPairs, match_tolerance, sourceFluxField, verbose):
268 """Implementation of matching sources to position reference stars 270 Unlike matchObjectsToSources, this method does not check if the sources 275 refCat : `lsst.afw.table.SimpleCatalog` 276 catalog of position reference stars that overlap an exposure 277 sourceCat : `lsst.afw.table.SourceCatalog` 278 catalog of sources found on the exposure 279 wcs : `lsst.afw.geom.SkyWcs` 280 estimated WCS of exposure 282 field of refCat to use for flux 283 numUsableSources : `int` 284 number of usable sources (sources with known centroid that are not 285 near the edge, but may be saturated) 286 minMatchedPairs : `int` 287 minimum number of matches 288 match_tolerance : `lsst.meas.astrom.MatchTolerancePessimistic` 289 a MatchTolerance object containing variables specifying matcher 290 tolerances and state from possible previous runs. 291 sourceFluxField : `str` 292 Name of the flux field in the source catalog. 294 Set true to print diagnostic information to std::cout 299 Results struct with components: 301 - ``matches`` : a list the matches found 302 (`list` of `lsst.afw.table.ReferenceMatch`). 303 - ``match_tolerance`` : MatchTolerance containing updated values from 304 this fit iteration (`lsst.meas.astrom.MatchTolerancePessimistic`) 313 ref_array = np.empty((len(refCat), 4), dtype=np.float64)
314 for ref_idx, refObj
in enumerate(refCat):
315 theta = np.pi / 2 - refObj.getDec().asRadians()
316 phi = refObj.getRa().asRadians()
317 flux = refObj[refFluxField]
318 ref_array[ref_idx, :] = \
321 src_array = np.empty((len(sourceCat), 4), dtype=np.float64)
322 for src_idx, srcObj
in enumerate(sourceCat):
323 coord = wcs.pixelToSky(srcObj.getCentroid())
324 theta = np.pi / 2 - coord.getLatitude().asRadians()
325 phi = coord.getLongitude().asRadians()
326 flux = srcObj.getPsfInstFlux()
327 src_array[src_idx, :] = \
332 if match_tolerance.maxShift
is None:
333 maxShiftArcseconds = (self.config.maxOffsetPix *
334 wcs.getPixelScale().asArcseconds())
338 maxShiftArcseconds = np.max(
339 (match_tolerance.maxShift.asArcseconds(),
340 self.config.minMatchDistPixels *
341 wcs.getPixelScale().asArcseconds()))
347 if match_tolerance.maxMatchDist
is None:
348 self.log.debug(
"Computing source statistics...")
351 self.log.debug(
"Computing reference statistics...")
354 maxMatchDistArcSec = np.min((maxMatchDistArcSecSrc,
355 maxMatchDistArcSecRef))
356 match_tolerance.autoMaxDist = afwgeom.Angle(maxMatchDistArcSec,
359 maxMatchDistArcSec = np.max(
360 (self.config.minMatchDistPixels *
361 wcs.getPixelScale().asArcseconds(),
362 np.min((match_tolerance.maxMatchDist.asArcseconds(),
363 match_tolerance.autoMaxDist.asArcseconds()))))
368 numConsensus = self.config.numPatternConsensus
369 minObjectsForConsensus = \
370 self.config.numBrightStars + self.config.numPointsForShapeAttempt
371 if ref_array.shape[0] < minObjectsForConsensus
or \
372 src_array.shape[0] < minObjectsForConsensus:
375 self.log.debug(
"Current tol maxDist: %.4f arcsec" %
377 self.log.debug(
"Current shift: %.4f arcsec" %
385 for soften_pattern
in range(self.config.matcherIterations):
386 for soften_dist
in range(self.config.matcherIterations):
387 if soften_pattern == 0
and soften_dist == 0
and \
388 match_tolerance.lastMatchedPattern
is not None:
397 run_n_consent = numConsensus
400 matcher_struct = pyPPMb.match(
401 source_array=src_array,
402 n_check=self.config.numPointsForShapeAttempt + soften_pattern,
403 n_match=self.config.numPointsForShape,
404 n_agree=run_n_consent,
405 max_n_patterns=self.config.numBrightStars,
406 max_shift=maxShiftArcseconds,
407 max_rotation=self.config.maxRotationDeg,
408 max_dist=maxMatchDistArcSec * 2. ** soften_dist,
409 min_matches=minMatchedPairs,
410 pattern_skip_array=np.array(
411 match_tolerance.failedPatternList)
414 if soften_pattern == 0
and soften_dist == 0
and \
415 len(matcher_struct.match_ids) == 0
and \
416 match_tolerance.lastMatchedPattern
is not None:
423 match_tolerance.failedPatternList.append(
424 match_tolerance.lastMatchedPattern)
425 match_tolerance.lastMatchedPattern =
None 426 maxShiftArcseconds = \
427 self.config.maxOffsetPix * wcs.getPixelScale().asArcseconds()
428 elif len(matcher_struct.match_ids) > 0:
431 match_tolerance.maxShift = \
432 matcher_struct.shift * afwgeom.arcseconds
433 match_tolerance.lastMatchedPattern = \
434 matcher_struct.pattern_idx
442 return pipeBase.Struct(
444 match_tolerance=match_tolerance,
456 distances_arcsec = np.degrees(matcher_struct.distances_rad) * 3600
457 clip_max_dist = np.max(
458 (sigmaclip(distances_arcsec, low=100, high=2)[-1],
459 self.config.minMatchDistPixels * wcs.getPixelScale().asArcseconds())
464 if not np.isfinite(clip_max_dist):
465 clip_max_dist = maxMatchDistArcSec
467 if clip_max_dist < maxMatchDistArcSec
and \
468 len(distances_arcsec[distances_arcsec < clip_max_dist]) < \
470 dist_cut_arcsec = maxMatchDistArcSec
472 dist_cut_arcsec = np.min((clip_max_dist, maxMatchDistArcSec))
477 for match_id_pair, dist_arcsec
in zip(matcher_struct.match_ids,
479 if dist_arcsec < dist_cut_arcsec:
481 match.first = refCat[match_id_pair[1]]
482 match.second = sourceCat[match_id_pair[0]]
486 match.distance = match.first.getCoord().separation(
487 match.second.getCoord()).asArcseconds()
488 matches.append(match)
490 return pipeBase.Struct(
492 match_tolerance=match_tolerance,
495 def _latlong_flux_to_xyz_mag(self, theta, phi, flux):
496 """Convert angles theta and phi and a flux into unit sphere 497 x, y, z, and a relative magnitude. 499 Takes in a afw catalog object and converts the catalog object RA, DECs 500 to points on the unit sphere. Also converts the flux into a simple, 501 non-zero-pointed magnitude for relative sorting. 506 Angle from the north pole (z axis) of the sphere 508 Rotation around the sphere 512 output_array : `numpy.ndarray`, (N, 4) 513 Spherical unit vector x, y, z with flux. 515 output_array = np.empty(4, dtype=np.float64)
516 output_array[0] = np.sin(theta)*np.cos(phi)
517 output_array[1] = np.sin(theta)*np.sin(phi)
518 output_array[2] = np.cos(theta)
520 output_array[3] = -2.5 * np.log10(flux)
524 output_array[3] = 99.
528 def _get_pair_pattern_statistics(self, cat_array):
529 """ Compute the tolerances for the matcher automatically by comparing 530 pinwheel patterns as we would in the matcher. 532 We test how similar the patterns we can create from a given set of 533 objects by computing the spoke lengths for each pattern and sorting 534 them from smallest to largest. The match tolerance is the average 535 distance per spoke between the closest two patterns in the sorted 540 cat_array : `numpy.ndarray`, (N, 3) 541 array of 3 vectors representing the x, y, z position of catalog 542 objects on the unit sphere. 547 Suggested max match tolerance distance calculated from comparisons 548 between pinwheel patterns used in optimistic/pessimistic pattern 552 self.log.debug(
"Starting automated tolerance calculation...")
556 pattern_array = np.empty(
557 (cat_array.shape[0] - self.config.numPointsForShape,
558 self.config.numPointsForShape - 1))
559 flux_args_array = np.argsort(cat_array[:, -1])
562 tmp_sort_array = cat_array[flux_args_array]
565 for start_idx
in range(cat_array.shape[0] -
566 self.config.numPointsForShape):
567 pattern_points = tmp_sort_array[start_idx:start_idx +
568 self.config.numPointsForShape, :-1]
569 pattern_delta = pattern_points[1:, :] - pattern_points[0, :]
570 pattern_array[start_idx, :] = np.sqrt(
571 pattern_delta[:, 0] ** 2 +
572 pattern_delta[:, 1] ** 2 +
573 pattern_delta[:, 2] ** 2)
578 pattern_array[start_idx, :] = pattern_array[
579 start_idx, np.argsort(pattern_array[start_idx, :])]
585 pattern_array[:, :(self.config.numPointsForShape - 1)])
586 dist_nearest_array, ids = dist_tree.query(
587 pattern_array[:, :(self.config.numPointsForShape - 1)], k=2)
588 dist_nearest_array = dist_nearest_array[:, 1]
589 dist_nearest_array.sort()
593 dist_tol = (np.degrees(dist_nearest_array[dist_idx]) * 3600. /
594 (self.config.numPointsForShape - 1.))
596 self.log.debug(
"Automated tolerance")
597 self.log.debug(
"\tdistance/match tol: %.4f [arcsec]" % dist_tol)
def __init__(self, kwargs)
def _doMatch(self, refCat, sourceCat, wcs, refFluxField, numUsableSources, minMatchedPairs, match_tolerance, sourceFluxField, verbose)
def __init__(self, maxMatchDist=None, autoMaxMatchDist=None, maxShift=None, lastMatchedPattern=None, failedPatternList=None)
def matchObjectsToSources(self, refCat, sourceCat, wcs, refFluxField, match_tolerance=None)
def _get_pair_pattern_statistics(self, cat_array)
def _latlong_flux_to_xyz_mag(self, theta, phi, flux)