Coverage for python / lsst / summit / extras / fastStarTrackerAnalysis.py: 15%

276 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-04 17:51 +0000

1# This file is part of summit_extras. 

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 

22import glob 

23import os 

24import typing 

25from dataclasses import dataclass 

26 

27import galsim 

28import matplotlib 

29import matplotlib.pyplot as plt 

30import numpy as np 

31from matplotlib.collections import PatchCollection 

32from mpl_toolkits.axes_grid1 import make_axes_locatable 

33 

34import lsst.afw.image as afwImage 

35import lsst.afw.math as afwMath 

36import lsst.geom as geom 

37from lsst.summit.utils.starTracker import ( 

38 dayObsSeqNumFrameNumFromFilename, 

39 fastCam, 

40 getRawDataDirForDayObs, 

41 isStreamingModeFile, 

42 openFile, 

43) 

44from lsst.summit.utils.utils import bboxToMatplotlibRectanle, detectObjectsInExp, getBboxAround, getSite 

45from lsst.utils.iteration import ensure_iterable 

46 

47__all__ = ( 

48 "getStreamingSequences", 

49 "getFlux", 

50 "getBackgroundLevel", 

51 "countOverThresholdPixels", 

52 "sortSourcesByFlux", 

53 "findFastStarTrackerImageSources", 

54 "checkResultConsistency", 

55 "plotSourceMovement", 

56 "plotSource", 

57 "plotSourcesOnImage", 

58 "Source", 

59 "NanSource", 

60) 

61 

62 

63@dataclass(slots=True) 

64class Source: 

65 """A single fast star tracker source-measurement result. 

66 

67 Holds the raw centroid and flux derived from the footprint, plus 

68 the results of the galsim HSM adaptive-moments fit, along with 

69 metadata describing the parent image and exposure. 

70 """ 

71 

72 dayObs: int # mandatory attribute - the dayObs 

73 seqNum: int # mandatory attribute - the seqNum 

74 frameNum: int # mandatory attribute - the sub-sequence number, the position in the sequence 

75 

76 # raw numbers 

77 centroidX: float = np.nan # in image coordinates 

78 centroidY: float = np.nan # in image coordinates 

79 rawFlux: float = np.nan 

80 nPix: int | float = np.nan 

81 bbox: geom.Box2I | None = None 

82 cutout: np.ndarray | None = None 

83 localCentroidX: float = np.nan # in cutout coordinates 

84 localCentroidY: float = np.nan # in cutout coordinates 

85 

86 # numbers from the hsm moments fit 

87 hsmFittedFlux: float = np.nan 

88 hsmCentroidX: float = np.nan 

89 hsmCentroidY: float = np.nan 

90 moments: galsim.hsm.ShapeData | None = None # keep the full fit even though we pull some things out too 

91 

92 imageBackground: float = np.nan 

93 imageStddev: float = np.nan 

94 nSourcesInImage: int | float = np.nan 

95 parentImageWidth: int | float = np.nan 

96 parentImageHeight: int | float = np.nan 

97 expTime: float = np.nan 

98 

99 def __repr__(self) -> str: 

100 """Return a concise multi-line summary of this `Source`. 

101 

102 Floats are rounded to three decimal places and the full 

103 contents of the ``moments``, ``bbox``, and ``cutout`` slots 

104 are replaced with their types to avoid flooding logs. 

105 

106 Returns 

107 ------- 

108 summary : `str` 

109 The human-readable summary. 

110 """ 

111 retStr = "" 

112 for itemName in self.__slots__: 

113 v = getattr(self, itemName) 

114 if isinstance(v, int): # print ints as ints 

115 retStr += f"{itemName} = {v}\n" 

116 elif isinstance(v, float): # but round floats at 3dp 

117 retStr += f"{itemName} = {v:.3f}\n" 

118 elif itemName == "moments": # and don't spam the full moments 

119 retStr += f"moments = {type(v)}\n" 

