32from lsst.utils.timer
import timeMethod
34__all__ = (
"ImageMapReduceTask",
"ImageMapReduceConfig",
35 "ImageMapper",
"ImageMapperConfig",
36 "ImageReducer",
"ImageReducerConfig")
39"""Tasks for processing an exposure via processing on
40multiple sub-exposures and then collecting the results
41to either re-stitch the sub-exposures back into a new
42exposure, or return summary results for each sub-exposure.
44This provides a framework for arbitrary mapper-reducer
45operations on an exposure by implementing simple operations in
46subTasks. It currently is not parallelized, although it could be in
47the future. It does enable operations such as spatially-mapped
48processing on a grid across an image, processing regions surrounding
49centroids (such as for PSF processing), etc.
51It is implemented as primary Task, `ImageMapReduceTask` which contains
52two subtasks, `ImageMapper` and `ImageReducer`.
53`ImageMapReduceTask` configures the centroids and sub-exposure
54dimensions to be processed, and then calls the `run` methods of the
55`ImageMapper` and `ImageReducer` on those sub-exposures.
56`ImageMapReduceTask` may be configured with a list of sub-exposure
57centroids (`config.cellCentroidsX` and `config.cellCentroidsY`) and a
58single pair of bounding boxes defining their dimensions, or a set of
59parameters defining a regular grid of centroids (`config.gridStepX`
60and `config.gridStepY`).
62`ImageMapper` is an abstract class and must be subclassed with
63an implemented `run` method to provide the desired operation for
64processing individual sub-exposures. It is called from
65`ImageMapReduceTask.run`, and may return a new, processed sub-exposure
66which is to be "stitched" back into a new resulting larger exposure
67(depending on the configured `ImageMapReduceTask.mapper`);
68otherwise if it does not return an lsst.afw.image.Exposure, then the results are
69passed back directly to the caller.
71`ImageReducer` will either stitch the `mapperResults` list of
72results generated by the `ImageMapper` together into a new
73Exposure (by default) or pass it through to the
74caller. `ImageReducer` has an implemented `run` method for
75basic reducing operations (`reduceOperation`) such as `average` (which
76will average all overlapping pixels from sub-exposures produced by the
77`ImageMapper` into the new exposure). Another notable
78implemented `reduceOperation` is 'none', in which case the
79`mapperResults` list is simply returned directly.
84 """Configuration parameters for ImageMapper
90 """Abstract base class for any task that is to be
91 used as `ImageMapReduceConfig.mapper`.
95 An `ImageMapper`
is responsible
for processing individual
96 sub-exposures
in its `run` method, which
is called
from
97 `ImageMapReduceTask.run`. `run` may
return a processed new
98 sub-exposure which can be be
"stitched" back into a new resulting
99 larger exposure (depending on the configured
100 `ImageReducer`); otherwise
if it does
not return an
102 `ImageReducer.config.reducer.reduceOperation`
103 should be set to
'none' and the result will be propagated
106 ConfigClass = ImageMapperConfig
107 _DefaultName = "ip_diffim_ImageMapper"
110 def run(self, subExposure, expandedSubExposure, fullBBox, **kwargs):
111 """Perform operation on `subExposure`.
113 To be implemented by subclasses. See class docstring for more
114 details. This method
is given the `subExposure` which
115 is to be operated upon,
and an `expandedSubExposure` which
116 will contain `subExposure`
with additional surrounding
117 pixels. This allows
for,
for example, convolutions (which
118 should be performed on `expandedSubExposure`), to prevent the
119 returned sub-exposure
from containing invalid pixels.
121 This method may
return a new, processed sub-exposure which can
122 be be
"stitched" back into a new resulting larger exposure
123 (depending on the paired, configured `ImageReducer`);
125 `ImageReducer.config.mapper.reduceOperation`
126 should be set to
'none' and the result will be propagated
132 the sub-exposure upon which to operate
134 the expanded sub-exposure upon which to operate
136 the bounding box of the original exposure
138 additional keyword arguments propagated
from
139 `ImageMapReduceTask.run`.
143 result : `lsst.pipe.base.Struct`
144 A structure containing the result of the `subExposure` processing,
145 which may itself be of any type. See above
for details. If it
is an
147 the Struct should be
'subExposure'. This
is implemented here
as a
148 pass-through example only.
150 return pipeBase.Struct(subExposure=subExposure)
154 """Configuration parameters for the ImageReducer
156 reduceOperation = pexConfig.ChoiceField(
158 doc="""Operation to use for reducing subimages into new image.""",
161 "none":
"""simply return a list of values and don't re-map results into
162 a new image (noop operation)""",
163 "copy":
"""copy pixels directly from subimage into correct location in
164 new exposure (potentially non-deterministic for overlaps)
""",
165 "sum":
"""add pixels from overlaps (probably never wanted; used for testing)
166 into correct location in new exposure
""",
167 "average":
"""same as copy, but also average pixels from overlapped regions
169 "coaddPsf":
"""Instead of constructing an Exposure, take a list of returned
170 PSFs and use CoaddPsf to construct a single PSF that covers the
171 entire input exposure
""",
174 badMaskPlanes = pexConfig.ListField(
176 doc="""Mask planes to set for invalid pixels""",
177 default=(
'INVALID_MAPREDUCE',
'BAD',
'NO_DATA')
182 """Base class for any 'reduce' task that is to be
183 used as `ImageMapReduceConfig.reducer`.
185 Basic reduce operations are provided by the `run` method
186 of this
class, to be selected by its config.
188 ConfigClass = ImageReducerConfig
189 _DefaultName = "ip_diffim_ImageReducer"
191 def run(self, mapperResults, exposure, **kwargs):
192 """Reduce a list of items produced by `ImageMapper`.
194 Either stitch the passed `mapperResults` list
195 together into a new Exposure (default) or pass it through
196 (
if `self.config.reduceOperation`
is 'none').
198 If `self.config.reduceOperation`
is not 'none', then expect
199 that the `pipeBase.Struct`s
in the `mapperResults` list
200 contain sub-exposures named
'subExposure', to be stitched back
201 into a single Exposure
with the same dimensions, PSF,
and mask
202 as the input `exposure`. Otherwise, the `mapperResults` list
203 is simply returned directly.
207 mapperResults : `list`
208 list of `lsst.pipe.base.Struct` returned by `ImageMapper.run`.
210 the original exposure which
is cloned to use
as the
211 basis
for the resulting exposure (
if
212 ``self.config.mapper.reduceOperation``
is not 'None')
214 additional keyword arguments propagated
from
215 `ImageMapReduceTask.run`.
220 (named
'exposure')
or a list (named
'result'),
221 depending on `config.reduceOperation`.
225 1. This currently correctly handles overlapping sub-exposures.
226 For overlapping sub-exposures, use `config.reduceOperation=
'average'`.
227 2. This correctly handles varying PSFs, constructing the resulting
228 exposure
's PSF via CoaddPsf (DM-9629).
232 1. To be done: correct handling of masks (nearly there)
233 2. This logic currently makes *two* copies of the original exposure
234 (one here and one
in `mapper.run()`). Possibly of concern
235 for large images on memory-constrained systems.
238 if self.config.reduceOperation ==
'none':
239 return pipeBase.Struct(result=mapperResults)
241 if self.config.reduceOperation ==
'coaddPsf':
244 return pipeBase.Struct(result=coaddPsf)
246 newExp = exposure.clone()
247 newMI = newExp.getMaskedImage()
249 reduceOp = self.config.reduceOperation
250 if reduceOp ==
'copy':
252 newMI.image.array[:, :] = np.nan
253 newMI.variance.array[:, :] = np.nan
255 newMI.image.array[:, :] = 0.
256 newMI.variance.array[:, :] = 0.
257 if reduceOp ==
'average':
258 weights = afwImage.ImageI(newMI.getBBox())
260 for item
in mapperResults:
261 item = item.subExposure
262 if not (isinstance(item, afwImage.ExposureF)
or isinstance(item, afwImage.ExposureI)
263 or isinstance(item, afwImage.ExposureU)
or isinstance(item, afwImage.ExposureD)):
264 raise TypeError(
"""Expecting an Exposure type, got %s.
265 Consider using `reduceOperation="none".
""" % str(type(item)))
266 subExp = newExp.Factory(newExp, item.getBBox())
267 subMI = subExp.getMaskedImage()
268 patchMI = item.getMaskedImage()
269 isValid = ~np.isnan(patchMI.image.array * patchMI.variance.array)
271 if reduceOp ==
'copy':
272 subMI.image.array[isValid] = patchMI.image.array[isValid]
273 subMI.variance.array[isValid] = patchMI.variance.array[isValid]
274 subMI.mask.array[:, :] |= patchMI.mask.array
276 if reduceOp ==
'sum' or reduceOp ==
'average':
277 subMI.image.array[isValid] += patchMI.image.array[isValid]
278 subMI.variance.array[isValid] += patchMI.variance.array[isValid]
279 subMI.mask.array[:, :] |= patchMI.mask.array
280 if reduceOp ==
'average':
282 wtsView = afwImage.ImageI(weights, item.getBBox())
283 wtsView.array[isValid] += 1
287 for m
in self.config.badMaskPlanes:
289 bad = mask.getPlaneBitMask(self.config.badMaskPlanes)
291 isNan = np.where(np.isnan(newMI.image.array * newMI.variance.array))
292 if len(isNan[0]) > 0:
294 mask.array[isNan[0], isNan[1]] |= bad
296 if reduceOp ==
'average':
297 wts = weights.array.astype(float)
298 self.log.info(
'AVERAGE: Maximum overlap: %f', np.nanmax(wts))
299 self.log.info(
'AVERAGE: Average overlap: %f', np.nanmean(wts))
300 self.log.info(
'AVERAGE: Minimum overlap: %f', np.nanmin(wts))
301 wtsZero = np.equal(wts, 0.)
302 wtsZeroInds = np.where(wtsZero)
303 wtsZeroSum = len(wtsZeroInds[0])
304 self.log.info(
'AVERAGE: Number of zero pixels: %f (%f%%)', wtsZeroSum,
305 wtsZeroSum * 100. / wtsZero.size)
306 notWtsZero = ~wtsZero
307 tmp = newMI.image.array
308 np.divide(tmp, wts, out=tmp, where=notWtsZero)
309 tmp = newMI.variance.array
310 np.divide(tmp, wts, out=tmp, where=notWtsZero)
311 if len(wtsZeroInds[0]) > 0:
312 newMI.image.array[wtsZeroInds] = np.nan
313 newMI.variance.array[wtsZeroInds] = np.nan
316 mask.array[wtsZeroInds] |= bad
319 if reduceOp ==
'sum' or reduceOp ==
'average':
323 return pipeBase.Struct(exposure=newExp)
326 """Construct a CoaddPsf based on PSFs from individual subExposures
328 Currently uses (and returns) a CoaddPsf. TBD
if we want to
329 create a custom subclass of CoaddPsf to differentiate it.
333 mapperResults : `list`
334 list of `pipeBase.Struct` returned by `ImageMapper.run`.
335 For this to work, each element of `mapperResults` must contain
336 a `subExposure` element,
from which the component Psfs are
337 extracted (thus the reducerTask cannot have
338 `reduceOperation =
'none'`.
340 the original exposure which
is used here solely
for its
341 bounding-box
and WCS.
346 A psf constructed
from the PSFs of the individual subExposures.
348 schema = afwTable.ExposureTable.makeMinimalSchema()
349 schema.addField("weight", type=
"D", doc=
"Coadd weight")
354 wcsref = exposure.getWcs()
355 for i, res
in enumerate(mapperResults):
356 record = mycatalog.getTable().makeRecord()
357 if 'subExposure' in res.getDict():
358 subExp = res.subExposure
359 if subExp.getWcs() != wcsref:
360 raise ValueError(
'Wcs of subExposure is different from exposure')
361 record.setPsf(subExp.getPsf())
362 record.setWcs(subExp.getWcs())
363 record.setBBox(subExp.getBBox())
364 elif 'psf' in res.getDict():
365 record.setPsf(res.psf)
366 record.setWcs(wcsref)
367 record.setBBox(res.bbox)
368 record[
'weight'] = 1.0
370 mycatalog.append(record)
373 psf = measAlg.CoaddPsf(mycatalog, wcsref,
'weight')
378 """Configuration parameters for the ImageMapReduceTask
380 mapper = pexConfig.ConfigurableField(
381 doc="Task to run on each subimage",
385 reducer = pexConfig.ConfigurableField(
386 doc=
"Task to combine results of mapper task",
394 cellCentroidsX = pexConfig.ListField(
396 doc=
"""Input X centroids around which to place subimages.
397 If None, use grid config options below.
""",
402 cellCentroidsY = pexConfig.ListField(
404 doc=
"""Input Y centroids around which to place subimages.
405 If None, use grid config options below.
""",
410 cellSizeX = pexConfig.Field(
412 doc=
"""Dimensions of each grid cell in x direction""",
414 check=
lambda x: x > 0.
417 cellSizeY = pexConfig.Field(
419 doc=
"""Dimensions of each grid cell in y direction""",
421 check=
lambda x: x > 0.
424 gridStepX = pexConfig.Field(
426 doc=
"""Spacing between subsequent grid cells in x direction. If equal to
427 cellSizeX, then there is no overlap
in the x direction.
""",
429 check=lambda x: x > 0.
432 gridStepY = pexConfig.Field(
434 doc=
"""Spacing between subsequent grid cells in y direction. If equal to
435 cellSizeY, then there is no overlap
in the y direction.
""",
437 check=lambda x: x > 0.
440 borderSizeX = pexConfig.Field(
442 doc=
"""Dimensions of grid cell border in +/- x direction, to be used
443 for generating `expandedSubExposure`.
""",
445 check=lambda x: x > 0.
448 borderSizeY = pexConfig.Field(
450 doc=
"""Dimensions of grid cell border in +/- y direction, to be used
451 for generating `expandedSubExposure`.
""",
453 check=lambda x: x > 0.
456 adjustGridOption = pexConfig.ChoiceField(
458 doc=
"""Whether and how to adjust grid to fit evenly within, and cover entire
462 "spacing":
"adjust spacing between centers of grid cells (allowing overlaps)",
463 "size":
"adjust the sizes of the grid cells (disallowing overlaps)",
464 "none":
"do not adjust the grid sizes or spacing"
468 scaleByFwhm = pexConfig.Field(
470 doc=
"""Scale cellSize/gridStep/borderSize/overlapSize by PSF FWHM rather
475 returnSubImages = pexConfig.Field(
477 doc=
"""Return the input subExposures alongside the processed ones (for debugging)""",
481 ignoreMaskPlanes = pexConfig.ListField(
483 doc=
"""Mask planes to ignore for sigma-clipped statistics""",
484 default=(
"INTRP",
"EDGE",
"DETECTED",
"SAT",
"CR",
"BAD",
"NO_DATA",
"DETECTED_NEGATIVE")
489 """Split an Exposure into subExposures (optionally on a grid) and
490 perform the same operation on each.
492 Perform 'simple' operations on a gridded set of subExposures of a
493 larger Exposure,
and then (by default) have those subExposures
494 stitched back together into a new, full-sized image.
496 Contrary to the expectation given by its name, this task does
not
497 perform these operations
in parallel, although it could be updatd
498 to provide such functionality.
500 The actual operations are performed by two subTasks passed to the
501 config. The exposure passed to this task
's `run` method will be
502 divided, and those subExposures will be passed to the subTasks,
503 along
with the original exposure. The reducing operation
is
504 performed by the second subtask.
506 ConfigClass = ImageMapReduceConfig
507 _DefaultName = "ip_diffim_imageMapReduce"
510 """Create the image map-reduce task
515 arguments to be passed to
516 `lsst.pipe.base.task.Task.__init__`
518 additional keyword arguments to be passed to
519 `lsst.pipe.base.task.Task.__init__`
521 pipeBase.Task.__init__(self, *args, **kwargs)
524 self.makeSubtask(
"mapper")
525 self.makeSubtask(
"reducer")
528 def run(self, exposure, **kwargs):
529 """Perform a map-reduce operation on the given exposure.
531 Split the exposure into sub-expposures on a grid (parameters
532 given by `ImageMapReduceConfig`) and perform
533 `config.mapper.run()` on each. Reduce the resulting
534 sub-exposures by running `config.reducer.run()`.
539 the full exposure to process
541 additional keyword arguments to be passed to
542 subtask `run` methods
546 output of `reducer.run()`
549 self.log.info("Mapper sub-task: %s", self.mapper._DefaultName)
550 mapperResults = self.
_runMapper(exposure, **kwargs)
551 self.log.info(
"Reducer sub-task: %s", self.reducer._DefaultName)
552 result = self.
_reduceImage(mapperResults, exposure, **kwargs)
556 """Perform `mapper.run` on each sub-exposure
558 Perform `mapper.run` on each sub-exposure across a
559 grid on `exposure` generated by `_generateGrid`. Also pass to
560 `mapper.run` an
'expanded sub-exposure' containing the
561 same region
as the sub-exposure but
with an expanded bounding box.
566 the original exposure which
is used
as the template
568 if True, clone the subimages before passing to subtask;
569 in that case, the sub-exps do
not have to be considered
as read-only
571 additional keyword arguments to be passed to
572 `mapper.run`
and `self.
_generateGrid`, including `forceEvenSized`.
576 a list of `pipeBase.Struct`s
as returned by `mapper.run`.
581 raise ValueError(
'Bounding boxes list and expanded bounding boxes list are of different lengths')
583 self.log.info(
"Processing %d sub-exposures", len(self.
boxes0))
586 subExp = exposure.Factory(exposure, box0)
587 expandedSubExp = exposure.Factory(exposure, box1)
589 subExp = subExp.clone()
590 expandedSubExp = expandedSubExp.clone()
591 result = self.mapper.run(subExp, expandedSubExp, exposure.getBBox(), **kwargs)
592 if self.config.returnSubImages:
593 toAdd = pipeBase.Struct(inputSubExposure=subExp,
594 inputExpandedSubExposure=expandedSubExp)
595 result.mergeItems(toAdd,
'inputSubExposure',
'inputExpandedSubExposure')
596 mapperResults.append(result)
601 """Reduce/merge a set of sub-exposures into a final result
603 Return an exposure of the same dimensions as `exposure`.
604 `mapperResults`
is expected to have been produced by `runMapper`.
608 mapperResults : `list`
609 `list` of `lsst.pipe.base.Struct`, each of which was produced by
612 the original exposure
614 additional keyword arguments
618 Output of `reducer.run` which
is a `pipeBase.Struct`.
620 result = self.reducer.run(mapperResults, exposure, **kwargs)
624 """Generate two lists of bounding boxes that evenly grid `exposure`
626 Unless the config was provided with `cellCentroidsX`
and
627 `cellCentroidsY`, grid (subimage) centers are spaced evenly
628 by gridStepX/Y. Then the grid
is adjusted
as little
as
629 possible to evenly cover the input exposure (
if
630 adjustGridOption
is not 'none'). Then the second set of
631 bounding boxes
is expanded by borderSizeX/Y. The expanded
632 bounding boxes are adjusted to ensure that they intersect the
633 exposure
's bounding box. The resulting lists of bounding boxes
634 and corresponding expanded bounding boxes are set to
640 input exposure whose full bounding box
is to be evenly gridded.
641 forceEvenSized : `bool`
642 force grid elements to have even-valued x-
and y- dimensions?
643 (Potentially useful
if doing Fourier transform of subExposures.)
647 bbox = exposure.getBBox()
650 cellCentroidsX = self.config.cellCentroidsX
651 cellCentroidsY = self.config.cellCentroidsY
652 cellSizeX = self.config.cellSizeX
653 cellSizeY = self.config.cellSizeY
654 gridStepX = self.config.gridStepX
655 gridStepY = self.config.gridStepY
656 borderSizeX = self.config.borderSizeX
657 borderSizeY = self.config.borderSizeY
658 adjustGridOption = self.config.adjustGridOption
659 scaleByFwhm = self.config.scaleByFwhm
661 if cellCentroidsX
is None or len(cellCentroidsX) <= 0:
663 psf = exposure.getPsf()
664 psfFwhm = (psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
665 * 2.*np.sqrt(2.*np.log(2.)))
667 self.log.info(
"Scaling grid parameters by %f", psfFwhm)
669 def rescaleValue(val):
671 return np.rint(val*psfFwhm).astype(int)
673 return np.rint(val).astype(int)
675 cellSizeX = rescaleValue(cellSizeX)
676 cellSizeY = rescaleValue(cellSizeY)
677 gridStepX = rescaleValue(gridStepX)
678 gridStepY = rescaleValue(gridStepY)
679 borderSizeX = rescaleValue(borderSizeX)
680 borderSizeY = rescaleValue(borderSizeY)
682 nGridX = bbox.getWidth()//gridStepX
683 nGridY = bbox.getHeight()//gridStepY
685 if adjustGridOption ==
'spacing':
687 nGridX = bbox.getWidth()//cellSizeX + 1
688 nGridY = bbox.getHeight()//cellSizeY + 1
689 xLinSpace = np.linspace(cellSizeX//2, bbox.getWidth() - cellSizeX//2, nGridX)
690 yLinSpace = np.linspace(cellSizeY//2, bbox.getHeight() - cellSizeY//2, nGridY)
692 elif adjustGridOption ==
'size':
693 cellSizeX = gridStepX
694 cellSizeY = gridStepY
695 xLinSpace = np.arange(cellSizeX//2, bbox.getWidth() + cellSizeX//2, cellSizeX)
696 yLinSpace = np.arange(cellSizeY//2, bbox.getHeight() + cellSizeY//2, cellSizeY)
701 xLinSpace = np.arange(cellSizeX//2, bbox.getWidth() + cellSizeX//2, gridStepX)
702 yLinSpace = np.arange(cellSizeY//2, bbox.getHeight() + cellSizeY//2, gridStepY)
704 cellCentroids = [(x, y)
for x
in xLinSpace
for y
in yLinSpace]
708 cellCentroids = [(cellCentroidsX[i], cellCentroidsY[i])
for i
in range(len(cellCentroidsX))]
719 def _makeBoxEvenSized(bb):
720 """Force a bounding-box to have dimensions that are modulo 2."""
722 if bb.getWidth() % 2 == 1:
725 if bb.getWidth() % 2 == 1:
728 if bb.getHeight() % 2 == 1:
731 if bb.getHeight() % 2 == 1:
734 if bb.getWidth() % 2 == 1
or bb.getHeight() % 2 == 1:
735 raise RuntimeError(
'Cannot make bounding box even-sized. Probably too big.')
740 if cellCentroids
is not None and len(cellCentroids) > 0:
741 for x, y
in cellCentroids:
744 xoff = int(np.floor(centroid.getX())) - bb0.getWidth()//2
745 yoff = int(np.floor(centroid.getY())) - bb0.getHeight()//2
749 bb0 = _makeBoxEvenSized(bb0)
754 bb1 = _makeBoxEvenSized(bb1)
756 if bb0.getArea() > 1
and bb1.getArea() > 1:
763 """Plot both grids of boxes using matplotlib.
765 Will compute the grid via `_generateGrid` if
766 `self.
boxes0`
and `self.
boxes1` have
not already been set.
771 Exposure whose bounding box
is gridded by this task.
773 Plot every skip-ped box (help make plots less confusing)
775 import matplotlib.pyplot
as plt
778 raise RuntimeError(
'Cannot plot boxes. Run _generateGrid first.')
782 plt.gca().set_prop_cycle(
None)
786 """Plot a grid of boxes using matplotlib.
791 a list of bounding boxes.
793 an overall bounding box
795 additional keyword arguments for matplotlib
797 import matplotlib.pyplot
as plt
800 corners = np.array([np.array([pt.getX(), pt.getY()])
for pt
in box.getCorners()])
801 corners = np.vstack([corners, corners[0, :]])
802 plt.plot(corners[:, 0], corners[:, 1], **kwargs)
806 plt.xlim(bbox.getBeginX(), bbox.getEndX())
807 plt.ylim(bbox.getBeginY(), bbox.getEndY())
A class to contain the data, WCS, and other information needed to describe an image of the sky.
Custom catalog class for ExposureRecord/Table.
An integer coordinate rectangle.
_generateGrid(self, exposure, forceEvenSized=False, **kwargs)
__init__(self, *args, **kwargs)
_reduceImage(self, mapperResults, exposure, **kwargs)
_runMapper(self, exposure, doClone=False, **kwargs)
plotBoxes(self, fullBBox, skip=3)
_plotBoxGrid(self, boxes, bbox, **kwargs)
_constructPsf(self, mapperResults, exposure)
CoaddPsf is the Psf derived to be used for non-PSF-matched Coadd images.