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