120 elif itemName == "bbox": # and don't spam the full moments 

121 retStr += f"bbox = lsst.geom.{repr(v)}\n" 

122 elif itemName == "cutout": # and don't spam the full moments 

123 if v is None: 

124 retStr += "cutout = None\n" 

125 else: 

126 retStr += f"cutout = {type(v)}\n" 

127 return retStr 

128 

129 

130class NanSource: 

131 """Stand-in for `Source` used when no detections are present. 

132 

133 Every attribute access returns ``numpy.nan`` so that downstream 

134 plotting and aggregation code can treat empty images uniformly 

135 without special-casing. 

136 """ 

137 

138 def __getattribute__(self, name: str) -> float: 

139 return np.nan 

140 

141 

142def getStreamingSequences(dayObs: int) -> dict[int, list[str]]: 

143 """Get the streaming sequences for a dayObs. 

144 

145 Note that this will need rewriting very soon once the way the data is 

146 organised on disk is changed. 

147 

148 Parameters 

149 ---------- 

150 dayObs : `int` 

151 The dayObs. 

152 

153 Returns 

154 ------- 

155 sequences : `dict` [`int`, `list` [`str`]] 

156 The streaming sequences in a dict, keyed by sequence number, 

157 with each value being the sorted list of fits files in that 

158 sequence. 

159 

160 Raises 

161 ------ 

162 ValueError 

163 Raised when running at a site where the StarTracker raw data 

164 layout is not known. 

165 """ 

166 site = getSite() 

167 if site in ["rubin-devl", "staff-rsp"]: 

168 rootDataPath = "/sdf/data/rubin/offline/s3-backup/lfa/" 

169 elif site == "summit": 

170 rootDataPath = "/project" 

171 else: 

172 raise ValueError(f"Finding StarTracker data isn't supported at {site}") 

173 

174 dataDir = getRawDataDirForDayObs(rootDataPath, fastCam, dayObs) 

175 files = glob.glob(os.path.join(dataDir, "*.fits")) 

176 regularFiles = [f for f in files if not isStreamingModeFile(f)] 

177 streamingFiles = [f for f in files if isStreamingModeFile(f)] 

178 print(f"Found {len(regularFiles)} regular files on dayObs {dayObs}") 

179 

180 data = {} 

181 if dayObs < 20240311: 

182 # after this is when we changed the data layout on disk for streaming 

183 # mode data in the GenericCamera 

184 for filename in sorted(streamingFiles): 

185 basename = os.path.basename(filename) 

186 seqNum = int(basename.split("_")[3]) 

187 if seqNum not in data: 

188 data[seqNum] = [filename] 

189 else: 

190 data[seqNum].append(filename) 

191 else: 

192 # dirNames here doesn't contain the full path, it's just the individual 

193 # directory name and needs joining with dataDir for the full path 

194 dirNames = sorted(d for d in os.listdir(dataDir) if os.path.isdir(os.path.join(dataDir, d))) 

195 for d in dirNames: 

196 files = sorted(glob.glob(os.path.join(dataDir, d, "*.fits"))) 

197 seqNum = int(d.split("_")[3]) 

198 data[seqNum] = files 

199 

200 print(f"Found {len(data)} streaming sequences on dayObs {dayObs}:") 

201 for seqNum, files in data.items(): 

202 print(f"seqNum {seqNum} with {len(files)} frames") 

203 

204 return data 

205 

206 

207def getFlux(cutout: np.ndarray, backgroundLevel: float = 0) -> float: 

208 """Get the flux inside a cutout, subtracting the image-background. 

209 

210 Here the flux is simply summed, and if the image background level is 

211 supplied, it is subtracted off, assuming it is constant over the cutout. A 

212 more accurate(?) flux is obtained by the hsm model fit. 

213 

214 Parameters 

215 ---------- 

216 cutout : `np.array` 

217 The cutout as a raw array. 

218 backgroundLevel : `float`, optional 

219 If supplied, this is subtracted as a constant background level. 

220 

221 Returns 

222 ------- 

223 flux : `float` 

224 The flux of the source in the cutout. 

225 """ 

