Coverage for python/lsst/meas/algorithms/maskStreaks.py: 24%

245 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-26 10:20 +0000

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__ = ["MaskStreaksConfig", "MaskStreaksTask"] 

23 

24import numpy as np 

25import scipy 

26import textwrap 

27import copy 

28from skimage.feature import canny 

29from sklearn.cluster import KMeans 

30from dataclasses import dataclass 

31 

32import lsst.pex.config as pexConfig 

33import lsst.pipe.base as pipeBase 

34import lsst.kht 

35from lsst.utils.timer import timeMethod 

36 

37 

38@dataclass 

39class Line: 

40 """A simple data class to describe a line profile. The parameter `rho` 

41 describes the distance from the center of the image, `theta` describes 

42 the angle, and `sigma` describes the width of the line. 

43 """ 

44 

45 rho: float 

46 theta: float 

47 sigma: float = 0 

48 

49 

50class LineCollection: 

51 """Collection of `Line` objects. 

52 

53 Parameters 

54 ---------- 

55 rhos : `np.ndarray` 

56 Array of `Line` rho parameters. 

57 thetas : `np.ndarray` 

58 Array of `Line` theta parameters. 

59 sigmas : `np.ndarray`, optional 

60 Array of `Line` sigma parameters. 

61 """ 

62 

63 def __init__(self, rhos, thetas, sigmas=None): 

64 if sigmas is None: 

65 sigmas = np.zeros(len(rhos)) 

66 

67 self._lines = [Line(rho, theta, sigma) for (rho, theta, sigma) in 

68 zip(rhos, thetas, sigmas)] 

69 

70 def __len__(self): 

71 return len(self._lines) 

72 

73 def __getitem__(self, index): 

74 return self._lines[index] 

75 

76 def __iter__(self): 

77 return iter(self._lines) 

78 

79 def __repr__(self): 

80 joinedString = ", ".join(str(line) for line in self._lines) 

81 return textwrap.shorten(joinedString, width=160, placeholder="...") 

82 

83 @property 

84 def rhos(self): 

85 return np.array([line.rho for line in self._lines]) 

86 

87 @property 

88 def thetas(self): 

89 return np.array([line.theta for line in self._lines]) 

90 

91 @property 

92 def sigmas(self): 

93 return np.array([line.sigma for line in self._lines]) 

94 

95 def append(self, newLine): 

96 """Add line to current collection of lines. 

97 

98 Parameters 

99 ---------- 

100 newLine : `Line` 

101 `Line` to add to current collection of lines 

102 """ 

103 self._lines.append(copy.copy(newLine)) 

104 

105 

106class LineProfile: 

107 """Construct and/or fit a model for a linear streak. 

108 

109 This assumes a simple model for a streak, in which the streak 

110 follows a straight line in pixels space, with a Moffat-shaped profile. The 

111 model is fit to data using a Newton-Raphson style minimization algorithm. 

112 The initial guess for the line parameters is assumed to be fairly accurate, 

113 so only a narrow band of pixels around the initial line estimate is used in 

114 fitting the model, which provides a significant speed-up over using all the 

115 data. The class can also be used just to construct a model for the data with 

116 a line following the given coordinates. 

117 

118 Parameters 

119 ---------- 

120 data : `np.ndarray` 

121 2d array of data. 

122 weights : `np.ndarray` 

123 2d array of weights. 

124 line : `Line`, optional 

125 Guess for position of line. Data far from line guess is masked out. 

126 Defaults to None, in which case only data with `weights` = 0 is masked 

127 out. 

128 """ 

129 

130 def __init__(self, data, weights, line=None): 

131 self.data = data 

132 self.weights = weights 

133 self._ymax, self._xmax = data.shape 

134 self._dtype = data.dtype 

135 xrange = np.arange(self._xmax) - self._xmax / 2. 

136 yrange = np.arange(self._ymax) - self._ymax / 2. 

137 self._rhoMax = ((0.5 * self._ymax)**2 + (0.5 * self._xmax)**2)**0.5 

138 self._xmesh, self._ymesh = np.meshgrid(xrange, yrange) 

139 self.mask = (weights != 0) 

140 

141 self._initLine = line 

142 self.setLineMask(line) 

143 

144 def setLineMask(self, line): 

145 """Set mask around the image region near the line. 

146 

147 Parameters 

148 ---------- 

149 line : `Line` 

150 Parameters of line in the image. 

151 """ 

152 if line: 

153 # Only fit pixels within 5 sigma of the estimated line 

