Coverage for tests/test_isPrimaryFlag.py: 94%

176 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-24 09:42 +0000

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/>. 

21 

22import os 

23import unittest 

24import numpy as np 

25 

26from lsst.geom import Point2I, Box2I, Extent2I 

27from lsst.skymap import TractInfo 

28from lsst.skymap.patchInfo import PatchInfo 

29import lsst.afw.detection as afwDetect 

30import lsst.afw.image as afwImage 

31import lsst.utils.tests 

32from lsst.pipe.tasks.characterizeImage import CharacterizeImageTask, CharacterizeImageConfig 

33from lsst.pipe.tasks.calibrate import CalibrateTask, CalibrateConfig 

34from lsst.meas.algorithms import SourceDetectionTask, SkyObjectsTask, SetPrimaryFlagsTask 

35import lsst.meas.extensions.scarlet as mes 

36from lsst.meas.extensions.scarlet.scarletDeblendTask import ScarletDeblendTask 

37from lsst.meas.extensions.scarlet.deconvolveExposureTask import DeconvolveExposureTask 

38from lsst.meas.base import SingleFrameMeasurementTask 

39from lsst.afw.table import SourceCatalog 

40 

41TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

42 

43 

44class NullTract(TractInfo): 

45 """A Tract not contained in the MockSkyMap. 

46 

47 BaseSkyMap.findTract(coord) will always return a Tract, 

48 even if the coord isn't located in the Tract. 

49 In order to mimick this functionality we create a 

50 NullTract for regions of the MockSkyMap that 

51 aren't contained in any of the tracts. 

52 """ 

53 def __init__(self): 

54 pass 

55 

56 def getId(self): 

57 return None 

58 

59 

60class MockTractInfo: 

61 """A Tract based on a bounding box and WCS. 

62 

63 Testing is made easier when we can specifically define 

64 a Tract in terms of its bounding box in pixel coordinates 

65 along with a WCS for the exposure. 

66 

67 Only the relevant methods from `TractInfo` needed to make 

68 test pass are implemented here. Since this is just for 

69 testing, it isn't sophisticated and requires developers to 

70 ensure that the size of the bounding box is evenly divisible 

71 by the number of patches in the Tract. 

72 """ 

73 def __init__(self, name, bbox, wcs, numPatches): 

74 self.name = name 

75 self.bbox = bbox 

76 self.wcs = wcs 

77 self._numPatches = numPatches 

78 assert bbox.getWidth()%numPatches[0] == 0 

79 assert bbox.getHeight()%numPatches[1] == 0 

80 self.patchWidth = bbox.getWidth()//numPatches[0] 

81 self.patchHeight = bbox.getHeight()//numPatches[1] 

82 

83 def contains(self, coord): 

84 pixel = self.wcs.skyToPixel(coord) 

85 return self.bbox.contains(Point2I(pixel)) 

86 

87 def getId(self): 

88 return self.name 

89 

90 def getNumPatches(self): 

91 return self._numPatches 

92 

93 def getPatchInfo(self, index): 

94 x, y = index 

95 width = self.patchWidth 

96 height = self.patchHeight 

97 

98 x = x*self.patchWidth 

99 y = y*self.patchHeight 

100 

101 bbox = Box2I(Point2I(x, y), Extent2I(width, height)) 

102 

103 nx, ny = self._numPatches 

104 sequentialIndex = nx*y + x 

105 

106 patchInfo = PatchInfo( 

107 index=index, 

108 innerBBox=bbox, 

109 outerBBox=bbox, 

110 sequentialIndex=sequentialIndex, 

111 tractWcs=self.wcs 

112 ) 

113 return patchInfo 

114 

115 def __getitem__(self, index): 

116 return self.getPatchInfo(index) 

117 

118 def __iter__(self): 

119 xNum, yNum = self.getNumPatches() 

120 for y in range(yNum): 

121 for x in range(xNum): 

122 yield self.getPatchInfo((x, y)) 

123 

124 

125class MockSkyMap: 

126 """A SkyMap based on a list of bounding boxes. 

127 

128 Testing is made easier when we can specifically define 

129 a Tract in terms of its bounding box in pixel coordinates 

130 along with a WCS for the exposure. This class allows us 

131 to define the tract(s) in the SkyMap and create 

132 them. 

133 """ 

134 def __init__(self, bboxes, wcs, numPatches): 

135 self.bboxes = bboxes 

136 self.wcs = wcs 

137 self.numPatches = numPatches 

138 

139 def __iter__(self): 

140 for b, bbox in enumerate(self.bboxes): 

141 yield self.generateTract(b) 

142 

143 def __getitem__(self, index): 

144 return self.generateTract(index) 

145 

146 def generateTract(self, index): 

147 return MockTractInfo(index, self.bboxes[index], self.wcs, self.numPatches) 

148 

149 def findTract(self, coord): 

150 for tractInfo in self: 

151 if tractInfo.contains(coord): 

152 return tractInfo 

153 

154 return NullTract() 

155 

156 