226 rawFlux = np.sum(cutout) 

227 if not backgroundLevel: 

228 return rawFlux 

229 

230 return rawFlux - (cutout.size * backgroundLevel) 

231 

232 

233def getBackgroundLevel(exp: afwImage.Exposure, nSigma: float = 3) -> tuple[float, float]: 

234 """Calculate the clipped image mean and stddev of an exposure. 

235 

236 Testing shows on images like this, 2 rounds of sigma clipping is more than 

237 enough so this is left fixed here. 

238 

239 Parameters 

240 ---------- 

241 exp : `lsst.afw.image.Exposure` 

242 The exposure. 

243 nSigma : `float`, optional 

244 The number of sigma to clip to for the background estimation. 

245 

246 Returns 

247 ------- 

248 mean : `float` 

249 The clipped mean, as an estimate of the background level 

250 stddev : `float` 

251 The clipped standard deviation, as an estimate of the background noise. 

252 """ 

253 sctrl = afwMath.StatisticsControl() 

254 sctrl.setNumSigmaClip(nSigma) 

255 sctrl.setNumIter(2) # this is always plenty here 

256 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP 

257 stats = afwMath.makeStatistics(exp.maskedImage, statTypes, sctrl) 

258 std, _ = stats.getResult(afwMath.STDEVCLIP) 

259 mean, _ = stats.getResult(afwMath.MEANCLIP) 

260 return mean, std 

261 

262 

263def countOverThresholdPixels(cutout: np.ndarray, bgMean: float, bgStd: float, nSigma: float = 15) -> int: 

264 """Get the number of pixels in the cutout which are 'in the source'. 

265 

266 From the one image I've looked at so far, the drop-off is quite slow 

267 probably due to some combination of focus, plate scale, star brightness, 

268 pointing quality etc, so the default nSigma is 15 here as that looked about 

269 right when I plotted it by eye. 

270 

271 Parameters 

272 ---------- 

273 cutout : `np.array` 

274 The cutout to measure. 

275 bgMean : `float` 

276 The background level. 

277 bgStd : `float` 

278 The clipped standard deviation in the image. 

279 nSigma : `float`, optional 

280 The number of sigma above background at which to count pixels as being 

281 over threshold. 

282 

283 Returns 

284 ------- 

285 nPix : `int` 

286 The number of pixels above threshold. 

287 """ 

288 inds = np.where(cutout > (bgMean + nSigma * bgStd)) 

289 return len(inds[0]) 

290 

291 

292def sortSourcesByFlux(sources: list[Source], reverse: bool = False) -> list[Source]: 

293 """Sort the sources by flux, returning the brightest first. 

294 

295 Parameters 

296 ---------- 

297 sources : `list` of 

298 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

299 The list of sources to sort. 

300 reverse : `bool`, optional 

301 Return the brightest at the start of the list if ``reverse`` is 

302 ``False``, or the brightest last if ``reverse`` is ``True``. 

303 

304 Returns 

305 ------- 

306 sources : `list` of 

307 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

308 The sources, sorted by flux. 

309 """ 

310 # invert reverse because we want brightest first by default, but want the 

311 # reverse arg to still behave as one would expect 

312 return sorted(sources, key=lambda s: s.rawFlux, reverse=not reverse) 

313 

314 

315def findFastStarTrackerImageSources( 

316 filename: str, boxSize: int, attachCutouts: bool = True 

317) -> list[Source | NanSource]: 

318 """Analyze a single FastStarTracker image. 

319 

320 Parameters 

321 ---------- 

322 filename : `str` 

323 The full name and path of the file. 

324 boxSize : `int` 

325 The size of the box to put around each source for measurement. 

326 attachCutouts : `bool`, optional 

327 Attach the cutouts to the ``Source`` objects? Useful for 

328 debug/plotting but adds memory usage. 

329 

330 Returns 

331 ------- 

332 sources : `list` of 

333 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

334 The sources in the image, sorted by rawFlux. 

335 """ 