154 radtheta = np.deg2rad(line.theta) 

155 distance = (np.cos(radtheta) * self._xmesh + np.sin(radtheta) * self._ymesh - line.rho) 

156 m = (abs(distance) < 5 * line.sigma) 

157 self.lineMask = self.mask & m 

158 else: 

159 self.lineMask = np.copy(self.mask) 

160 

161 self.lineMaskSize = self.lineMask.sum() 

162 self._maskData = self.data[self.lineMask] 

163 self._maskWeights = self.weights[self.lineMask] 

164 self._mxmesh = self._xmesh[self.lineMask] 

165 self._mymesh = self._ymesh[self.lineMask] 

166 

167 def _makeMaskedProfile(self, line, fitFlux=True): 

168 """Construct the line model in the masked region and calculate its 

169 derivatives. 

170 

171 Parameters 

172 ---------- 

173 line : `Line` 

174 Parameters of line profile for which to make profile in the masked 

175 region. 

176 fitFlux : `bool` 

177 Fit the amplitude of the line profile to the data. 

178 

179 Returns 

180 ------- 

181 model : `np.ndarray` 

182 Model in the masked region. 

183 dModel : `np.ndarray` 

184 Derivative of the model in the masked region. 

185 """ 

186 invSigma = line.sigma**-1 

187 # Calculate distance between pixels and line 

188 radtheta = np.deg2rad(line.theta) 

189 costheta = np.cos(radtheta) 

190 sintheta = np.sin(radtheta) 

191 distance = (costheta * self._mxmesh + sintheta * self._mymesh - line.rho) 

192 distanceSquared = distance**2 

193 

194 # Calculate partial derivatives of distance 

195 drad = np.pi / 180 

196 dDistanceSqdRho = 2 * distance * (-np.ones_like(self._mxmesh)) 

197 dDistanceSqdTheta = (2 * distance * (-sintheta * self._mxmesh + costheta * self._mymesh) * drad) 

198 

199 # Use pixel-line distances to make Moffat profile 

200 profile = (1 + distanceSquared * invSigma**2)**-2.5 

201 dProfile = -2.5 * (1 + distanceSquared * invSigma**2)**-3.5 

202 

203 if fitFlux: 

204 # Calculate line flux from profile and data 

205 flux = ((self._maskWeights * self._maskData * profile).sum() 

206 / (self._maskWeights * profile**2).sum()) 

207 else: 

208 # Approximately normalize the line 

209 flux = invSigma**-1 

210 if np.isnan(flux): 

211 flux = 0 

212 

213 model = flux * profile 

214 

215 # Calculate model derivatives 

216 fluxdProfile = flux * dProfile 

217 fluxdProfileInvSigma = fluxdProfile * invSigma**2 

218 dModeldRho = fluxdProfileInvSigma * dDistanceSqdRho 

219 dModeldTheta = fluxdProfileInvSigma * dDistanceSqdTheta 

220 dModeldInvSigma = fluxdProfile * distanceSquared * 2 * invSigma 

221 

222 dModel = np.array([dModeldRho, dModeldTheta, dModeldInvSigma]) 

223 return model, dModel 

224 

225 def makeProfile(self, line, fitFlux=True): 

226 """Construct the line profile model. 

227 

228 Parameters 

229 ---------- 

230 line : `Line` 

231 Parameters of the line profile to model. 

232 fitFlux : `bool`, optional 

233 Fit the amplitude of the line profile to the data. 

234 

235 Returns 

236 ------- 

237 finalModel : `np.ndarray` 

238 Model for line profile. 

239 """ 

240 model, _ = self._makeMaskedProfile(line, fitFlux=fitFlux) 

241 finalModel = np.zeros((self._ymax, self._xmax), dtype=self._dtype) 

242 finalModel[self.lineMask] = model 

243 return finalModel 

244 

245 def _lineChi2(self, line, grad=True): 

246 """Construct the chi2 between the data and the model. 

247 

248 Parameters 

249 ---------- 

250 line : `Line` 

251 `Line` parameters for which to build model and calculate chi2. 

252 grad : `bool`, optional 

253 Whether or not to return the gradient and hessian. 

254 

255 Returns 

256 ------- 

257 reducedChi : `float` 

258 Reduced chi2 of the model. 

259 reducedDChi : `np.ndarray` 

260 Derivative of the chi2 with respect to rho, theta, invSigma. 

261 reducedHessianChi : `np.ndarray` 

262 Hessian of the chi2 with respect to rho, theta, invSigma. 

263 """ 