157class IsPrimaryTestCase(lsst.utils.tests.TestCase): 

158 

159 def setUp(self): 

160 # Load sample input from disk 

161 expPath = os.path.join(TESTDIR, "data", "v695833-e0-c000-a00.sci.fits") 

162 self.exposure = afwImage.ExposureF(expPath) 

163 

164 # Characterize the image (create PSF, etc.) 

165 charImConfig = CharacterizeImageConfig() 

166 charImConfig.measureApCorr.sourceSelector["science"].doSignalToNoise = False 

167 charImTask = CharacterizeImageTask(config=charImConfig) 

168 self.charImResults = charImTask.run(self.exposure) 

169 

170 def tearDown(self): 

171 del self.exposure 

172 self.charImResults 

173 

174 def testIsSinglePrimaryFlag(self): 

175 """Tests detect_isPrimary column gets added when run, and that sources 

176 labelled as detect_isPrimary are not sky sources and have no children. 

177 """ 

178 calibConfig = CalibrateConfig() 

179 calibConfig.doAstrometry = False 

180 calibConfig.doPhotoCal = False 

181 calibConfig.doComputeSummaryStats = False 

182 calibTask = CalibrateTask(config=calibConfig) 

183 calibResults = calibTask.run(self.charImResults.exposure) 

184 outputCat = calibResults.outputCat 

185 self.assertTrue("detect_isPrimary" in outputCat.schema.getNames()) 

186 # make sure all sky sources are flagged as not primary 

187 self.assertEqual(sum((outputCat["detect_isPrimary"]) & (outputCat["sky_source"])), 0) 

188 # make sure all parent sources are flagged as not primary 

189 self.assertEqual(sum((outputCat["detect_isPrimary"]) & (outputCat["deblend_nChild"] > 0)), 0) 

190 

191 with self.assertRaises(KeyError): 

192 outputCat.getSchema().find("detect_isDelendedModelPrimary") 

193 

194 def testIsScarletPrimaryFlag(self): 

195 """Test detect_isPrimary column when scarlet is used as the deblender 

196 """ 

197 # We need a multiband coadd for scarlet, 

198 # even though there is only one band 

199 coadds = afwImage.MultibandExposure.fromExposures(["test"], [self.exposure]) 

200 

201 # Create a SkyMap with a tract that contains a portion of the image, 

202 # subdivided into 3x3 patches 

203 wcs = self.exposure.getWcs() 

204 tractBBox = Box2I(Point2I(100, 100), Extent2I(900, 900)) 

205 skyMap = MockSkyMap([tractBBox], wcs, (3, 3)) 

206 tractInfo = skyMap[0] 

207 patchInfo = tractInfo[0, 0] 

208 patchBBox = patchInfo.getInnerBBox() 

209 

210 schema = SourceCatalog.Table.makeMinimalSchema() 

211 # Initialize the detection task 

212 detectionTask = SourceDetectionTask(schema=schema) 

213 

214 # Initialize the fake source injection task 

215 skyConfig = SkyObjectsTask.ConfigClass() 

216 skySourcesTask = SkyObjectsTask(name="skySources", config=skyConfig) 

217 

218 # Build the Footprint peak schema that the coadd merge step would 

219 # provide. The deblender reads the pseudo-source flag from each 

220 # Footprint's peak (not the source record) and transfers any extra 

221 # peak fields onto the source schema, so `merge_peak_sky` must live on 

222 # the peak schema and must not be pre-added to the source schema here. 

223 peakSchema = afwDetect.PeakTable.makeMinimalSchema() 

224 skyPeakKey = peakSchema.addField("merge_peak_sky", type="Flag") 

225 

226 # Initialize the deconvolution task 

227 deconvolveConfig = DeconvolveExposureTask.ConfigClass() 

228 deconvolveTask = DeconvolveExposureTask(config=deconvolveConfig) 

229 

230 # Initialize the deblender task 

231 scarletConfig = ScarletDeblendTask.ConfigClass() 

232 scarletConfig.maxIter = 20 

233 scarletConfig.columnInheritance["merge_peak_sky"] = "merge_peak_sky" 

234 scarletConfig.processSingles = True 

235 deblendTask = ScarletDeblendTask(schema=schema, peakSchema=peakSchema, config=scarletConfig) 

236 

237 # We'll customize the configuration of measurement to just run the 

238 # minimal number of plugins to make setPrimaryFlags work. 

239 # As of DM-51670 we also include `base_PsfFlux` to ensure that 

240 # the measurement plugins run correctly with the split between 

241 # parent and child catalogs. 

242 measureConfig = SingleFrameMeasurementTask.ConfigClass() 

243 measureConfig.plugins.names = ["base_SdssCentroid", "base_SkyCoord", "base_PsfFlux"] 

244 measureConfig.slots.apFlux = None 

245 measureConfig.slots.shape = None 

246 measureConfig.slots.modelFlux = None 

247 measureConfig.slots.calibFlux = None 

248 measureConfig.slots.gaussianFlux = None 

