23__all__ = [
"MaskStreaksConfig",
"MaskStreaksTask",
"setDetectionMask"]
28from lsst.utils.timer
import timeMethod
34from skimage.feature
import canny
35from sklearn.cluster
import KMeans
37from dataclasses
import dataclass
40def setDetectionMask(maskedImage, forceSlowBin=False, binning=None, detectedPlane="DETECTED",
41 badMaskPlanes=(
"NO_DATA",
"INTRP",
"BAD",
"SAT",
"EDGE"), detectionThreshold=5):
42 """Make detection mask and set the mask plane
44 Creat a binary image from a masked image by setting all data
with signal-to-
45 noise below some threshold to zero,
and all data above the threshold to one.
46 If the binning parameter has been set, this procedure will be preceded by a
47 weighted binning of the data
in order to smooth the result, after which the
48 result
is scaled back to the original dimensions. Set the detection mask
49 plane
with this binary image.
54 Image to be (optionally) binned
and converted
55 forceSlowBin : bool (optional)
56 Force usage of slower binning method to check that the two methods
58 binning : int (optional)
59 Number of pixels by which to bin image
60 detectedPlane : str (optional)
61 Name of mask
with pixels that were detected above threshold
in image
62 badMaskPlanes : set (optional)
63 Names of masks
with pixels that are rejected
64 detectionThreshold : float (optional)
65 Boundary
in signal-to-noise between non-detections
and detections
for
66 making a binary image
from the original input image
68 data = maskedImage.image.array
69 weights = 1 / maskedImage.variance.array
70 mask = maskedImage.getMask()
72 detectionMask = ((mask.array & mask.getPlaneBitMask(detectedPlane)))
73 badPixelMask = mask.getPlaneBitMask(badMaskPlanes)
74 badMask = (mask.array & badPixelMask) > 0
75 fitMask = detectionMask.astype(bool) & ~badMask
77 fitData = np.copy(data)
79 fitWeights = np.copy(weights)
80 fitWeights[~fitMask] = 0
84 ymax, xmax = fitData.shape
85 if (ymax % binning == 0)
and (xmax % binning == 0)
and (
not forceSlowBin):
87 binNumeratorReshape = (fitData * fitWeights).reshape(ymax // binning, binning,
88 xmax // binning, binning)
89 binDenominatorReshape = fitWeights.reshape(binNumeratorReshape.shape)
90 binnedNumerator = binNumeratorReshape.sum(axis=3).sum(axis=1)
91 binnedDenominator = binDenominatorReshape.sum(axis=3).sum(axis=1)
94 warnings.warn(
'Using slow binning method--consider choosing a binsize that evenly divides '
95 f
'into the image size, so that {ymax} mod binning == 0 '
96 f
'and {xmax} mod binning == 0')
97 xarray = np.arange(xmax)
98 yarray = np.arange(ymax)
99 xmesh, ymesh = np.meshgrid(xarray, yarray)
100 xbins = np.arange(0, xmax + binning, binning)
101 ybins = np.arange(0, ymax + binning, binning)
102 numerator = fitWeights * fitData
103 binnedNumerator, *_ = scipy.stats.binned_statistic_2d(ymesh.ravel(), xmesh.ravel(),
104 numerator.ravel(), statistic=
'sum',
106 binnedDenominator, *_ = scipy.stats.binned_statistic_2d(ymesh.ravel(), xmesh.ravel(),
107 fitWeights.ravel(), statistic=
'sum',
109 binnedData = np.zeros(binnedNumerator.shape)
110 ind = binnedDenominator != 0
111 np.divide(binnedNumerator, binnedDenominator, out=binnedData, where=ind)
112 binnedWeight = binnedDenominator
113 binMask = (binnedData * binnedWeight**0.5) > detectionThreshold
114 tmpOutputMask = binMask.repeat(binning, axis=0)[:ymax]
115 outputMask = tmpOutputMask.repeat(binning, axis=1)[:, :xmax]
117 outputMask = (fitData * fitWeights**0.5) > detectionThreshold
120 maskedImage.mask.array &= ~maskedImage.mask.getPlaneBitMask(detectedPlane)
123 maskedImage.mask.array[outputMask] |= maskedImage.mask.getPlaneBitMask(detectedPlane)
128 """A simple data class to describe a line profile. The parameter `rho`
129 describes the distance from the center of the image, `theta` describes
130 the angle,
and `sigma` describes the width of the line.
138 """Collection of `Line` objects.
143 Array of `Line` rho parameters
145 Array of `Line` theta parameters
146 sigmas : np.ndarray (optional)
147 Array of `Line` sigma parameters
152 sigmas = np.zeros(len(rhos))
154 self.
_lines = [
Line(rho, theta, sigma)
for (rho, theta, sigma)
in
155 zip(rhos, thetas, sigmas)]
167 joinedString =
", ".join(
str(line)
for line
in self.
_lines)
168 return textwrap.shorten(joinedString, width=160, placeholder=
"...")
172 return np.array([line.rho
for line
in self.
_lines])
176 return np.array([line.theta
for line
in self.
_lines])
179 """Add line to current collection of lines.
184 `Line` to add to current collection of lines
190 """Construct and/or fit a model for a linear streak.
192 This assumes a simple model for a streak,
in which the streak
193 follows a straight line
in pixels space,
with a Moffat-shaped profile. The
194 model
is fit to data using a Newton-Raphson style minimization algorithm.
195 The initial guess
for the line parameters
is assumed to be fairly accurate,
196 so only a narrow band of pixels around the initial line estimate
is used
in
197 fitting the model, which provides a significant speed-up over using all the
198 data. The
class can also be used just to construct a model for the
data with
199 a line following the given coordinates.
207 line : `Line` (optional)
208 Guess
for position of line. Data far
from line guess
is masked out.
209 Defaults to
None,
in which case only data
with `weights` = 0
is masked
216 self._ymax, self.
_xmax = data.shape
218 xrange = np.arange(self.
_xmax) - self.
_xmax / 2.
219 yrange = np.arange(self._ymax) - self._ymax / 2.
220 self.
_rhoMax = ((0.5 * self._ymax)**2 + (0.5 * self.
_xmax)**2)**0.5
221 self._xmesh, self.
_ymesh = np.meshgrid(xrange, yrange)
228 """Set mask around the image region near the line
233 Parameters of line in the image
237 radtheta = np.deg2rad(line.theta)
238 distance = (np.cos(radtheta) * self._xmesh + np.sin(radtheta) * self.
_ymesh - line.rho)
239 m = (abs(distance) < 5 * line.sigma)
250 def _makeMaskedProfile(self, line, fitFlux=True):
251 """Construct the line model in the masked region and calculate its
257 Parameters of line profile for which to make profile
in the masked
260 Fit the amplitude of the line profile to the data
265 Model
in the masked region
267 Derivative of the model
in the masked region
269 invSigma = line.sigma**-1
271 radtheta = np.deg2rad(line.theta)
272 costheta = np.cos(radtheta)
273 sintheta = np.sin(radtheta)
274 distance = (costheta * self.
_mxmesh + sintheta * self.
_mymesh - line.rho)
275 distanceSquared = distance**2
279 dDistanceSqdRho = 2 * distance * (-np.ones_like(self.
_mxmesh))
280 dDistanceSqdTheta = (2 * distance * (-sintheta * self.
_mxmesh + costheta * self.
_mymesh) * drad)
283 profile = (1 + distanceSquared * invSigma**2)**-2.5
284 dProfile = -2.5 * (1 + distanceSquared * invSigma**2)**-3.5
296 model = flux * profile
299 fluxdProfile = flux * dProfile
300 fluxdProfileInvSigma = fluxdProfile * invSigma**2
301 dModeldRho = fluxdProfileInvSigma * dDistanceSqdRho
302 dModeldTheta = fluxdProfileInvSigma * dDistanceSqdTheta
303 dModeldInvSigma = fluxdProfile * distanceSquared * 2 * invSigma
305 dModel = np.array([dModeldRho, dModeldTheta, dModeldInvSigma])
309 """Construct the line profile model
314 Parameters of the line profile to model
315 fitFlux : bool (optional)
316 Fit the amplitude of the line profile to the data
320 finalModel : np.ndarray
321 Model for line profile
324 finalModel = np.zeros((self._ymax, self._xmax), dtype=self._dtype)
328 def _lineChi2(self, line, grad=True):
329 """Construct the chi2 between the data and the model
334 `Line` parameters for which to build model
and calculate chi2
335 grad : bool (optional)
336 Whether
or not to
return the gradient
and hessian
341 Reduced chi2 of the model
342 reducedDChi : np.ndarray
343 Derivative of the chi2
with respect to rho, theta, invSigma
344 reducedHessianChi : np.ndarray
345 Hessian of the chi2
with respect to rho, theta, invSigma
355 hessianChi2 = (2 * self.
_maskWeights * dModel[:,
None, :] * dModel[
None, :, :]).sum(axis=2)
360 return reducedChi, reducedDChi, reducedHessianChi
362 def fit(self, dChi2Tol=0.1, maxIter=100, log=None):
363 """Perform Newton-Raphson minimization to find line parameters.
365 This method takes advantage of having known derivative and Hessian of
366 the multivariate function to quickly
and efficiently find the minimum.
367 This
is more efficient than the scipy implementation of the Newton-
368 Raphson method, which doesn
't take advantage of the Hessian matrix. The
369 method here also performs a line search in the direction of the steepest
370 derivative at each iteration, which reduces the number of iterations
375 dChi2Tol : float (optional)
376 Change
in Chi2 tolerated
for fit convergence
377 maxIter : int (optional)
378 Maximum number of fit iterations allowed. The fit should converge
in
379 ~10 iterations, depending on the value of dChi2Tol, but this
380 maximum provides a backup.
381 log : `lsst.utils.logging.LsstLogAdapter`, optional
382 Logger to use
for reporting more details
for fitting failures.
387 Coordinates
and inverse width of fit line
389 Reduced Chi2 of model fit to data
391 Boolean where `
False` corresponds to a successful fit
401 def line_search(c, dx):
403 testLine =
Line(testx[0], testx[1], testx[2]**-1)
404 return self.
_lineChi2(testLine, grad=
False)
406 while abs(dChi2) > dChi2Tol:
407 line =
Line(x[0], x[1], x[2]**-1)
411 if not np.isfinite(A).all():
414 log.warning(
"Hessian matrix has non-finite elements.")
416 dChi2 = oldChi2 - chi2
418 cholesky = scipy.linalg.cho_factor(A)
419 except np.linalg.LinAlgError:
422 log.warning(
"Hessian matrix is not invertible.")
424 dx = scipy.linalg.cho_solve(cholesky, b)
426 factor, fmin, _, _ = scipy.optimize.brent(line_search, args=(dx,), full_output=
True, tol=0.05)
428 if (abs(x[0]) > 1.5 * self.
_rhoMax)
or (iter > maxIter):
434 outline =
Line(x[0], x[1], abs(x[2])**-1)
436 return outline, chi2, fitFailure
440 """Configuration parameters for `MaskStreaksTask`def fit(self, dChi2Tol=0.1, maxIter=100
442 minimumKernelHeight = pexConfig.Field(
443 doc="Minimum height of the streak-finding kernel relative to the tallest kernel",
447 absMinimumKernelHeight = pexConfig.Field(
448 doc=
"Minimum absolute height of the streak-finding kernel",
452 clusterMinimumSize = pexConfig.Field(
453 doc=
"Minimum size in pixels of detected clusters",
457 clusterMinimumDeviation = pexConfig.Field(
458 doc=
"Allowed deviation (in pixels) from a straight line for a detected "
463 delta = pexConfig.Field(
464 doc=
"Stepsize in angle-radius parameter space",
468 nSigma = pexConfig.Field(
469 doc=
"Number of sigmas from center of kernel to include in voting "
474 rhoBinSize = pexConfig.Field(
475 doc=
"Binsize in pixels for position parameter rho when finding "
476 "clusters of detected lines",
480 thetaBinSize = pexConfig.Field(
481 doc=
"Binsize in degrees for angle parameter theta when finding "
482 "clusters of detected lines",
486 invSigma = pexConfig.Field(
487 doc=
"Inverse of the Moffat sigma parameter (in units of pixels)"
488 "describing the profile of the streak",
492 footprintThreshold = pexConfig.Field(
493 doc=
"Threshold at which to determine edge of line, in units of "
498 dChi2Tolerance = pexConfig.Field(
499 doc=
"Absolute difference in Chi2 between iterations of line profile"
500 "fitting that is acceptable for convergence",
504 detectedMaskPlane = pexConfig.Field(
505 doc=
"Name of mask with pixels above detection threshold, used for first"
506 "estimate of streak locations",
510 streaksMaskPlane = pexConfig.Field(
511 doc=
"Name of mask plane holding detected streaks",
518 """Find streaks or other straight lines in image data.
520 Nearby objects passing through the field of view of the telescope leave a
521 bright trail in images. This
class uses the Kernel Hough Transform (KHT)
522 (Fernandes
and Oliveira, 2007), implemented
in `lsst.houghtransform`. The
523 procedure works by taking a binary image, either provided
as put
or produced
524 from the input data image, using a Canny filter to make an image of the
525 edges
in the original image, then running the KHT on the edge image. The KHT
526 identifies clusters of non-zero points, breaks those clusters of points into
527 straight lines, keeps clusters
with a size greater than the user-set
528 threshold, then performs a voting procedure to find the best-fit coordinates
529 of any straight lines. Given the results of the KHT algorithm, clusters of
530 lines are identified
and grouped (generally these correspond to the two
531 edges of a strea)
and a profile
is fit to the streak
in the original
535 ConfigClass = MaskStreaksConfig
536 _DefaultName = "maskStreaks"
540 """Find streaks in a masked image
545 The image in which to search
for streaks.
549 result : `lsst.pipe.base.Struct`
550 Result struct
with components:
552 - ``originalLines``: lines identified by kernel hough transform
553 - ``lineClusters``: lines grouped into clusters
in rho-theta space
554 - ``lines``: final result
for lines after line-profile fit
555 - ``mask``: 2-d boolean mask where detected lines are
True
557 mask = maskedImage.getMask()
558 detectionMask = (mask.array & mask.getPlaneBitMask(self.config.detectedMaskPlane))
563 if len(self.
lines) == 0:
564 lineMask = np.zeros(detectionMask.shape, dtype=bool)
569 fitLines, lineMask = self.
_fitProfile(clusters, maskedImage)
572 outputMask = lineMask & detectionMask.astype(bool)
574 return pipeBase.Struct(
576 lineClusters=clusters,
577 originalLines=self.
lines,
582 def run(self, maskedImage):
583 """Find and mask streaks in a masked image.
585 Finds streaks in the image
and modifies maskedImage
in place by adding a
586 mask plane
with any identified streaks.
591 The image
in which to search
for streaks. The mask detection plane
592 corresponding to `config.detectedMaskPlane` must be set
with the
597 result : `lsst.pipe.base.Struct`
598 Result struct
with components:
600 - ``originalLines``: lines identified by kernel hough transform
601 - ``lineClusters``: lines grouped into clusters
in rho-theta space
602 - ``lines``: final result
for lines after line-profile fit
604 streaks = self.find(maskedImage)
606 maskedImage.mask.addMaskPlane(self.config.streaksMaskPlane)
607 maskedImage.mask.array[streaks.mask] |= maskedImage.mask.getPlaneBitMask(self.config.streaksMaskPlane)
609 return pipeBase.Struct(
611 lineClusters=streaks.lineClusters,
612 originalLines=streaks.originalLines,
615 def _cannyFilter(self, image):
616 """Apply a canny filter to the data in order to detect edges
621 2-d image data on which to run filter
625 cannyData : `np.ndarray`
626 2-d image of edges found in input image
628 filterData = image.astype(int)
629 return canny(filterData, low_threshold=0, high_threshold=1, sigma=0.1)
631 def _runKHT(self, image):
632 """Run Kernel Hough Transform on image.
637 2-d image data on which to detect lines
641 result : `LineCollection`
642 Collection of detected lines, with their detected rho
and theta
645 lines = lsst.kht.find_lines(image, self.config.clusterMinimumSize,
646 self.config.clusterMinimumDeviation, self.config.delta,
647 self.config.minimumKernelHeight, self.config.nSigma,
648 self.config.absMinimumKernelHeight)
649 self.log.info("The Kernel Hough Transform detected %s line(s)", len(lines))
653 def _findClusters(self, lines):
654 """Group lines that are close in parameter space and likely describe
659 lines : `LineCollection`
660 Collection of lines to group into clusters
664 result : `LineCollection`
665 Average `Line` for each cluster of `Line`s
in the input
672 x = lines.rhos / self.config.rhoBinSize
673 y = lines.thetas / self.config.thetaBinSize
674 X = np.array([x, y]).T
683 kmeans = KMeans(n_clusters=nClusters).fit(X)
684 clusterStandardDeviations = np.zeros((nClusters, 2))
685 for c
in range(nClusters):
686 inCluster = X[kmeans.labels_ == c]
687 clusterStandardDeviations[c] = np.std(inCluster, axis=0)
689 if (clusterStandardDeviations <= 1).all():
694 finalClusters = kmeans.cluster_centers_.T
697 finalRhos = finalClusters[0] * self.config.rhoBinSize
698 finalThetas = finalClusters[1] * self.config.thetaBinSize
700 self.log.info(
"Lines were grouped into %s potential streak(s)", len(finalRhos))
704 def _fitProfile(self, lines, maskedImage):
705 """Fit the profile of the streak.
707 Given the initial parameters of detected lines, fit a model for the
708 streak to the original (non-binary image). The assumed model
is a
709 straight line
with a Moffat profile.
713 lines : `LineCollection`
714 Collection of guesses
for `Line`s detected
in the image
716 Original image to be used to fit profile of streak.
720 lineFits : `LineCollection`
721 Collection of `Line` profiles fit to the data
722 finalMask : `np.ndarray`
723 2d mask array
with detected streaks=1.
725 data = maskedImage.image.array
726 weights = maskedImage.variance.array**-1
728 weights[~np.isfinite(weights) | ~np.isfinite(data)] = 0
731 finalLineMasks = [np.zeros(data.shape, dtype=bool)]
734 line.sigma = self.config.invSigma**-1
737 if lineModel.lineMaskSize == 0:
740 fit, chi2, fitFailure = lineModel.fit(dChi2Tol=self.config.dChi2Tolerance, log=self.log)
742 self.log.warning(
"Streak fit failed.")
746 if ((abs(fit.rho - line.rho) > 2 * self.config.rhoBinSize)
747 or (abs(fit.theta - line.theta) > 2 * self.config.thetaBinSize)):
749 self.log.warning(
"Streak fit moved too far from initial estimate. Line will be dropped.")
754 self.log.debug(
"Best fit streak parameters are rho=%.2f, theta=%.2f, and sigma=%.2f", fit.rho,
755 fit.theta, fit.sigma)
758 lineModel.setLineMask(fit)
759 finalModel = lineModel.makeProfile(fit)
761 finalModelMax = abs(finalModel).max()
762 finalLineMask = abs(finalModel) > self.config.footprintThreshold
764 if not finalLineMask.any():
767 fit.finalModelMax = finalModelMax
769 finalLineMasks.append(finalLineMask)
772 finalMask = np.array(finalLineMasks).any(axis=0)
773 nMaskedPixels = finalMask.sum()
774 percentMasked = (nMaskedPixels / finalMask.size) * 100
775 self.log.info(
"%d streak(s) fit, with %d pixels masked (%0.2f%% of image)", nFinalLines,
776 nMaskedPixels, percentMasked)
778 return lineFits, finalMask
def __init__(self, rhos, thetas, sigmas=None)
def append(self, newLine)
def __getitem__(self, index)
def __init__(self, data, weights, line=None)
def makeProfile(self, line, fitFlux=True)
def _makeMaskedProfile(self, line, fitFlux=True)
def _lineChi2(self, line, grad=True)
def fit(self, dChi2Tol=0.1, maxIter=100, log=None)
def setLineMask(self, line)
def run(self, maskedImage)
def find(self, maskedImage)
def _fitProfile(self, lines, maskedImage)
def _findClusters(self, lines)
def _cannyFilter(self, image)
def setDetectionMask(maskedImage, forceSlowBin=False, binning=None, detectedPlane="DETECTED", badMaskPlanes=("NO_DATA", "INTRP", "BAD", "SAT", "EDGE"), detectionThreshold=5)