264 # Calculate chi2 

265 model, dModel = self._makeMaskedProfile(line) 

266 chi2 = (self._maskWeights * (self._maskData - model)**2).sum() 

267 if not grad: 

268 return chi2.sum() / self.lineMaskSize 

269 

270 # Calculate derivative and Hessian of chi2 

271 derivChi2 = ((-2 * self._maskWeights * (self._maskData - model))[None, :] * dModel).sum(axis=1) 

272 hessianChi2 = (2 * self._maskWeights * dModel[:, None, :] * dModel[None, :, :]).sum(axis=2) 

273 

274 reducedChi = chi2 / self.lineMaskSize 

275 reducedDChi = derivChi2 / self.lineMaskSize 

276 reducedHessianChi = hessianChi2 / self.lineMaskSize 

277 return reducedChi, reducedDChi, reducedHessianChi 

278 

279 def fit(self, dChi2Tol=0.1, maxIter=100, log=None): 

280 """Perform Newton-Raphson minimization to find line parameters. 

281 

282 This method takes advantage of having known derivative and Hessian of 

283 the multivariate function to quickly and efficiently find the minimum. 

284 This is more efficient than the scipy implementation of the Newton- 

285 Raphson method, which doesn't take advantage of the Hessian matrix. The 

286 method here also performs a line search in the direction of the steepest 

287 derivative at each iteration, which reduces the number of iterations 

288 needed. 

289 

290 Parameters 

291 ---------- 

292 dChi2Tol : `float`, optional 

293 Change in Chi2 tolerated for fit convergence. 

294 maxIter : `int`, optional 

295 Maximum number of fit iterations allowed. The fit should converge in 

296 ~10 iterations, depending on the value of dChi2Tol, but this 

297 maximum provides a backup. 

298 log : `lsst.utils.logging.LsstLogAdapter`, optional 

299 Logger to use for reporting more details for fitting failures. 

300 

301 Returns 

302 ------- 

303 outline : `np.ndarray` 

304 Coordinates and inverse width of fit line. 

305 chi2 : `float` 

306 Reduced Chi2 of model fit to data. 

307 fitFailure : `bool` 

308 Boolean where `False` corresponds to a successful fit. 

309 """ 

310 # Do minimization on inverse of sigma to simplify derivatives: 

311 x = np.array([self._initLine.rho, self._initLine.theta, self._initLine.sigma**-1]) 

312 

313 dChi2 = 1 

314 iter = 0 

315 oldChi2 = 0 

316 fitFailure = False 

317 

318 def line_search(c, dx): 

319 testx = x - c * dx 

320 testLine = Line(testx[0], testx[1], testx[2]**-1) 

321 return self._lineChi2(testLine, grad=False) 

322 

323 while abs(dChi2) > dChi2Tol: 

324 line = Line(x[0], x[1], x[2]**-1) 

325 chi2, b, A = self._lineChi2(line) 

326 if chi2 == 0: 

327 break 

328 if not np.isfinite(A).all(): 

329 fitFailure = True 

330 if log is not None: 

331 log.warning("Hessian matrix has non-finite elements.") 

332 break 

333 dChi2 = oldChi2 - chi2 

334 try: 

335 cholesky = scipy.linalg.cho_factor(A) 

336 except np.linalg.LinAlgError: 

337 fitFailure = True 

338 if log is not None: 

339 log.warning("Hessian matrix is not invertible.") 

340 break 

341 dx = scipy.linalg.cho_solve(cholesky, b) 

342 

343 factor, fmin, _, _ = scipy.optimize.brent(line_search, args=(dx,), full_output=True, tol=0.05) 

344 x -= factor * dx 

345 if (abs(x[0]) > 1.5 * self._rhoMax) or (iter > maxIter): 

346 fitFailure = True 

347 break 

348 oldChi2 = chi2 

349 iter += 1 

350 

351 outline = Line(x[0], x[1], abs(x[2])**-1) 

352 

353 return outline, chi2, fitFailure 

354 

355 

356class MaskStreaksConfig(pexConfig.Config): 

357 """Configuration parameters for `MaskStreaksTask`. 

358 """ 

359 

360 minimumKernelHeight = pexConfig.Field( 

361 doc="Minimum height of the streak-finding kernel relative to the tallest kernel", 

362 dtype=float, 

363 default=0.0, 

364 ) 

