LSST Applications g0f08755f38+82efc23009,g12f32b3c4e+e7bdf1200e,g1653933729+a8ce1bb630,g1a0ca8cf93+50eff2b06f,g28da252d5a+52db39f6a5,g2bbee38e9b+37c5a29d61,g2bc492864f+37c5a29d61,g2cdde0e794+c05ff076ad,g3156d2b45e+41e33cbcdc,g347aa1857d+37c5a29d61,g35bb328faa+a8ce1bb630,g3a166c0a6a+37c5a29d61,g3e281a1b8c+fb992f5633,g414038480c+7f03dfc1b0,g41af890bb2+11b950c980,g5fbc88fb19+17cd334064,g6b1c1869cb+12dd639c9a,g781aacb6e4+a8ce1bb630,g80478fca09+72e9651da0,g82479be7b0+04c31367b4,g858d7b2824+82efc23009,g9125e01d80+a8ce1bb630,g9726552aa6+8047e3811d,ga5288a1d22+e532dc0a0b,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gc28159a63d+37c5a29d61,gcf0d15dbbd+2acd6d4d48,gd7358e8bfb+778a810b6e,gda3e153d99+82efc23009,gda6a2b7d83+2acd6d4d48,gdaeeff99f8+1711a396fd,ge2409df99d+6b12de1076,ge79ae78c31+37c5a29d61,gf0baf85859+d0a5978c5a,gf3967379c6+4954f8c433,gfb92a5be7c+82efc23009,gfec2e1e490+2aaed99252,w.2024.46
LSST Data Management Base Package
Loading...
Searching...
No Matches
objectSizeStarSelector.py
Go to the documentation of this file.
1# This file is part of meas_algorithms.
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
22__all__ = ["ObjectSizeStarSelectorConfig", "ObjectSizeStarSelectorTask"]
23
24import sys
25
26import numpy
27import warnings
28from functools import reduce
29
30from lsst.utils.logging import getLogger
31from lsst.pipe.base import Struct
32import lsst.geom
33from lsst.afw.cameraGeom import PIXELS, TAN_PIXELS
34import lsst.afw.geom as afwGeom
35import lsst.pex.config as pexConfig
36import lsst.afw.display as afwDisplay
37from .sourceSelector import BaseSourceSelectorTask, sourceSelectorRegistry
38
39afwDisplay.setDefaultMaskTransparency(75)
40
41_LOG = getLogger(__name__)
42
43
44class ObjectSizeStarSelectorConfig(BaseSourceSelectorTask.ConfigClass):
45 doFluxLimit = pexConfig.Field(
46 doc="Apply flux limit to Psf Candidate selection?",
47 dtype=bool,
48 default=False,
49 )
50 fluxMin = pexConfig.Field(
51 doc="Minimum flux value for good Psf Candidates.",
52 dtype=float,
53 default=12500.0,
54 check=lambda x: x >= 0.0,
55 )
56 fluxMax = pexConfig.Field(
57 doc="Maximum flux value for good Psf Candidates (ignored if == 0).",
58 dtype=float,
59 default=0.0,
60 check=lambda x: x >= 0.0,
61 )
62 doSignalToNoiseLimit = pexConfig.Field(
63 doc="Apply signal-to-noise (i.e. flux/fluxErr) limit to Psf Candidate selection?",
64 dtype=bool,
65 default=True,
66 )
67 # Note that the current default is conditioned on the detection thresholds
68 # set in the characterizeImage setDefaults function for the measurePsf
69 # stage.
70 signalToNoiseMin = pexConfig.Field(
71 doc="Minimum signal-to-noise for good Psf Candidates "
72 "(value should take into consideration the detection thresholds "
73 "set for the catalog of interest).",
74 dtype=float,
75 default=50.0,
76 check=lambda x: x >= 0.0,
77 )
78 signalToNoiseMax = pexConfig.Field(
79 doc="Maximum signal-to-noise for good Psf Candidates (ignored if == 0).",
80 dtype=float,
81 default=0.0,
82 check=lambda x: x >= 0.0,
83 )
84 widthMin = pexConfig.Field(
85 doc="Minimum width to include in histogram.",
86 dtype=float,
87 default=0.9,
88 check=lambda x: x >= 0.0,
89 )
90 widthMax = pexConfig.Field(
91 doc="Maximum width to include in histogram.",
92 dtype=float,
93 default=10.0,
94 check=lambda x: x >= 0.0,
95 )
96 sourceFluxField = pexConfig.Field(
97 doc="Name of field in Source to use for flux measurement.",
98 dtype=str,
99 default="base_PsfFlux_instFlux",
100 )
101 widthStdAllowed = pexConfig.Field(
102 doc="Standard deviation of width allowed to be interpreted as good stars.",
103 dtype=float,
104 default=0.15,
105 check=lambda x: x >= 0.0,
106 )
107 nSigmaClip = pexConfig.Field(
108 doc="Keep objects within this many sigma of cluster 0's median.",
109 dtype=float,
110 default=2.0,
111 check=lambda x: x >= 0.0,
112 )
113 badFlags = pexConfig.ListField(
114 doc="List of flags which cause a source to be rejected as bad.",
115 dtype=str,
116 default=[
117 "base_PixelFlags_flag_edge",
118 "base_PixelFlags_flag_interpolatedCenter",
119 "base_PixelFlags_flag_saturatedCenter",
120 "base_PixelFlags_flag_crCenter",
121 "base_PixelFlags_flag_bad",
122 "base_PixelFlags_flag_interpolated",
123 ],
124 )
125
126 def validate(self):
127 BaseSourceSelectorTask.ConfigClass.validate(self)
128 if self.widthMin > self.widthMax:
129 msg = f"widthMin ({self.widthMin}) > widthMax ({self.widthMax})"
130 raise pexConfig.FieldValidationError(ObjectSizeStarSelectorConfig.widthMin, self, msg)
131
132
134 """A class to handle key strokes with matplotlib displays.
135 """
136
137 def __init__(self, axes, xs, ys, x, y, frames=[0]):
138 self.axes = axes
139 self.xs = xs
140 self.ys = ys
141 self.x = x
142 self.y = y
143 self.frames = frames
144
145 self.cid = self.axes.figure.canvas.mpl_connect('key_press_event', self)
146
147 def __call__(self, ev):
148 if ev.inaxes != self.axes:
149 return
150
151 if ev.key and ev.key in ("p"):
152 dist = numpy.hypot(self.xs - ev.xdata, self.ys - ev.ydata)
153 dist[numpy.where(numpy.isnan(dist))] = 1e30
154
155 which = numpy.where(dist == min(dist))
156
157 x = self.x[which][0]
158 y = self.y[which][0]
159 for frame in self.frames:
160 disp = afwDisplay.Display(frame=frame)
161 disp.pan(x, y)
162 disp.flush()
163 else:
164 pass
165
166
167def _assignClusters(yvec, centers):
168 """Return a vector of centerIds based on their distance to the centers.
169 """
170 assert len(centers) > 0
171
172 minDist = numpy.nan*numpy.ones_like(yvec)
173 clusterId = numpy.empty_like(yvec)
174 clusterId.dtype = int # zeros_like(..., dtype=int) isn't in numpy 1.5
175 dbl = _LOG.getChild("_assignClusters")
176 dbl.setLevel(dbl.INFO)
177
178 # Make sure we are logging aall numpy warnings...
179 oldSettings = numpy.seterr(all="warn")
180 with warnings.catch_warnings(record=True) as w:
181 warnings.simplefilter("always")
182 for i, mean in enumerate(centers):
183 dist = abs(yvec - mean)
184 if i == 0:
185 update = dist == dist # True for all points
186 else:
187 update = dist < minDist
188 if w: # Only do if w is not empty i.e. contains a warning message
189 dbl.trace(str(w[-1]))
190
191 minDist[update] = dist[update]
192 clusterId[update] = i
193 numpy.seterr(**oldSettings)
194
195 return clusterId
196
197
198def _kcenters(yvec, nCluster, useMedian=False, widthStdAllowed=0.15):
199 """A classic k-means algorithm, clustering yvec into nCluster clusters
200
201 Return the set of centres, and the cluster ID for each of the points
202
203 If useMedian is true, use the median of the cluster as its centre, rather than
204 the traditional mean
205
206 Serge Monkewitz points out that there other (maybe smarter) ways of seeding the means:
207 "e.g. why not use the Forgy or random partition initialization methods"
208 however, the approach adopted here seems to work well for the particular sorts of things
209 we're clustering in this application
210 """
211
212 assert nCluster > 0
213
214 mean0 = sorted(yvec)[len(yvec)//10] # guess
215 delta = mean0 * widthStdAllowed * 2.0
216 centers = mean0 + delta * numpy.arange(nCluster)
217
218 func = numpy.median if useMedian else numpy.mean
219
220 clusterId = numpy.zeros_like(yvec) - 1 # which cluster the points are assigned to
221 clusterId.dtype = int # zeros_like(..., dtype=int) isn't in numpy 1.5
222 while True:
223 oclusterId = clusterId
224 clusterId = _assignClusters(yvec, centers)
225
226 if numpy.all(clusterId == oclusterId):
227 break
228
229 for i in range(nCluster):
230 # Only compute func if some points are available; otherwise, default to NaN.
231 pointsInCluster = (clusterId == i)
232 if numpy.any(pointsInCluster):
233 centers[i] = func(yvec[pointsInCluster])
234 else:
235 centers[i] = numpy.nan
236
237 return centers, clusterId
238
239
240def _improveCluster(yvec, centers, clusterId, nsigma=2.0, nIteration=10, clusterNum=0, widthStdAllowed=0.15):
241 """Improve our estimate of one of the clusters (clusterNum) by sigma-clipping around its median.
242 """
243
244 nMember = sum(clusterId == clusterNum)
245 if nMember < 5: # can't compute meaningful interquartile range, so no chance of improvement
246 return clusterId
247 for iter in range(nIteration):
248 old_nMember = nMember
249
250 inCluster0 = clusterId == clusterNum
251 yv = yvec[inCluster0]
252
253 centers[clusterNum] = numpy.median(yv)
254 stdev = numpy.std(yv)
255
256 syv = sorted(yv)
257 stdev_iqr = 0.741*(syv[int(0.75*nMember)] - syv[int(0.25*nMember)])
258 median = syv[int(0.5*nMember)]
259
260 sd = stdev if stdev < stdev_iqr else stdev_iqr
261
262 if False:
263 print("sigma(iqr) = %.3f, sigma = %.3f" % (stdev_iqr, numpy.std(yv)))
264 newCluster0 = abs(yvec - centers[clusterNum]) < nsigma*sd
265 clusterId[numpy.logical_and(inCluster0, newCluster0)] = clusterNum
266 clusterId[numpy.logical_and(inCluster0, numpy.logical_not(newCluster0))] = -1
267
268 nMember = sum(clusterId == clusterNum)
269 # 'sd < widthStdAllowed * median' prevents too much rejections
270 if nMember == old_nMember or sd < widthStdAllowed * median:
271 break
272
273 return clusterId
274
275
276def plot(mag, width, centers, clusterId, marker="o", markersize=2, markeredgewidth=0, ltype='-',
277 magType="model", clear=True):
278
279 log = _LOG.getChild("plot")
280 try:
281 import matplotlib.pyplot as plt
282 except ImportError as e:
283 log.warning("Unable to import matplotlib: %s", e)
284 return
285
286 try:
287 fig
288 except NameError:
289 fig = plt.figure()
290 else:
291 if clear:
292 fig.clf()
293
294 axes = fig.add_axes((0.1, 0.1, 0.85, 0.80))
295
296 xmin = sorted(mag)[int(0.05*len(mag))]
297 xmax = sorted(mag)[int(0.95*len(mag))]
298
299 axes.set_xlim(-17.5, -13)
300 axes.set_xlim(xmin - 0.1*(xmax - xmin), xmax + 0.1*(xmax - xmin))
301 axes.set_ylim(0, 10)
302
303 colors = ["r", "g", "b", "c", "m", "k", ]
304 for k, mean in enumerate(centers):
305 if k == 0:
306 axes.plot(axes.get_xlim(), (mean, mean,), "k%s" % ltype)
307
308 li = (clusterId == k)
309 axes.plot(mag[li], width[li], marker, markersize=markersize, markeredgewidth=markeredgewidth,
310 color=colors[k % len(colors)])
311
312 li = (clusterId == -1)
313 axes.plot(mag[li], width[li], marker, markersize=markersize, markeredgewidth=markeredgewidth,
314 color='k')
315
316 if clear:
317 axes.set_xlabel("Instrumental %s mag" % magType)
318 axes.set_ylabel(r"$\sqrt{(I_{xx} + I_{yy})/2}$")
319
320 return fig
321
322
323@pexConfig.registerConfigurable("objectSize", sourceSelectorRegistry)
325 r"""A star selector that looks for a cluster of small objects in a size-magnitude plot.
326 """
327 ConfigClass = ObjectSizeStarSelectorConfig
328 usesMatches = False # selectStars does not use its matches argument
329
330 def selectSources(self, sourceCat, matches=None, exposure=None):
331 """Return a selection of PSF candidates that represent likely stars.
332
333 A list of PSF candidates may be used by a PSF fitter to construct a PSF.
334
335 Parameters
336 ----------
337 sourceCat : `lsst.afw.table.SourceCatalog`
338 Catalog of sources to select from.
339 This catalog must be contiguous in memory.
340 matches : `list` of `lsst.afw.table.ReferenceMatch` or None
341 Ignored in this SourceSelector.
342 exposure : `lsst.afw.image.Exposure` or None
343 The exposure the catalog was built from; used to get the detector
344 to transform to TanPix, and for debug display.
345
346 Returns
347 -------
348 struct : `lsst.pipe.base.Struct`
349 The struct contains the following data:
350
351 ``selected``
352 Boolean array of sources that were selected, same length as
353 sourceCat. (`numpy.ndarray` of `bool`)
354 """
355 if len(sourceCat) == 0:
356 raise RuntimeError("Input catalog for source selection is empty.")
357
358 import lsstDebug
359 display = lsstDebug.Info(__name__).display
360 displayExposure = lsstDebug.Info(__name__).displayExposure # display the Exposure + spatialCells
361 plotMagSize = lsstDebug.Info(__name__).plotMagSize # display the magnitude-size relation
362 dumpData = lsstDebug.Info(__name__).dumpData # dump data to pickle file?
363
364 detector = None
365 pixToTanPix = None
366 if exposure:
367 detector = exposure.getDetector()
368 if detector:
369 pixToTanPix = detector.getTransform(PIXELS, TAN_PIXELS)
370 #
371 # Look at the distribution of stars in the magnitude-size plane
372 #
373 flux = sourceCat[self.config.sourceFluxField]
374 fluxErr = sourceCat[self.config.sourceFluxField + "Err"]
375
376 xx = numpy.empty(len(sourceCat))
377 xy = numpy.empty_like(xx)
378 yy = numpy.empty_like(xx)
379 for i, source in enumerate(sourceCat):
380 Ixx, Ixy, Iyy = source.getIxx(), source.getIxy(), source.getIyy()
381 if pixToTanPix:
382 p = lsst.geom.Point2D(source.getX(), source.getY())
383 linTransform = afwGeom.linearizeTransform(pixToTanPix, p).getLinear()
384 m = afwGeom.Quadrupole(Ixx, Iyy, Ixy)
385 m.transform(linTransform)
386 Ixx, Iyy, Ixy = m.getIxx(), m.getIyy(), m.getIxy()
387
388 xx[i], xy[i], yy[i] = Ixx, Ixy, Iyy
389
390 width = numpy.sqrt(0.5*(xx + yy))
391 with numpy.errstate(invalid="ignore"): # suppress NAN warnings
392 bad = reduce(lambda x, y: numpy.logical_or(x, sourceCat[y]), self.config.badFlags, False)
393 bad = numpy.logical_or(bad, numpy.logical_not(numpy.isfinite(width)))
394 bad = numpy.logical_or(bad, numpy.logical_not(numpy.isfinite(flux)))
395 if self.config.doFluxLimit:
396 bad = numpy.logical_or(bad, flux < self.config.fluxMin)
397 if self.config.fluxMax > 0:
398 bad = numpy.logical_or(bad, flux > self.config.fluxMax)
399 if self.config.doSignalToNoiseLimit:
400 bad = numpy.logical_or(bad, flux/fluxErr < self.config.signalToNoiseMin)
401 if self.config.signalToNoiseMax > 0:
402 bad = numpy.logical_or(bad, flux/fluxErr > self.config.signalToNoiseMax)
403 bad = numpy.logical_or(bad, width < self.config.widthMin)
404 bad = numpy.logical_or(bad, width > self.config.widthMax)
405 good = numpy.logical_not(bad)
406
407 if not numpy.any(good):
408 raise RuntimeError("No objects passed our cuts for consideration as psf stars")
409
410 mag = -2.5*numpy.log10(flux[good])
411 width = width[good]
412 #
413 # Look for the maximum in the size histogram, then search upwards for the minimum that separates
414 # the initial peak (of, we presume, stars) from the galaxies
415 #
416 if dumpData:
417 import os
418 import pickle as pickle
419 _ii = 0
420 while True:
421 pickleFile = os.path.expanduser(os.path.join("~", "widths-%d.pkl" % _ii))
422 if not os.path.exists(pickleFile):
423 break
424 _ii += 1
425
426 with open(pickleFile, "wb") as fd:
427 pickle.dump(mag, fd, -1)
428 pickle.dump(width, fd, -1)
429
430 centers, clusterId = _kcenters(width, nCluster=4, useMedian=True,
431 widthStdAllowed=self.config.widthStdAllowed)
432
433 if display and plotMagSize:
434 fig = plot(mag, width, centers, clusterId,
435 magType=self.config.sourceFluxField.split(".")[-1].title(),
436 marker="+", markersize=3, markeredgewidth=None, ltype=':', clear=True)
437 else:
438 fig = None
439
440 clusterId = _improveCluster(width, centers, clusterId,
441 nsigma=self.config.nSigmaClip,
442 widthStdAllowed=self.config.widthStdAllowed)
443
444 if display and plotMagSize:
445 plot(mag, width, centers, clusterId, marker="x", markersize=3, markeredgewidth=None, clear=False)
446
447 stellar = (clusterId == 0)
448 #
449 # We know enough to plot, if so requested
450 #
451 frame = 0
452
453 if fig:
454 if display and displayExposure:
455 disp = afwDisplay.Display(frame=frame)
456 disp.mtv(exposure.getMaskedImage(), title="PSF candidates")
457
458 global eventHandler
459 eventHandler = EventHandler(fig.get_axes()[0], mag, width,
460 sourceCat.getX()[good], sourceCat.getY()[good], frames=[frame])
461
462 fig.show()
463
464 while True:
465 try:
466 reply = input("continue? [c h(elp) q(uit) p(db)] ").strip()
467 except EOFError:
468 reply = None
469 if not reply:
470 reply = "c"
471
472 if reply:
473 if reply[0] == "h":
474 print("""\
475 We cluster the points; red are the stellar candidates and the other colours are other clusters.
476 Points labelled + are rejects from the cluster (only for cluster 0).
477
478 At this prompt, you can continue with almost any key; 'p' enters pdb, and 'h' prints this text
479
480 If displayExposure is true, you can put the cursor on a point and hit 'p' to see it in the
481 image display.
482 """)
483 elif reply[0] == "p":
484 import pdb
485 pdb.set_trace()
486 elif reply[0] == 'q':
487 sys.exit(1)
488 else:
489 break
490
491 if display and displayExposure:
492 mi = exposure.getMaskedImage()
493 with disp.Buffering():
494 for i, source in enumerate(sourceCat):
495 if good[i]:
496 ctype = afwDisplay.GREEN # star candidate
497 else:
498 ctype = afwDisplay.RED # not star
499
500 disp.dot("+", source.getX() - mi.getX0(), source.getY() - mi.getY0(), ctype=ctype)
501
502 # stellar only applies to good==True objects
503 mask = good == True # noqa (numpy bool comparison): E712
504 good[mask] = stellar
505
506 return Struct(selected=good)
int min
An ellipse core with quadrupole moments as parameters.
Definition Quadrupole.h:47
bool strip
Definition fits.cc:930
lsst::geom::AffineTransform linearizeTransform(TransformPoint2ToPoint2 const &original, lsst::geom::Point2D const &inPoint)
Approximate a Transform by its local linearization.
_improveCluster(yvec, centers, clusterId, nsigma=2.0, nIteration=10, clusterNum=0, widthStdAllowed=0.15)
_kcenters(yvec, nCluster, useMedian=False, widthStdAllowed=0.15)
plot(mag, width, centers, clusterId, marker="o", markersize=2, markeredgewidth=0, ltype='-', magType="model", clear=True)