336 exp = openFile(filename) 

337 # if the upstream exposure reading code hasn't set the 

338 # visitInfo.exposureTime then this will return nan, as desired 

339 expTime = exp.visitInfo.exposureTime 

340 footprintSet = detectObjectsInExp(exp) 

341 footprints = footprintSet.getFootprints() 

342 bgMean, bgStd = getBackgroundLevel(exp) 

343 

344 dayObs, seqNum, frameNum = dayObsSeqNumFrameNumFromFilename(filename) 

345 

346 sources: list[Source | NanSource] = [] 

347 if len(footprints) == 0: 

348 sources = [NanSource()] 

349 return sources 

350 

351 for footprint in footprints: 

352 source = Source(dayObs=dayObs, seqNum=seqNum, frameNum=frameNum) 

353 source.expTime = expTime 

354 source.nSourcesInImage = len(footprints) 

355 source.parentImageWidth, source.parentImageHeight = exp.getDimensions() 

356 

357 centroid = footprint.getCentroid() 

358 bbox = getBboxAround(centroid, boxSize, exp) 

359 source.bbox = bbox 

360 cutout = exp.image[bbox].array 

361 if attachCutouts: 

362 source.cutout = cutout 

363 source.centroidX = centroid[0] 

364 source.centroidY = centroid[1] 

365 source.rawFlux = getFlux(cutout, bgMean) 

366 source.imageBackground = bgMean 

367 source.imageStddev = bgStd 

368 source.nPix = countOverThresholdPixels(cutout, bgMean, bgStd) 

369 

370 moments = galsim.hsm.FindAdaptiveMom(galsim.Image(cutout)) 

371 source.moments = moments 

372 source.hsmFittedFlux = moments.moments_amp 

373 source.hsmCentroidX = moments.moments_centroid.x + bbox.minX - 1 

374 source.hsmCentroidY = moments.moments_centroid.y + bbox.minY - 1 

375 source.localCentroidX = moments.moments_centroid.x - 1 

376 source.localCentroidY = moments.moments_centroid.y - 1 

377 sources.append(source) 

378 return sortSourcesByFlux(sources) # type: ignore[arg-type, return-value] 

379 

380 

381def checkResultConsistency( 

382 results: list[list[Source]] | typing.ValuesView[list[Source]], 

383 maxAllowableShift: float = 5, 

384 silent: bool = False, 

385) -> bool: 

386 """Check if a set of results are self-consistent. 

387 

388 Check the number of detected sources are the same in each image, that no 

389 sources have been removed from each image's source list, and that all the 

390 input images were the same size (because we read out sub frames, and 

391 analyzing these with full frame data invalidates the centroid coordinates). 

392 

393 Also displays the maximum (x, y) movements between adjacent exposures, and 

394 the mean and stddev of the main source's flux. 

395 

396 Parameters 

397 ---------- 

398 results : `dict` of `list` of 

399 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

400 A dict, keyed by sequence number, with each value being a list of the 

401 sources found in the image, e.g. as returned by 

402 ``findFastStarTrackerImageSources()``. 

403 maxAllowableShift : `float` 

404 The biggest centroid shift between adjacent images allowable before 

405 something is considered to have gone wrong. 

406 silent : `bool`, optional 

407 Print some useful checks and measurements if ``False``, otherwise just 

408 return whether the results appear nominally OK silently (for use when 

409 being called by other code rather than users). 

410 

411 Returns 

412 ------- 

413 consistent : `bool` 

414 Are the results nominally consistent? 

415 """ 

416 if isinstance(results, typing.ValuesView): # in case we're passed a .values() 

417 results = list(results) 

418 