365 absMinimumKernelHeight = pexConfig.Field( 

366 doc="Minimum absolute height of the streak-finding kernel", 

367 dtype=float, 

368 default=5, 

369 ) 

370 clusterMinimumSize = pexConfig.Field( 

371 doc="Minimum size in pixels of detected clusters", 

372 dtype=int, 

373 default=50, 

374 ) 

375 clusterMinimumDeviation = pexConfig.Field( 

376 doc="Allowed deviation (in pixels) from a straight line for a detected " 

377 "line", 

378 dtype=int, 

379 default=2, 

380 ) 

381 delta = pexConfig.Field( 

382 doc="Stepsize in angle-radius parameter space", 

383 dtype=float, 

384 default=0.2, 

385 ) 

386 nSigma = pexConfig.Field( 

387 doc="Number of sigmas from center of kernel to include in voting " 

388 "procedure", 

389 dtype=float, 

390 default=2, 

391 ) 

392 rhoBinSize = pexConfig.Field( 

393 doc="Binsize in pixels for position parameter rho when finding " 

394 "clusters of detected lines", 

395 dtype=float, 

396 default=30, 

397 ) 

398 thetaBinSize = pexConfig.Field( 

399 doc="Binsize in degrees for angle parameter theta when finding " 

400 "clusters of detected lines", 

401 dtype=float, 

402 default=2, 

403 ) 

404 invSigma = pexConfig.Field( 

405 doc="Inverse of the Moffat sigma parameter (in units of pixels)" 

406 "describing the profile of the streak", 

407 dtype=float, 

408 default=10.**-1, 

409 ) 

410 footprintThreshold = pexConfig.Field( 

411 doc="Threshold at which to determine edge of line, in units of " 

412 "nanoJanskys", 

413 dtype=float, 

414 default=0.01 

415 ) 

416 dChi2Tolerance = pexConfig.Field( 

417 doc="Absolute difference in Chi2 between iterations of line profile" 

418 "fitting that is acceptable for convergence", 

419 dtype=float, 

420 default=0.1 

421 ) 

422 detectedMaskPlane = pexConfig.Field( 

423 doc="Name of mask with pixels above detection threshold, used for first" 

424 "estimate of streak locations", 

425 dtype=str, 

426 default="DETECTED" 

427 ) 

428 streaksMaskPlane = pexConfig.Field( 

429 doc="Name of mask plane holding detected streaks", 

430 dtype=str, 

431 default="STREAK" 

432 ) 

433 

434 

435class MaskStreaksTask(pipeBase.Task): 

436 """Find streaks or other straight lines in image data. 

437 

438 Nearby objects passing through the field of view of the telescope leave a 

439 bright trail in images. This class uses the Kernel Hough Transform (KHT) 

440 (Fernandes and Oliveira, 2007), implemented in `lsst.houghtransform`. The 

441 procedure works by taking a binary image, either provided as put or produced 

442 from the input data image, using a Canny filter to make an image of the 

443 edges in the original image, then running the KHT on the edge image. The KHT 

444 identifies clusters of non-zero points, breaks those clusters of points into 

445 straight lines, keeps clusters with a size greater than the user-set 

446 threshold, then performs a voting procedure to find the best-fit coordinates 

447 of any straight lines. Given the results of the KHT algorithm, clusters of 

448 lines are identified and grouped (generally these correspond to the two 

449 edges of a strea) and a profile is fit to the streak in the original 

450 (non-binary) image. 

451 """ 

452 

453 ConfigClass = MaskStreaksConfig 

454 _DefaultName = "maskStreaks" 

455 

456 @timeMethod 

457 def find(self, maskedImage): 

458 """Find streaks in a masked image. 

459 

460 Parameters 

461 ---------- 

462 maskedImage : `lsst.afw.image.maskedImage` 

463 The image in which to search for streaks. 

464 

465 Returns 

466 ------- 

467 result : `lsst.pipe.base.Struct` 

468 Results as a struct with attributes: 

469 

470 ``originalLines`` 

471 Lines identified by kernel hough transform. 

472 ``lineClusters`` 

473 Lines grouped into clusters in rho-theta space. 

474 ``lines`` 

475 Final result for lines after line-profile fit. 

476 ``mask`` 

477 2-d boolean mask where detected lines are True. 

478 """ 

479 mask = maskedImage.mask 

480 detectionMask = (mask.array & mask.getPlaneBitMask(self.config.detectedMaskPlane)) 

481 

482 self.edges = self._cannyFilter(detectionMask) 

483 self.lines = self._runKHT(self.edges) 

