32 from astro_metadata_translator
import merge_headers, ObservationGroup
33 from astro_metadata_translator.serialize
import dates_to_fits
38 """Parameters controlling the measurement of background statistics.
40 stat = pexConfig.Field(
42 default=int(afwMath.MEANCLIP),
43 doc=
"Statistic to use to estimate background (from lsst.afw.math)",
45 clip = pexConfig.Field(
48 doc=
"Clipping threshold for background",
50 nIter = pexConfig.Field(
53 doc=
"Clipping iterations for background",
55 mask = pexConfig.ListField(
57 default=[
"DETECTED",
"BAD",
"NO_DATA"],
58 doc=
"Mask planes to reject",
63 """Measure statistics on the background
65 This can be useful for scaling the background, e.g., for flats and fringe frames.
67 ConfigClass = CalibStatsConfig
69 def run(self, exposureOrImage):
70 """Measure a particular statistic on an image (of some sort).
74 exposureOrImage : `lsst.afw.image.Exposure`, `lsst.afw.image.MaskedImage`, or `lsst.afw.image.Image`
75 Exposure or image to calculate statistics on.
80 Resulting statistic value.
83 afwImage.Mask.getPlaneBitMask(self.config.mask))
85 image = exposureOrImage.getMaskedImage()
88 image = exposureOrImage.getImage()
90 image = exposureOrImage
96 dimensions=(
"instrument",
"detector")):
99 doc=
"Input pre-processed exposures to combine.",
100 storageClass=
"Exposure",
101 dimensions=(
"instrument",
"detector",
"exposure"),
104 inputScales = cT.Input(
106 doc=
"Input scale factors to use.",
107 storageClass=
"StructuredDataDict",
108 dimensions=(
"instrument", ),
112 outputData = cT.Output(
114 doc=
"Output combined proposed calibration.",
115 storageClass=
"ExposureF",
116 dimensions=(
"instrument",
"detector"),
123 if config
and config.exposureScaling !=
'InputList':
124 self.inputs.discard(
"inputScales")
126 if config
and len(config.calibrationDimensions) != 0:
127 newDimensions = tuple(config.calibrationDimensions)
128 newOutputData = cT.Output(
132 dimensions=self.allConnections[
'outputData'].dimensions + newDimensions,
135 self.dimensions.update(config.calibrationDimensions)
138 if config.exposureScaling ==
'InputList':
139 newInputScales = cT.PrerequisiteInput(
143 dimensions=self.allConnections[
'inputScales'].dimensions + newDimensions
145 self.dimensions.update(config.calibrationDimensions)
151 pipelineConnections=CalibCombineConnections):
152 """Configuration for combining calib exposures.
154 calibrationType = pexConfig.Field(
156 default=
"calibration",
157 doc=
"Name of calibration to be generated.",
159 calibrationDimensions = pexConfig.ListField(
162 doc=
"List of updated dimensions to append to output."
165 exposureScaling = pexConfig.ChoiceField(
168 "None":
"No scaling used.",
169 "ExposureTime":
"Scale inputs by their exposure time.",
170 "DarkTime":
"Scale inputs by their dark time.",
171 "MeanStats":
"Scale inputs based on their mean values.",
172 "InputList":
"Scale inputs based on a list of values.",
175 doc=
"Scaling to be applied to each input exposure.",
177 scalingLevel = pexConfig.ChoiceField(
180 "DETECTOR":
"Scale by detector.",
181 "AMP":
"Scale by amplifier.",
184 doc=
"Region to scale.",
186 maxVisitsToCalcErrorFromInputVariance = pexConfig.Field(
189 doc=
"Maximum number of visits to estimate variance from input variance, not per-pixel spread",
192 doVignette = pexConfig.Field(
195 doc=
"Copy vignette polygon to output and censor vignetted pixels?"
198 mask = pexConfig.ListField(
200 default=[
"SAT",
"DETECTED",
"INTRP"],
201 doc=
"Mask planes to respect",
203 combine = pexConfig.Field(
205 default=int(afwMath.MEANCLIP),
206 doc=
"Statistic to use for combination (from lsst.afw.math)",
208 clip = pexConfig.Field(
211 doc=
"Clipping threshold for combination",
213 nIter = pexConfig.Field(
216 doc=
"Clipping iterations for combination",
218 stats = pexConfig.ConfigurableField(
219 target=CalibStatsTask,
220 doc=
"Background statistics configuration",
225 pipeBase.CmdLineTask):
226 """Task to combine calib exposures."""
227 ConfigClass = CalibCombineConfig
228 _DefaultName =
'cpCombine'
232 self.makeSubtask(
"stats")
235 inputs = butlerQC.get(inputRefs)
237 dimensions = [exp.dataId.byName()
for exp
in inputRefs.inputExps]
238 inputs[
'inputDims'] = dimensions
240 outputs = self.
run(**inputs)
241 butlerQC.put(outputs, outputRefs)
243 def run(self, inputExps, inputScales=None, inputDims=None):
244 """Combine calib exposures for a single detector.
248 inputExps : `list` [`lsst.afw.image.Exposure`]
249 Input list of exposures to combine.
250 inputScales : `dict` [`dict` [`dict` [`float`]]], optional
251 Dictionary of scales, indexed by detector (`int`),
252 amplifier (`int`), and exposure (`int`). Used for
254 inputDims : `list` [`dict`]
255 List of dictionaries of input data dimensions/values.
256 Each list entry should contain:
259 exposure id value (`int`)
261 detector id value (`int`)
265 combinedExp : `lsst.afw.image.Exposure`
266 Final combined exposure generated from the inputs.
271 Raised if no input data is found. Also raised if
272 config.exposureScaling == InputList, and a necessary scale
277 afwImage.Mask.getPlaneBitMask(self.config.mask))
278 numExps = len(inputExps)
280 raise RuntimeError(
"No valid input data")
281 if numExps < self.config.maxVisitsToCalcErrorFromInputVariance:
282 stats.setCalcErrorFromInputVariance(
True)
285 combined = afwImage.MaskedImageF(width, height)
290 if inputDims
is None:
291 inputDims = [dict()
for i
in inputExps]
293 for index, (exp, dims)
in enumerate(zip(inputExps, inputDims)):
296 self.log.
warn(
"Input %d is None (%s); unable to scale exp.", index, dims)
299 if self.config.exposureScaling ==
"ExposureTime":
300 scale = exp.getInfo().getVisitInfo().getExposureTime()
301 elif self.config.exposureScaling ==
"DarkTime":
302 scale = exp.getInfo().getVisitInfo().getDarkTime()
303 elif self.config.exposureScaling ==
"MeanStats":
304 scale = self.stats.
run(exp)
305 elif self.config.exposureScaling ==
"InputList":
306 visitId = dims.get(
'exposure',
None)
307 detectorId = dims.get(
'detector',
None)
308 if visitId
is None or detectorId
is None:
309 raise RuntimeError(f
"Could not identify scaling for input {index} ({dims})")
310 if detectorId
not in inputScales[
'expScale']:
311 raise RuntimeError(f
"Could not identify a scaling for input {index}"
312 f
" detector {detectorId}")
314 if self.config.scalingLevel ==
"DETECTOR":
315 if visitId
not in inputScales[
'expScale'][detectorId]:
316 raise RuntimeError(f
"Could not identify a scaling for input {index}"
317 f
"detector {detectorId} visit {visitId}")
318 scale = inputScales[
'expScale'][detectorId][visitId]
319 elif self.config.scalingLevel ==
'AMP':
320 scale = [inputScales[
'expScale'][detectorId][amp.getName()][visitId]
321 for amp
in exp.getDetector()]
323 raise RuntimeError(f
"Unknown scaling level: {self.config.scalingLevel}")
324 elif self.config.exposureScaling ==
'None':
327 raise RuntimeError(f
"Unknown scaling type: {self.config.exposureScaling}.")
329 expScales.append(scale)
330 self.log.
info(
"Scaling input %d by %s", index, scale)
333 self.
combine(combined, inputExps, stats)
337 if self.config.doVignette:
338 polygon = inputExps[0].
getInfo().getValidPolygon()
340 doSetValue=
True, vignetteValue=0.0)
344 calibType=self.config.calibrationType, scales=expScales)
347 return pipeBase.Struct(
348 outputData=combinedExp,
352 """Get dimensions of the inputs.
356 expList : `list` [`lsst.afw.image.Exposure`]
357 Exps to check the sizes of.
361 width, height : `int`
362 Unique set of input dimensions.
364 dimList = [exp.getDimensions()
for exp
in expList
if exp
is not None]
368 """Determine a consistent size, given a list of image sizes.
372 dimList : iterable of `tuple` (`int`, `int`)
378 If input dimensions are inconsistent.
382 width, height : `int`
385 dim =
set((w, h)
for w, h
in dimList)
387 raise RuntimeError(
"Inconsistent dimensions: %s" % dim)
391 """Apply scale to input exposure.
393 This implementation applies a flux scaling: the input exposure is
394 divided by the provided scale.
398 exposure : `lsst.afw.image.Exposure`
400 scale : `float` or `list` [`float`], optional
401 Constant scale to divide the exposure by.
403 if scale
is not None:
404 mi = exposure.getMaskedImage()
405 if isinstance(scale, list):
406 for amp, ampScale
in zip(exposure.getDetector(), scale):
407 ampIm = mi[amp.getBBox()]
413 """Combine multiple images.
417 target : `lsst.afw.image.Exposure`
418 Output exposure to construct.
419 expList : `list` [`lsst.afw.image.Exposure`]
420 Input exposures to combine.
421 stats : `lsst.afw.math.StatisticsControl`
422 Control explaining how to combine the input images.
424 images = [img.getMaskedImage()
for img
in expList
if img
is not None]
428 """Combine input headers to determine the set of common headers,
429 supplemented by calibration inputs.
433 expList : `list` of `lsst.afw.image.Exposure`
434 Input list of exposures to combine.
435 calib : `lsst.afw.image.Exposure`
436 Output calibration to construct headers for.
437 calibType: `str`, optional
438 OBSTYPE the output should claim.
439 scales: `list` of `float`, optional
440 Scale values applied to each input to record.
444 header : `lsst.daf.base.PropertyList`
448 header = calib.getMetadata()
449 header.set(
"OBSTYPE", calibType)
452 comments = {
"TIMESYS":
"Time scale for all dates",
453 "DATE-OBS":
"Start date of earliest input observation",
454 "MJD-OBS":
"[d] Start MJD of earliest input observation",
455 "DATE-END":
"End date of oldest input observation",
456 "MJD-END":
"[d] End MJD of oldest input observation",
457 "MJD-AVG":
"[d] MJD midpoint of all input observations",
458 "DATE-AVG":
"Midpoint date of all input observations"}
461 now = time.localtime()
462 calibDate = time.strftime(
"%Y-%m-%d", now)
463 calibTime = time.strftime(
"%X %Z", now)
464 header.set(
"CALIB_CREATE_DATE", calibDate)
465 header.set(
"CALIB_CREATE_TIME", calibTime)
468 inputHeaders = [exp.getMetadata()
for exp
in expList
if exp
is not None]
469 merged = merge_headers(inputHeaders, mode=
'drop')
470 for k, v
in merged.items():
472 md = expList[0].getMetadata()
473 comment = md.getComment(k)
if k
in md
else None
474 header.set(k, v, comment=comment)
477 visitInfoList = [exp.getInfo().getVisitInfo()
for exp
in expList
if exp
is not None]
478 for i, visit
in enumerate(visitInfoList):
481 header.set(
"CPP_INPUT_%d" % (i,), visit.getExposureId())
482 header.set(
"CPP_INPUT_DATE_%d" % (i,), str(visit.getDate()))
483 header.set(
"CPP_INPUT_EXPT_%d" % (i,), visit.getExposureTime())
484 if scales
is not None:
485 header.set(
"CPP_INPUT_SCALE_%d" % (i,), scales[i])
492 group = ObservationGroup(visitInfoList, pedantic=
False)
494 self.log.
warn(
"Exception making an obs group for headers. Continuing.")
496 dateCards = {
"DATE-OBS":
"{}T00:00:00.00".
format(calibDate)}
497 comments[
"DATE-OBS"] =
"Date of start of day of calibration midpoint"
499 oldest, newest = group.extremes()
500 dateCards = dates_to_fits(oldest.datetime_begin, newest.datetime_end)
502 for k, v
in dateCards.items():
503 header.set(k, v, comment=comments.get(k,
None))
508 """Interpolate over NANs in the combined image.
510 NANs can result from masked areas on the CCD. We don't want them getting
511 into our science images, so we replace them with the median of the image.
515 exp : `lsst.afw.image.Exposure`
516 Exp to check for NaNs.
518 array = exp.getImage().getArray()
519 bad = np.isnan(array)
521 median = np.median(array[np.logical_not(bad)])
522 count = np.sum(np.logical_not(bad))
525 self.log.
warn(
"Found %s NAN pixels", count)
529 doUpdateMask=True, maskPlane='BAD',
530 doSetValue=False, vignetteValue=0.0,
532 """Apply vignetted polygon to image pixels.
536 exposure : `lsst.afw.image.Exposure`
538 doUpdateMask : `bool`, optional
539 Update the exposure mask for vignetted area?
540 maskPlane : `str`, optional,
541 Mask plane to assign.
542 doSetValue : `bool`, optional
543 Set image value for vignetted area?
544 vignetteValue : `float`, optional
546 log : `lsst.log.Log`, optional
552 Raised if no valid polygon exists.
554 polygon = polygon
if polygon
else exposure.getInfo().getValidPolygon()
556 raise RuntimeError(
"Could not find valid polygon!")
557 log = log
if log
else Log.getLogger(__name__.partition(
".")[2])
559 fullyIlluminated =
True
560 for corner
in exposure.getBBox().getCorners():
561 if not polygon.contains(
Point2D(corner)):
562 fullyIlluminated =
False
564 log.info(
"Exposure is fully illuminated? %s", fullyIlluminated)
566 if not fullyIlluminated:
568 mask = exposure.getMask()
569 numPixels = mask.getBBox().getArea()
571 xx, yy = np.meshgrid(np.arange(0, mask.getWidth(), dtype=int),
572 np.arange(0, mask.getHeight(), dtype=int))
574 vignMask = np.array([
not polygon.contains(
Point2D(x, y))
for x, y
in
575 zip(xx.reshape(numPixels), yy.reshape(numPixels))])
576 vignMask = vignMask.reshape(mask.getHeight(), mask.getWidth())
579 bitMask = mask.getPlaneBitMask(maskPlane)
580 maskArray = mask.getArray()
581 maskArray[vignMask] |= bitMask
583 imageArray = exposure.getImage().getArray()
584 imageArray[vignMask] = vignetteValue
585 log.info(
"Exposure contains %d vignetted pixels.",
586 np.count_nonzero(vignMask))