419 sourceCounts = set([len(sourceSet) for sourceSet in results]) 

420 if sourceCounts == {0}: # none of the images contain any detections 

421 if not silent: 

422 print("No images contain any sources. Results are technically consistent, but also useless.") 

423 # this is technically consistent, so return True, but any downstream 

424 # code which tries to make plots with these will fail, of course. 

425 return True 

426 

427 if 0 in ([len(sourceSet) for sourceSet in results]): 

428 if not silent: 

429 print( 

430 "Some results contain no sources. Results are therefore fundamentally inconsistent" 

431 " and other checks cannot be run" 

432 ) 

433 return False 

434 

435 consistent = True 

436 toPrint = [] 

437 nSources = set([sourceSet[0].nSourcesInImage for sourceSet in results]) 

438 if len(nSources) != 1: 

439 toPrint.append(f"❌ Images contain a variable number of sources: {nSources}") 

440 consistent = False 

441 else: 

442 n = nSources.pop() 

443 toPrint.append(f"✅ All images contain the same nominal number of sources at detection stage: {n}") 

444 

445 nSourcesCounted = set([len(sourceSet) for sourceSet in results]) 

446 if len(nSourcesCounted) != 1: 

447 toPrint.append( 

448 f"❌ Number of actual sources in each sourceSet varies, got: {nSourcesCounted}." 

449 " If some were manually removed you can ignore this" 

450 ) 

451 consistent = False 

452 else: 

453 n = nSourcesCounted.pop() 

454 toPrint.append(f"✅ All results contain the same number of actual sources per image: {n}") 

455 

456 widths = set([sourceSet[0].parentImageWidth for sourceSet in results]) 

457 heights = set([sourceSet[0].parentImageHeight for sourceSet in results]) 

458 if len(widths) != 1 or len(heights) != 1: 

459 toPrint.append(f"❌ Input images were of variable dimenions! {widths=}, {heights=}") 

460 consistent = False 

461 else: 

462 toPrint.append("✅ All input images were of the same dimensions") 

463 

464 if len(results) > 1: # can't np.diff an array of length 1 so these are not useful/defined 

465 # now the basic checks have passed, do some sanity checks on the 

466 # maximum deltas for the primary sources 

467 sources = [sourceSet[0] for sourceSet in results] 

468 dx = np.diff([s.centroidX for s in sources]) 

469 dy = np.diff([s.centroidY for s in sources]) 

470 maxMovementX = np.max(np.abs(dx)) 

471 maxMovementY = np.max(np.abs(dy)) 

472 happyOrSad = "✅" 

473 if max(maxMovementX, maxMovementY) > maxAllowableShift: 

474 consistent = False 

475 happyOrSad = "❌" 

476 

477 toPrint.append( 

478 f"{happyOrSad} Maximum centroid movement of brightest object between images in (x, y)" 

479 f" = ({maxMovementX:.2f}, {maxMovementY:.2f}) pix" 

480 ) 

481 

482 fluxStd = np.nanstd([s.rawFlux for s in sources]) 

483 fluxMean = np.nanmean([s.rawFlux for s in sources]) 

484 toPrint.append(f"Mean and stddev of flux from brightest object = {fluxMean:.1f} ± {fluxStd:.1f} ADU") 

485 

486 if not silent: 

487 for line in toPrint: 

488 print(line) 

489 

490 return consistent 

491 

492 

493def plotSourceMovement( 

494 results: dict[int, list[Source]], 

495 sourceIndex: int = 0, 

496 allowInconsistent: bool = False, 

497) -> list[matplotlib.figure.Figure]: 