484 

485 if len(self.lines) == 0: 

486 lineMask = np.zeros(detectionMask.shape, dtype=bool) 

487 fitLines = LineCollection([], []) 

488 clusters = LineCollection([], []) 

489 else: 

490 clusters = self._findClusters(self.lines) 

491 fitLines, lineMask = self._fitProfile(clusters, maskedImage) 

492 

493 # The output mask is the intersection of the fit streaks and the image detections 

494 outputMask = lineMask & detectionMask.astype(bool) 

495 

496 return pipeBase.Struct( 

497 lines=fitLines, 

498 lineClusters=clusters, 

499 originalLines=self.lines, 

500 mask=outputMask, 

501 ) 

502 

503 @timeMethod 

504 def run(self, maskedImage): 

505 """Find and mask streaks in a masked image. 

506 

507 Finds streaks in the image and modifies maskedImage in place by adding a 

508 mask plane with any identified streaks. 

509 

510 Parameters 

511 ---------- 

512 maskedImage : `lsst.afw.image.Exposure` or `lsst.afw.image.maskedImage` 

513 The image in which to search for streaks. The mask detection plane 

514 corresponding to `config.detectedMaskPlane` must be set with the 

515 detected pixels. The mask will have a plane added with any detected 

516 streaks, and with the mask plane name set by 

517 self.config.streaksMaskPlane. 

518 

519 Returns 

520 ------- 

521 result : `lsst.pipe.base.Struct` 

522 Results as a struct with attributes: 

523 

524 ``originalLines`` 

525 Lines identified by kernel hough transform. 

526 ``lineClusters`` 

527 Lines grouped into clusters in rho-theta space. 

528 ``lines`` 

529 Final result for lines after line-profile fit. 

530 """ 

531 streaks = self.find(maskedImage) 

532 

533 maskedImage.mask.addMaskPlane(self.config.streaksMaskPlane) 

534 maskedImage.mask.array[streaks.mask] |= maskedImage.mask.getPlaneBitMask(self.config.streaksMaskPlane) 

535 

536 return pipeBase.Struct( 

537 lines=streaks.lines, 

538 lineClusters=streaks.lineClusters, 

539 originalLines=streaks.originalLines, 

540 ) 

541 

542 def _cannyFilter(self, image): 

543 """Apply a canny filter to the data in order to detect edges. 

544 

545 Parameters 

546 ---------- 

547 image : `np.ndarray` 

548 2-d image data on which to run filter. 

549 

550 Returns 

551 ------- 

552 cannyData : `np.ndarray` 

553 2-d image of edges found in input image. 

554 """ 

555 # Ensure that the pixels are zero or one. Change the datatype to 

556 # np.float64 to be compatible with the Canny filter routine. 

557 filterData = (image > 0).astype(np.float64) 

558 return canny(filterData, use_quantiles=True, sigma=0.1) 

559 

560 def _runKHT(self, image): 

561 """Run Kernel Hough Transform on image. 

562 

563 Parameters 

564 ---------- 

565 image : `np.ndarray` 

566 2-d image data on which to detect lines. 

567 

568 Returns 

569 ------- 

570 result : `LineCollection` 

571 Collection of detected lines, with their detected rho and theta 

572 coordinates. 

573 """ 

574 lines = lsst.kht.find_lines(image, self.config.clusterMinimumSize, 

575 self.config.clusterMinimumDeviation, self.config.delta, 

576 self.config.minimumKernelHeight, self.config.nSigma, 

577 self.config.absMinimumKernelHeight) 

578 self.log.info("The Kernel Hough Transform detected %s line(s)", len(lines)) 

579 

580 return LineCollection(lines.rho, lines.theta) 

581 

582 def _findClusters(self, lines): 

583 """Group lines that are close in parameter space and likely describe 

584 the same streak. 

585 

586 Parameters 

587 ---------- 

588 lines : `LineCollection` 

589 Collection of lines to group into clusters. 

590 

591 Returns 

592 ------- 

593 result : `LineCollection` 

594 Average `Line` for each cluster of `Line`s in the input 

595 `LineCollection`. 

596 """ 

597 # Scale variables by threshold bin-size variable so that rho and theta 

598 # are on the same scale. Since the clustering algorithm below stops when 

599 # the standard deviation <= 1, after rescaling each cluster will have a 

600 # standard deviation at or below the bin-size. 

601 x = lines.rhos / self.config.rhoBinSize 

602 y = lines.thetas / self.config.thetaBinSize 

