From 7a7cc38beeb0e351e3e0c98ad8204c09bfdbab1e Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Fri, 20 Sep 2024 18:52:34 +0100 Subject: [PATCH 1/4] Add binExposureTask to spatially bin exposures --- python/lsst/ip/isr/__init__.py | 1 + python/lsst/ip/isr/binExposureTask.py | 201 ++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 python/lsst/ip/isr/binExposureTask.py diff --git a/python/lsst/ip/isr/__init__.py b/python/lsst/ip/isr/__init__.py index 3d9f7c6c8..f3141a8d0 100755 --- a/python/lsst/ip/isr/__init__.py +++ b/python/lsst/ip/isr/__init__.py @@ -48,3 +48,4 @@ from .transmissionCurve import * from .ampOffset import * from .overscanAmpConfig import * +from .binExposureTask import * diff --git a/python/lsst/ip/isr/binExposureTask.py b/python/lsst/ip/isr/binExposureTask.py new file mode 100644 index 000000000..cfeb2fc44 --- /dev/null +++ b/python/lsst/ip/isr/binExposureTask.py @@ -0,0 +1,201 @@ +# This file is part of ip_isr. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__all__ = ["BinExposureTask", + "BinExposureConfig", + "binExposure"] + +import lsst.pipe.base as pipeBase +import lsst.pipe.base.connectionTypes as cT +import lsst.pex.config as pexConfig +import lsst.afw.math as afwMath +from lsst.utils.timer import timeMethod +import lsst.afw.image as afwImage + + +class BinExposureConnections( + pipeBase.PipelineTaskConnections, + dimensions=("instrument", "exposure", "detector"), + defaultTemplates={"inputName": "postISRCCD", "outputName": "postISRCCDBin"} +): + + inputExposure = cT.Input( + name="{inputName}", + doc="Input exposure to bin.", + storageClass="Exposure", + dimensions=["instrument", "exposure", "detector"], + ) + binnedExposure = cT.Output( + name="{outputName}", + doc="Binned exsposure.", + storageClass="Exposure", + dimensions=["instrument", "exposure", "detector"], + ) + + def __init__(self, *, config=None): + """Customize the connections and storageClass for a specific + instance. This enables both to be dynamically set at runtime, + allowing BinExposureTask to work with different types of + Exposures such as postISRCCD, calexp, deepCoadd_calexp, etc. + + Parameters + ---------- + config : `BinExposureConfig` + A config for `BinExposureTask`. + """ + super().__init__(config=config) + if config and config.exposureDimensions != self.inputExposure.dimensions: + self.dimensions.clear() + self.dimensions.update(config.exposureDimensions) + self.inputExposure = cT.Input( + name=self.inputExposure.name, + doc=self.inputExposure.doc, + storageClass=self.inputExposure.storageClass, + dimensions=frozenset(config.exposureDimensions), + ) + self.binnedExposure = cT.Output( + name=self.binnedExposure.name, + doc=self.binnedExposure.doc, + storageClass=self.binnedExposure.storageClass, + dimensions=frozenset(config.exposureDimensions), + ) + if config and config.exposureStorageClass != self.inputExposure.storageClass: + self.inputExposure = cT.Input( + name=self.inputExposure.name, + doc=self.inputExposure.doc, + storageClass=config.exposureStorageClass, + dimensions=self.inputExposure.dimensions, + ) + self.binnedExposure = cT.Output( + name=self.binnedExposure.name, + doc=self.binnedExposure.doc, + storageClass=config.exposureStorageClass, + dimensions=self.binnedExposure.dimensions, + ) + + +class BinExposureConfig( + pipeBase.PipelineTaskConfig, + pipelineConnections=BinExposureConnections +): + """Config for BinExposureTask""" + exposureDimensions = pexConfig.ListField( + # Sort to ensure default order is consistent between runs + default=sorted(BinExposureConnections.dimensions), + dtype=str, + doc="Override for the dimensions of the input and binned exposures.", + ) + exposureStorageClass = pexConfig.Field( + default='ExposureF', + dtype=str, + doc=( + "Override the storageClass of the input and binned exposures. " + "Must be of type lsst.afw.Image.Exposure, or one of its subtypes." + ) + ) + binFactor = pexConfig.Field( + dtype=int, + doc="Binning factor applied to both spatial dimensions.", + default=8, + check=lambda x: x > 1, + ) + + +class BinExposureTask(pipeBase.PipelineTask): + """Perform an nxn binning of an Exposure dataset type. + + The binning factor is the same in both spatial dimensions (i.e., + an nxn binning is performed). Each of the input Exposure's image + arrays are binned by the same factor. + """ + # TODO: DM-46501: Add tasks to nxn bin Image and MaskedImage classes + ConfigClass = BinExposureConfig + _DefaultName = "binExposure" + + @timeMethod + def run(self, inputExposure, binFactor=None): + """Perform an nxn binning of an Exposure. + + Parameters: + ----------- + inputExposure : `lsst.afw.image.Exposure` or one of its + sub-types. + Exposure to spatially bin + binFactor : `int`, optional. + nxn binning factor. If not provided then self.config.binFactor + is used. + + Returns: + -------- + result : `lsst.pipe.base.Struct` + Results as a struct with attributes: + + ``binnedExposure`` + Binned exposure (`lsst.afw.image.Exposure` or one of its + sub-types. The type matches that of the inputExposure). + """ + if not binFactor: + binFactor = self.config.binFactor + return pipeBase.Struct( + binnedExposure=binExposure(inputExposure, binFactor) + ) + + +def binExposure(inputExposure, binFactor=8): + """Bin an exposure to reduce its spatial dimensions. + + Performs an nxn binning of the input exposure, reducing both spatial + dimensions of each of the input exposure's image data by the provided + factor. + + Parameters: + ----------- + inputExposure: `lsst.afw.image.Exposure` or one of its sub-types. + Input exposure data to bin. + binFactor: `int` + Binning factor to apply to each input exposure's image data. + Default 8. + + Returns: + -------- + binnedExposure: `lsst.afw.image.Exposure` or one of its sub-types. + Binned version of input image. + + Raises + ------ + TypeError + Raised if either the binning factor is not of type `int`, or if the + input data to be binned is not of type `lsst.afw.image.Exposure` + or one of its sub-types. + """ + + if not isinstance(binFactor, int): + raise TypeError("binFactor must be of type int") + if not isinstance(inputExposure, afwImage.Exposure): + raise TypeError("inputExp must be of type lsst.afw.image.Exposure or one of its sub-tyoes.") + + binned = inputExposure.getMaskedImage() + binned = afwMath.binImage(binned, binFactor) + binnedExposure = afwImage.makeExposure(binned) + + binnedExposure.setInfo(inputExposure.getInfo()) + + return binnedExposure From 5b17645e9c641391eecd37fb641622089bb7b0a4 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Sun, 22 Sep 2024 14:42:51 -0700 Subject: [PATCH 2/4] Added unit test for binExposureTask --- tests/test_binExposure.py | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_binExposure.py diff --git a/tests/test_binExposure.py b/tests/test_binExposure.py new file mode 100644 index 000000000..ffae10743 --- /dev/null +++ b/tests/test_binExposure.py @@ -0,0 +1,88 @@ +# This file is part of ip_isr. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import unittest + +import lsst.utils.tests +import lsst.afw.image as afwImage +from lsst.ip.isr.binExposureTask import binExposure + + +class BinExposureTestCases(lsst.utils.tests.TestCase): + + def setUp(self): + '''Set up both ExposureF and ExposureI inputs, + plus reference images that represent the expected + output from an 8x8 binning. + ''' + self.imageF = afwImage.ImageF(2048, 2048) + self.imageF.set(100) + self.miF = afwImage.makeMaskedImage(self.imageF) + self.expF = afwImage.makeExposure(self.miF) + + self.imageI = afwImage.ImageI(2048, 2048) + self.imageI.set(100) + self.miI = afwImage.makeMaskedImage(self.imageI) + self.expI = afwImage.makeExposure(self.miI) + + refImageF = afwImage.ImageF(256, 256) + refImageF.set(100) + refMiF = afwImage.makeMaskedImage(refImageF) + self.refExpF = afwImage.makeExposure(refMiF) + + refImageI = afwImage.ImageI(256, 256) + refImageI.set(100) + refMiI = afwImage.makeMaskedImage(refImageI) + self.refExpI = afwImage.makeExposure(refMiI) + + def test_ExposureBinning(self): + '''Test an 8x8 binning.''' + binnedExposure = binExposure(self.expF, binFactor=8) + self.assertMaskedImagesEqual( + self.refExpF.getMaskedImage(), + binnedExposure.getMaskedImage(), + ) + + binnedExposure = binExposure(self.expI, binFactor=8) + self.assertMaskedImagesEqual( + self.refExpI.getMaskedImage(), + binnedExposure.getMaskedImage(), + ) + + def test_BinExsposureDataTypes(self): + '''Test that a TypeError is raised should the input + not be of type `lsst.afw.image.Exposure or one of its sub-types. + ''' + with self.assertRaises(TypeError): + _ = binExposure(None) + with self.assertRaises(TypeError): + _ = binExposure(self.imageF) + with self.assertRaises(TypeError): + _ = binExposure(self.miF) + + +def setup_module(module): + lsst.utils.tests.init() + + +if __name__ == "__main__": + import sys + setup_module(sys.modules[__name__]) + unittest.main() From 86bb4181972c1781646e79326f9d88b6230c778d Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Fri, 20 Sep 2024 18:53:53 +0100 Subject: [PATCH 3/4] Add binning subtask to isrTask IsrTask subtasks BinExposureTask to perform exposure binning. --- python/lsst/ip/isr/isrTask.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/python/lsst/ip/isr/isrTask.py b/python/lsst/ip/isr/isrTask.py index 4b68e14e7..f59a61a73 100644 --- a/python/lsst/ip/isr/isrTask.py +++ b/python/lsst/ip/isr/isrTask.py @@ -33,6 +33,7 @@ import lsst.pipe.base.connectionTypes as cT from contextlib import contextmanager +from deprecated.sphinx import deprecated from lsstDebug import getDebugFrame from lsst.afw.cameraGeom import NullLinearityType @@ -46,6 +47,7 @@ from .defects import Defects from .assembleCcdTask import AssembleCcdTask +from .binExposureTask import BinExposureTask from .crosstalk import CrosstalkTask, CrosstalkCalib from .fringe import FringeTask from .isr import maskNans @@ -948,6 +950,10 @@ class IsrTaskConfig(pipeBase.PipelineTaskConfig, doc="Should binned exposures be calculated?", default=False, ) + binning = pexConfig.ConfigurableField( + target=BinExposureTask, + doc="Task to bin the exposure.", + ) binFactor1 = pexConfig.Field( dtype=int, doc="Binning factor for first binned exposure. This is intended for a finely binned output.", @@ -1027,6 +1033,7 @@ def __init__(self, **kwargs): self.makeSubtask("ampOffset") self.makeSubtask("deferredChargeCorrection") self.makeSubtask("isrStats") + self.makeSubtask("binning") def runQuantum(self, butlerQC, inputRefs, outputRefs): inputs = butlerQC.get(inputRefs) @@ -1777,7 +1784,15 @@ def run(self, ccdExposure, *, camera=None, bias=None, linearizer=None, outputBin1Exposure = None outputBin2Exposure = None if self.config.doBinnedExposures: - outputBin1Exposure, outputBin2Exposure = self.makeBinnedImages(ccdExposure) + self.log.info("Creating binned exposures.") + outputBin1Exposure = self.binning.run( + ccdExposure, + binFactor=self.config.binFactor1, + ).binnedExposure + outputBin2Exposure = self.binning.run( + ccdExposure, + binFactor=self.config.binFactor2, + ).binnedExposure self.debugView(ccdExposure, "postISRCCD") @@ -2773,6 +2788,13 @@ def flatContext(self, exp, flat, dark=None): if self.config.doDark and dark is not None: self.darkCorrection(exp, dark, invert=True) + @deprecated( + reason=( + "makeBinnedImages is no longer used. " + "Please subtask lsst.ip.isr.BinExposureTask instead." + ), + version="v28", category=FutureWarning + ) def makeBinnedImages(self, exposure): """Make visualizeVisit style binned exposures. From 1c10223f105b3447632fef2a69e225761411867d Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Wed, 25 Sep 2024 08:16:10 -0700 Subject: [PATCH 4/4] Add binning subtask to isrTaskLSST IsrTaskLSST subtasks BinExposureTask to perform exposure binning. --- python/lsst/ip/isr/isrTaskLSST.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/python/lsst/ip/isr/isrTaskLSST.py b/python/lsst/ip/isr/isrTaskLSST.py index 4b2318dce..69223e039 100644 --- a/python/lsst/ip/isr/isrTaskLSST.py +++ b/python/lsst/ip/isr/isrTaskLSST.py @@ -8,6 +8,7 @@ from .defects import Defects from contextlib import contextmanager +from deprecated.sphinx import deprecated import lsst.pex.config as pexConfig import lsst.afw.math as afwMath import lsst.pipe.base as pipeBase @@ -16,6 +17,7 @@ from lsst.meas.algorithms.detection import SourceDetectionTask from .ampOffset import AmpOffsetTask +from .binExposureTask import BinExposureTask from .overscan import SerialOverscanCorrectionTask, ParallelOverscanCorrectionTask from .overscanAmpConfig import OverscanCameraConfig from .assembleCcdTask import AssembleCcdTask @@ -502,6 +504,10 @@ class IsrTaskLSSTConfig(pipeBase.PipelineTaskConfig, doc="Should binned exposures be calculated?", default=False, ) + binning = pexConfig.ConfigurableField( + target=BinExposureTask, + doc="Task to bin the exposure.", + ) binFactor1 = pexConfig.Field( dtype=int, doc="Binning factor for first binned exposure. This is intended for a finely binned output.", @@ -550,6 +556,7 @@ def __init__(self, **kwargs): self.makeSubtask("masking") self.makeSubtask("isrStats") self.makeSubtask("ampOffset") + self.makeSubtask("binning") def runQuantum(self, butlerQC, inputRefs, outputRefs): @@ -1439,6 +1446,13 @@ def flatCorrection(self, exposure, flatExposure, invert=False): invert=invert, ) + @deprecated( + reason=( + "makeBinnedImages is no longer used. " + "Please subtask lsst.ip.isr.BinExposureTask instead." + ), + version="v28", category=FutureWarning + ) def makeBinnedImages(self, exposure): """Make visualizeVisit style binned exposures. @@ -1863,7 +1877,15 @@ def run(self, ccdExposure, *, dnlLUT=None, bias=None, deferredChargeCalib=None, outputBin1Exposure = None outputBin2Exposure = None if self.config.doBinnedExposures: - outputBin1Exposure, outputBin2Exposure = self.makeBinnedImages(ccdExposure) + self.log.info("Creating binned exposures.") + outputBin1Exposure = self.binning.run( + ccdExposure, + binFactor=self.config.binFactor1, + ).binnedExposure + outputBin2Exposure = self.binning.run( + ccdExposure, + binFactor=self.config.binFactor2, + ).binnedExposure return pipeBase.Struct( exposure=ccdExposure,