22 from __future__
import absolute_import, division, print_function
24 __all__ = [
"ANetAstrometryConfig",
"ANetAstrometryTask",
"showAstrometry"]
26 from builtins
import input
27 from builtins
import zip
28 from builtins
import range
29 from contextlib
import contextmanager
34 import lsst.pex.exceptions
35 import lsst.afw.geom
as afwGeom
36 from lsst.afw.cameraGeom
import TAN_PIXELS
37 from lsst.afw.table
import Point2DKey, CovarianceMatrix2fKey
38 import lsst.pex.config
as pexConfig
39 import lsst.pipe.base
as pipeBase
40 from lsst.meas.astrom
import displayAstrometry
41 from lsst.meas.astrom.sip
import makeCreateWcsWithSip
42 from .anetBasicAstrometry
import ANetBasicAstrometryTask
46 solver = pexConfig.ConfigurableField(
47 target=ANetBasicAstrometryTask,
48 doc=
"Basic astrometry solver",
50 forceKnownWcs = pexConfig.Field(dtype=bool, doc=(
51 "Assume that the input image's WCS is correct, without comparing it to any external reality." +
52 " (In contrast to using Astrometry.net). NOTE, if you set this, you probably also want to" +
53 " un-set 'solver.calculateSip'; otherwise we'll still try to find a TAN-SIP WCS starting " +
54 " from the existing WCS"), default=
False)
55 rejectThresh = pexConfig.RangeField(dtype=float, default=3.0, doc=
"Rejection threshold for Wcs fitting",
56 min=0.0, inclusiveMin=
False)
57 rejectIter = pexConfig.RangeField(dtype=int, default=3, doc=
"Rejection iterations for Wcs fitting",
62 """An alias, for a uniform interface with the standard AstrometryTask"""
74 """!Use astrometry.net to match input sources with a reference catalog and solve for the Wcs
76 @anchor ANetAstrometryTask_
78 The actual matching and solving is done by the 'solver'; this Task
79 serves as a wrapper for taking into account the known optical distortion.
81 \section pipe_tasks_astrometry_Contents Contents
83 - \ref pipe_tasks_astrometry_Purpose
84 - \ref pipe_tasks_astrometry_Initialize
85 - \ref pipe_tasks_astrometry_IO
86 - \ref pipe_tasks_astrometry_Config
87 - \ref pipe_tasks_astrometry_Debug
88 - \ref pipe_tasks_astrometry_Example
90 \section pipe_tasks_astrometry_Purpose Description
92 \copybrief ANetAstrometryTask
94 \section pipe_tasks_astrometry_Initialize Task initialisation
98 \section pipe_tasks_astrometry_IO Invoking the Task
102 \section pipe_tasks_astrometry_Config Configuration parameters
104 See \ref ANetAstrometryConfig
106 \section pipe_tasks_astrometry_Debug Debug variables
108 The \link lsst.pipe.base.cmdLineTask.CmdLineTask command line task\endlink interface supports a
109 flag \c -d to import \b debug.py from your \c PYTHONPATH;
110 see \ref baseDebug for more about \b debug.py files.
112 The available variables in ANetAstrometryTask are:
115 <DD> If True call showAstrometry while iterating ANetAstrometryConfig.rejectIter times,
116 and also after converging; and call displayAstrometry after applying the distortion correction.
118 <DD> ds9 frame to use in showAstrometry and displayAstrometry
120 <DD> Pause after showAstrometry and displayAstrometry?
123 \section pipe_tasks_astrometry_Example A complete example of using ANetAstrometryTask
125 See \ref pipe_tasks_photocal_Example.
127 To investigate the \ref pipe_tasks_astrometry_Debug, put something like
131 di = lsstDebug.getInfo(name) # N.b. lsstDebug.Info(name) would call us recursively
132 if name in ("lsst.pipe.tasks.anetAstrometry", "lsst.pipe.tasks.anetBasicAstrometry"):
139 lsstDebug.Info = DebugInfo
141 into your debug.py file and run photoCalTask.py with the \c --debug flag.
143 ConfigClass = ANetAstrometryConfig
145 def __init__(self, schema, refObjLoader=None, **kwds):
146 """!Create the astrometric calibration task. Most arguments are simply passed onto pipe.base.Task.
148 \param schema An lsst::afw::table::Schema used to create the output lsst.afw.table.SourceCatalog
149 \param refObjLoader The AstrometryTask constructor requires a refObjLoader. In order to make this
150 task retargettable for AstrometryTask it needs to take the same arguments. This argument will be
151 ignored since it uses its own internal loader.
152 \param **kwds keyword arguments to be passed to the lsst.pipe.base.task.Task constructor
154 A centroid field "centroid.distorted" (used internally during the Task's operation)
155 will be added to the schema.
157 pipeBase.Task.__init__(self, **kwds)
160 doc=
"centroid distorted for astrometry solver")
162 doc=
"centroid distorted for astrometry solver")
164 doc=
"centroid distorted err for astrometry solver")
166 doc=
"centroid distorted err for astrometry solver")
168 doc=
"centroid distorted flag astrometry solver")
175 def run(self, exposure, sourceCat):
176 """!Load reference objects, match sources and optionally fit a WCS
178 This is a thin layer around solve or loadAndMatch, depending on config.forceKnownWcs
180 @param[in,out] exposure exposure whose WCS is to be fit
181 The following are read only:
183 - calib (may be absent)
184 - filter (may be unset)
185 - detector (if wcs is pure tangent; may be absent)
186 The following are updated:
187 - wcs (the initial value is used as an initial guess, and is required)
188 @param[in] sourceCat catalog of sourceCat detected on the exposure (an lsst.afw.table.SourceCatalog)
189 @return an lsst.pipe.base.Struct with these fields:
190 - refCat reference object catalog of objects that overlap the exposure (with some margin)
191 (an lsst::afw::table::SimpleCatalog)
192 - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
193 - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
195 if self.config.forceKnownWcs:
196 return self.
loadAndMatch(exposure=exposure, sourceCat=sourceCat)
198 return self.
solve(exposure=exposure, sourceCat=sourceCat)
201 def solve(self, exposure, sourceCat):
202 """!Match with reference sources and calculate an astrometric solution
204 \param[in,out] exposure Exposure to calibrate; wcs is updated
205 \param[in] sourceCat catalog of measured sources (an lsst.afw.table.SourceCatalog)
206 \return a pipeBase.Struct with fields:
207 - refCat reference object catalog of objects that overlap the exposure (with some margin)
208 (an lsst::afw::table::SimpleCatalog)
209 - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
210 - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
212 The reference catalog actually used is up to the implementation
213 of the solver; it will be manifested in the returned matches as
214 a list of lsst.afw.table.ReferenceMatch objects (\em i.e. of lsst.afw.table.Match with
215 \c first being of type lsst.afw.table.SimpleRecord and \c second type lsst.afw.table.SourceRecord ---
216 the reference object and matched object respectively).
219 The input sources have the centroid slot moved to a new column "centroid.distorted"
220 which has the positions corrected for any known optical distortion;
221 the 'solver' (which is instantiated in the 'astrometry' member)
222 should therefore simply use the centroids provided by calling
223 afw.table.Source.getCentroid() on the individual source records. This column \em must
224 be present in the sources table.
226 \note ignores config.forceKnownWcs
229 results = self.
_astrometry(sourceCat=sourceCat, exposure=exposure, bbox=bbox)
232 self.
refitWcs(sourceCat=sourceCat, exposure=exposure, matches=results.matches)
238 """!Calculate distorted source positions
240 CCD images are often affected by optical distortion that makes
241 the astrometric solution higher order than linear. Unfortunately,
242 most (all?) matching algorithms require that the distortion be
243 small or zero, and so it must be removed. We do this by calculating
244 (un-)distorted positions, based on a known optical distortion model
247 The distortion correction moves sources, so we return the distorted bounding box.
249 \param[in] exposure Exposure to process
250 \param[in,out] sourceCat SourceCatalog; getX() and getY() will be used as inputs,
251 with distorted points in "centroid.distorted" field.
252 \return bounding box of distorted exposure
254 detector = exposure.getDetector()
255 pixToTanXYTransform =
None
257 self.log.warn(
"No detector associated with exposure; assuming null distortion")
259 tanSys = detector.makeCameraSys(TAN_PIXELS)
260 pixToTanXYTransform = detector.getTransformMap().get(tanSys)
262 if pixToTanXYTransform
is None:
263 self.log.info(
"Null distortion correction")
268 return exposure.getBBox()
271 self.log.info(
"Applying distortion correction")
273 centroid = pixToTanXYTransform.forwardTransform(s.getCentroid())
279 bboxD = afwGeom.Box2D()
280 for corner
in detector.getCorners(TAN_PIXELS):
281 bboxD.include(corner)
283 if lsstDebug.Info(__name__).display:
284 frame = lsstDebug.Info(__name__).frame
285 pause = lsstDebug.Info(__name__).pause
286 displayAstrometry(sourceCat=sourceCat, distortedCentroidKey=self.
centroidKey,
287 exposure=exposure, frame=frame, pause=pause)
289 return afwGeom.Box2I(bboxD)
293 """!Context manager that applies and removes distortion
295 We move the "centroid" definition in the catalog table to
296 point to the distorted positions. This is undone on exit
299 The input Wcs is taken to refer to the coordinate system
300 with the distortion correction applied, and hence no shift
301 is required when the sources are distorted. However, after
302 Wcs fitting, the Wcs is in the distorted frame so when the
303 distortion correction is removed, the Wcs needs to be
304 shifted to compensate.
306 \param sourceCat Sources on exposure, an lsst.afw.table.SourceCatalog
307 \param exposure Exposure holding Wcs, an lsst.afw.image.ExposureF or D
308 \return bounding box of distorted exposure
311 if exposure.getWcs().hasDistortion():
312 yield exposure.getBBox()
314 bbox = self.
distort(sourceCat=sourceCat, exposure=exposure)
315 oldCentroidName = sourceCat.table.getCentroidDefinition()
321 sourceCat.table.defineCentroid(oldCentroidName)
322 x0, y0 = exposure.getXY0()
323 wcs = exposure.getWcs()
325 wcs.shiftReferencePixel(-bbox.getMinX() + x0, -bbox.getMinY() + y0)
329 """!Load reference objects overlapping an exposure and match to sources detected on that exposure
331 @param[in] exposure exposure whose WCS is to be fit
332 @param[in] sourceCat catalog of sourceCat detected on the exposure (an lsst.afw.table.SourceCatalog)
333 @param[in] bbox bounding box go use for finding reference objects; if None, use exposure's bbox
335 @return an lsst.pipe.base.Struct with these fields:
336 - refCat reference object catalog of objects that overlap the exposure (with some margin)
337 (an lsst::afw::table::SimpleCatalog)
338 - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
339 - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
341 @note ignores config.forceKnownWcs
345 self.makeSubtask(
"solver")
347 astrom = self.solver.useKnownWcs(
354 if astrom
is None or astrom.getWcs()
is None:
355 raise RuntimeError(
"Unable to solve astrometry")
357 matches = astrom.getMatches()
358 matchMeta = astrom.getMatchMetadata()
359 if matches
is None or len(matches) == 0:
360 raise RuntimeError(
"No astrometric matches")
361 self.log.info(
"%d astrometric matches" % (len(matches)))
364 frame = lsstDebug.Info(__name__).frame
365 displayAstrometry(exposure=exposure, sourceCat=sourceCat, matches=matches,
366 frame=frame, pause=
False)
368 return pipeBase.Struct(
369 refCat=astrom.refCat,
375 def _astrometry(self, sourceCat, exposure, bbox=None):
376 """!Solve astrometry to produce WCS
378 \param[in] sourceCat Sources on exposure, an lsst.afw.table.SourceCatalog
379 \param[in,out] exposure Exposure to process, an lsst.afw.image.ExposureF or D; wcs is updated
380 \param[in] bbox Bounding box, or None to use exposure
381 \return a pipe.base.Struct with fields:
382 - refCat reference object catalog of objects that overlap the exposure (with some margin)
383 (an lsst::afw::table::SimpleCatalog)
384 - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
385 - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
387 self.log.info(
"Solving astrometry")
389 bbox = exposure.getBBox()
392 self.makeSubtask(
"solver")
394 astrom = self.solver.determineWcs(sourceCat=sourceCat, exposure=exposure, bbox=bbox)
396 if astrom
is None or astrom.getWcs()
is None:
397 raise RuntimeError(
"Unable to solve astrometry")
399 matches = astrom.getMatches()
400 matchMeta = astrom.getMatchMetadata()
401 if matches
is None or len(matches) == 0:
402 raise RuntimeError(
"No astrometric matches")
403 self.log.info(
"%d astrometric matches" % (len(matches)))
406 exposure.setWcs(astrom.getWcs())
409 frame = lsstDebug.Info(__name__).frame
410 displayAstrometry(exposure=exposure, sourceCat=sourceCat, matches=matches,
411 frame=frame, pause=
False)
413 return pipeBase.Struct(
414 refCat=astrom.refCat,
421 """!A final Wcs solution after matching and removing distortion
423 Specifically, fitting the non-linear part, since the linear
424 part has been provided by the matching engine.
426 @param sourceCat Sources on exposure, an lsst.afw.table.SourceCatalog
427 @param exposure Exposure of interest, an lsst.afw.image.ExposureF or D
428 @param matches Astrometric matches, as a list of lsst.afw.table.ReferenceMatch
430 @return the resolved-Wcs object, or None if config.solver.calculateSip is False.
433 if self.config.solver.calculateSip:
434 self.log.info(
"Refitting WCS")
435 origMatches = matches
436 wcs = exposure.getWcs()
439 display = lsstDebug.Info(__name__).display
440 frame = lsstDebug.Info(__name__).frame
441 pause = lsstDebug.Info(__name__).pause
443 def fitWcs(initialWcs, title=None):
444 """!Do the WCS fitting and display of the results"""
445 sip = makeCreateWcsWithSip(matches, initialWcs, self.config.solver.sipOrder)
446 resultWcs = sip.getNewWcs()
448 showAstrometry(exposure, resultWcs, origMatches, matches, frame=frame,
449 title=title, pause=pause)
450 return resultWcs, sip.getScatterOnSky()
454 for i
in range(self.config.rejectIter):
455 wcs, scatter = fitWcs(wcs, title=
"Iteration %d" % i)
457 ref = np.array([wcs.skyToPixel(m.first.getCoord())
for m
in matches])
458 src = np.array([m.second.getCentroid()
for m
in matches])
462 for d, m
in zip(diff, matches):
463 if np.all(np.abs(d) < self.config.rejectThresh*rms):
467 if len(matches) == len(trimmed):
472 wcs, scatter = fitWcs(wcs, title=
"Final astrometry")
474 except lsst.pex.exceptions.LengthError
as e:
475 self.log.warn(
"Unable to fit SIP: %s" % e)
477 self.log.info(
"Astrometric scatter: %f arcsec (%s non-linear terms, %d matches, %d rejected)" %
478 (scatter.asArcseconds(),
"with" if wcs.hasDistortion()
else "without",
479 len(matches), numRejected))
483 for index, source
in enumerate(sourceCat):
484 sky = wcs.pixelToSky(source.getX(), source.getY())
487 self.log.warn(
"Not calculating a SIP solution; matches may be suspect")
490 frame = lsstDebug.Info(__name__).frame
491 displayAstrometry(exposure=exposure, sourceCat=sourceCat, matches=matches,
492 frame=frame, pause=
False)
497 def showAstrometry(exposure, wcs, allMatches, useMatches, frame=0, title=None, pause=False):
498 """!Show results of astrometry fitting
500 \param exposure Image to display
501 \param wcs Astrometric solution
502 \param allMatches List of all astrometric matches (including rejects)
503 \param useMatches List of used astrometric matches
504 \param frame Frame number for display
505 \param title Title for display
506 \param pause Pause to allow viewing of the display and optional debugging?
508 - Matches are shown in yellow if used in the Wcs solution, otherwise red
509 - +: Detected objects
510 - x: Catalogue objects
512 import lsst.afw.display.ds9
as ds9
513 ds9.mtv(exposure, frame=frame, title=title)
515 useIndices = set(m.second.getId()
for m
in useMatches)
518 with ds9.Buffering():
519 for i, m
in enumerate(allMatches):
520 x, y = m.second.getX(), m.second.getY()
521 pix = wcs.skyToPixel(m.first.getCoord())
523 isUsed = m.second.getId()
in useIndices
525 radii.append(np.hypot(pix[0] - x, pix[1] - y))
527 color = ds9.YELLOW
if isUsed
else ds9.RED
529 ds9.dot(
"+", x, y, size=10, frame=frame, ctype=color)
530 ds9.dot(
"x", pix[0], pix[1], size=10, frame=frame, ctype=color)
532 radii = np.array(radii)
533 print(
"<dr> = %.4g +- %.4g pixels [%d/%d matches]" % (radii.mean(), radii.std(),
534 len(useMatches), len(allMatches)))
540 reply = input(
"Debugging? [p]db [q]uit; any other key to continue... ").strip()
def distortionContext
Context manager that applies and removes distortion.
def _astrometry
Solve astrometry to produce WCS.
def loadAndMatch
Load reference objects overlapping an exposure and match to sources detected on that exposure...
def run
Load reference objects, match sources and optionally fit a WCS.
def solve
Match with reference sources and calculate an astrometric solution.
def refitWcs
A final Wcs solution after matching and removing distortion.
Use astrometry.net to match input sources with a reference catalog and solve for the Wcs...
def distort
Calculate distorted source positions.
def showAstrometry
Show results of astrometry fitting.
def __init__
Create the astrometric calibration task.