LSST Applications g04e9c324dd+8c5ae1fdc5,g134cb467dc+b203dec576,g18429d2f64+358861cd2c,g199a45376c+0ba108daf9,g1fd858c14a+dd066899e3,g262e1987ae+ebfced1d55,g29ae962dfc+72fd90588e,g2cef7863aa+aef1011c0b,g35bb328faa+8c5ae1fdc5,g3fd5ace14f+b668f15bc5,g4595892280+3897dae354,g47891489e3+abcf9c3559,g4d44eb3520+fb4ddce128,g53246c7159+8c5ae1fdc5,g67b6fd64d1+abcf9c3559,g67fd3c3899+1f72b5a9f7,g74acd417e5+cb6b47f07b,g786e29fd12+668abc6043,g87389fa792+8856018cbb,g89139ef638+abcf9c3559,g8d7436a09f+bcf525d20c,g8ea07a8fe4+9f5ccc88ac,g90f42f885a+6054cc57f1,g97be763408+06f794da49,g9dd6db0277+1f72b5a9f7,ga681d05dcb+7e36ad54cd,gabf8522325+735880ea63,gac2eed3f23+abcf9c3559,gb89ab40317+abcf9c3559,gbf99507273+8c5ae1fdc5,gd8ff7fe66e+1f72b5a9f7,gdab6d2f7ff+cb6b47f07b,gdc713202bf+1f72b5a9f7,gdfd2d52018+8225f2b331,ge365c994fd+375fc21c71,ge410e46f29+abcf9c3559,geaed405ab2+562b3308c0,gf9a733ac38+8c5ae1fdc5,w.2025.35
LSST Data Management Base Package
Loading...
Searching...
No Matches
linearize.py
Go to the documentation of this file.
2# LSST Data Management System
3# Copyright 2016 AURA/LSST.
4#
5# This product includes software developed by the
6# LSST Project (http://www.lsst.org/).
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <http://www.lsstcorp.org/LegalNotices/>.
21#
22
23__all__ = ["Linearizer",
24 "LinearizeBase", "LinearizeLookupTable", "LinearizeSquared",
25 "LinearizeProportional", "LinearizePolynomial", "LinearizeSpline", "LinearizeNone"]
26
27import abc
28import numpy as np
29from scipy.interpolate import Akima1DInterpolator
30
31from astropy.table import Table
32
33from lsst.pipe.base import Struct
34from lsst.geom import Box2I, Point2I, Extent2I
35from .applyLookupTable import applyLookupTable
36from .calibType import IsrCalib
37from .isrFunctions import isTrimmedImage
38
39
41 """Parameter set for linearization.
42
43 These parameters are included in `lsst.afw.cameraGeom.Amplifier`, but
44 should be accessible externally to allow for testing.
45
46 Parameters
47 ----------
48 table : `numpy.array`, optional
49 Lookup table; a 2-dimensional array of floats:
50
51 - one row for each row index (value of coef[0] in the amplifier)
52 - one column for each image value
53
54 To avoid copying the table the last index should vary fastest
55 (numpy default "C" order)
56 detector : `lsst.afw.cameraGeom.Detector`, optional
57 Detector object. Passed to self.fromDetector() on init.
58 log : `logging.Logger`, optional
59 Logger to handle messages.
60 kwargs : `dict`, optional
61 Other keyword arguments to pass to the parent init.
62
63 Raises
64 ------
65 RuntimeError
66 Raised if the supplied table is not 2D, or if the table has fewer
67 columns than rows (indicating that the indices are swapped).
68
69 Notes
70 -----
71 The linearizer attributes stored are:
72
73 hasLinearity : `bool`
74 Whether a linearity correction is defined for this detector.
75 override : `bool`
76 Whether the detector parameters should be overridden.
77 ampNames : `list` [`str`]
78 List of amplifier names to correct.
79 linearityCoeffs : `dict` [`str`, `np.ndarray`]
80 Coefficients to use in correction. Indexed by amplifier
81 names. The format of the array depends on the type of
82 correction to apply.
83 linearityType : `dict` [`str`, `str`]
84 Type of correction to use, indexed by amplifier names.
85 linearityBBox : `dict` [`str`, `lsst.geom.Box2I`]
86 Bounding box the correction is valid over, indexed by
87 amplifier names.
88 fitParams : `dict` [`str`, `np.ndarray`], optional
89 Linearity fit parameters used to construct the correction
90 coefficients, indexed as above.
91 fitParamsErr : `dict` [`str`, `np.ndarray`], optional
92 Uncertainty values of the linearity fit parameters used to
93 construct the correction coefficients, indexed as above.
94 fitChiSq : `dict` [`str`, `float`], optional
95 Chi-squared value of the linearity fit, indexed as above.
96 fitResiduals : `dict` [`str`, `np.ndarray`], optional
97 Residuals of the fit, indexed as above. Used for
98 calculating photodiode corrections
99 fitResidualsSigmaMad : `dict` [`str`, `float`], optional
100 Robust median-absolute-deviation of fit residuals, scaled
101 by the signal level.
102 fitResidualsUnmasked : `dict` [`str`, `np.ndarray`], optional
103 Same as fitResiduals, but all outliers are included and
104 not masked as nans.
105 fitResidualsModel : `dict` [`str`, `np.ndarray`], optional
106 The model count level for each of the fitResiduals.
107 linearFit : The linear fit to the low flux region of the curve.
108 [intercept, slope].
109 tableData : `np.ndarray`, optional
110 Lookup table data for the linearity correction.
111 inputAbscissa : `dict` [`str`, `np.ndarray`], optional
112 Input abscissa used to construct linearizer (usually photodiode
113 or exposure time).
114 inputOrdinate : `dict` [`str`, `np.ndarray`], optional
115 Input ordinate used to construct linearizer (raw mean counts).
116 inputMask : `dict` [`str`, `np.ndarray`], optional
117 Input mask used for the fitting.
118 inputGroupingIndex : `dict` [`str`, `np.ndarray`], optional
119 Input grouping index used for fitting.
120 inputNormalization : `dict` [`str`, `np.ndarray`], optional
121 Input normalization that was applied to the abscissa for
122 each pair from the PTC used for the linearization fit.
123
124 Version 1.4 adds ``linearityTurnoff`` and ``linearityMaxSignal``.
125 Version 1.5 adds ``fitResidualsUnmasked``, ``inputAbscissa``,
126 ``inputOrdinate``, ``inputMask``, ``inputGroupingIndex``,
127 ``fitResidualsModel``, and ``inputNormalization``.
128 """
129 _OBSTYPE = "LINEARIZER"
130 _SCHEMA = 'Gen3 Linearizer'
131 _VERSION = 1.5
132
133 def __init__(self, table=None, **kwargs):
134 self.hasLinearity = False
135 self.override = False
136
137 self.ampNames = list()
138 self.linearityCoeffs = dict()
139 self.linearityType = dict()
140 self.linearityBBox = dict()
141 self.inputAbscissa = dict()
142 self.inputOrdinate = dict()
143 self.inputMask = dict()
144 self.inputGroupingIndex = dict()
145 self.inputNormalization = dict()
146 self.fitParams = dict()
147 self.fitParamsErr = dict()
148 self.fitChiSq = dict()
149 self.fitResiduals = dict()
152 self.fitResidualsModel = dict()
153 self.linearFit = dict()
154 self.linearityTurnoff = dict()
155 self.linearityMaxSignal = dict()
156 self.tableData = None
157 if table is not None:
158 if len(table.shape) != 2:
159 raise RuntimeError("table shape = %s; must have two dimensions" % (table.shape,))
160 if table.shape[1] < table.shape[0]:
161 raise RuntimeError("table shape = %s; indices are switched" % (table.shape,))
162 self.tableData = np.array(table, order="C")
163
164 # The linearizer is always natively in adu because it
165 # is computed prior to computing gains.
166 self.linearityUnits = 'adu'
167
168 super().__init__(**kwargs)
169 self.requiredAttributes.update(['hasLinearity', 'override',
170 'ampNames',
171 'linearityCoeffs', 'linearityType', 'linearityBBox',
172 'fitParams', 'fitParamsErr', 'fitChiSq',
173 'fitResiduals', 'fitResidualsSigmaMad', 'linearFit', 'tableData',
174 'units', 'linearityTurnoff', 'linearityMaxSignal',
175 'fitResidualsUnmasked', 'inputAbscissa', 'inputOrdinate',
176 'inputMask', 'inputGroupingIndex', 'fitResidualsModel',
177 'inputNormalization'])
178
179 def updateMetadata(self, setDate=False, **kwargs):
180 """Update metadata keywords with new values.
181
182 This calls the base class's method after ensuring the required
183 calibration keywords will be saved.
184
185 Parameters
186 ----------
187 setDate : `bool`, optional
188 Update the CALIBDATE fields in the metadata to the current
189 time. Defaults to False.
190 kwargs :
191 Other keyword parameters to set in the metadata.
192 """
193 kwargs['HAS_LINEARITY'] = self.hasLinearity
194 kwargs['OVERRIDE'] = self.override
195 kwargs['HAS_TABLE'] = self.tableData is not None
196 kwargs['LINEARITY_UNITS'] = self.linearityUnits
197
198 super().updateMetadata(setDate=setDate, **kwargs)
199
200 def fromDetector(self, detector):
201 """Read linearity parameters from a detector.
202
203 Parameters
204 ----------
205 detector : `lsst.afw.cameraGeom.detector`
206 Input detector with parameters to use.
207
208 Returns
209 -------
210 calib : `lsst.ip.isr.Linearizer`
211 The calibration constructed from the detector.
212 """
213 self._detectorName = detector.getName()
214 self._detectorSerial = detector.getSerial()
215 self._detectorId = detector.getId()
216 self.hasLinearity = True
217
218 # Do not translate Threshold, Maximum, Units.
219 for amp in detector.getAmplifiers():
220 ampName = amp.getName()
221 self.ampNames.append(ampName)
222 self.linearityType[ampName] = amp.getLinearityType()
223 self.linearityCoeffs[ampName] = amp.getLinearityCoeffs()
224 self.linearityBBox[ampName] = amp.getBBox()
225
226 # Detector linearizers (legacy) are assumed to be adu units.
227 self.linearityUnits = 'adu'
228
229 return self
230
231 @classmethod
232 def fromDict(cls, dictionary):
233 """Construct a calibration from a dictionary of properties
234
235 Parameters
236 ----------
237 dictionary : `dict`
238 Dictionary of properties
239
240 Returns
241 -------
242 calib : `lsst.ip.isr.Linearity`
243 Constructed calibration.
244
245 Raises
246 ------
247 RuntimeError
248 Raised if the supplied dictionary is for a different
249 calibration.
250 """
251
252 calib = cls()
253
254 if calib._OBSTYPE != dictionary['metadata']['OBSTYPE']:
255 raise RuntimeError(f"Incorrect linearity supplied. Expected {calib._OBSTYPE}, "
256 f"found {dictionary['metadata']['OBSTYPE']}")
257
258 calib.setMetadata(dictionary['metadata'])
259
260 calib.hasLinearity = dictionary.get('hasLinearity',
261 dictionary['metadata'].get('HAS_LINEARITY', False))
262 calib.override = dictionary.get('override', True)
263
264 # Old linearizers which do not have linearityUnits are
265 # assumed to be adu because that's all that has been
266 # supported.
267 calib.linearityUnits = dictionary.get('linearityUnits', 'adu')
268
269 if calib.hasLinearity:
270 for ampName in dictionary['amplifiers']:
271 amp = dictionary['amplifiers'][ampName]
272 calib.ampNames.append(ampName)
273 calib.linearityCoeffs[ampName] = np.array(amp.get('linearityCoeffs', [0.0]))
274 calib.linearityType[ampName] = amp.get('linearityType', 'None')
275 calib.linearityBBox[ampName] = amp.get('linearityBBox', None)
276
277 calib.inputAbscissa[ampName] = np.array(amp.get('inputAbscissa', [0.0]))
278 calib.inputOrdinate[ampName] = np.array(amp.get('inputOrdinate', [0.0]))
279 calib.inputMask[ampName] = np.array(amp.get('inputMask', [False]))
280 calib.inputGroupingIndex[ampName] = np.array(amp.get('inputGroupingIndex', [0.0]))
281 calib.inputNormalization[ampName] = np.array(amp.get('inputNormalization', [1.0]))
282
283 calib.fitParams[ampName] = np.array(amp.get('fitParams', [0.0]))
284 calib.fitParamsErr[ampName] = np.array(amp.get('fitParamsErr', [0.0]))
285 calib.fitChiSq[ampName] = amp.get('fitChiSq', np.nan)
286 calib.fitResiduals[ampName] = np.array(amp.get('fitResiduals', [0.0]))
287 calib.fitResidualsSigmaMad[ampName] = np.array(amp.get('fitResidualsSigmaMad', np.nan))
288 calib.fitResidualsUnmasked[ampName] = np.array(amp.get('fitResidualsUnmasked', [0.0]))
289 calib.fitResidualsModel[ampName] = np.array(amp.get('fitResidualsModel', [0.0]))
290 calib.linearFit[ampName] = np.array(amp.get('linearFit', [0.0]))
291
292 calib.linearityTurnoff[ampName] = np.array(amp.get('linearityTurnoff', np.nan))
293 calib.linearityMaxSignal[ampName] = np.array(amp.get('linearityMaxSignal', np.nan))
294
295 calib.tableData = dictionary.get('tableData', None)
296 if calib.tableData:
297 calib.tableData = np.array(calib.tableData)
298
299 return calib
300
301 def toDict(self):
302 """Return linearity parameters as a dict.
303
304 Returns
305 -------
306 outDict : `dict`:
307 """
308 self.updateMetadata()
309
310 outDict = {'metadata': self.getMetadata(),
311 'detectorName': self._detectorName,
312 'detectorSerial': self._detectorSerial,
313 'detectorId': self._detectorId,
314 'hasTable': self.tableData is not None,
315 'amplifiers': dict(),
316 'linearityUnits': self.linearityUnits,
317 }
318 for ampName in self.linearityType:
319 outDict['amplifiers'][ampName] = {
320 'linearityType': self.linearityType[ampName],
321 'linearityCoeffs': self.linearityCoeffs[ampName].tolist(),
322 'linearityBBox': self.linearityBBox[ampName],
323 'inputAbscissa': self.inputAbscissa[ampName].tolist(),
324 'inputOrdinate': self.inputOrdinate[ampName].tolist(),
325 'inputMask': self.inputMask[ampName].tolist(),
326 'inputGroupingIndex': self.inputGroupingIndex[ampName].tolist(),
327 'inputNormalization': self.inputNormalization[ampName].tolist(),
328 'fitParams': self.fitParams[ampName].tolist(),
329 'fitParamsErr': self.fitParamsErr[ampName].tolist(),
330 'fitChiSq': self.fitChiSq[ampName],
331 'fitResiduals': self.fitResiduals[ampName].tolist(),
332 'fitResidualsSigmaMad': self.fitResidualsSigmaMad[ampName],
333 'fitResidualsUnmasked': self.fitResidualsUnmasked[ampName].tolist(),
334 'fitResidualsModel': self.fitResidualsModel[ampName].tolist(),
335 'linearFit': self.linearFit[ampName].tolist(),
336 'linearityTurnoff': self.linearityTurnoff[ampName],
337 'linearityMaxSignal': self.linearityMaxSignal[ampName],
338 }
339 if self.tableData is not None:
340 outDict['tableData'] = self.tableData.tolist()
341
342 return outDict
343
344 @classmethod
345 def fromTable(cls, tableList):
346 """Read linearity from a FITS file.
347
348 This method uses the `fromDict` method to create the
349 calibration, after constructing an appropriate dictionary from
350 the input tables.
351
352 Parameters
353 ----------
354 tableList : `list` [`astropy.table.Table`]
355 afwTable read from input file name.
356
357 Returns
358 -------
359 linearity : `~lsst.ip.isr.linearize.Linearizer``
360 Linearity parameters.
361
362 Notes
363 -----
364 The method reads a FITS file with 1 or 2 extensions. The metadata is
365 read from the header of extension 1, which must exist. Then the table
366 is loaded, and the ['AMPLIFIER_NAME', 'TYPE', 'COEFFS', 'BBOX_X0',
367 'BBOX_Y0', 'BBOX_DX', 'BBOX_DY'] columns are read and used to set each
368 dictionary by looping over rows.
369 Extension 2 is then attempted to read in the try block (which only
370 exists for lookup tables). It has a column named 'LOOKUP_VALUES' that
371 contains a vector of the lookup entries in each row.
372 """
373 coeffTable = tableList[0]
374
375 metadata = coeffTable.meta
376 inDict = dict()
377 inDict['metadata'] = metadata
378 inDict['hasLinearity'] = metadata.get('HAS_LINEARITY', False)
379 inDict['amplifiers'] = dict()
380 inDict['linearityUnits'] = metadata.get('LINEARITY_UNITS', 'adu')
381
382 for record in coeffTable:
383 ampName = record['AMPLIFIER_NAME']
384
385 inputAbscissa = record['INP_ABSCISSA'] if 'INP_ABSCISSA' in record.columns else np.array([0.0])
386 inputOrdinate = record['INP_ORDINATE'] if 'INP_ORDINATE' in record.columns else np.array([0.0])
387 inputMask = record['INP_MASK'] if 'INP_MASK' in record.columns else np.array([False])
388 inputGroupingIndex = record['INP_GROUPING_INDEX'] if 'INP_GROUPING_INDEX' in record.columns \
389 else np.array([0])
390 inputNormalization = record['INP_NORMALIZATION'] if 'INP_NORMALIZATION' in record.columns \
391 else np.array([1.0])
392 fitParams = record['FIT_PARAMS'] if 'FIT_PARAMS' in record.columns else np.array([0.0])
393 fitParamsErr = record['FIT_PARAMS_ERR'] if 'FIT_PARAMS_ERR' in record.columns else np.array([0.0])
394 fitChiSq = record['RED_CHI_SQ'] if 'RED_CHI_SQ' in record.columns else np.nan
395 fitResiduals = record['FIT_RES'] if 'FIT_RES' in record.columns else np.array([0.0])
396 fitResidualsSigmaMad = record['FIT_RES_SIGMAD'] if 'FIT_RES_SIGMAD' in record.columns else np.nan
397 fitResidualsUnmasked = record['FIT_RES_UNMASKED'] \
398 if 'FIT_RES_UNMASKED' in record.columns else np.array([0.0])
399 fitResidualsModel = record['FIT_RES_MODEL'] \
400 if 'FIT_RES_MODEL' in record.columns else np.array([0.0])
401 linearFit = record['LIN_FIT'] if 'LIN_FIT' in record.columns else np.array([0.0])
402
403 linearityTurnoff = record['LINEARITY_TURNOFF'] if 'LINEARITY_TURNOFF' in record.columns \
404 else np.nan
405 linearityMaxSignal = record['LINEARITY_MAX_SIGNAL'] if 'LINEARITY_MAX_SIGNAL' in record.columns \
406 else np.nan
407
408 inDict['amplifiers'][ampName] = {
409 'linearityType': record['TYPE'],
410 'linearityCoeffs': record['COEFFS'],
411 'linearityBBox': Box2I(Point2I(record['BBOX_X0'], record['BBOX_Y0']),
412 Extent2I(record['BBOX_DX'], record['BBOX_DY'])),
413 'inputAbscissa': inputAbscissa,
414 'inputOrdinate': inputOrdinate,
415 'inputMask': inputMask,
416 'inputGroupingIndex': inputGroupingIndex,
417 'inputNormalization': inputNormalization,
418 'fitParams': fitParams,
419 'fitParamsErr': fitParamsErr,
420 'fitChiSq': fitChiSq,
421 'fitResiduals': fitResiduals,
422 'fitResidualsSigmaMad': fitResidualsSigmaMad,
423 'fitResidualsUnmasked': fitResidualsUnmasked,
424 'fitResidualsModel': fitResidualsModel,
425 'linearFit': linearFit,
426 'linearityTurnoff': linearityTurnoff,
427 'linearityMaxSignal': linearityMaxSignal,
428 }
429
430 if len(tableList) > 1:
431 tableData = tableList[1]
432 inDict['tableData'] = [record['LOOKUP_VALUES'] for record in tableData]
433
434 return cls().fromDict(inDict)
435
436 def toTable(self):
437 """Construct a list of tables containing the information in this
438 calibration.
439
440 The list of tables should create an identical calibration
441 after being passed to this class's fromTable method.
442
443 Returns
444 -------
445 tableList : `list` [`astropy.table.Table`]
446 List of tables containing the linearity calibration
447 information.
448 """
449
450 tableList = []
451 self.updateMetadata()
452 catalog = Table([{'AMPLIFIER_NAME': ampName,
453 'TYPE': self.linearityType[ampName],
454 'COEFFS': self.linearityCoeffs[ampName],
455 'BBOX_X0': self.linearityBBox[ampName].getMinX(),
456 'BBOX_Y0': self.linearityBBox[ampName].getMinY(),
457 'BBOX_DX': self.linearityBBox[ampName].getWidth(),
458 'BBOX_DY': self.linearityBBox[ampName].getHeight(),
459 'INP_ABSCISSA': self.inputAbscissa[ampName],
460 'INP_ORDINATE': self.inputOrdinate[ampName],
461 'INP_MASK': self.inputMask[ampName],
462 'INP_GROUPING_INDEX': self.inputGroupingIndex[ampName],
463 'INP_NORMALIZATION': self.inputNormalization[ampName],
464 'FIT_PARAMS': self.fitParams[ampName],
465 'FIT_PARAMS_ERR': self.fitParamsErr[ampName],
466 'RED_CHI_SQ': self.fitChiSq[ampName],
467 'FIT_RES': self.fitResiduals[ampName],
468 'FIT_RES_SIGMAD': self.fitResidualsSigmaMad[ampName],
469 'FIT_RES_UNMASKED': self.fitResidualsUnmasked[ampName],
470 'FIT_RES_MODEL': self.fitResidualsModel[ampName],
471 'LIN_FIT': self.linearFit[ampName],
472 'LINEARITY_TURNOFF': self.linearityTurnoff[ampName],
473 'LINEARITY_MAX_SIGNAL': self.linearityMaxSignal[ampName],
474 } for ampName in self.ampNames])
475 catalog.meta = self.getMetadata().toDict()
476 tableList.append(catalog)
477
478 if self.tableData is not None:
479 catalog = Table([{'LOOKUP_VALUES': value} for value in self.tableData])
480 tableList.append(catalog)
481 return tableList
482
483 def getLinearityTypeByName(self, linearityTypeName):
484 """Determine the linearity class to use from the type name.
485
486 Parameters
487 ----------
488 linearityTypeName : str
489 String name of the linearity type that is needed.
490
491 Returns
492 -------
493 linearityType : `~lsst.ip.isr.linearize.LinearizeBase`
494 The appropriate linearity class to use. If no matching class
495 is found, `None` is returned.
496 """
497 for t in [LinearizeLookupTable,
498 LinearizeSquared,
499 LinearizePolynomial,
500 LinearizeProportional,
501 LinearizeSpline,
502 LinearizeNone]:
503 if t.LinearityType == linearityTypeName:
504 return t
505 return None
506
507 def validate(self, detector=None, amplifier=None):
508 """Validate linearity for a detector/amplifier.
509
510 Parameters
511 ----------
512 detector : `lsst.afw.cameraGeom.Detector`, optional
513 Detector to validate, along with its amplifiers.
514 amplifier : `lsst.afw.cameraGeom.Amplifier`, optional
515 Single amplifier to validate.
516
517 Raises
518 ------
519 RuntimeError
520 Raised if there is a mismatch in linearity parameters, and
521 the cameraGeom parameters are not being overridden.
522 """
523 amplifiersToCheck = []
524 if detector:
525 if self._detectorName != detector.getName():
526 raise RuntimeError("Detector names don't match: %s != %s" %
527 (self._detectorName, detector.getName()))
528 if int(self._detectorId) != int(detector.getId()):
529 raise RuntimeError("Detector IDs don't match: %s != %s" %
530 (int(self._detectorId), int(detector.getId())))
531 # TODO: DM-38778: This check fails on LATISS due to an
532 # error in the camera configuration.
533 # if self._detectorSerial != detector.getSerial():
534 # raise RuntimeError(
535 # "Detector serial numbers don't match: %s != %s" %
536 # (self._detectorSerial, detector.getSerial()))
537 if len(detector.getAmplifiers()) != len(self.linearityCoeffs.keys()):
538 raise RuntimeError("Detector number of amps = %s does not match saved value %s" %
539 (len(detector.getAmplifiers()),
540 len(self.linearityCoeffs.keys())))
541 amplifiersToCheck.extend(detector.getAmplifiers())
542
543 if amplifier:
544 amplifiersToCheck.extend(amplifier)
545
546 for amp in amplifiersToCheck:
547 ampName = amp.getName()
548 if ampName not in self.linearityCoeffs.keys():
549 raise RuntimeError("Amplifier %s is not in linearity data" %
550 (ampName, ))
551 if amp.getLinearityType() != self.linearityType[ampName]:
552 if self.override:
553 self.log.debug("Overriding amplifier defined linearityType (%s) for %s",
554 self.linearityType[ampName], ampName)
555 else:
556 raise RuntimeError("Amplifier %s type %s does not match saved value %s" %
557 (ampName, amp.getLinearityType(), self.linearityType[ampName]))
558 if (amp.getLinearityCoeffs().shape != self.linearityCoeffs[ampName].shape or not
559 np.allclose(amp.getLinearityCoeffs(), self.linearityCoeffs[ampName], equal_nan=True)):
560 if self.override:
561 self.log.debug("Overriding amplifier defined linearityCoeffs (%s) for %s",
562 self.linearityCoeffs[ampName], ampName)
563 else:
564 raise RuntimeError("Amplifier %s coeffs %s does not match saved value %s" %
565 (ampName, amp.getLinearityCoeffs(), self.linearityCoeffs[ampName]))
566
567 def applyLinearity(self, image, detector=None, log=None, gains=None):
568 """Apply the linearity to an image.
569
570 If the linearity parameters are populated, use those,
571 otherwise use the values from the detector.
572
573 Parameters
574 ----------
575 image : `~lsst.afw.image.image`
576 Image to correct.
577 detector : `~lsst.afw.cameraGeom.detector`, optional
578 Detector to use to determine exposure trimmed state. If
579 supplied, but no other linearity information is provided
580 by the calibration, then the static solution stored in the
581 detector will be used.
582 log : `~logging.Logger`, optional
583 Log object to use for logging.
584 gains : `dict` [`str`, `float`], optional
585 Dictionary of amp name to gain. If this is provided then
586 linearity terms will be converted from adu to electrons.
587 Only used for Spline linearity corrections.
588 """
589 if log is None:
590 log = self.log
591 if detector and not self.hasLinearity:
592 self.fromDetector(detector)
593
594 self.validate(detector)
595
596 # Check if the image is trimmed.
597 isTrimmed = None
598 if detector:
599 isTrimmed = isTrimmedImage(image, detector)
600
601 numAmps = 0
602 numLinearized = 0
603 numOutOfRange = 0
604 for ampName in self.linearityType.keys():
605 linearizer = self.getLinearityTypeByName(self.linearityType[ampName])
606 numAmps += 1
607
608 if gains and self.linearityUnits == 'adu':
609 gainValue = gains[ampName]
610 else:
611 gainValue = 1.0
612
613 if linearizer is not None:
614 match isTrimmed:
615 case True:
616 bbox = detector[ampName].getBBox()
617 case False:
618 bbox = detector[ampName].getRawBBox()
619 case None:
620 bbox = self.linearityBBox[ampName]
621
622 ampView = image.Factory(image, bbox)
623 success, outOfRange = linearizer()(ampView, **{'coeffs': self.linearityCoeffs[ampName],
624 'table': self.tableData,
625 'log': self.log,
626 'gain': gainValue})
627 numOutOfRange += outOfRange
628 if success:
629 numLinearized += 1
630 elif log is not None:
631 log.warning("Amplifier %s did not linearize.",
632 ampName)
633 return Struct(
634 numAmps=numAmps,
635 numLinearized=numLinearized,
636 numOutOfRange=numOutOfRange
637 )
638
639
640class LinearizeBase(metaclass=abc.ABCMeta):
641 """Abstract base class functor for correcting non-linearity.
642
643 Subclasses must define ``__call__`` and set class variable
644 LinearityType to a string that will be used for linearity type in
645 the cameraGeom.Amplifier.linearityType field.
646
647 All linearity corrections should be defined in terms of an
648 additive correction, such that:
649
650 corrected_value = uncorrected_value + f(uncorrected_value)
651 """
652 LinearityType = None # linearity type, a string used for AmpInfoCatalogs
653
654 @abc.abstractmethod
655 def __call__(self, image, **kwargs):
656 """Correct non-linearity.
657
658 Parameters
659 ----------
660 image : `lsst.afw.image.Image`
661 Image to be corrected
662 kwargs : `dict`
663 Dictionary of parameter keywords:
664
665 ``coeffs``
666 Coefficient vector (`list` or `np.ndarray`).
667 ``table``
668 Lookup table data (`np.ndarray`).
669 ``log``
670 Logger to handle messages (`logging.Logger`).
671
672 Returns
673 -------
674 output : `bool`
675 If `True`, a correction was applied successfully.
676
677 Raises
678 ------
679 RuntimeError:
680 Raised if the linearity type listed in the
681 detector does not match the class type.
682 """
683 pass
684
685
686class LinearizeLookupTable(LinearizeBase):
687 """Correct non-linearity with a persisted lookup table.
688
689 The lookup table consists of entries such that given
690 "coefficients" c0, c1:
691
692 for each i,j of image:
693 rowInd = int(c0)
694 colInd = int(c1 + uncorrImage[i,j])
695 corrImage[i,j] = uncorrImage[i,j] + table[rowInd, colInd]
696
697 - c0: row index; used to identify which row of the table to use
698 (typically one per amplifier, though one can have multiple
699 amplifiers use the same table)
700 - c1: column index offset; added to the uncorrected image value
701 before truncation; this supports tables that can handle
702 negative image values; also, if the c1 ends with .5 then
703 the nearest index is used instead of truncating to the
704 next smaller index
705 """
706 LinearityType = "LookupTable"
707
708 def __call__(self, image, **kwargs):
709 """Correct for non-linearity.
710
711 Parameters
712 ----------
713 image : `lsst.afw.image.Image`
714 Image to be corrected
715 kwargs : `dict`
716 Dictionary of parameter keywords:
717
718 ``coeffs``
719 Columnation vector (`list` or `np.ndarray`).
720 ``table``
721 Lookup table data (`np.ndarray`).
722 ``log``
723 Logger to handle messages (`logging.Logger`).
724
725 Returns
726 -------
727 output : `tuple` [`bool`, `int`]
728 If true, a correction was applied successfully. The
729 integer indicates the number of pixels that were
730 uncorrectable by being out of range.
731
732 Raises
733 ------
734 RuntimeError:
735 Raised if the requested row index is out of the table
736 bounds.
737 """
738 numOutOfRange = 0
739
740 rowInd, colIndOffset = kwargs['coeffs'][0:2]
741 table = kwargs['table']
742 log = kwargs['log']
743
744 numTableRows = table.shape[0]
745 rowInd = int(rowInd)
746 if rowInd < 0 or rowInd > numTableRows:
747 raise RuntimeError("LinearizeLookupTable rowInd=%s not in range[0, %s)" %
748 (rowInd, numTableRows))
749 tableRow = np.array(table[rowInd, :], dtype=image.getArray().dtype)
750
751 numOutOfRange += applyLookupTable(image, tableRow, colIndOffset)
752
753 if numOutOfRange > 0 and log is not None:
754 log.warning("%s pixels were out of range of the linearization table",
755 numOutOfRange)
756 if numOutOfRange < image.getArray().size:
757 return True, numOutOfRange
758 else:
759 return False, numOutOfRange
760
761
763 """Correct non-linearity with a polynomial mode.
764
765 .. code-block::
766
767 corrImage = uncorrImage + sum_i c_i uncorrImage^(2 + i)
768
769 where ``c_i`` are the linearity coefficients for each amplifier.
770 Lower order coefficients are not included as they duplicate other
771 calibration parameters:
772
773 ``k0``
774 A coefficient multiplied by ``uncorrImage**0`` is equivalent to
775 bias level. Irrelevant for correcting non-linearity.
776 ``k1``
777 A coefficient multiplied by ``uncorrImage**1`` is proportional
778 to the gain. Not necessary for correcting non-linearity.
779 """
780 LinearityType = "Polynomial"
781
782 def __call__(self, image, **kwargs):
783 """Correct non-linearity.
784
785 Parameters
786 ----------
787 image : `lsst.afw.image.Image`
788 Image to be corrected
789 kwargs : `dict`
790 Dictionary of parameter keywords:
791
792 ``coeffs``
793 Coefficient vector (`list` or `np.ndarray`).
794 If the order of the polynomial is n, this list
795 should have a length of n-1 ("k0" and "k1" are
796 not needed for the correction).
797 ``log``
798 Logger to handle messages (`logging.Logger`).
799
800 Returns
801 -------
802 output : `tuple` [`bool`, `int`]
803 If true, a correction was applied successfully. The
804 integer indicates the number of pixels that were
805 uncorrectable by being out of range.
806 """
807 if not np.any(np.isfinite(kwargs['coeffs'])):
808 return False, 0
809 if not np.any(kwargs['coeffs']):
810 return False, 0
811
812 ampArray = image.getArray()
813 correction = np.zeros_like(ampArray)
814 for order, coeff in enumerate(kwargs['coeffs'], start=2):
815 correction += coeff * np.power(ampArray, order)
816 ampArray += correction
817
818 return True, 0
819
820
822 """Correct non-linearity with a squared model.
823
824 corrImage = uncorrImage + c0*uncorrImage^2
825
826 where c0 is linearity coefficient 0 for each amplifier.
827 """
828 LinearityType = "Squared"
829
830 def __call__(self, image, **kwargs):
831 """Correct for non-linearity.
832
833 Parameters
834 ----------
835 image : `lsst.afw.image.Image`
836 Image to be corrected
837 kwargs : `dict`
838 Dictionary of parameter keywords:
839
840 ``coeffs``
841 Coefficient vector (`list` or `np.ndarray`).
842 ``log``
843 Logger to handle messages (`logging.Logger`).
844
845 Returns
846 -------
847 output : `tuple` [`bool`, `int`]
848 If true, a correction was applied successfully. The
849 integer indicates the number of pixels that were
850 uncorrectable by being out of range.
851 """
852
853 sqCoeff = kwargs['coeffs'][0]
854 if sqCoeff != 0:
855 ampArr = image.getArray()
856 ampArr *= (1 + sqCoeff*ampArr)
857 return True, 0
858 else:
859 return False, 0
860
861
863 """Correct non-linearity with a spline model.
864
865 corrImage = uncorrImage - Spline(coeffs, uncorrImage)
866
867 Notes
868 -----
869
870 The spline fit calculates a correction as a function of the
871 expected linear flux term. Because of this, the correction needs
872 to be subtracted from the observed flux.
873
874 """
875 LinearityType = "Spline"
876
877 def __call__(self, image, **kwargs):
878 """Correct for non-linearity.
879
880 Parameters
881 ----------
882 image : `lsst.afw.image.Image`
883 Image to be corrected
884 kwargs : `dict`
885 Dictionary of parameter keywords:
886
887 ``coeffs``
888 Coefficient vector (`list` or `np.ndarray`).
889 ``log``
890 Logger to handle messages (`logging.Logger`).
891 ``gain``
892 Gain value to apply.
893
894 Returns
895 -------
896 output : `tuple` [`bool`, `int`]
897 If true, a correction was applied successfully. The
898 integer indicates the number of pixels that were
899 uncorrectable by being out of range.
900 """
901 splineCoeff = kwargs['coeffs']
902 gain = kwargs.get('gain', 1.0)
903 centers, values = np.split(splineCoeff, 2)
904 values = values*gain
905 # If the spline is not anchored at zero, remove the offset
906 # found at the lowest flux available, and add an anchor at
907 # flux=0.0 if there's no entry at that point.
908 if values[0] != 0:
909 offset = values[0]
910 values -= offset
911 if centers[0] != 0.0:
912 centers = np.concatenate(([0.0], centers))
913 values = np.concatenate(([0.0], values))
914
915 ampArr = image.array
916
917 if np.any(~np.isfinite(values)):
918 # This cannot be used; turns everything into nans.
919 ampArr[:] = np.nan
920 else:
921 interp = Akima1DInterpolator(centers, values, method="akima")
922 # Clip to avoid extrapolation and hitting the top end.
923 ampArr -= interp(np.clip(ampArr, centers[0], centers[-1] - 0.1))
924
925 return True, 0
926
927
929 """Do not correct non-linearity.
930 """
931 LinearityType = "Proportional"
932
933 def __call__(self, image, **kwargs):
934 """Do not correct for non-linearity.
935
936 Parameters
937 ----------
938 image : `lsst.afw.image.Image`
939 Image to be corrected
940 kwargs : `dict`
941 Dictionary of parameter keywords:
942
943 ``coeffs``
944 Coefficient vector (`list` or `np.ndarray`).
945 ``log``
946 Logger to handle messages (`logging.Logger`).
947
948 Returns
949 -------
950 output : `tuple` [`bool`, `int`]
951 If true, a correction was applied successfully. The
952 integer indicates the number of pixels that were
953 uncorrectable by being out of range.
954 """
955 return True, 0
956
957
959 """Do not correct non-linearity.
960 """
961 LinearityType = "None"
962
963 def __call__(self, image, **kwargs):
964 """Do not correct for non-linearity.
965
966 Parameters
967 ----------
968 image : `lsst.afw.image.Image`
969 Image to be corrected
970 kwargs : `dict`
971 Dictionary of parameter keywords:
972
973 ``coeffs``
974 Coefficient vector (`list` or `np.ndarray`).
975 ``log``
976 Logger to handle messages (`logging.Logger`).
977
978 Returns
979 -------
980 output : `tuple` [`bool`, `int`]
981 If true, a correction was applied successfully. The
982 integer indicates the number of pixels that were
983 uncorrectable by being out of range.
984 """
985 return True, 0
An integer coordinate rectangle.
Definition Box.h:55
updateMetadata(self, camera=None, detector=None, filterName=None, setCalibId=False, setCalibInfo=False, setDate=False, **kwargs)
Definition calibType.py:210
__call__(self, image, **kwargs)
Definition linearize.py:655
__call__(self, image, **kwargs)
Definition linearize.py:963
getLinearityTypeByName(self, linearityTypeName)
Definition linearize.py:483
__init__(self, table=None, **kwargs)
Definition linearize.py:133
updateMetadata(self, setDate=False, **kwargs)
Definition linearize.py:179
applyLinearity(self, image, detector=None, log=None, gains=None)
Definition linearize.py:567
validate(self, detector=None, amplifier=None)
Definition linearize.py:507
isTrimmedImage(image, detector)