249 measureTask = SingleFrameMeasurementTask(config=measureConfig, schema=schema) 

250 setPrimaryTask = SetPrimaryFlagsTask(schema=schema, isSingleFrame=False) 

251 

252 table = SourceCatalog.Table.make(schema) 

253 # detect sources 

254 detectionResult = detectionTask.run(table, coadds["test"]) 

255 catalog = detectionResult.sources 

256 # The deblender requires every Footprint's peaks to share the peak 

257 # schema passed to it, so rebuild the detected Footprints with the 

258 # merge peak schema (their peaks carry no pseudo-source flag). 

259 for src in catalog: 

260 oldFoot = src.getFootprint() 

261 newFoot = afwDetect.Footprint(oldFoot.spans, peakSchema) 

262 for peak in oldFoot.peaks: 

263 newFoot.addPeak(peak.getFx(), peak.getFy(), peak.getPeakValue()) 

264 src.setFootprint(newFoot) 

265 # add fake sources, flagging the sky peak as the coadd merge step does 

266 # in `MergeDetectionsTask.getSkySourceFootprints` 

267 skySources = skySourcesTask.run(mask=self.exposure.mask, seed=0) 

268 for foot in skySources[:5]: 

269 src = catalog.addNew() 

270 peak = foot.getPeaks()[0] 

271 newFoot = afwDetect.Footprint(foot.spans, peakSchema) 

272 newFoot.addPeak(peak.getFx(), peak.getFy(), peak.getPeakValue()) 

273 newFoot.getPeaks()[0].set(skyPeakKey, True) 

274 src.setFootprint(newFoot) 

275 # deconvolve the images 

276 deconvolved = deconvolveTask.run(coadds["test"], catalog).deconvolved 

277 mDeconvolved = afwImage.MultibandExposure.fromExposures(["test"], [deconvolved]) 

278 # deblend 

279 # This is a hack because the variance is not calibrated properly 

280 # (it is 3 orders of magnitude too high), which causes the deblender 

281 # to improperly deblend most sources due to the sparsity constraint. 

282 coadds.variance.array[:] = 2e-1 

283 mDeconvolved.variance.array[:] = 2e-1 

284 result = deblendTask.run(coadds, mDeconvolved, catalog) 

285 modelData = result.scarletModelData 

286 catalog = result.deblendedCatalog 

287 # Attach footprints to the catalog 

288 mes.io.updateCatalogFootprints( 

289 modelData=modelData, 

290 catalog=catalog, 

291 band="test", 

292 imageForRedistribution=coadds["test"], 

293 removeScarletData=True, 

294 updateFluxColumns=True, 

295 ) 

296 

297 # measure 

298 measureTask.run(catalog, self.exposure) 

299 outputCat = catalog 

300 # Set the primary flags 

301 setPrimaryTask.run(outputCat, skyMap=skyMap, tractInfo=tractInfo, patchInfo=patchInfo) 

302 

303 # There should be the same number of deblenedPrimary and 

304 # deblendedModelPrimary sources, 

305 # since they both have the same blended sources and only differ 

306 # over which model to use for the isolated sources. 

307 isPseudo = outputCat["merge_peak_sky"] 

308 

309 # Check that all 5 pseudo-sources were created 

310 self.assertEqual(np.sum(isPseudo), 5) 

311 

312 self.assertEqual( 

313 np.sum(outputCat["detect_isDeblendedSource"] & ~isPseudo), 

314 np.sum(outputCat["detect_isDeblendedModelSource"])) 

315 

316 # Check that the sources contained in a tract are all marked appropriately 

317 x = outputCat["slot_Centroid_x"] 

318 y = outputCat["slot_Centroid_y"] 

319 tractInner = tractBBox.contains(x, y) 

320 np.testing.assert_array_equal(outputCat["detect_isTractInner"], tractInner) 

321 

322 # Check that the sources contained in a patch are all marked appropriately 

323 patchInner = patchBBox.contains(x, y) 

324 np.testing.assert_array_equal(outputCat["detect_isPatchInner"], patchInner) 

325 

326 # make sure all sky sources are flagged as not primary 

327 self.assertEqual(sum((outputCat["detect_isPrimary"]) & (outputCat["merge_peak_sky"])), 0) 

328 

329 # Check that sky objects have not been deblended 

330 # (deblended sources have parent > 0) 

331 np.testing.assert_array_equal( 

332 isPseudo, 

333 isPseudo & (outputCat["parent"] == 0) 

334 ) 

335 

336 # Check that measurements were performed on all of the children 

337 self.assertTrue(np.all(outputCat["base_PsfFlux_instFlux"] != 0) and np.all(np.isfinite( 

338 outputCat["base_PsfFlux_instFlux"]))) 

339 

340 

341class MemoryTester(lsst.utils.tests.MemoryTestCase): 

342 pass 

343 

344 

345def setup_module(module): 

346 lsst.utils.tests.init() 

347 

348 

349if __name__ == "__main__": 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 lsst.utils.tests.init() 

351 unittest.main()