LSSTApplications  10.0+286,10.0+36,10.0+46,10.0-2-g4f67435,10.1+152,10.1+37,11.0,11.0+1,11.0-1-g47edd16,11.0-1-g60db491,11.0-1-g7418c06,11.0-2-g04d2804,11.0-2-g68503cd,11.0-2-g818369d,11.0-2-gb8b8ce7
LSSTDataManagementBasePackage
anetBasicAstrometry.py
Go to the documentation of this file.
1 from __future__ import absolute_import, division
2 
3 import math
4 import sys
5 
6 import numpy as np # for isfinite()
7 
8 import lsst.daf.base as dafBase
9 import lsst.pex.logging as pexLog
10 from lsst.pex.config import Field, RangeField, ListField
11 import lsst.pex.exceptions as pexExceptions
12 import lsst.pipe.base as pipeBase
13 import lsst.afw.coord as afwCoord
14 import lsst.afw.geom as afwGeom
15 import lsst.afw.math as afwMath
16 import lsst.afw.table as afwTable
17 import lsst.afw.image as afwImage
18 import lsst.meas.algorithms.utils as maUtils
19 from .loadAstrometryNetObjects import LoadAstrometryNetObjectsTask, LoadMultiIndexes
20 from .display import displayAstrometry
21 from .astromLib import makeMatchStatisticsInRadians
22 
23 from . import sip as astromSip
24 
25 __all__ = ["InitialAstrometry", "ANetBasicAstrometryConfig", "ANetBasicAstrometryTask"]
26 
27 class InitialAstrometry(object):
28  """
29  Object returned by Astrometry.determineWcs
30 
31  getWcs(): sipWcs or tanWcs
32  getMatches(): sipMatches or tanMatches
33 
34  Other fields are:
35  solveQa (PropertyList)
36  tanWcs (Wcs)
37  tanMatches (MatchList)
38  sipWcs (Wcs)
39  sipMatches (MatchList)
40  refCat (lsst::afw::table::SimpleCatalog)
41  matchMeta (PropertyList)
42  """
43  def __init__(self):
44  self.tanWcs = None
45  self.tanMatches = None
46  self.sipWcs = None
47  self.sipMatches = None
48  self.refCat = None
50  self.solveQa = None
51 
52  def getMatches(self):
53  """
54  Get "sipMatches" -- MatchList using the SIP WCS solution, if it
55  exists, or "tanMatches" -- MatchList using the TAN WCS solution
56  otherwise.
57  """
58  return self.sipMatches or self.tanMatches
59 
60  def getWcs(self):
61  """
62  Returns the SIP WCS, if one was found, or a TAN WCS
63  """
64  return self.sipWcs or self.tanWcs
65 
66  matches = property(getMatches)
67  wcs = property(getWcs)
68 
69  ### "Not very pythonic!" complains Paul.
70  # Consider these methods deprecated; if you want these elements, just
71  # .grab them.
72  def getSipWcs(self):
73  return self.sipWcs
74  def getTanWcs(self):
75  return self.tanWcs
76  def getSipMatches(self):
77  return self.sipMatches
78  def getTanMatches(self):
79  return self.tanMatches
80  def getMatchMetadata(self):
81  return self.matchMeta
82  def getSolveQaMetadata(self):
83  return self.solveQa
84 
85 
86 class ANetBasicAstrometryConfig(LoadAstrometryNetObjectsTask.ConfigClass):
87 
88  maxCpuTime = RangeField(
89  doc = "Maximum CPU time to spend solving, in seconds",
90  dtype = float,
91  default = 0.,
92  min = 0.,
93  )
94  matchThreshold = RangeField(
95  doc = "Matching threshold for Astrometry.net solver (log-odds)",
96  dtype = float,
97  default=math.log(1e12),
98  min=math.log(1e6),
99  )
100  maxStars = RangeField(
101  doc = "Maximum number of stars to use in Astrometry.net solving",
102  dtype = int,
103  default=50,
104  min=10,
105  )
106  useWcsPixelScale = Field(
107  doc = "Use the pixel scale from the input exposure\'s WCS headers?",
108  dtype = bool,
109  default = True,
110  )
111  useWcsRaDecCenter = Field(
112  doc="Use the RA,Dec center information from the input exposure\'s WCS headers?",
113  dtype=bool,
114  default=True,
115  )
116  useWcsParity = Field(
117  doc="Use the parity (flip / handedness) of the image from the input exposure\'s WCS headers?",
118  dtype=bool,
119  default=True,
120  )
121  raDecSearchRadius = RangeField(
122  doc = "When useWcsRaDecCenter=True, this is the radius, in degrees, around the RA,Dec center " +
123  "specified in the input exposure\'s WCS to search for a solution.",
124  dtype = float,
125  default=1.0,
126  min=0.0,
127  )
128  pixelScaleUncertainty = RangeField(
129  doc = "Range of pixel scales, around the value in the WCS header, to search. If the value of this field " +
130  "is X and the nominal scale is S, the range searched will be S/X to S*X",
131  dtype = float,
132  default = 1.1,
133  min=1.001,
134  )
135  catalogMatchDist = RangeField(
136  doc = "Matching radius (arcsec) for matching sources to reference objects",
137  dtype = float,
138  default=1.0,
139  min=0.0,
140  )
141  cleaningParameter = RangeField(
142  doc = "Sigma-clipping parameter in sip/cleanBadPoints.py",
143  dtype = float,
144  default=3.0,
145  min=0.0,
146  )
147  calculateSip = Field(
148  doc = "Compute polynomial SIP distortion terms?",
149  dtype = bool,
150  default=True,
151  )
152  sipOrder = RangeField(
153  doc = "Polynomial order of SIP distortion terms",
154  dtype = int,
155  default=4,
156  min=2,
157  )
158  badFlags = ListField(
159  doc = "List of flags which cause a source to be rejected as bad",
160  dtype = str,
161  default = [
162  "slot_Centroid_flag", # bad centroids
163  "base_PixelFlags_flag_edge",
164  "base_PixelFlags_flag_saturated",
165  "base_PixelFlags_flag_crCenter", # cosmic rays
166  ],
167  )
168  allFluxes = Field(
169  doc = "Retrieve all available fluxes (and errors) from catalog?",
170  dtype = bool,
171  default = True,
172  )
173  maxIter = RangeField(
174  doc = "maximum number of iterations of match sources and fit WCS" +
175  "ignored if not fitting a WCS",
176  dtype = int,
177  default = 5,
178  min = 1,
179  )
180  matchDistanceSigma = RangeField(
181  doc = "The match and fit loop stops when maxMatchDist minimized: "
182  " maxMatchDist = meanMatchDist + matchDistanceSigma*stdDevMatchDistance " +
183  " (where the mean and std dev are computed using outlier rejection);" +
184  " ignored if not fitting a WCS",
185  dtype = float,
186  default = 2,
187  min = 0,
188  )
189 
190 
191 class ANetBasicAstrometryTask(pipeBase.Task):
192  """!Basic implemeentation of the astrometry.net astrometrical fitter
193 
194  A higher-level class ANetAstrometryTask takes care of dealing with the fact
195  that the initial WCS is probably only a pure TAN SIP, yet we may have
196  significant distortion and a good estimate for that distortion.
197 
198 
199  About Astrometry.net index files (astrometry_net_data):
200 
201  There are three components of an index file: a list of stars
202  (stored as a star kd-tree), a list of quadrangles of stars ("quad
203  file") and a list of the shapes ("codes") of those quadrangles,
204  stored as a code kd-tree.
205 
206  Each index covers a region of the sky, defined by healpix nside
207  and number, and a range of angular scales. In LSST, we share the
208  list of stars in a part of the sky between multiple indexes. That
209  is, the star kd-tree is shared between multiple indices (quads and
210  code kd-trees). In the astrometry.net code, this is called a
211  "multiindex".
212 
213  It is possible to "unload" and "reload" multiindex (and index)
214  objects. When "unloaded", they consume no FILE or mmap resources.
215 
216  The multiindex object holds the star kd-tree and gives each index
217  object it holds a pointer to it, so it is necessary to
218  multiindex_reload_starkd() before reloading the indices it holds.
219  The multiindex_unload() method, on the other hand, unloads its
220  starkd and unloads each index it holds.
221  """
222  ConfigClass = ANetBasicAstrometryConfig
223  _DefaultName = "aNetBasicAstrometry"
224  def __init__(self,
225  config,
226  andConfig=None,
227  **kwargs):
228  """!Construct an ANetBasicAstrometryTask
229 
230  @param[in] config configuration (an instance of self.ConfigClass)
231  @param[in] andConfig astrometry.net data config (an instance of AstromNetDataConfig, or None);
232  if None then use andConfig.py in the astrometry_net_data product (which must be setup)
233  @param[in] kwargs additional keyword arguments for pipe_base Task.\_\_init\_\_
234 
235  @throw RuntimeError if andConfig is None and the configuration cannot be found,
236  either because astrometry_net_data is not setup in eups
237  or because the setup version does not include the file "andConfig.py"
238  """
239  pipeBase.Task.__init__(self, config=config, **kwargs)
240  self.config = config
241  # this is not a subtask because it cannot safely be retargeted
242  self.refObjLoader = LoadAstrometryNetObjectsTask(config=self.config, andConfig=andConfig, log=self.log,
243  name="loadAN")
244  self.refObjLoader._readIndexFiles()
245 
246  def memusage(self, prefix=''):
247  # Not logging at DEBUG: do nothing
248  if self.log.getThreshold() > pexLog.Log.DEBUG:
249  return
250  from astrometry.util.ttime import get_memusage
251  mu = get_memusage()
252  ss = []
253  for key in ['VmPeak', 'VmSize', 'VmRSS', 'VmData']:
254  if key in mu:
255  ss.append(key + ': ' + ' '.join(mu[key]))
256  if 'mmaps' in mu:
257  ss.append('Mmaps: %i' % len(mu['mmaps']))
258  if 'mmaps_total' in mu:
259  ss.append('Mmaps: %i kB' % (mu['mmaps_total'] / 1024))
260  self.log.logdebug(prefix + 'Memory: ' + ', '.join(ss))
261 
262  def _getImageParams(self, exposure=None, bbox=None, wcs=None, filterName=None, wcsRequired=True):
263  """Get image parameters
264 
265  @param[in] exposure exposure (an afwImage.Exposure) or None
266  @param[in] bbox bounding box (an afwGeom.Box2I) or None; if None then bbox must be specified
267  @param[in] wcs WCS (an afwImage.Wcs) or None; if None then exposure must be specified
268  @param[in] filterName filter name, a string, or None; if None exposure must be specified
269  @param[in] wcsRequired if True then either wcs must be specified or exposure must contain a wcs;
270  if False then the returned wcs may be None
271  @return these items:
272  - bbox bounding box; guaranteed to be set
273  - wcs WCS if known, else None
274  - filterName filter name if known, else None
275  @throw RuntimeError if bbox cannot be determined, or wcs cannot be determined and wcsRequired True
276  """
277  if exposure is not None:
278  if bbox is None:
279  bbox = exposure.getBBox()
280  self.log.logdebug("Setting bbox = %s from exposure metadata" % (bbox,))
281  if wcs is None:
282  self.log.logdebug("Setting wcs from exposure metadata")
283  wcs = exposure.getWcs()
284  if filterName is None:
285  filterName = exposure.getFilter().getName()
286  self.log.logdebug("Setting filterName = %r from exposure metadata" % (filterName,))
287  if bbox is None:
288  raise RuntimeError("bbox or exposure must be specified")
289  if wcs is None and wcsRequired:
290  raise RuntimeError("wcs or exposure (with a WCS) must be specified")
291  return bbox, wcs, filterName
292 
293  def useKnownWcs(self, sourceCat, wcs=None, exposure=None, filterName=None, bbox=None, calculateSip=None):
294  """!Return an InitialAstrometry object, just like determineWcs,
295  but assuming the given input WCS is correct.
296 
297  This involves searching for reference sources within the WCS
298  area, and matching them to the given 'sourceCat'. If
299  'calculateSip' is set, we will try to compute a TAN-SIP
300  distortion correction.
301 
302  @param[in] sourceCat list of detected sources in this image.
303  @param[in] wcs your known WCS, or None to get from exposure
304  @param[in] exposure the exposure holding metadata for this image;
305  if None then you must specify wcs, filterName and bbox
306  @param[in] filterName string, filter name, eg "i", or None to get from exposure`
307  @param[in] bbox bounding box of image, or None to get from exposure
308  @param[in] calculateSip calculate SIP distortion terms for the WCS? If None
309  then use self.config.calculateSip. To disable WCS fitting set calculateSip=False
310 
311  @note this function is also called by 'determineWcs' (via 'determineWcs2'), since the steps are all
312  the same.
313  """
314  # return value:
315  astrom = InitialAstrometry()
316 
317  if calculateSip is None:
318  calculateSip = self.config.calculateSip
319 
320  bbox, wcs, filterName = self._getImageParams(
321  exposure = exposure,
322  bbox = bbox,
323  wcs = wcs,
324  filterName = filterName,
325  wcsRequired = True,
326  )
327  refCat = self.refObjLoader.loadPixelBox(
328  bbox = bbox,
329  wcs = wcs,
330  filterName = filterName,
331  calib = None,
332  ).refCat
333  astrom.refCat = refCat
334  catids = [src.getId() for src in refCat]
335  uids = set(catids)
336  self.log.logdebug('%i reference sources; %i unique IDs' % (len(catids), len(uids)))
337  matches = self._getMatchList(sourceCat, refCat, wcs)
338  uniq = set([sm.second.getId() for sm in matches])
339  if len(matches) != len(uniq):
340  self.log.warn(('The list of matched stars contains duplicate reference source IDs ' +
341  '(%i sources, %i unique ids)') % (len(matches), len(uniq)))
342  if len(matches) == 0:
343  self.log.warn('No matches found between input sources and reference catalogue.')
344  return astrom
345 
346  self.log.logdebug('%i reference objects match input sources using input WCS' % (len(matches)))
347  astrom.tanMatches = matches
348  astrom.tanWcs = wcs
349 
350  srcids = [s.getId() for s in sourceCat]
351  for m in matches:
352  assert(m.second.getId() in srcids)
353  assert(m.second in sourceCat)
354 
355  if calculateSip:
356  sipwcs,matches = self._calculateSipTerms(wcs, refCat, sourceCat, matches, bbox=bbox)
357  if sipwcs == wcs:
358  self.log.logdebug('Failed to find a SIP WCS better than the initial one.')
359  else:
360  self.log.logdebug('%i reference objects match input sources using SIP WCS' %
361  (len(matches),))
362  astrom.sipWcs = sipwcs
363  astrom.sipMatches = matches
364 
365  wcs = astrom.getWcs()
366  # _getMatchList() modifies the source list RA,Dec coordinates.
367  # Here, we make them consistent with the WCS we are returning.
368  for src in sourceCat:
369  src.updateCoord(wcs)
370  astrom.matchMeta = _createMetadata(bbox, wcs, filterName)
371  return astrom
372 
373  def determineWcs(self,
374  sourceCat,
375  exposure,
376  **kwargs):
377  """Find a WCS solution for the given 'sourceCat' in the given
378  'exposure', getting other parameters from config.
379 
380  Valid kwargs include:
381 
382  'radecCenter', an afw.coord.Coord giving the RA,Dec position
383  of the center of the field. This is used to limit the
384  search done by Astrometry.net (to make it faster and load
385  fewer index files, thereby using less memory). Defaults to
386  the RA,Dec center from the exposure's WCS; turn that off
387  with the boolean kwarg 'useRaDecCenter' or config option
388  'useWcsRaDecCenter'
389 
390  'useRaDecCenter', a boolean. Don't use the RA,Dec center from
391  the exposure's initial WCS.
392 
393  'searchRadius', in degrees, to search for a solution around
394  the given 'radecCenter'; default from config option
395  'raDecSearchRadius'.
396 
397  'useParity': parity is the 'flip' of the image. Knowing it
398  reduces the search space (hence time) for Astrometry.net.
399  The parity can be computed from the exposure's WCS (the
400  sign of the determinant of the CD matrix); this option
401  controls whether we do that or force Astrometry.net to
402  search both parities. Default from config.useWcsParity.
403 
404  'pixelScale': afwGeom.Angle, estimate of the angle-per-pixel
405  (ie, arcseconds per pixel). Defaults to a value derived
406  from the exposure's WCS. If enabled, this value, plus or
407  minus config.pixelScaleUncertainty, will be used to limit
408  Astrometry.net's search.
409 
410  'usePixelScale': boolean. Use the pixel scale to limit
411  Astrometry.net's search? Defaults to config.useWcsPixelScale.
412 
413  'filterName', a string, the filter name of this image. Will
414  be mapped through the 'filterMap' config dictionary to a
415  column name in the astrometry_net_data index FITS files.
416  Defaults to the exposure.getFilter() value.
417 
418  'bbox', bounding box of exposure; defaults to exposure.getBBox()
419 
420  """
421  assert(exposure is not None)
422 
423  margs = kwargs.copy()
424  if not 'searchRadius' in margs:
425  margs.update(searchRadius = self.config.raDecSearchRadius * afwGeom.degrees)
426  if not 'usePixelScale' in margs:
427  margs.update(usePixelScale = self.config.useWcsPixelScale)
428  if not 'useRaDecCenter' in margs:
429  margs.update(useRaDecCenter = self.config.useWcsRaDecCenter)
430  if not 'useParity' in margs:
431  margs.update(useParity = self.config.useWcsParity)
432  margs.update(exposure=exposure)
433  return self.determineWcs2(sourceCat=sourceCat, **margs)
434 
435  def determineWcs2(self, sourceCat, **kwargs):
436  """Get a blind astrometric solution for the given catalog of sources.
437 
438  We need:
439  -the image size;
440  -the filter
441 
442  And if available, we can use:
443  -an initial Wcs estimate;
444  --> RA,Dec center
445  --> pixel scale
446  --> "parity"
447 
448  (all of which are metadata of Exposure).
449 
450  filterName: string
451  imageSize: (W,H) integer tuple/iterable
452  pixelScale: afwGeom::Angle per pixel.
453  radecCenter: afwCoord::Coord
454  """
455  wcs,qa = self.getBlindWcsSolution(sourceCat, **kwargs)
456  kw = {}
457  # Keys passed to useKnownWcs
458  for key in ['exposure', 'bbox', 'filterName']:
459  if key in kwargs:
460  kw[key] = kwargs[key]
461  astrom = self.useKnownWcs(sourceCat, wcs=wcs, **kw)
462  astrom.solveQa = qa
463  astrom.tanWcs = wcs
464  return astrom
465 
466  def getBlindWcsSolution(self, sourceCat,
467  exposure=None,
468  wcs=None,
469  bbox=None,
470  radecCenter=None,
471  searchRadius=None,
472  pixelScale=None,
473  filterName=None,
474  doTrim=False,
475  usePixelScale=True,
476  useRaDecCenter=True,
477  useParity=True,
478  searchRadiusScale=2.):
479  if not useRaDecCenter and radecCenter is not None:
480  raise RuntimeError('radecCenter is set, but useRaDecCenter is False. Make up your mind!')
481  if not usePixelScale and pixelScale is not None:
482  raise RuntimeError('pixelScale is set, but usePixelScale is False. Make up your mind!')
483 
484  bbox, wcs, filterName = self._getImageParams(
485  exposure = exposure,
486  bbox = bbox,
487  wcs = wcs,
488  filterName = filterName,
489  wcsRequired = False,
490  )
491 
492  bboxD = afwGeom.Box2D(bbox)
493  xc, yc = bboxD.getCenter()
494  parity = None
495 
496  if wcs is not None:
497  if pixelScale is None:
498  if usePixelScale:
499  pixelScale = wcs.pixelScale()
500  self.log.logdebug('Setting pixel scale estimate = %.3f from given WCS estimate' %
501  (pixelScale.asArcseconds()))
502 
503  if radecCenter is None:
504  if useRaDecCenter:
505  radecCenter = wcs.pixelToSky(xc, yc)
506  self.log.logdebug(('Setting RA,Dec center estimate = (%.3f, %.3f) from given WCS '
507  + 'estimate, using pixel center = (%.1f, %.1f)') %
508  (radecCenter.getLongitude().asDegrees(),
509  radecCenter.getLatitude().asDegrees(), xc, yc))
510 
511  if searchRadius is None:
512  if useRaDecCenter:
513  assert(pixelScale is not None)
514  pixRadius = math.hypot(*bboxD.getDimensions()) / 2
515  searchRadius = (pixelScale * pixRadius * searchRadiusScale)
516  self.log.logdebug(('Using RA,Dec search radius = %.3f deg, from pixel scale, '
517  + 'image size, and searchRadiusScale = %g') %
518  (searchRadius, searchRadiusScale))
519  if useParity:
520  parity = wcs.isFlipped()
521  self.log.logdebug('Using parity = %s' % (parity and 'True' or 'False'))
522 
523  if doTrim:
524  n = len(sourceCat)
525  if exposure is not None:
526  exposureBBoxD = afwGeom.Box2D(exposure.getMaskedImage().getBBox())
527  else:
528  exposureBBoxD = bboxD
529  sourceCat = self._trimBadPoints(sourceCat, exposureBBoxD)
530  self.log.logdebug("Trimming: kept %i of %i sources" % (n, len(sourceCat)))
531 
532  wcs,qa = self._solve(
533  sourceCat = sourceCat,
534  wcs = wcs,
535  bbox = bbox,
536  pixelScale = pixelScale,
537  radecCenter = radecCenter,
538  searchRadius = searchRadius,
539  parity = parity,
540  filterName = filterName,
541  )
542  if wcs is None:
543  raise RuntimeError("Unable to match sources with catalog.")
544  self.log.info('Got astrometric solution from Astrometry.net')
545 
546  rdc = wcs.pixelToSky(xc, yc)
547  self.log.logdebug('New WCS says image center pixel (%.1f, %.1f) -> RA,Dec (%.3f, %.3f)' %
548  (xc, yc, rdc.getLongitude().asDegrees(), rdc.getLatitude().asDegrees()))
549  return wcs,qa
550 
551  def getSipWcsFromWcs(self, wcs, bbox, ngrid=20, linearizeAtCenter=True):
552  """!Get a TAN-SIP WCS, starting from an existing WCS.
553 
554  It uses your WCS to compute a fake grid of corresponding "stars" in pixel and sky coords,
555  and feeds that to the regular SIP code.
556 
557  @param[in] wcs initial WCS
558  @param[in] bbox bounding box of image
559  @param[in] ngrid number of grid points along x and y for fitting (fit at ngrid^2 points)
560  @param[in] linearizeAtCenter if True, get a linear approximation of the input
561  WCS at the image center and use that as the TAN initialization for
562  the TAN-SIP solution. You probably want this if your WCS has its
563  CRPIX outside the image bounding box.
564  """
565  # Ugh, build src and ref tables
566  srcSchema = afwTable.SourceTable.makeMinimalSchema()
567  key = srcSchema.addField("centroid", type="PointD")
568  srcTable = afwTable.SourceTable.make(srcSchema)
569  srcTable.defineCentroid("centroid")
570  srcs = srcTable
571  refs = afwTable.SimpleTable.make(afwTable.SimpleTable.makeMinimalSchema())
572  cref = []
573  csrc = []
574  (W,H) = bbox.getDimensions()
575  x0, y0 = bbox.getMin()
576  for xx in np.linspace(0., W, ngrid):
577  for yy in np.linspace(0, H, ngrid):
578  src = srcs.makeRecord()
579  src.set(key.getX(), x0 + xx)
580  src.set(key.getY(), y0 + yy)
581  csrc.append(src)
582  rd = wcs.pixelToSky(afwGeom.Point2D(xx + x0, yy + y0))
583  ref = refs.makeRecord()
584  ref.setCoord(rd)
585  cref.append(ref)
586 
587  if linearizeAtCenter:
588  # Linearize the original WCS around the image center to create a
589  # TAN WCS.
590  # Reference pixel in LSST coords
591  crpix = afwGeom.Box2D(bbox).getCenter()
592  crval = wcs.pixelToSky(crpix)
593  crval = crval.getPosition(afwGeom.degrees)
594  # Linearize *AT* crval to get effective CD at crval.
595  # (we use the default skyUnit of degrees as per WCS standard)
596  aff = wcs.linearizePixelToSky(crval)
597  cd = aff.getLinear().getMatrix()
598  wcs = afwImage.Wcs(crval, crpix, cd)
599 
600  return self.getSipWcsFromCorrespondences(wcs, cref, csrc, (W,H),
601  x0=x0, y0=y0)
602 
603 
604  def getSipWcsFromCorrespondences(self, origWcs, refCat, sourceCat, bbox):
605  """Produce a SIP solution given a list of known correspondences.
606 
607  Unlike _calculateSipTerms, this does not iterate the solution;
608  it assumes you have given it a good sets of corresponding stars.
609 
610  NOTE that "refCat" and "sourceCat" are assumed to be the same length;
611  entries "refCat[i]" and "sourceCat[i]" are assumed to be correspondences.
612 
613  @param[in] origWcs the WCS to linearize in order to get the TAN part of the TAN-SIP WCS.
614  @param[in] refCat reference source catalog
615  @param[in] sourceCat source catalog
616  @param[in] bbox bounding box of image
617  """
618  sipOrder = self.config.sipOrder
619  matches = []
620  for ci,si in zip(refCat, sourceCat):
621  matches.append(afwTable.ReferenceMatch(ci, si, 0.))
622 
623  sipObject = astromSip.makeCreateWcsWithSip(matches, origWcs, sipOrder, bbox)
624  return sipObject.getNewWcs()
625 
626  def _calculateSipTerms(self, origWcs, refCat, sourceCat, matches, bbox):
627  """!Iteratively calculate SIP distortions and regenerate matches based on improved WCS.
628 
629  @param[in] origWcs original WCS object, probably (but not necessarily) a TAN WCS;
630  this is used to set the baseline when determining whether a SIP
631  solution is any better; it will be returned if no better SIP solution
632  can be found.
633  @param[in] refCat reference source catalog
634  @param[in] sourceCat sources in the image to be solved
635  @param[in] matches list of supposedly matched sources, using the "origWcs".
636  @param[in] bbox bounding box of image, which is used when finding reverse SIP coefficients.
637  """
638  sipOrder = self.config.sipOrder
639  wcs = origWcs
640 
641  lastMatchSize = len(matches)
642  lastMatchStats = self._computeMatchStatsOnSky(wcs=wcs, matchList=matches)
643  for i in range(self.config.maxIter):
644  # fit SIP terms
645  try:
646  sipObject = astromSip.makeCreateWcsWithSip(matches, wcs, sipOrder, bbox)
647  proposedWcs = sipObject.getNewWcs()
648  self.plotSolution(matches, proposedWcs, bbox.getDimensions())
649  except pexExceptions.Exception as e:
650  self.log.warn('Failed to calculate distortion terms. Error: ' + str(e))
651  break
652 
653  # use new WCS to get new matchlist.
654  proposedMatchlist = self._getMatchList(sourceCat, refCat, proposedWcs)
655  proposedMatchSize = len(proposedMatchlist)
656  proposedMatchStats = self._computeMatchStatsOnSky(wcs=proposedWcs, matchList=proposedMatchlist)
657 
658  self.log.logdebug(
659  "SIP iteration %i: %i objects match, previous = %i;" %
660  (i, proposedMatchSize, lastMatchSize) +
661  " clipped mean scatter = %s arcsec, previous = %s; " %
662  (proposedMatchStats.distMean.asArcseconds(), lastMatchStats.distMean.asArcseconds()) +
663  " max match dist = %s arcsec, previous = %s" %
664  (proposedMatchStats.maxMatchDist.asArcseconds(),
665  lastMatchStats.maxMatchDist.asArcseconds())
666  )
667 
668  if lastMatchStats.maxMatchDist <= proposedMatchStats.maxMatchDist:
669  self.log.logdebug(
670  "Fit WCS: use iter %s because max match distance no better in next iter: " % (i-1,) +
671  " %g < %g arcsec" % (lastMatchStats.maxMatchDist.asArcseconds(),
672  proposedMatchStats.maxMatchDist.asArcseconds()))
673  break
674 
675 
676  wcs = proposedWcs
677  matches = proposedMatchlist
678  lastMatchSize = proposedMatchSize
679  lastMatchStats = proposedMatchStats
680 
681  return wcs, matches
682 
683  def plotSolution(self, matches, wcs, imageSize):
684  """Plot the solution, when debugging is turned on.
685 
686  @param matches The list of matches
687  @param wcs The Wcs
688  @param imageSize 2-tuple with the image size (W,H)
689  """
690  import lsstDebug
691  display = lsstDebug.Info(__name__).display
692  if not display:
693  return
694 
695  try:
696  import matplotlib.pyplot as plt
697  import numpy
698  except ImportError:
699  print >> sys.stderr, "Unable to import matplotlib"
700  return
701 
702  fig = plt.figure(1)
703  fig.clf()
704  try:
705  fig.canvas._tkcanvas._root().lift() # == Tk's raise, but raise is a python reserved word
706  except: # protect against API changes
707  pass
708 
709  num = len(matches)
710  x = numpy.zeros(num)
711  y = numpy.zeros(num)
712  dx = numpy.zeros(num)
713  dy = numpy.zeros(num)
714  for i, m in enumerate(matches):
715  x[i] = m.second.getX()
716  y[i] = m.second.getY()
717  pixel = wcs.skyToPixel(m.first.getCoord())
718  dx[i] = x[i] - pixel.getX()
719  dy[i] = y[i] - pixel.getY()
720 
721  subplots = maUtils.makeSubplots(fig, 2, 2, xgutter=0.1, ygutter=0.1, pygutter=0.04)
722 
723  def plotNext(x, y, xLabel, yLabel, xMax):
724  ax = subplots.next()
725  ax.set_autoscalex_on(False)
726  ax.set_xbound(lower=0, upper=xMax)
727  ax.scatter(x, y)
728  ax.set_xlabel(xLabel)
729  ax.set_ylabel(yLabel)
730  ax.axhline(0.0)
731 
732  plotNext(x, dx, "x", "dx", imageSize[0])
733  plotNext(x, dy, "x", "dy", imageSize[0])
734  plotNext(y, dx, "y", "dx", imageSize[1])
735  plotNext(y, dy, "y", "dy", imageSize[1])
736 
737  fig.show()
738 
739  while True:
740  try:
741  reply = raw_input("Pausing for inspection, enter to continue... [hpQ] ").strip()
742  except EOFError:
743  reply = "n"
744 
745  reply = reply.split()
746  if reply:
747  reply = reply[0]
748  else:
749  reply = ""
750 
751  if reply in ("", "h", "p", "Q"):
752  if reply == "h":
753  print "h[elp] p[db] Q[uit]"
754  continue
755  elif reply == "p":
756  import pdb; pdb.set_trace()
757  elif reply == "Q":
758  sys.exit(1)
759  break
760 
761  def _computeMatchStatsOnSky(self, wcs, matchList):
762  """Compute on-sky radial distance statistics for a match list
763 
764  @param[in] wcs WCS for match list; an lsst.afw.image.Wcs
765  @param[in] matchList list of matches between reference object and sources;
766  a list of lsst.afw.table.ReferenceMatch;
767  the source centroid and reference object coord are read
768 
769  @return a pipe_base Struct containing these fields:
770  - distMean clipped mean of on-sky radial separation
771  - distStdDev clipped standard deviation of on-sky radial separation
772  - maxMatchDist distMean + self.config.matchDistanceSigma*distStdDev
773  """
774  distStatsInRadians = makeMatchStatisticsInRadians(wcs, matchList, afwMath.MEANCLIP | afwMath.STDEVCLIP)
775  distMean = distStatsInRadians.getValue(afwMath.MEANCLIP)*afwGeom.radians
776  distStdDev = distStatsInRadians.getValue(afwMath.STDEVCLIP)*afwGeom.radians
777  return pipeBase.Struct(
778  distMean = distMean,
779  distStdDev = distStdDev,
780  maxMatchDist = distMean + self.config.matchDistanceSigma*distStdDev,
781  )
782 
783  def _getMatchList(self, sourceCat, refCat, wcs):
784  dist = self.config.catalogMatchDist * afwGeom.arcseconds
785  clean = self.config.cleaningParameter
786  matcher = astromSip.MatchSrcToCatalogue(refCat, sourceCat, wcs, dist)
787  matches = matcher.getMatches()
788  if matches is None:
789  # Produce debugging stats...
790  X = [src.getX() for src in sourceCat]
791  Y = [src.getY() for src in sourceCat]
792  R1 = [src.getRa().asDegrees() for src in sourceCat]
793  D1 = [src.getDec().asDegrees() for src in sourceCat]
794  R2 = [src.getRa().asDegrees() for src in refCat]
795  D2 = [src.getDec().asDegrees() for src in refCat]
796  # for src in sourceCat:
797  #self.log.logdebug("source: x,y (%.1f, %.1f), RA,Dec (%.3f, %.3f)" %
798  #(src.getX(), src.getY(), src.getRa().asDegrees(), src.getDec().asDegrees()))
799  #for src in refCat:
800  #self.log.logdebug("ref: RA,Dec (%.3f, %.3f)" %
801  #(src.getRa().asDegrees(), src.getDec().asDegrees()))
802  self.loginfo('_getMatchList: %i sources, %i reference sources' % (len(sourceCat), len(refCat)))
803  if len(sourceCat):
804  self.loginfo(
805  'Source range: x [%.1f, %.1f], y [%.1f, %.1f], RA [%.3f, %.3f], Dec [%.3f, %.3f]' %
806  (min(X), max(X), min(Y), max(Y), min(R1), max(R1), min(D1), max(D1)))
807  if len(refCat):
808  self.loginfo('Reference range: RA [%.3f, %.3f], Dec [%.3f, %.3f]' %
809  (min(R2), max(R2), min(D2), max(D2)))
810  raise RuntimeError('No matches found between image and catalogue')
811  matches = astromSip.cleanBadPoints.clean(matches, wcs, nsigma=clean)
812  return matches
813 
814  def getColumnName(self, filterName, columnMap, default=None):
815  """
816  Returns the column name in the astrometry_net_data index file that will be used
817  for the given filter name.
818 
819  @param filterName Name of filter used in exposure
820  @param columnMap Dict that maps filter names to column names
821  @param default Default column name
822  """
823  filterName = self.config.filterMap.get(filterName, filterName) # Exposure filter --> desired filter
824  try:
825  return columnMap[filterName] # Desired filter --> a_n_d column name
826  except KeyError:
827  self.log.warn("No column in configuration for filter '%s'; using default '%s'" %
828  (filterName, default))
829  return default
830 
831  def _solve(self, sourceCat, wcs, bbox, pixelScale, radecCenter,
832  searchRadius, parity, filterName=None):
833  solver = self.refObjLoader._getSolver()
834 
835  imageSize = bbox.getDimensions()
836  x0, y0 = bbox.getMin()
837 
838  # select sources with valid x,y, flux
839  xybb = afwGeom.Box2D()
840  goodsources = afwTable.SourceCatalog(sourceCat.table)
841  badkeys = [goodsources.getSchema().find(name).key for name in self.config.badFlags]
842 
843  for s in sourceCat:
844  if np.isfinite(s.getX()) and np.isfinite(s.getY()) and np.isfinite(s.getPsfFlux()) \
845  and self._isGoodSource(s, badkeys):
846  goodsources.append(s)
847  xybb.include(afwGeom.Point2D(s.getX() - x0, s.getY() - y0))
848  self.log.info("Number of selected sources for astrometry : %d" %(len(goodsources)))
849  if len(goodsources) < len(sourceCat):
850  self.log.logdebug('Keeping %i of %i sources with finite X,Y positions and PSF flux' %
851  (len(goodsources), len(sourceCat)))
852  self.log.logdebug(('Feeding sources in range x=[%.1f, %.1f], y=[%.1f, %.1f] ' +
853  '(after subtracting x0,y0 = %.1f,%.1f) to Astrometry.net') %
854  (xybb.getMinX(), xybb.getMaxX(), xybb.getMinY(), xybb.getMaxY(), x0, y0))
855  # setStars sorts them by PSF flux.
856  solver.setStars(goodsources, x0, y0)
857  solver.setMaxStars(self.config.maxStars)
858  solver.setImageSize(*imageSize)
859  solver.setMatchThreshold(self.config.matchThreshold)
860  raDecRadius = None
861  if radecCenter is not None:
862  raDecRadius = (radecCenter.getLongitude().asDegrees(),
863  radecCenter.getLatitude().asDegrees(),
864  searchRadius.asDegrees())
865  solver.setRaDecRadius(*raDecRadius)
866  self.log.logdebug('Searching for match around RA,Dec = (%g, %g) with radius %g deg' %
867  raDecRadius)
868 
869  if pixelScale is not None:
870  dscale = self.config.pixelScaleUncertainty
871  scale = pixelScale.asArcseconds()
872  lo = scale / dscale
873  hi = scale * dscale
874  solver.setPixelScaleRange(lo, hi)
875  self.log.logdebug(
876  'Searching for matches with pixel scale = %g +- %g %% -> range [%g, %g] arcsec/pix' %
877  (scale, 100.*(dscale-1.), lo, hi))
878 
879  if parity is not None:
880  solver.setParity(parity)
881  self.log.logdebug('Searching for match with parity = ' + str(parity))
882 
883  # Find and load index files within RA,Dec range and scale range.
884  if radecCenter is not None:
885  multiInds = self.refObjLoader._getMIndexesWithinRange(radecCenter, searchRadius)
886  else:
887  multiInds = self.refObjLoader.multiInds
888  qlo,qhi = solver.getQuadSizeLow(), solver.getQuadSizeHigh()
889 
890  toload_multiInds = set()
891  toload_inds = []
892  for mi in multiInds:
893  for i in range(len(mi)):
894  ind = mi[i]
895  if not ind.overlapsScaleRange(qlo, qhi):
896  continue
897  toload_multiInds.add(mi)
898  toload_inds.append(ind)
899 
900  import lsstDebug
901  if lsstDebug.Info(__name__).display:
902  # Use separate context for display, since astrometry.net can segfault if we don't...
903  with LoadMultiIndexes(toload_multiInds):
904  displayAstrometry(refCat=self.refObjLoader.loadPixelBox(bbox, wcs, filterName).refCat,
905  frame=lsstDebug.Info(__name__).frame, pause=lsstDebug.Info(__name__).pause)
906 
907  with LoadMultiIndexes(toload_multiInds):
908  solver.addIndices(toload_inds)
909  self.memusage('Index files loaded: ')
910 
911  cpulimit = self.config.maxCpuTime
912  solver.run(cpulimit)
913 
914  self.memusage('Solving finished: ')
915 
916  self.memusage('Index files unloaded: ')
917 
918  if solver.didSolve():
919  self.log.logdebug('Solved!')
920  wcs = solver.getWcs()
921  self.log.logdebug('WCS: %s' % wcs.getFitsMetadata().toString())
922 
923  if x0 != 0 or y0 != 0:
924  wcs.shiftReferencePixel(x0, y0)
925  self.log.logdebug('After shifting reference pixel by x0,y0 = (%i,%i), WCS is: %s' %
926  (x0, y0, wcs.getFitsMetadata().toString()))
927 
928  else:
929  self.log.warn('Did not get an astrometric solution from Astrometry.net')
930  wcs = None
931  # Gather debugging info...
932 
933  # -are there any reference stars in the proposed search area?
934  # log the number found and discard the results
935  if radecCenter is not None:
936  self.refObjLoader.loadSkyCircle(radecCenter, searchRadius, filterName)
937 
938  qa = solver.getSolveStats()
939  self.log.logdebug('qa: %s' % qa.toString())
940  return wcs, qa
941 
942  def _isGoodSource(self, candsource, keys):
943  for k in keys:
944  if candsource.get(k):
945  return False
946  return True
947 
948  @staticmethod
949  def _trimBadPoints(sourceCat, bbox, wcs=None):
950  """Remove elements from catalog whose xy positions are not within the given bbox.
951 
952  sourceCat: a Catalog of SimpleRecord or SourceRecord objects
953  bbox: an afwImage.Box2D
954  wcs: if not None, will be used to compute the xy positions on-the-fly;
955  this is required when sources actually contains SimpleRecords.
956 
957  Returns:
958  a list of Source objects with xAstrom,yAstrom within the bbox.
959  """
960  keep = type(sourceCat)(sourceCat.table)
961  for s in sourceCat:
962  point = s.getCentroid() if wcs is None else wcs.skyToPixel(s.getCoord())
963  if bbox.contains(point):
964  keep.append(s)
965  return keep
966 
967  def joinMatchListWithCatalog(self, packedMatches, sourceCat):
968  """
969  This function is required to reconstitute a ReferenceMatchVector after being
970  unpersisted. The persisted form of a ReferenceMatchVector is the
971  normalized Catalog of IDs produced by afw.table.packMatches(), with the result of
972  InitialAstrometry.getMatchMetadata() in the associated tables\' metadata.
973 
974  The "live" form of a matchlist has links to
975  the real record objects that are matched; it is "denormalized".
976  This function takes a normalized match catalog, along with the catalog of
977  sources to which the match catalog refers. It fetches the reference
978  sources that are within range, and then denormalizes the matches
979  -- sets the "matches[*].first" and "matches[*].second" entries
980  to point to the sources in the "sourceCat" argument, and to the
981  reference sources fetched from the astrometry_net_data files.
982 
983  @param[in] packedMatches Unpersisted match list (an lsst.afw.table.BaseCatalog).
984  packedMatches.table.getMetadata() must contain the
985  values from InitialAstrometry.getMatchMetadata()
986  @param[in,out] sourceCat Source catalog used for the 'second' side of the matches
987  (an lsst.afw.table.SourceCatalog). As a side effect,
988  the catalog will be sorted by ID.
989 
990  @return An lsst.afw.table.ReferenceMatchVector of denormalized matches.
991  """
992  matchmeta = packedMatches.table.getMetadata()
993  version = matchmeta.getInt('SMATCHV')
994  if version != 1:
995  raise ValueError('SourceMatchVector version number is %i, not 1.' % version)
996  filterName = matchmeta.getString('FILTER').strip()
997  ctrCoord = afwCoord.IcrsCoord(
998  matchmeta.getDouble('RA') * afwGeom.degrees,
999  matchmeta.getDouble('DEC') * afwGeom.degrees,
1000  )
1001  rad = matchmeta.getDouble('RADIUS') * afwGeom.degrees
1002  refCat = self.refObjLoader.loadSkyCircle(ctrCoord, rad, filterName).refCat
1003  refCat.sort()
1004  sourceCat.sort()
1005  return afwTable.unpackMatches(packedMatches, refCat, sourceCat)
1006 
1007 
1008 def _createMetadata(bbox, wcs, filterName):
1009  """
1010  Create match metadata entries required for regenerating the catalog
1011 
1012  @param bbox bounding box of image (pixels)
1013  @param filterName Name of filter, used for magnitudes
1014  @return Metadata
1015  """
1016  meta = dafBase.PropertyList()
1017 
1018  bboxD = afwGeom.Box2D(bbox)
1019  cx, cy = bboxD.getCenter()
1020  radec = wcs.pixelToSky(cx, cy).toIcrs()
1021  meta.add('RA', radec.getRa().asDegrees(), 'field center in degrees')
1022  meta.add('DEC', radec.getDec().asDegrees(), 'field center in degrees')
1023  pixelRadius = math.hypot(*bboxD.getDimensions())/2.0
1024  skyRadius = wcs.pixelScale() * pixelRadius
1025  meta.add('RADIUS', skyRadius.asDegrees(),
1026  'field radius in degrees, approximate')
1027  meta.add('SMATCHV', 1, 'SourceMatchVector version number')
1028  if filterName is not None:
1029  meta.add('FILTER', filterName, 'LSST filter name for tagalong data')
1030  return meta
Basic implemeentation of the astrometry.net astrometrical fitter.
Class for storing ordered metadata with comments.
Definition: PropertyList.h:81
def getSipWcs
&quot;Not very pythonic!&quot; complains Paul.
Implementation of the WCS standard for a any projection.
Definition: Wcs.h:107
def useKnownWcs
Return an InitialAstrometry object, just like determineWcs, but assuming the given input WCS is corre...
Custom catalog class for record/table subclasses that are guaranteed to have an ID, and should generally be sorted by that ID.
Definition: fwd.h:55
Lightweight representation of a geometric match between two records.
Definition: fwd.h:90
def _calculateSipTerms
Iteratively calculate SIP distortions and regenerate matches based on improved WCS.
def getSipWcsFromWcs
Get a TAN-SIP WCS, starting from an existing WCS.
afw::math::Statistics makeMatchStatisticsInRadians(afw::image::Wcs const &wcs, std::vector< MatchT > const &matchList, int const flags, afw::math::StatisticsControl const &sctrl=afw::math::StatisticsControl())
A floating-point coordinate rectangle geometry.
Definition: Box.h:271
A class to handle Icrs coordinates (inherits from Coord)
Definition: Coord.h:157
std::vector< Match< typename Cat1::Record, typename Cat2::Record > > unpackMatches(BaseCatalog const &matches, Cat1 const &cat1, Cat2 const &cat2)
Reconstruct a MatchVector from a BaseCatalog representation of the matches and a pair of catalogs...