498 """Plot the centroid movements and fluxes etc for a set of results. 

499 

500 By default the brightest source in each image is plotted, but this can be 

501 changed by setting ``sourceIndex`` to values greater than 0 to move through 

502 the list of sources in each image. 

503 

504 Parameters 

505 ---------- 

506 results : `dict` of `list` of 

507 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

508 A dict, keyed by sequence number, with each value being a list of the 

509 sources found in the image, e.g. as returned by 

510 ``findFastStarTrackerImageSources()``. 

511 sourceIndex : `int`, optional 

512 If there is more than one source in every image, which source number 

513 should the plot be made for? Defaults to zero, which is the brightest 

514 source by default. 

515 allowInconsistent : `bool`, optional 

516 Make the plots even if the input results appear to be inconsistent? 

517 

518 Returns 

519 ------- 

520 figs : `list` [`matplotlib.figure.Figure`] 

521 The figures. The first is the source's flux and x, y movement 

522 over the image sequence, and the second is a scatter plot of 

523 x vs. y, with the color showing the position in the sequence. 

524 

525 Raises 

526 ------ 

527 ValueError 

528 Raised if the supplied ``results`` are inconsistent (unless 

529 ``allowInconsistent`` is `True`) or if the sources span 

530 multiple dayObs or seqNum values. 

531 """ 

532 opts = { 

533 "marker": "o", 

534 "markersize": 6, 

535 "linestyle": "-", 

536 } 

537 

538 consistent = checkResultConsistency(results.values(), silent=True) 

539 if not consistent and not allowInconsistent: 

540 checkResultConsistency(results.values(), silent=False) # print the problem if we're raising 

541 raise ValueError("The sources were found to be inconsistent and allowInconsistent=False") 

542 

543 sourceDict = {k: v[sourceIndex] for k, v in results.items()} 

544 frameNums = [s.frameNum for s in sourceDict.values()] 

545 sources = list(sourceDict.values()) 

546 

547 allDayObs = set(s.dayObs for s in sources) 

548 allSeqNums = set(s.seqNum for s in sources) 

549 if len(allDayObs) > 1 or len(allSeqNums) > 1: 

550 raise ValueError( 

551 "The sources are from multiple days or sequences, found" 

552 f" {allDayObs} dayObs and {allSeqNums} seqNum values." 

553 ) 

554 dayObs = allDayObs.pop() 

555 seqNum = allSeqNums.pop() 

556 startFrame = min(frameNums) 

557 endFrame = max(frameNums) 

558 

559 title = f"dayObs {dayObs}, seqNum {seqNum}, frames {startFrame}-{endFrame}" 

560 

561 axisLabelSize = 18 

562 

563 figs = [] 

564 fig = plt.figure(figsize=(10, 16)) 

565 ax1, ax2, ax3 = fig.subplots(3, sharex=True) 

566 fig.subplots_adjust(hspace=0) 

567 

568 ax1.plot(frameNums, [s.rawFlux for s in sources], label="Raw Flux", **opts) 

569 ax1.plot(frameNums, [s.hsmFittedFlux for s in sources], label="Fitted Flux", **opts) 

570 ax1.set_ylabel("Flux (ADU)", size=axisLabelSize) 

571 ax1.set_title(title) 

572 ax1.legend() 

573 

574 ax2.plot(frameNums, [s.centroidX for s in sources], label="Raw centroid x", **opts) 

575 ax2.plot( 

576 frameNums, 

577 [s.hsmCentroidX for s in sources], 

578 label="Fitted centroid x", 

579 **opts, 

580 ) 

581 ax2.set_ylabel("x-centroid (pixels)", size=axisLabelSize) 

582 ax2.legend() 

583 

584 ax3.plot(frameNums, [s.centroidY for s in sources], label="Raw centroid y", **opts) 

585 ax3.plot( 

586 frameNums, 

587 [s.hsmCentroidY for s in sources], 

588 label="Fitted centroid y", 

589 **opts, 

590 ) 

591 ax3.set_ylabel("y-centroid (pixels)", size=axisLabelSize) 

592 ax3.set_xlabel("Frame number", size=axisLabelSize) 

593 ax3.legend() 

594 

595 figs.append(fig) 

596 

597 fig = plt.figure(figsize=(10, 10)) 

