Coverage for tests/test_isPrimaryFlag.py: 25%
164 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-19 02:12 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-19 02:12 -0800
1# This file is part of pipe_tasks.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import os
23import unittest
24import numpy as np
26from lsst.geom import Point2I, Box2I, Extent2I
27from lsst.skymap import TractInfo
28from lsst.skymap.patchInfo import PatchInfo
29import lsst.afw.image as afwImage
30import lsst.utils.tests
31from lsst.pipe.tasks.characterizeImage import CharacterizeImageTask, CharacterizeImageConfig
32from lsst.pipe.tasks.calibrate import CalibrateTask, CalibrateConfig
33from lsst.meas.algorithms import SourceDetectionTask, SkyObjectsTask, SetPrimaryFlagsTask
34import lsst.meas.extensions.scarlet as mes
35from lsst.meas.extensions.scarlet.scarletDeblendTask import ScarletDeblendTask
36from lsst.meas.extensions.scarlet.deconvolveExposureTask import DeconvolveExposureTask
37from lsst.meas.base import SingleFrameMeasurementTask
38from lsst.afw.table import SourceCatalog
40TESTDIR = os.path.abspath(os.path.dirname(__file__))
43class NullTract(TractInfo):
44 """A Tract not contained in the MockSkyMap.
46 BaseSkyMap.findTract(coord) will always return a Tract,
47 even if the coord isn't located in the Tract.
48 In order to mimick this functionality we create a
49 NullTract for regions of the MockSkyMap that
50 aren't contained in any of the tracts.
51 """
52 def __init__(self):
53 pass
55 def getId(self):
56 return None
59class MockTractInfo:
60 """A Tract based on a bounding box and WCS.
62 Testing is made easier when we can specifically define
63 a Tract in terms of its bounding box in pixel coordinates
64 along with a WCS for the exposure.
66 Only the relevant methods from `TractInfo` needed to make
67 test pass are implemented here. Since this is just for
68 testing, it isn't sophisticated and requires developers to
69 ensure that the size of the bounding box is evenly divisible
70 by the number of patches in the Tract.
71 """
72 def __init__(self, name, bbox, wcs, numPatches):
73 self.name = name
74 self.bbox = bbox
75 self.wcs = wcs
76 self._numPatches = numPatches
77 assert bbox.getWidth()%numPatches[0] == 0
78 assert bbox.getHeight()%numPatches[1] == 0
79 self.patchWidth = bbox.getWidth()//numPatches[0]
80 self.patchHeight = bbox.getHeight()//numPatches[1]
82 def contains(self, coord):
83 pixel = self.wcs.skyToPixel(coord)
84 return self.bbox.contains(Point2I(pixel))
86 def getId(self):
87 return self.name
89 def getNumPatches(self):
90 return self._numPatches
92 def getPatchInfo(self, index):
93 x, y = index
94 width = self.patchWidth
95 height = self.patchHeight
97 x = x*self.patchWidth
98 y = y*self.patchHeight
100 bbox = Box2I(Point2I(x, y), Extent2I(width, height))
102 nx, ny = self._numPatches
103 sequentialIndex = nx*y + x
105 patchInfo = PatchInfo(
106 index=index,
107 innerBBox=bbox,
108 outerBBox=bbox,
109 sequentialIndex=sequentialIndex,
110 tractWcs=self.wcs
111 )
112 return patchInfo
114 def __getitem__(self, index):
115 return self.getPatchInfo(index)
117 def __iter__(self):
118 xNum, yNum = self.getNumPatches()
119 for y in range(yNum):
120 for x in range(xNum):
121 yield self.getPatchInfo((x, y))
124class MockSkyMap:
125 """A SkyMap based on a list of bounding boxes.
127 Testing is made easier when we can specifically define
128 a Tract in terms of its bounding box in pixel coordinates
129 along with a WCS for the exposure. This class allows us
130 to define the tract(s) in the SkyMap and create
131 them.
132 """
133 def __init__(self, bboxes, wcs, numPatches):
134 self.bboxes = bboxes
135 self.wcs = wcs
136 self.numPatches = numPatches
138 def __iter__(self):
139 for b, bbox in enumerate(self.bboxes):
140 yield self.generateTract(b)
142 def __getitem__(self, index):
143 return self.generateTract(index)
145 def generateTract(self, index):
146 return MockTractInfo(index, self.bboxes[index], self.wcs, self.numPatches)
148 def findTract(self, coord):
149 for tractInfo in self:
150 if tractInfo.contains(coord):
151 return tractInfo
153 return NullTract()
156class IsPrimaryTestCase(lsst.utils.tests.TestCase):
158 def setUp(self):
159 # Load sample input from disk
160 expPath = os.path.join(TESTDIR, "data", "v695833-e0-c000-a00.sci.fits")
161 self.exposure = afwImage.ExposureF(expPath)
163 # Characterize the image (create PSF, etc.)
164 charImConfig = CharacterizeImageConfig()
165 charImConfig.measureApCorr.sourceSelector["science"].doSignalToNoise = False
166 charImTask = CharacterizeImageTask(config=charImConfig)
167 self.charImResults = charImTask.run(self.exposure)
169 def tearDown(self):
170 del self.exposure
171 self.charImResults
173 def testIsSinglePrimaryFlag(self):
174 """Tests detect_isPrimary column gets added when run, and that sources
175 labelled as detect_isPrimary are not sky sources and have no children.
176 """
177 calibConfig = CalibrateConfig()
178 calibConfig.doAstrometry = False
179 calibConfig.doPhotoCal = False
180 calibConfig.doComputeSummaryStats = False
181 calibTask = CalibrateTask(config=calibConfig)
182 calibResults = calibTask.run(self.charImResults.exposure)
183 outputCat = calibResults.outputCat
184 self.assertTrue("detect_isPrimary" in outputCat.schema.getNames())
185 # make sure all sky sources are flagged as not primary
186 self.assertEqual(sum((outputCat["detect_isPrimary"]) & (outputCat["sky_source"])), 0)
187 # make sure all parent sources are flagged as not primary
188 self.assertEqual(sum((outputCat["detect_isPrimary"]) & (outputCat["deblend_nChild"] > 0)), 0)
190 with self.assertRaises(KeyError):
191 outputCat.getSchema().find("detect_isDelendedModelPrimary")
193 def testIsScarletPrimaryFlag(self):
194 """Test detect_isPrimary column when scarlet is used as the deblender
195 """
196 # We need a multiband coadd for scarlet,
197 # even though there is only one band
198 coadds = afwImage.MultibandExposure.fromExposures(["test"], [self.exposure])
200 # Create a SkyMap with a tract that contains a portion of the image,
201 # subdivided into 3x3 patches
202 wcs = self.exposure.getWcs()
203 tractBBox = Box2I(Point2I(100, 100), Extent2I(900, 900))
204 skyMap = MockSkyMap([tractBBox], wcs, (3, 3))
205 tractInfo = skyMap[0]
206 patchInfo = tractInfo[0, 0]
207 patchBBox = patchInfo.getInnerBBox()
209 schema = SourceCatalog.Table.makeMinimalSchema()
210 # Initialize the detection task
211 detectionTask = SourceDetectionTask(schema=schema)
213 # Initialize the fake source injection task
214 skyConfig = SkyObjectsTask.ConfigClass()
215 skySourcesTask = SkyObjectsTask(name="skySources", config=skyConfig)
216 schema.addField("merge_peak_sky", type="Flag")
218 # Initialize the deconvolution task
219 deconvolveConfig = DeconvolveExposureTask.ConfigClass()
220 deconvolveTask = DeconvolveExposureTask(config=deconvolveConfig)
222 # Initialize the deblender task
223 scarletConfig = ScarletDeblendTask.ConfigClass()
224 scarletConfig.maxIter = 20
225 scarletConfig.columnInheritance["merge_peak_sky"] = "merge_peak_sky"
226 scarletConfig.processSingles = True
227 deblendTask = ScarletDeblendTask(schema=schema, config=scarletConfig)
229 # We'll customize the configuration of measurement to just run the
230 # minimal number of plugins to make setPrimaryFlags work.
231 measureConfig = SingleFrameMeasurementTask.ConfigClass()
232 measureConfig.plugins.names = ["base_SdssCentroid", "base_SkyCoord"]
233 measureConfig.slots.psfFlux = None
234 measureConfig.slots.apFlux = None
235 measureConfig.slots.shape = None
236 measureConfig.slots.modelFlux = None
237 measureConfig.slots.calibFlux = None
238 measureConfig.slots.gaussianFlux = None
239 measureTask = SingleFrameMeasurementTask(config=measureConfig, schema=schema)
240 setPrimaryTask = SetPrimaryFlagsTask(schema=schema, isSingleFrame=False)
242 table = SourceCatalog.Table.make(schema)
243 # detect sources
244 detectionResult = detectionTask.run(table, coadds["test"])
245 catalog = detectionResult.sources
246 # add fake sources
247 skySources = skySourcesTask.run(mask=self.exposure.mask, seed=0)
248 for foot in skySources[:5]:
249 src = catalog.addNew()
250 src.setFootprint(foot)
251 src.set("merge_peak_sky", True)
252 # deconvolve the images
253 deconvolved = deconvolveTask.run(coadds["test"], catalog).deconvolved
254 mDeconvolved = afwImage.MultibandExposure.fromExposures(["test"], [deconvolved])
255 # deblend
256 # This is a hack because the variance is not calibrated properly
257 # (it is 3 orders of magnitude too high), which causes the deblender
258 # to improperly deblend most sources due to the sparsity constraint.
259 coadds.variance.array[:] = 2e-1
260 mDeconvolved.variance.array[:] = 2e-1
261 result = deblendTask.run(coadds, mDeconvolved, catalog)
262 modelData = result.scarletModelData
263 catalog = result.deblendedCatalog
264 # Attach footprints to the catalog
265 mes.io.updateCatalogFootprints(
266 modelData=modelData,
267 catalog=catalog,
268 band="test",
269 imageForRedistribution=coadds["test"],
270 removeScarletData=True,
271 updateFluxColumns=True,
272 )
274 # measure
275 measureTask.run(catalog, self.exposure)
276 outputCat = catalog
277 # Set the primary flags
278 setPrimaryTask.run(outputCat, skyMap=skyMap, tractInfo=tractInfo, patchInfo=patchInfo)
280 # There should be the same number of deblenedPrimary and
281 # deblendedModelPrimary sources,
282 # since they both have the same blended sources and only differ
283 # over which model to use for the isolated sources.
284 isPseudo = outputCat["merge_peak_sky"]
285 self.assertEqual(
286 np.sum(outputCat["detect_isDeblendedSource"] & ~isPseudo),
287 np.sum(outputCat["detect_isDeblendedModelSource"]))
289 # Check that the sources contained in a tract are all marked appropriately
290 x = outputCat["slot_Centroid_x"]
291 y = outputCat["slot_Centroid_y"]
292 tractInner = tractBBox.contains(x, y)
293 np.testing.assert_array_equal(outputCat["detect_isTractInner"], tractInner)
295 # Check that the sources contained in a patch are all marked appropriately
296 patchInner = patchBBox.contains(x, y)
297 np.testing.assert_array_equal(outputCat["detect_isPatchInner"], patchInner)
299 # make sure all sky sources are flagged as not primary
300 self.assertEqual(sum((outputCat["detect_isPrimary"]) & (outputCat["merge_peak_sky"])), 0)
302 # Check that sky objects have not been deblended
303 np.testing.assert_array_equal(
304 isPseudo,
305 isPseudo & (outputCat["deblend_nChild"] == 0)
306 )
309class MemoryTester(lsst.utils.tests.MemoryTestCase):
310 pass
313def setup_module(module):
314 lsst.utils.tests.init()
317if __name__ == "__main__": 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true
318 lsst.utils.tests.init()
319 unittest.main()