26 from functools
import reduce
33 import lsst.pex.config
as pexConfig
35 from .sourceSelector
import BaseSourceSelectorTask, sourceSelectorRegistry
37 afwDisplay.setDefaultMaskTransparency(75)
41 doFluxLimit = pexConfig.Field(
42 doc=
"Apply flux limit to Psf Candidate selection?",
46 fluxMin = pexConfig.Field(
47 doc=
"specify the minimum psfFlux for good Psf Candidates",
50 check=
lambda x: x >= 0.0,
52 fluxMax = pexConfig.Field(
53 doc=
"specify the maximum psfFlux for good Psf Candidates (ignored if == 0)",
56 check=
lambda x: x >= 0.0,
58 doSignalToNoiseLimit = pexConfig.Field(
59 doc=
"Apply signal-to-noise (i.e. flux/fluxErr) limit to Psf Candidate selection?",
63 signalToNoiseMin = pexConfig.Field(
64 doc=
"specify the minimum signal-to-noise for good Psf Candidates",
67 check=
lambda x: x >= 0.0,
69 signalToNoiseMax = pexConfig.Field(
70 doc=
"specify the maximum signal-to-noise for good Psf Candidates (ignored if == 0)",
73 check=
lambda x: x >= 0.0,
75 widthMin = pexConfig.Field(
76 doc=
"minimum width to include in histogram",
79 check=
lambda x: x >= 0.0,
81 widthMax = pexConfig.Field(
82 doc=
"maximum width to include in histogram",
85 check=
lambda x: x >= 0.0,
87 sourceFluxField = pexConfig.Field(
88 doc=
"Name of field in Source to use for flux measurement",
90 default=
"base_GaussianFlux_instFlux",
92 widthStdAllowed = pexConfig.Field(
93 doc=
"Standard deviation of width allowed to be interpreted as good stars",
96 check=
lambda x: x >= 0.0,
98 nSigmaClip = pexConfig.Field(
99 doc=
"Keep objects within this many sigma of cluster 0's median",
102 check=
lambda x: x >= 0.0,
104 badFlags = pexConfig.ListField(
105 doc=
"List of flags which cause a source to be rejected as bad",
108 "base_PixelFlags_flag_edge",
109 "base_PixelFlags_flag_interpolatedCenter",
110 "base_PixelFlags_flag_saturatedCenter",
111 "base_PixelFlags_flag_crCenter",
112 "base_PixelFlags_flag_bad",
113 "base_PixelFlags_flag_interpolated",
118 BaseSourceSelectorTask.ConfigClass.validate(self)
120 msg = f
"widthMin ({self.widthMin}) > widthMax ({self.widthMax})"
121 raise pexConfig.FieldValidationError(ObjectSizeStarSelectorConfig.widthMin, self, msg)
125 """A class to handle key strokes with matplotlib displays"""
127 def __init__(self, axes, xs, ys, x, y, frames=[0]):
135 self.
cid = self.
axes.figure.canvas.mpl_connect(
'key_press_event', self)
138 if ev.inaxes != self.
axes:
141 if ev.key
and ev.key
in (
"p"):
142 dist = numpy.hypot(self.
xs - ev.xdata, self.
ys - ev.ydata)
143 dist[numpy.where(numpy.isnan(dist))] = 1e30
145 which = numpy.where(dist == min(dist))
150 disp = afwDisplay.Display(frame=frame)
157 def _assignClusters(yvec, centers):
158 """Return a vector of centerIds based on their distance to the centers"""
159 assert len(centers) > 0
161 minDist = numpy.nan*numpy.ones_like(yvec)
162 clusterId = numpy.empty_like(yvec)
163 clusterId.dtype = int
164 dbl = Log.getLogger(
"objectSizeStarSelector._assignClusters")
165 dbl.setLevel(dbl.INFO)
168 oldSettings = numpy.seterr(all=
"warn")
169 with warnings.catch_warnings(record=
True)
as w:
170 warnings.simplefilter(
"always")
171 for i, mean
in enumerate(centers):
172 dist = abs(yvec - mean)
174 update = dist == dist
176 update = dist < minDist
178 dbl.trace(str(w[-1]))
180 minDist[update] = dist[update]
181 clusterId[update] = i
182 numpy.seterr(**oldSettings)
187 def _kcenters(yvec, nCluster, useMedian=False, widthStdAllowed=0.15):
188 """A classic k-means algorithm, clustering yvec into nCluster clusters
190 Return the set of centres, and the cluster ID for each of the points
192 If useMedian is true, use the median of the cluster as its centre, rather than
195 Serge Monkewitz points out that there other (maybe smarter) ways of seeding the means:
196 "e.g. why not use the Forgy or random partition initialization methods"
197 however, the approach adopted here seems to work well for the particular sorts of things
198 we're clustering in this application
203 mean0 = sorted(yvec)[len(yvec)//10]
204 delta = mean0 * widthStdAllowed * 2.0
205 centers = mean0 + delta * numpy.arange(nCluster)
207 func = numpy.median
if useMedian
else numpy.mean
209 clusterId = numpy.zeros_like(yvec) - 1
210 clusterId.dtype = int
212 oclusterId = clusterId
213 clusterId = _assignClusters(yvec, centers)
215 if numpy.all(clusterId == oclusterId):
218 for i
in range(nCluster):
220 pointsInCluster = (clusterId == i)
221 if numpy.any(pointsInCluster):
222 centers[i] = func(yvec[pointsInCluster])
224 centers[i] = numpy.nan
226 return centers, clusterId
229 def _improveCluster(yvec, centers, clusterId, nsigma=2.0, nIteration=10, clusterNum=0, widthStdAllowed=0.15):
230 """Improve our estimate of one of the clusters (clusterNum) by sigma-clipping around its median"""
232 nMember = sum(clusterId == clusterNum)
235 for iter
in range(nIteration):
236 old_nMember = nMember
238 inCluster0 = clusterId == clusterNum
239 yv = yvec[inCluster0]
241 centers[clusterNum] = numpy.median(yv)
242 stdev = numpy.std(yv)
245 stdev_iqr = 0.741*(syv[int(0.75*nMember)] - syv[int(0.25*nMember)])
246 median = syv[int(0.5*nMember)]
248 sd = stdev
if stdev < stdev_iqr
else stdev_iqr
251 print(
"sigma(iqr) = %.3f, sigma = %.3f" % (stdev_iqr, numpy.std(yv)))
252 newCluster0 = abs(yvec - centers[clusterNum]) < nsigma*sd
253 clusterId[numpy.logical_and(inCluster0, newCluster0)] = clusterNum
254 clusterId[numpy.logical_and(inCluster0, numpy.logical_not(newCluster0))] = -1
256 nMember = sum(clusterId == clusterNum)
258 if nMember == old_nMember
or sd < widthStdAllowed * median:
264 def plot(mag, width, centers, clusterId, marker="o", markersize=2, markeredgewidth=0, ltype='-',
265 magType="model", clear=True):
267 log = Log.getLogger(
"objectSizeStarSelector.plot")
269 import matplotlib.pyplot
as plt
270 except ImportError
as e:
271 log.warn(
"Unable to import matplotlib: %s", e)
282 axes = fig.add_axes((0.1, 0.1, 0.85, 0.80))
284 xmin = sorted(mag)[int(0.05*len(mag))]
285 xmax = sorted(mag)[int(0.95*len(mag))]
287 axes.set_xlim(-17.5, -13)
288 axes.set_xlim(xmin - 0.1*(xmax - xmin), xmax + 0.1*(xmax - xmin))
291 colors = [
"r",
"g",
"b",
"c",
"m",
"k", ]
292 for k, mean
in enumerate(centers):
294 axes.plot(axes.get_xlim(), (mean, mean,),
"k%s" % ltype)
296 li = (clusterId == k)
297 axes.plot(mag[li], width[li], marker, markersize=markersize, markeredgewidth=markeredgewidth,
298 color=colors[k % len(colors)])
300 li = (clusterId == -1)
301 axes.plot(mag[li], width[li], marker, markersize=markersize, markeredgewidth=markeredgewidth,
305 axes.set_xlabel(
"Instrumental %s mag" % magType)
306 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 @anchor ObjectSizeStarSelectorTask_
324 @section meas_algorithms_objectSizeStarSelector_Contents Contents
326 - @ref meas_algorithms_objectSizeStarSelector_Purpose
327 - @ref meas_algorithms_objectSizeStarSelector_Initialize
328 - @ref meas_algorithms_objectSizeStarSelector_IO
329 - @ref meas_algorithms_objectSizeStarSelector_Config
330 - @ref meas_algorithms_objectSizeStarSelector_Debug
332 @section meas_algorithms_objectSizeStarSelector_Purpose Description
334 A star selector that looks for a cluster of small objects in a size-magnitude plot.
336 @section meas_algorithms_objectSizeStarSelector_Initialize Task initialisation
338 @copydoc \_\_init\_\_
340 @section meas_algorithms_objectSizeStarSelector_IO Invoking the Task
342 Like all star selectors, the main method is `run`.
344 @section meas_algorithms_objectSizeStarSelector_Config Configuration parameters
346 See @ref ObjectSizeStarSelectorConfig
348 @section meas_algorithms_objectSizeStarSelector_Debug Debug variables
350 ObjectSizeStarSelectorTask has a debug dictionary with the following keys:
353 <dd>bool; if True display debug information
355 <dd>bool; if True display the exposure and spatial cells
357 <dd>bool: if True display the magnitude-size relation using matplotlib
359 <dd>bool; if True dump data to a pickle file
362 For example, put something like:
366 di = lsstDebug.getInfo(name) # N.b. lsstDebug.Info(name) would call us recursively
367 if name.endswith("objectSizeStarSelector"):
369 di.displayExposure = True
370 di.plotMagSize = True
374 lsstDebug.Info = DebugInfo
376 into your `debug.py` file and run your task with the `--debug` flag.
378 ConfigClass = ObjectSizeStarSelectorConfig
382 """Return a selection of PSF candidates that represent likely stars.
384 A list of PSF candidates may be used by a PSF fitter to construct a PSF.
388 sourceCat : `lsst.afw.table.SourceCatalog`
389 Catalog of sources to select from.
390 This catalog must be contiguous in memory.
391 matches : `list` of `lsst.afw.table.ReferenceMatch` or None
392 Ignored in this SourceSelector.
393 exposure : `lsst.afw.image.Exposure` or None
394 The exposure the catalog was built from; used to get the detector
395 to transform to TanPix, and for debug display.
399 struct : `lsst.pipe.base.Struct`
400 The struct contains the following data:
402 - selected : `array` of `bool``
403 Boolean array of sources that were selected, same length as
415 detector = exposure.getDetector()
417 pixToTanPix = detector.getTransform(PIXELS, TAN_PIXELS)
421 flux = sourceCat.get(self.config.sourceFluxField)
422 fluxErr = sourceCat.get(self.config.sourceFluxField +
"Err")
424 xx = numpy.empty(len(sourceCat))
425 xy = numpy.empty_like(xx)
426 yy = numpy.empty_like(xx)
427 for i, source
in enumerate(sourceCat):
428 Ixx, Ixy, Iyy = source.getIxx(), source.getIxy(), source.getIyy()
431 linTransform = afwGeom.linearizeTransform(pixToTanPix, p).getLinear()
432 m = afwGeom.Quadrupole(Ixx, Iyy, Ixy)
433 m.transform(linTransform)
434 Ixx, Iyy, Ixy = m.getIxx(), m.getIyy(), m.getIxy()
436 xx[i], xy[i], yy[i] = Ixx, Ixy, Iyy
438 width = numpy.sqrt(0.5*(xx + yy))
439 with numpy.errstate(invalid=
"ignore"):
440 bad = reduce(
lambda x, y: numpy.logical_or(x, sourceCat.get(y)), self.config.badFlags,
False)
441 bad = numpy.logical_or(bad, numpy.logical_not(numpy.isfinite(width)))
442 bad = numpy.logical_or(bad, numpy.logical_not(numpy.isfinite(flux)))
443 if self.config.doFluxLimit:
444 bad = numpy.logical_or(bad, flux < self.config.fluxMin)
445 if self.config.fluxMax > 0:
446 bad = numpy.logical_or(bad, flux > self.config.fluxMax)
447 if self.config.doSignalToNoiseLimit:
448 bad = numpy.logical_or(bad, flux/fluxErr < self.config.signalToNoiseMin)
449 if self.config.signalToNoiseMax > 0:
450 bad = numpy.logical_or(bad, flux/fluxErr > self.config.signalToNoiseMax)
451 bad = numpy.logical_or(bad, width < self.config.widthMin)
452 bad = numpy.logical_or(bad, width > self.config.widthMax)
453 good = numpy.logical_not(bad)
455 if not numpy.any(good):
456 raise RuntimeError(
"No objects passed our cuts for consideration as psf stars")
458 mag = -2.5*numpy.log10(flux[good])
466 import pickle
as pickle
469 pickleFile = os.path.expanduser(os.path.join(
"~",
"widths-%d.pkl" % _ii))
470 if not os.path.exists(pickleFile):
474 with open(pickleFile,
"wb")
as fd:
475 pickle.dump(mag, fd, -1)
476 pickle.dump(width, fd, -1)
478 centers, clusterId = _kcenters(width, nCluster=4, useMedian=
True,
479 widthStdAllowed=self.config.widthStdAllowed)
481 if display
and plotMagSize:
482 fig =
plot(mag, width, centers, clusterId,
483 magType=self.config.sourceFluxField.split(
".")[-1].title(),
484 marker=
"+", markersize=3, markeredgewidth=
None, ltype=
':', clear=
True)
488 clusterId = _improveCluster(width, centers, clusterId,
489 nsigma=self.config.nSigmaClip,
490 widthStdAllowed=self.config.widthStdAllowed)
492 if display
and plotMagSize:
493 plot(mag, width, centers, clusterId, marker=
"x", markersize=3, markeredgewidth=
None, clear=
False)
495 stellar = (clusterId == 0)
502 if display
and displayExposure:
503 disp = afwDisplay.Display(frame=frame)
504 disp.mtv(exposure.getMaskedImage(), title=
"PSF candidates")
507 eventHandler =
EventHandler(fig.get_axes()[0], mag, width,
508 sourceCat.getX()[good], sourceCat.getY()[good], frames=[frame])
514 reply = input(
"continue? [c h(elp) q(uit) p(db)] ").strip()
523 We cluster the points; red are the stellar candidates and the other colours are other clusters.
524 Points labelled + are rejects from the cluster (only for cluster 0).
526 At this prompt, you can continue with almost any key; 'p' enters pdb, and 'h' prints this text
528 If displayExposure is true, you can put the cursor on a point and hit 'p' to see it in the
531 elif reply[0] ==
"p":
534 elif reply[0] ==
'q':
539 if display
and displayExposure:
540 mi = exposure.getMaskedImage()
541 with disp.Buffering():
542 for i, source
in enumerate(sourceCat):
544 ctype = afwDisplay.GREEN
546 ctype = afwDisplay.RED
548 disp.dot(
"+", source.getX() - mi.getX0(), source.getY() - mi.getY0(), ctype=ctype)
554 return Struct(selected=good)