22 __all__ = [
"ObjectSizeStarSelectorConfig",
"ObjectSizeStarSelectorTask"]
28 from functools
import reduce
30 from lsst.utils.logging
import getLogger
31 from lsst.pipe.base
import Struct
37 from .sourceSelector
import BaseSourceSelectorTask, sourceSelectorRegistry
39 afwDisplay.setDefaultMaskTransparency(75)
41 _LOG = getLogger(__name__)
45 doFluxLimit = pexConfig.Field(
46 doc=
"Apply flux limit to Psf Candidate selection?",
50 fluxMin = pexConfig.Field(
51 doc=
"specify the minimum psfFlux for good Psf Candidates",
54 check=
lambda x: x >= 0.0,
56 fluxMax = pexConfig.Field(
57 doc=
"specify the maximum psfFlux for good Psf Candidates (ignored if == 0)",
60 check=
lambda x: x >= 0.0,
62 doSignalToNoiseLimit = pexConfig.Field(
63 doc=
"Apply signal-to-noise (i.e. flux/fluxErr) limit to Psf Candidate selection?",
67 signalToNoiseMin = pexConfig.Field(
68 doc=
"specify the minimum signal-to-noise for good Psf Candidates",
71 check=
lambda x: x >= 0.0,
73 signalToNoiseMax = pexConfig.Field(
74 doc=
"specify the maximum signal-to-noise for good Psf Candidates (ignored if == 0)",
77 check=
lambda x: x >= 0.0,
79 widthMin = pexConfig.Field(
80 doc=
"minimum width to include in histogram",
83 check=
lambda x: x >= 0.0,
85 widthMax = pexConfig.Field(
86 doc=
"maximum width to include in histogram",
89 check=
lambda x: x >= 0.0,
91 sourceFluxField = pexConfig.Field(
92 doc=
"Name of field in Source to use for flux measurement",
94 default=
"base_GaussianFlux_instFlux",
96 widthStdAllowed = pexConfig.Field(
97 doc=
"Standard deviation of width allowed to be interpreted as good stars",
100 check=
lambda x: x >= 0.0,
102 nSigmaClip = pexConfig.Field(
103 doc=
"Keep objects within this many sigma of cluster 0's median",
106 check=
lambda x: x >= 0.0,
108 badFlags = pexConfig.ListField(
109 doc=
"List of flags which cause a source to be rejected as bad",
112 "base_PixelFlags_flag_edge",
113 "base_PixelFlags_flag_interpolatedCenter",
114 "base_PixelFlags_flag_saturatedCenter",
115 "base_PixelFlags_flag_crCenter",
116 "base_PixelFlags_flag_bad",
117 "base_PixelFlags_flag_interpolated",
122 BaseSourceSelectorTask.ConfigClass.validate(self)
124 msg = f
"widthMin ({self.widthMin}) > widthMax ({self.widthMax})"
125 raise pexConfig.FieldValidationError(ObjectSizeStarSelectorConfig.widthMin, self, msg)
129 """A class to handle key strokes with matplotlib displays.
132 def __init__(self, axes, xs, ys, x, y, frames=[0]):
140 self.
cidcid = self.
axesaxes.figure.canvas.mpl_connect(
'key_press_event', self)
143 if ev.inaxes != self.
axesaxes:
146 if ev.key
and ev.key
in (
"p"):
147 dist = numpy.hypot(self.
xsxs - ev.xdata, self.
ysys - ev.ydata)
148 dist[numpy.where(numpy.isnan(dist))] = 1e30
150 which = numpy.where(dist == min(dist))
152 x = self.
xx[which][0]
153 y = self.
yy[which][0]
154 for frame
in self.
framesframes:
155 disp = afwDisplay.Display(frame=frame)
162 def _assignClusters(yvec, centers):
163 """Return a vector of centerIds based on their distance to the centers.
165 assert len(centers) > 0
167 minDist = numpy.nan*numpy.ones_like(yvec)
168 clusterId = numpy.empty_like(yvec)
169 clusterId.dtype = int
170 dbl = _LOG.getChild(
"_assignClusters")
171 dbl.setLevel(dbl.INFO)
174 oldSettings = numpy.seterr(all=
"warn")
175 with warnings.catch_warnings(record=
True)
as w:
176 warnings.simplefilter(
"always")
177 for i, mean
in enumerate(centers):
178 dist = abs(yvec - mean)
180 update = dist == dist
182 update = dist < minDist
184 dbl.trace(str(w[-1]))
186 minDist[update] = dist[update]
187 clusterId[update] = i
188 numpy.seterr(**oldSettings)
193 def _kcenters(yvec, nCluster, useMedian=False, widthStdAllowed=0.15):
194 """A classic k-means algorithm, clustering yvec into nCluster clusters
196 Return the set of centres, and the cluster ID for each of the points
198 If useMedian is true, use the median of the cluster as its centre, rather than
201 Serge Monkewitz points out that there other (maybe smarter) ways of seeding the means:
202 "e.g. why not use the Forgy or random partition initialization methods"
203 however, the approach adopted here seems to work well for the particular sorts of things
204 we're clustering in this application
209 mean0 = sorted(yvec)[len(yvec)//10]
210 delta = mean0 * widthStdAllowed * 2.0
211 centers = mean0 + delta * numpy.arange(nCluster)
213 func = numpy.median
if useMedian
else numpy.mean
215 clusterId = numpy.zeros_like(yvec) - 1
216 clusterId.dtype = int
218 oclusterId = clusterId
219 clusterId = _assignClusters(yvec, centers)
221 if numpy.all(clusterId == oclusterId):
224 for i
in range(nCluster):
226 pointsInCluster = (clusterId == i)
227 if numpy.any(pointsInCluster):
228 centers[i] = func(yvec[pointsInCluster])
230 centers[i] = numpy.nan
232 return centers, clusterId
235 def _improveCluster(yvec, centers, clusterId, nsigma=2.0, nIteration=10, clusterNum=0, widthStdAllowed=0.15):
236 """Improve our estimate of one of the clusters (clusterNum) by sigma-clipping around its median.
239 nMember = sum(clusterId == clusterNum)
242 for iter
in range(nIteration):
243 old_nMember = nMember
245 inCluster0 = clusterId == clusterNum
246 yv = yvec[inCluster0]
248 centers[clusterNum] = numpy.median(yv)
249 stdev = numpy.std(yv)
252 stdev_iqr = 0.741*(syv[int(0.75*nMember)] - syv[int(0.25*nMember)])
253 median = syv[int(0.5*nMember)]
255 sd = stdev
if stdev < stdev_iqr
else stdev_iqr
258 print(
"sigma(iqr) = %.3f, sigma = %.3f" % (stdev_iqr, numpy.std(yv)))
259 newCluster0 = abs(yvec - centers[clusterNum]) < nsigma*sd
260 clusterId[numpy.logical_and(inCluster0, newCluster0)] = clusterNum
261 clusterId[numpy.logical_and(inCluster0, numpy.logical_not(newCluster0))] = -1
263 nMember = sum(clusterId == clusterNum)
265 if nMember == old_nMember
or sd < widthStdAllowed * median:
271 def plot(mag, width, centers, clusterId, marker="o", markersize=2, markeredgewidth=0, ltype='-',
272 magType="model", clear=True):
274 log = _LOG.getChild(
"plot")
276 import matplotlib.pyplot
as plt
277 except ImportError
as e:
278 log.warning(
"Unable to import matplotlib: %s", e)
289 axes = fig.add_axes((0.1, 0.1, 0.85, 0.80))
291 xmin = sorted(mag)[int(0.05*len(mag))]
292 xmax = sorted(mag)[int(0.95*len(mag))]
294 axes.set_xlim(-17.5, -13)
295 axes.set_xlim(xmin - 0.1*(xmax - xmin), xmax + 0.1*(xmax - xmin))
298 colors = [
"r",
"g",
"b",
"c",
"m",
"k", ]
299 for k, mean
in enumerate(centers):
301 axes.plot(axes.get_xlim(), (mean, mean,),
"k%s" % ltype)
303 li = (clusterId == k)
304 axes.plot(mag[li], width[li], marker, markersize=markersize, markeredgewidth=markeredgewidth,
305 color=colors[k % len(colors)])
307 li = (clusterId == -1)
308 axes.plot(mag[li], width[li], marker, markersize=markersize, markeredgewidth=markeredgewidth,
312 axes.set_xlabel(
"Instrumental %s mag" % magType)
313 axes.set_ylabel(
r"$\sqrt{(I_{xx} + I_{yy})/2}$")
318 @pexConfig.registerConfigurable("objectSize", sourceSelectorRegistry)
320 r"""A star selector that looks for a cluster of small objects in a size-magnitude plot.
322 ConfigClass = ObjectSizeStarSelectorConfig
326 """Return a selection of PSF candidates that represent likely stars.
328 A list of PSF candidates may be used by a PSF fitter to construct a PSF.
332 sourceCat : `lsst.afw.table.SourceCatalog`
333 Catalog of sources to select from.
334 This catalog must be contiguous in memory.
335 matches : `list` of `lsst.afw.table.ReferenceMatch` or None
336 Ignored in this SourceSelector.
337 exposure : `lsst.afw.image.Exposure` or None
338 The exposure the catalog was built from; used to get the detector
339 to transform to TanPix, and for debug display.
343 struct : `lsst.pipe.base.Struct`
344 The struct contains the following data:
346 - selected : `array` of `bool``
347 Boolean array of sources that were selected, same length as
359 detector = exposure.getDetector()
361 pixToTanPix = detector.getTransform(PIXELS, TAN_PIXELS)
365 flux = sourceCat.get(self.config.sourceFluxField)
366 fluxErr = sourceCat.get(self.config.sourceFluxField +
"Err")
368 xx = numpy.empty(len(sourceCat))
369 xy = numpy.empty_like(xx)
370 yy = numpy.empty_like(xx)
371 for i, source
in enumerate(sourceCat):
372 Ixx, Ixy, Iyy = source.getIxx(), source.getIxy(), source.getIyy()
375 linTransform = afwGeom.linearizeTransform(pixToTanPix, p).getLinear()
376 m = afwGeom.Quadrupole(Ixx, Iyy, Ixy)
377 m.transform(linTransform)
378 Ixx, Iyy, Ixy = m.getIxx(), m.getIyy(), m.getIxy()
380 xx[i], xy[i], yy[i] = Ixx, Ixy, Iyy
382 width = numpy.sqrt(0.5*(xx + yy))
383 with numpy.errstate(invalid=
"ignore"):
384 bad = reduce(
lambda x, y: numpy.logical_or(x, sourceCat.get(y)), self.config.badFlags,
False)
385 bad = numpy.logical_or(bad, numpy.logical_not(numpy.isfinite(width)))
386 bad = numpy.logical_or(bad, numpy.logical_not(numpy.isfinite(flux)))
387 if self.config.doFluxLimit:
388 bad = numpy.logical_or(bad, flux < self.config.fluxMin)
389 if self.config.fluxMax > 0:
390 bad = numpy.logical_or(bad, flux > self.config.fluxMax)
391 if self.config.doSignalToNoiseLimit:
392 bad = numpy.logical_or(bad, flux/fluxErr < self.config.signalToNoiseMin)
393 if self.config.signalToNoiseMax > 0:
394 bad = numpy.logical_or(bad, flux/fluxErr > self.config.signalToNoiseMax)
395 bad = numpy.logical_or(bad, width < self.config.widthMin)
396 bad = numpy.logical_or(bad, width > self.config.widthMax)
397 good = numpy.logical_not(bad)
399 if not numpy.any(good):
400 raise RuntimeError(
"No objects passed our cuts for consideration as psf stars")
402 mag = -2.5*numpy.log10(flux[good])
410 import pickle
as pickle
413 pickleFile = os.path.expanduser(os.path.join(
"~",
"widths-%d.pkl" % _ii))
414 if not os.path.exists(pickleFile):
418 with open(pickleFile,
"wb")
as fd:
419 pickle.dump(mag, fd, -1)
420 pickle.dump(width, fd, -1)
422 centers, clusterId = _kcenters(width, nCluster=4, useMedian=
True,
423 widthStdAllowed=self.config.widthStdAllowed)
425 if display
and plotMagSize:
426 fig =
plot(mag, width, centers, clusterId,
427 magType=self.config.sourceFluxField.split(
".")[-1].title(),
428 marker=
"+", markersize=3, markeredgewidth=
None, ltype=
':', clear=
True)
432 clusterId = _improveCluster(width, centers, clusterId,
433 nsigma=self.config.nSigmaClip,
434 widthStdAllowed=self.config.widthStdAllowed)
436 if display
and plotMagSize:
437 plot(mag, width, centers, clusterId, marker=
"x", markersize=3, markeredgewidth=
None, clear=
False)
439 stellar = (clusterId == 0)
446 if display
and displayExposure:
447 disp = afwDisplay.Display(frame=frame)
448 disp.mtv(exposure.getMaskedImage(), title=
"PSF candidates")
451 eventHandler =
EventHandler(fig.get_axes()[0], mag, width,
452 sourceCat.getX()[good], sourceCat.getY()[good], frames=[frame])
458 reply = input(
"continue? [c h(elp) q(uit) p(db)] ").strip()
467 We cluster the points; red are the stellar candidates and the other colours are other clusters.
468 Points labelled + are rejects from the cluster (only for cluster 0).
470 At this prompt, you can continue with almost any key; 'p' enters pdb, and 'h' prints this text
472 If displayExposure is true, you can put the cursor on a point and hit 'p' to see it in the
475 elif reply[0] ==
"p":
478 elif reply[0] ==
'q':
483 if display
and displayExposure:
484 mi = exposure.getMaskedImage()
485 with disp.Buffering():
486 for i, source
in enumerate(sourceCat):
488 ctype = afwDisplay.GREEN
490 ctype = afwDisplay.RED
492 disp.dot(
"+", source.getX() - mi.getX0(), source.getY() - mi.getY0(), ctype=ctype)
498 return Struct(selected=good)
def __init__(self, axes, xs, ys, x, y, frames=[0])
def selectSources(self, sourceCat, matches=None, exposure=None)
def plot(mag, width, centers, clusterId, marker="o", markersize=2, markeredgewidth=0, ltype='-', magType="model", clear=True)