603 X = np.array([x, y]).T 

604 nClusters = 1 

605 

606 # Put line parameters in clusters by starting with all in one, then 

607 # subdividing until the parameters of each cluster have std dev=1. 

608 # If nClusters == len(lines), each line will have its own 'cluster', so 

609 # the standard deviations of each cluster must be zero and the loop 

610 # is guaranteed to stop. 

611 while True: 

612 kmeans = KMeans(n_clusters=nClusters, n_init='auto').fit(X) 

613 clusterStandardDeviations = np.zeros((nClusters, 2)) 

614 for c in range(nClusters): 

615 inCluster = X[kmeans.labels_ == c] 

616 clusterStandardDeviations[c] = np.std(inCluster, axis=0) 

617 # Are the rhos and thetas in each cluster all below the threshold? 

618 if (clusterStandardDeviations <= 1).all(): 

619 break 

620 nClusters += 1 

621 

622 # The cluster centers are final line estimates 

623 finalClusters = kmeans.cluster_centers_.T 

624 

625 # Rescale variables: 

626 finalRhos = finalClusters[0] * self.config.rhoBinSize 

627 finalThetas = finalClusters[1] * self.config.thetaBinSize 

628 result = LineCollection(finalRhos, finalThetas) 

629 self.log.info("Lines were grouped into %s potential streak(s)", len(finalRhos)) 

630 

631 return result 

632 

633 def _fitProfile(self, lines, maskedImage): 

634 """Fit the profile of the streak. 

635 

636 Given the initial parameters of detected lines, fit a model for the 

637 streak to the original (non-binary image). The assumed model is a 

638 straight line with a Moffat profile. 

639 

640 Parameters 

641 ---------- 

642 lines : `LineCollection` 

643 Collection of guesses for `Line`s detected in the image. 

644 maskedImage : `lsst.afw.image.maskedImage` 

645 Original image to be used to fit profile of streak. 

646 

647 Returns 

648 ------- 

649 lineFits : `LineCollection` 

650 Collection of `Line` profiles fit to the data. 

651 finalMask : `np.ndarray` 

652 2d mask array with detected streaks=1. 

653 """ 

654 data = maskedImage.image.array 

655 weights = maskedImage.variance.array**-1 

656 # Mask out any pixels with non-finite weights 

657 weights[~np.isfinite(weights) | ~np.isfinite(data)] = 0 

658 

659 lineFits = LineCollection([], []) 

660 finalLineMasks = [np.zeros(data.shape, dtype=bool)] 

661 nFinalLines = 0 

662 nFitFailures = 0 

663 for line in lines: 

664 line.sigma = self.config.invSigma**-1 

665 lineModel = LineProfile(data, weights, line=line) 

666 # Skip any lines that do not cover any data (sometimes happens because of chip gaps) 

667 if lineModel.lineMaskSize == 0: 

668 continue 

669 

670 fit, chi2, fitFailure = lineModel.fit(dChi2Tol=self.config.dChi2Tolerance, log=self.log) 

671 

672 # Initial estimate should be quite close: fit is deemed unsuccessful if rho or theta 

673 # change more than the allowed bin in rho or theta: 

674 if ((abs(fit.rho - line.rho) > 2 * self.config.rhoBinSize) 

675 or (abs(fit.theta - line.theta) > 2 * self.config.thetaBinSize)): 

676 fitFailure = True 

677 self.log.debug("Streak fit moved too far from initial estimate. Line will be dropped.") 

678 

679 if fitFailure: 

680 nFitFailures += 1 

681 continue 

682 

683 # Make mask 

684 lineModel.setLineMask(fit) 

685 finalModel = lineModel.makeProfile(fit) 

686 # Take absolute value, as streaks are allowed to be negative 

687 finalModelMax = abs(finalModel).max() 

688 finalLineMask = abs(finalModel) > self.config.footprintThreshold 

689 # Drop this line if the model profile is below the footprint threshold 

690 if not finalLineMask.any(): 

691 continue 

692 fit.chi2 = chi2 

693 fit.finalModelMax = finalModelMax 

694 lineFits.append(fit) 

695 finalLineMasks.append(finalLineMask) 

696 nFinalLines += 1 

697 

698 if nFitFailures > 0: 

699 self.log.info("Streak profile could not be fit for %d out of %d detected lines.", nFitFailures, 

700 len(lines)) 

701 finalMask = np.array(finalLineMasks).any(axis=0) 

702 

703 return lineFits, finalMask