lsst.pipe.tasks g88ce0b65e2+63aa580cb9
Loading...
Searching...
No Matches
maskStreaks.py
Go to the documentation of this file.
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
22__all__ = ["MaskStreaksConfig", "MaskStreaksTask", "setDetectionMask"]
23
24import lsst.pex.config as pexConfig
25import lsst.pipe.base as pipeBase
26import lsst.kht
27from lsst.utils.timer import timeMethod
28
29import numpy as np
30import scipy
31import textwrap
32import copy
33from skimage.feature import canny
34from sklearn.cluster import KMeans
35import warnings
36from dataclasses import dataclass
37
38
39def setDetectionMask(maskedImage, forceSlowBin=False, binning=None, detectedPlane="DETECTED",
40 badMaskPlanes=("NO_DATA", "INTRP", "BAD", "SAT", "EDGE"), detectionThreshold=5):
41 """Make detection mask and set the mask plane.
42
43 Creat a binary image from a masked image by setting all data with signal-to-
44 noise below some threshold to zero, and all data above the threshold to one.
45 If the binning parameter has been set, this procedure will be preceded by a
46 weighted binning of the data in order to smooth the result, after which the
47 result is scaled back to the original dimensions. Set the detection mask
48 plane with this binary image.
49
50 Parameters
51 ----------
52 maskedImage : `lsst.afw.image.maskedImage`
53 Image to be (optionally) binned and converted.
54 forceSlowBin : `bool`, optional
55 Force usage of slower binning method to check that the two methods
56 give the same result.
57 binning : `int`, optional
58 Number of pixels by which to bin image.
59 detectedPlane : `str`, optional
60 Name of mask with pixels that were detected above threshold in image.
61 badMaskPlanes : `set`, optional
62 Names of masks with pixels that are rejected.
63 detectionThreshold : `float`, optional
64 Boundary in signal-to-noise between non-detections and detections for
65 making a binary image from the original input image.
66 """
67 data = maskedImage.image.array
68 weights = 1 / maskedImage.variance.array
69 mask = maskedImage.getMask()
70
71 detectionMask = ((mask.array & mask.getPlaneBitMask(detectedPlane)))
72 badPixelMask = mask.getPlaneBitMask(badMaskPlanes)
73 badMask = (mask.array & badPixelMask) > 0
74 fitMask = detectionMask.astype(bool) & ~badMask
75
76 fitData = np.copy(data)
77 fitData[~fitMask] = 0
78 fitWeights = np.copy(weights)
79 fitWeights[~fitMask] = 0
80
81 if binning:
82 # Do weighted binning:
83 ymax, xmax = fitData.shape
84 if (ymax % binning == 0) and (xmax % binning == 0) and (not forceSlowBin):
85 # Faster binning method
86 binNumeratorReshape = (fitData * fitWeights).reshape(ymax // binning, binning,
87 xmax // binning, binning)
88 binDenominatorReshape = fitWeights.reshape(binNumeratorReshape.shape)
89 binnedNumerator = binNumeratorReshape.sum(axis=3).sum(axis=1)
90 binnedDenominator = binDenominatorReshape.sum(axis=3).sum(axis=1)
91 else:
92 # Slower binning method when (image shape mod binsize) != 0
93 warnings.warn('Using slow binning method--consider choosing a binsize that evenly divides '
94 f'into the image size, so that {ymax} mod binning == 0 '
95 f'and {xmax} mod binning == 0', stacklevel=2)
96 xarray = np.arange(xmax)
97 yarray = np.arange(ymax)
98 xmesh, ymesh = np.meshgrid(xarray, yarray)
99 xbins = np.arange(0, xmax + binning, binning)
100 ybins = np.arange(0, ymax + binning, binning)
101 numerator = fitWeights * fitData
102 binnedNumerator, *_ = scipy.stats.binned_statistic_2d(ymesh.ravel(), xmesh.ravel(),
103 numerator.ravel(), statistic='sum',
104 bins=(ybins, xbins))
105 binnedDenominator, *_ = scipy.stats.binned_statistic_2d(ymesh.ravel(), xmesh.ravel(),
106 fitWeights.ravel(), statistic='sum',
107 bins=(ybins, xbins))
108 binnedData = np.zeros(binnedNumerator.shape)
109 ind = binnedDenominator != 0
110 np.divide(binnedNumerator, binnedDenominator, out=binnedData, where=ind)
111 binnedWeight = binnedDenominator
112 binMask = (binnedData * binnedWeight**0.5) > detectionThreshold
113 tmpOutputMask = binMask.repeat(binning, axis=0)[:ymax]
114 outputMask = tmpOutputMask.repeat(binning, axis=1)[:, :xmax]
115 else:
116 outputMask = (fitData * fitWeights**0.5) > detectionThreshold
117
118 # Clear existing Detected Plane:
119 maskedImage.mask.array &= ~maskedImage.mask.getPlaneBitMask(detectedPlane)
120
121 # Set Detected Plane with the binary detection mask:
122 maskedImage.mask.array[outputMask] |= maskedImage.mask.getPlaneBitMask(detectedPlane)
123
124
125@dataclass
126class Line:
127 """A simple data class to describe a line profile. The parameter `rho`
128 describes the distance from the center of the image, `theta` describes
129 the angle, and `sigma` describes the width of the line.
130 """
131
132 rho: float
133 theta: float
134 sigma: float = 0
135
136
138 """Collection of `Line` objects.
139
140 Parameters
141 ----------
142 rhos : `np.ndarray`
143 Array of `Line` rho parameters.
144 thetas : `np.ndarray`
145 Array of `Line` theta parameters.
146 sigmas : `np.ndarray`, optional
147 Array of `Line` sigma parameters.
148 """
149
150 def __init__(self, rhos, thetas, sigmas=None):
151 if sigmas is None:
152 sigmas = np.zeros(len(rhos))
153
154 self._lines = [Line(rho, theta, sigma) for (rho, theta, sigma) in
155 zip(rhos, thetas, sigmas)]
156
157 def __len__(self):
158 return len(self._lines)
159
160 def __getitem__(self, index):
161 return self._lines[index]
162
163 def __iter__(self):
164 return iter(self._lines)
165
166 def __repr__(self):
167 joinedString = ", ".join(str(line) for line in self._lines)
168 return textwrap.shorten(joinedString, width=160, placeholder="...")
169
170 @property
171 def rhos(self):
172 return np.array([line.rho for line in self._lines])
173
174 @property
175 def thetas(self):
176 return np.array([line.theta for line in self._lines])
177
178 def append(self, newLine):
179 """Add line to current collection of lines.
180
181 Parameters
182 ----------
183 newLine : `Line`
184 `Line` to add to current collection of lines
185 """
186 self._lines.append(copy.copy(newLine))
187
188
190 """Construct and/or fit a model for a linear streak.
191
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.
200
201 Parameters
202 ----------
203 data : `np.ndarray`
204 2d array of data.
205 weights : `np.ndarray`
206 2d array of weights.
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
210 out.
211 """
212
213 def __init__(self, data, weights, line=None):
214 self.data = data
215 self.weights = weights
216 self._ymax, self._xmax = data.shape
217 self._dtype = data.dtype
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)
222 self.mask = (weights != 0)
223
224 self._initLine = line
225 self.setLineMask(line)
226
227 def setLineMask(self, line):
228 """Set mask around the image region near the line.
229
230 Parameters
231 ----------
232 line : `Line`
233 Parameters of line in the image.
234 """
235 if line:
236 # Only fit pixels within 5 sigma of the estimated line
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)
240 self.lineMask = self.mask & m
241 else:
242 self.lineMask = np.copy(self.mask)
243
244 self.lineMaskSize = self.lineMask.sum()
245 self._maskData = self.data[self.lineMask]
246 self._maskWeights = self.weights[self.lineMask]
247 self._mxmesh = self._xmesh[self.lineMask]
248 self._mymesh = self._ymesh[self.lineMask]
249
250 def _makeMaskedProfile(self, line, fitFlux=True):
251 """Construct the line model in the masked region and calculate its
252 derivatives.
253
254 Parameters
255 ----------
256 line : `Line`
257 Parameters of line profile for which to make profile in the masked
258 region.
259 fitFlux : `bool`
260 Fit the amplitude of the line profile to the data.
261
262 Returns
263 -------
264 model : `np.ndarray`
265 Model in the masked region.
266 dModel : `np.ndarray`
267 Derivative of the model in the masked region.
268 """
269 invSigma = line.sigma**-1
270 # Calculate distance between pixels and line
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
276
277 # Calculate partial derivatives of distance
278 drad = np.pi / 180
279 dDistanceSqdRho = 2 * distance * (-np.ones_like(self._mxmesh))
280 dDistanceSqdTheta = (2 * distance * (-sintheta * self._mxmesh + costheta * self._mymesh) * drad)
281
282 # Use pixel-line distances to make Moffat profile
283 profile = (1 + distanceSquared * invSigma**2)**-2.5
284 dProfile = -2.5 * (1 + distanceSquared * invSigma**2)**-3.5
285
286 if fitFlux:
287 # Calculate line flux from profile and data
288 flux = ((self._maskWeights * self._maskData * profile).sum()
289 / (self._maskWeights * profile**2).sum())
290 else:
291 # Approximately normalize the line
292 flux = invSigma**-1
293 if np.isnan(flux):
294 flux = 0
295
296 model = flux * profile
297
298 # Calculate model derivatives
299 fluxdProfile = flux * dProfile
300 fluxdProfileInvSigma = fluxdProfile * invSigma**2
301 dModeldRho = fluxdProfileInvSigma * dDistanceSqdRho
302 dModeldTheta = fluxdProfileInvSigma * dDistanceSqdTheta
303 dModeldInvSigma = fluxdProfile * distanceSquared * 2 * invSigma
304
305 dModel = np.array([dModeldRho, dModeldTheta, dModeldInvSigma])
306 return model, dModel
307
308 def makeProfile(self, line, fitFlux=True):
309 """Construct the line profile model.
310
311 Parameters
312 ----------
313 line : `Line`
314 Parameters of the line profile to model.
315 fitFlux : `bool`, optional
316 Fit the amplitude of the line profile to the data.
317
318 Returns
319 -------
320 finalModel : `np.ndarray`
321 Model for line profile.
322 """
323 model, _ = self._makeMaskedProfile(line, fitFlux=fitFlux)
324 finalModel = np.zeros((self._ymax, self._xmax), dtype=self._dtype)
325 finalModel[self.lineMask] = model
326 return finalModel
327
328 def _lineChi2(self, line, grad=True):
329 """Construct the chi2 between the data and the model.
330
331 Parameters
332 ----------
333 line : `Line`
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.
337
338 Returns
339 -------
340 reducedChi : `float`
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.
346 """
347 # Calculate chi2
348 model, dModel = self._makeMaskedProfile(line)
349 chi2 = (self._maskWeights * (self._maskData - model)**2).sum()
350 if not grad:
351 return chi2.sum() / self.lineMaskSize
352
353 # Calculate derivative and Hessian of chi2
354 derivChi2 = ((-2 * self._maskWeights * (self._maskData - model))[None, :] * dModel).sum(axis=1)
355 hessianChi2 = (2 * self._maskWeights * dModel[:, None, :] * dModel[None, :, :]).sum(axis=2)
356
357 reducedChi = chi2 / self.lineMaskSize
358 reducedDChi = derivChi2 / self.lineMaskSize
359 reducedHessianChi = hessianChi2 / self.lineMaskSize
360 return reducedChi, reducedDChi, reducedHessianChi
361
362 def fit(self, dChi2Tol=0.1, maxIter=100, log=None):
363 """Perform Newton-Raphson minimization to find line parameters.
364
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
371 needed.
372
373 Parameters
374 ----------
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.
383
384 Returns
385 -------
386 outline : `np.ndarray`
387 Coordinates and inverse width of fit line.
388 chi2 : `float`
389 Reduced Chi2 of model fit to data.
390 fitFailure : `bool`
391 Boolean where `False` corresponds to a successful fit.
392 """
393 # Do minimization on inverse of sigma to simplify derivatives:
394 x = np.array([self._initLine.rho, self._initLine.theta, self._initLine.sigma**-1])
395
396 dChi2 = 1
397 iter = 0
398 oldChi2 = 0
399 fitFailure = False
400
401 def line_search(c, dx):
402 testx = x - c * dx
403 testLine = Line(testx[0], testx[1], testx[2]**-1)
404 return self._lineChi2(testLine, grad=False)
405
406 while abs(dChi2) > dChi2Tol:
407 line = Line(x[0], x[1], x[2]**-1)
408 chi2, b, A = self._lineChi2(line)
409 if chi2 == 0:
410 break
411 if not np.isfinite(A).all():
412 fitFailure = True
413 if log is not None:
414 log.warning("Hessian matrix has non-finite elements.")
415 break
416 dChi2 = oldChi2 - chi2
417 try:
418 cholesky = scipy.linalg.cho_factor(A)
419 except np.linalg.LinAlgError:
420 fitFailure = True
421 if log is not None:
422 log.warning("Hessian matrix is not invertible.")
423 break
424 dx = scipy.linalg.cho_solve(cholesky, b)
425
426 factor, fmin, _, _ = scipy.optimize.brent(line_search, args=(dx,), full_output=True, tol=0.05)
427 x -= factor * dx
428 if (abs(x[0]) > 1.5 * self._rhoMax) or (iter > maxIter):
429 fitFailure = True
430 break
431 oldChi2 = chi2
432 iter += 1
433
434 outline = Line(x[0], x[1], abs(x[2])**-1)
435
436 return outline, chi2, fitFailure
437
438
439class MaskStreaksConfig(pexConfig.Config):
440 """Configuration parameters for `MaskStreaksTask`.
441 """
442
443 minimumKernelHeight = pexConfig.Field(
444 doc="Minimum height of the streak-finding kernel relative to the tallest kernel",
445 dtype=float,
446 default=0.0,
447 )
448 absMinimumKernelHeight = pexConfig.Field(
449 doc="Minimum absolute height of the streak-finding kernel",
450 dtype=float,
451 default=5,
452 )
453 clusterMinimumSize = pexConfig.Field(
454 doc="Minimum size in pixels of detected clusters",
455 dtype=int,
456 default=50,
457 )
458 clusterMinimumDeviation = pexConfig.Field(
459 doc="Allowed deviation (in pixels) from a straight line for a detected "
460 "line",
461 dtype=int,
462 default=2,
463 )
464 delta = pexConfig.Field(
465 doc="Stepsize in angle-radius parameter space",
466 dtype=float,
467 default=0.2,
468 )
469 nSigma = pexConfig.Field(
470 doc="Number of sigmas from center of kernel to include in voting "
471 "procedure",
472 dtype=float,
473 default=2,
474 )
475 rhoBinSize = pexConfig.Field(
476 doc="Binsize in pixels for position parameter rho when finding "
477 "clusters of detected lines",
478 dtype=float,
479 default=30,
480 )
481 thetaBinSize = pexConfig.Field(
482 doc="Binsize in degrees for angle parameter theta when finding "
483 "clusters of detected lines",
484 dtype=float,
485 default=2,
486 )
487 invSigma = pexConfig.Field(
488 doc="Inverse of the Moffat sigma parameter (in units of pixels)"
489 "describing the profile of the streak",
490 dtype=float,
491 default=10.**-1,
492 )
493 footprintThreshold = pexConfig.Field(
494 doc="Threshold at which to determine edge of line, in units of "
495 "nanoJanskys",
496 dtype=float,
497 default=0.01
498 )
499 dChi2Tolerance = pexConfig.Field(
500 doc="Absolute difference in Chi2 between iterations of line profile"
501 "fitting that is acceptable for convergence",
502 dtype=float,
503 default=0.1
504 )
505 detectedMaskPlane = pexConfig.Field(
506 doc="Name of mask with pixels above detection threshold, used for first"
507 "estimate of streak locations",
508 dtype=str,
509 default="DETECTED"
510 )
511 streaksMaskPlane = pexConfig.Field(
512 doc="Name of mask plane holding detected streaks",
513 dtype=str,
514 default="STREAK"
515 )
516
517
518class MaskStreaksTask(pipeBase.Task):
519 """Find streaks or other straight lines in image data.
520
521 Nearby objects passing through the field of view of the telescope leave a
522 bright trail in images. This class uses the Kernel Hough Transform (KHT)
523 (Fernandes and Oliveira, 2007), implemented in `lsst.houghtransform`. The
524 procedure works by taking a binary image, either provided as put or produced
525 from the input data image, using a Canny filter to make an image of the
526 edges in the original image, then running the KHT on the edge image. The KHT
527 identifies clusters of non-zero points, breaks those clusters of points into
528 straight lines, keeps clusters with a size greater than the user-set
529 threshold, then performs a voting procedure to find the best-fit coordinates
530 of any straight lines. Given the results of the KHT algorithm, clusters of
531 lines are identified and grouped (generally these correspond to the two
532 edges of a strea) and a profile is fit to the streak in the original
533 (non-binary) image.
534 """
535
536 ConfigClass = MaskStreaksConfig
537 _DefaultName = "maskStreaks"
538
539 @timeMethod
540 def find(self, maskedImage):
541 """Find streaks in a masked image.
542
543 Parameters
544 ----------
545 maskedImage : `lsst.afw.image.maskedImage`
546 The image in which to search for streaks.
547
548 Returns
549 -------
550 result : `lsst.pipe.base.Struct`
551 Results as a struct with attributes:
552
553 ``originalLines``
554 Lines identified by kernel hough transform.
555 ``lineClusters``
556 Lines grouped into clusters in rho-theta space.
557 ``lines``
558 Final result for lines after line-profile fit.
559 ``mask``
560 2-d boolean mask where detected lines are True.
561 """
562 mask = maskedImage.getMask()
563 detectionMask = (mask.array & mask.getPlaneBitMask(self.config.detectedMaskPlane))
564
565 self.edges = self._cannyFilter(detectionMask)
566 self.lines = self._runKHT(self.edges)
567
568 if len(self.lines) == 0:
569 lineMask = np.zeros(detectionMask.shape, dtype=bool)
570 fitLines = LineCollection([], [])
571 clusters = LineCollection([], [])
572 else:
573 clusters = self._findClusters(self.lines)
574 fitLines, lineMask = self._fitProfile(clusters, maskedImage)
575
576 # The output mask is the intersection of the fit streaks and the image detections
577 outputMask = lineMask & detectionMask.astype(bool)
578
579 return pipeBase.Struct(
580 lines=fitLines,
581 lineClusters=clusters,
582 originalLines=self.lines,
583 mask=outputMask,
584 )
585
586 @timeMethod
587 def run(self, maskedImage):
588 """Find and mask streaks in a masked image.
589
590 Finds streaks in the image and modifies maskedImage in place by adding a
591 mask plane with any identified streaks.
592
593 Parameters
594 ----------
595 maskedImage : `lsst.afw.image.maskedImage`
596 The image in which to search for streaks. The mask detection plane
597 corresponding to `config.detectedMaskPlane` must be set with the
598 detected pixels.
599
600 Returns
601 -------
602 result : `lsst.pipe.base.Struct`
603 Results as a struct with attributes:
604
605 ``originalLines``
606 Lines identified by kernel hough transform.
607 ``lineClusters``
608 Lines grouped into clusters in rho-theta space.
609 ``lines``
610 Final result for lines after line-profile fit.
611 """
612 streaks = self.find(maskedImage)
613
614 maskedImage.mask.addMaskPlane(self.config.streaksMaskPlane)
615 maskedImage.mask.array[streaks.mask] |= maskedImage.mask.getPlaneBitMask(self.config.streaksMaskPlane)
616
617 return pipeBase.Struct(
618 lines=streaks.lines,
619 lineClusters=streaks.lineClusters,
620 originalLines=streaks.originalLines,
621 )
622
623 def _cannyFilter(self, image):
624 """Apply a canny filter to the data in order to detect edges.
625
626 Parameters
627 ----------
628 image : `np.ndarray`
629 2-d image data on which to run filter.
630
631 Returns
632 -------
633 cannyData : `np.ndarray`
634 2-d image of edges found in input image.
635 """
636 # Ensure that the pixels are zero or one. Change the datatype to
637 # np.float64 to be compatible with the Canny filter routine.
638 filterData = (image > 0).astype(np.float64)
639 return canny(filterData, use_quantiles=True, sigma=0.1)
640
641 def _runKHT(self, image):
642 """Run Kernel Hough Transform on image.
643
644 Parameters
645 ----------
646 image : `np.ndarray`
647 2-d image data on which to detect lines.
648
649 Returns
650 -------
651 result : `LineCollection`
652 Collection of detected lines, with their detected rho and theta
653 coordinates.
654 """
655 lines = lsst.kht.find_lines(image, self.config.clusterMinimumSize,
656 self.config.clusterMinimumDeviation, self.config.delta,
657 self.config.minimumKernelHeight, self.config.nSigma,
658 self.config.absMinimumKernelHeight)
659 self.log.info("The Kernel Hough Transform detected %s line(s)", len(lines))
660
661 return LineCollection(lines.rho, lines.theta)
662
663 def _findClusters(self, lines):
664 """Group lines that are close in parameter space and likely describe
665 the same streak.
666
667 Parameters
668 ----------
669 lines : `LineCollection`
670 Collection of lines to group into clusters.
671
672 Returns
673 -------
674 result : `LineCollection`
675 Average `Line` for each cluster of `Line`s in the input
676 `LineCollection`.
677 """
678 # Scale variables by threshold bin-size variable so that rho and theta
679 # are on the same scale. Since the clustering algorithm below stops when
680 # the standard deviation <= 1, after rescaling each cluster will have a
681 # standard deviation at or below the bin-size.
682 x = lines.rhos / self.config.rhoBinSize
683 y = lines.thetas / self.config.thetaBinSize
684 X = np.array([x, y]).T
685 nClusters = 1
686
687 # Put line parameters in clusters by starting with all in one, then
688 # subdividing until the parameters of each cluster have std dev=1.
689 # If nClusters == len(lines), each line will have its own 'cluster', so
690 # the standard deviations of each cluster must be zero and the loop
691 # is guaranteed to stop.
692 while True:
693 kmeans = KMeans(n_clusters=nClusters, n_init='auto').fit(X)
694 clusterStandardDeviations = np.zeros((nClusters, 2))
695 for c in range(nClusters):
696 inCluster = X[kmeans.labels_ == c]
697 clusterStandardDeviations[c] = np.std(inCluster, axis=0)
698 # Are the rhos and thetas in each cluster all below the threshold?
699 if (clusterStandardDeviations <= 1).all():
700 break
701 nClusters += 1
702
703 # The cluster centers are final line estimates
704 finalClusters = kmeans.cluster_centers_.T
705
706 # Rescale variables:
707 finalRhos = finalClusters[0] * self.config.rhoBinSize
708 finalThetas = finalClusters[1] * self.config.thetaBinSize
709 result = LineCollection(finalRhos, finalThetas)
710 self.log.info("Lines were grouped into %s potential streak(s)", len(finalRhos))
711
712 return result
713
714 def _fitProfile(self, lines, maskedImage):
715 """Fit the profile of the streak.
716
717 Given the initial parameters of detected lines, fit a model for the
718 streak to the original (non-binary image). The assumed model is a
719 straight line with a Moffat profile.
720
721 Parameters
722 ----------
723 lines : `LineCollection`
724 Collection of guesses for `Line`s detected in the image.
725 maskedImage : `lsst.afw.image.maskedImage`
726 Original image to be used to fit profile of streak.
727
728 Returns
729 -------
730 lineFits : `LineCollection`
731 Collection of `Line` profiles fit to the data.
732 finalMask : `np.ndarray`
733 2d mask array with detected streaks=1.
734 """
735 data = maskedImage.image.array
736 weights = maskedImage.variance.array**-1
737 # Mask out any pixels with non-finite weights
738 weights[~np.isfinite(weights) | ~np.isfinite(data)] = 0
739
740 lineFits = LineCollection([], [])
741 finalLineMasks = [np.zeros(data.shape, dtype=bool)]
742 nFinalLines = 0
743 for line in lines:
744 line.sigma = self.config.invSigma**-1
745 lineModel = LineProfile(data, weights, line=line)
746 # Skip any lines that do not cover any data (sometimes happens because of chip gaps)
747 if lineModel.lineMaskSize == 0:
748 continue
749
750 fit, chi2, fitFailure = lineModel.fit(dChi2Tol=self.config.dChi2Tolerance, log=self.log)
751 if fitFailure:
752 self.log.warning("Streak fit failed.")
753
754 # Initial estimate should be quite close: fit is deemed unsuccessful if rho or theta
755 # change more than the allowed bin in rho or theta:
756 if ((abs(fit.rho - line.rho) > 2 * self.config.rhoBinSize)
757 or (abs(fit.theta - line.theta) > 2 * self.config.thetaBinSize)):
758 fitFailure = True
759 self.log.warning("Streak fit moved too far from initial estimate. Line will be dropped.")
760
761 if fitFailure:
762 continue
763
764 self.log.debug("Best fit streak parameters are rho=%.2f, theta=%.2f, and sigma=%.2f", fit.rho,
765 fit.theta, fit.sigma)
766
767 # Make mask
768 lineModel.setLineMask(fit)
769 finalModel = lineModel.makeProfile(fit)
770 # Take absolute value, as streaks are allowed to be negative
771 finalModelMax = abs(finalModel).max()
772 finalLineMask = abs(finalModel) > self.config.footprintThreshold
773 # Drop this line if the model profile is below the footprint threshold
774 if not finalLineMask.any():
775 continue
776 fit.chi2 = chi2
777 fit.finalModelMax = finalModelMax
778 lineFits.append(fit)
779 finalLineMasks.append(finalLineMask)
780 nFinalLines += 1
781
782 finalMask = np.array(finalLineMasks).any(axis=0)
783 nMaskedPixels = finalMask.sum()
784 percentMasked = (nMaskedPixels / finalMask.size) * 100
785 self.log.info("%d streak(s) fit, with %d pixels masked (%0.2f%% of image)", nFinalLines,
786 nMaskedPixels, percentMasked)
787
788 return lineFits, finalMask
__init__(self, rhos, thetas, sigmas=None)
__init__(self, data, weights, line=None)
makeProfile(self, line, fitFlux=True)
fit(self, dChi2Tol=0.1, maxIter=100, log=None)
_makeMaskedProfile(self, line, fitFlux=True)
setDetectionMask(maskedImage, forceSlowBin=False, binning=None, detectedPlane="DETECTED", badMaskPlanes=("NO_DATA", "INTRP", "BAD", "SAT", "EDGE"), detectionThreshold=5)