598 ax4 = fig.subplots(1) 

599 

600 colors = np.arange(len(sources)) 

601 # gnuplot2 has a nice balance of nothing white, and having an intuitive 

602 # progression of colours so the eye can pick out trends on the point cloud. 

603 axRef = ax4.scatter( 

604 [s.centroidX for s in sources], 

605 [s.centroidY for s in sources], 

606 c=colors, 

607 cmap="gnuplot2", 

608 ) 

609 ax4.set_xlabel("x-centroid (pixels)", size=axisLabelSize) 

610 ax4.set_ylabel("y-centroid (pixels)", size=axisLabelSize) 

611 ax4.set_aspect("equal", "box") 

612 # move the colorbar 

613 divider = make_axes_locatable(ax4) 

614 cax = divider.append_axes("right", size="5%", pad=0.05) 

615 cbar = plt.colorbar(axRef, cax=cax) 

616 ax4.set_title(title) 

617 cbar.set_label("Frame number in series", size=axisLabelSize * 0.75) 

618 figs.append(fig) 

619 

620 return figs 

621 

622 

623# -------------- plotting tools 

624 

625 

626def plotSourcesOnImage( 

627 parentFilename: str, 

628 sources: Source | list[Source], 

629) -> None: 

630 """Plot one or more sources overlaid on their parent image. 

631 

632 Parameters 

633 ---------- 

634 parentFilename : `str` 

635 The full path to the parent FITS file. 

636 sources : `lsst.summit.extras.fastStarTrackerAnalysis.Source` or \ 

637 `list` [`lsst.summit.extras.fastStarTrackerAnalysis.Source`] 

638 The source or sources found in the image. 

639 """ 

640 exp = openFile(parentFilename) 

641 data = exp.image.array 

642 

643 fig = plt.figure(figsize=(16, 8)) 

644 ax = fig.subplots(1) 

645 

646 plt.imshow(data, interpolation="None", origin="lower") 

647 

648 sources = list(ensure_iterable(sources)) 

649 patches = [] 

650 for source in sources: 

651 ax.scatter(source.centroidX, source.centroidY, color="red", marker="x") # mark the centroid 

652 patch = bboxToMatplotlibRectanle(source.bbox) 

653 patches.append(patch) 

654 

655 # move the colorbar 

656 divider = make_axes_locatable(ax) 

657 cax = divider.append_axes("right", size="5%", pad=0.05) 

658 plt.colorbar(cax=cax) 

659 

660 # plot the bboxes on top 

661 pc = PatchCollection(patches, edgecolor="r", facecolor="none") 

662 ax.add_collection(pc) 

663 

664 plt.tight_layout() 

665 

666 

667def plotSource(source: Source) -> None: 

668 """Plot a single source's cutout with its fitted centroid marked. 

669 

670 Parameters 

671 ---------- 

672 source : `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

673 The source to plot. Must have been measured with 

674 ``attachCutouts=True`` in `findFastStarTrackerImageSources`. 

675 

676 Raises 

677 ------ 

678 RuntimeError 

679 Raised if the source has no attached cutout. 

680 """ 

681 if source.cutout is None: 

682 raise RuntimeError( 

683 "Can only plot sources with attached cutouts. Either set attachCutouts=True " 

684 "in findFastStarTrackerImageSources() or try using plotSourcesOnImage() instead" 

685 ) 

686 

687 fig = plt.figure(figsize=(16, 8)) 

688 ax = fig.subplots(1) 

689 

690 plt.imshow(source.cutout, interpolation="None", origin="lower") # plot the image 

691 ax.scatter(source.localCentroidX, source.localCentroidY, color="red", marker="x", s=200) # mark centroid 

692 

693 # move the colorbar 

694 divider = make_axes_locatable(ax) 

695 cax = divider.append_axes("right", size="5%", pad=0.05) 

696 plt.colorbar(cax=cax) 

697 

698 plt.tight_layout()