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

276 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-30 09:36 +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 dataclass for FastStarTracker analysis results.""" 

66 

67 dayObs: int # mandatory attribute - the dayObs 

68 seqNum: int # mandatory attribute - the seqNum 

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

70 

71 # raw numbers 

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

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

74 rawFlux: float = np.nan 

75 nPix: int | float = np.nan 

76 bbox: geom.Box2I | None = None 

77 cutout: np.ndarray | None = None 

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

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

80 

81 # numbers from the hsm moments fit 

82 hsmFittedFlux: float = np.nan 

83 hsmCentroidX: float = np.nan 

84 hsmCentroidY: float = np.nan 

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

86 

87 imageBackground: float = np.nan 

88 imageStddev: float = np.nan 

89 nSourcesInImage: int | float = np.nan 

90 parentImageWidth: int | float = np.nan 

91 parentImageHeight: int | float = np.nan 

92 expTime: float = np.nan 

93 

94 def __repr__(self): 

95 """Print everything except the full details of the moments.""" 

96 retStr = "" 

97 for itemName in self.__slots__: 

98 v = getattr(self, itemName) 

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

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

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

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

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

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

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

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

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

108 if v is None: 

109 retStr += "cutout = None\n" 

110 else: 

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

112 return retStr 

113 

114 

115class NanSource: 

116 def __getattribute__(self, name: str): 

117 return np.nan 

118 

119 

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

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

122 

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

124 organised on disk is changed. 

125 

126 Parameters 

127 ---------- 

128 dayObs : `int` 

129 The dayObs. 

130 

131 Returns 

132 ------- 

133 sequences : `dict` [`int`, `list`] 

134 The streaming sequences in a dict, keyed by sequence number, with each 

135 value being a list of the files in that sequence. 

136 """ 

137 site = getSite() 

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

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

140 elif site == "summit": 

141 rootDataPath = "/project" 

142 else: 

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

144 

145 dataDir = getRawDataDirForDayObs(rootDataPath, fastCam, dayObs) 

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

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

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

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

150 

151 data = {} 

152 if dayObs < 20240311: 

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

154 # mode data in the GenericCamera 

155 for filename in sorted(streamingFiles): 

156 basename = os.path.basename(filename) 

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

158 if seqNum not in data: 

159 data[seqNum] = [filename] 

160 else: 

161 data[seqNum].append(filename) 

162 else: 

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

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

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

166 for d in dirNames: 

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

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

169 data[seqNum] = files 

170 

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

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

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

174 

175 return data 

176 

177 

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

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

180 

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

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

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

184 

185 Parameters 

186 ---------- 

187 cutout : `np.array` 

188 The cutout as a raw array. 

189 backgroundLevel : `float`, optional 

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

191 

192 Returns 

193 ------- 

194 flux : `float` 

195 The flux of the source in the cutout. 

196 """ 

197 rawFlux = np.sum(cutout) 

198 if not backgroundLevel: 

199 return rawFlux 

200 

201 return rawFlux - (cutout.size * backgroundLevel) 

202 

203 

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

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

206 

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

208 enough so this is left fixed here. 

209 

210 Parameters 

211 ---------- 

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

213 The exposure. 

214 nSigma : `float`, optional 

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

216 

217 Returns 

218 ------- 

219 mean : `float` 

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

221 stddev : `float` 

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

223 """ 

224 sctrl = afwMath.StatisticsControl() 

225 sctrl.setNumSigmaClip(nSigma) 

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

227 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP 

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

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

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

231 return mean, std 

232 

233 

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

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

236 

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

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

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

240 right when I plotted it by eye. 

241 

242 Parameters 

243 ---------- 

244 cutout : `np.array` 

245 The cutout to measure. 

246 bgMean : `float` 

247 The background level. 

248 bgStd : `float` 

249 The clipped standard deviation in the image. 

250 nSigma : `float`, optional 

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

252 over threshold. 

253 

254 Returns 

255 ------- 

256 nPix : `int` 

257 The number of pixels above threshold. 

258 """ 

259 inds = np.where(cutout > (bgMean + 0 * bgStd)) 

260 return len(inds[0]) 

261 

262 

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

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

265 

266 Parameters 

267 ---------- 

268 sources : `list` of 

269 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

270 The list of sources to sort. 

271 reverse : `bool`, optional 

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

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

274 

275 Returns 

276 ------- 

277 sources : `list` of 

278 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

279 The sources, sorted by flux. 

280 """ 

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

282 # reverse arg to still behave as one would expect 

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

284 

285 

286def findFastStarTrackerImageSources( 

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

288) -> list[Source | NanSource]: 

289 """Analyze a single FastStarTracker image. 

290 

291 Parameters 

292 ---------- 

293 filename : `str` 

294 The full name and path of the file. 

295 boxSize : `int` 

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

297 attachCutouts : `bool`, optional 

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

299 debug/plotting but adds memory usage. 

300 

301 Returns 

302 ------- 

303 sources : `list` of 

304 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

305 The sources in the image, sorted by rawFlux. 

306 """ 

307 exp = openFile(filename) 

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

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

310 expTime = exp.visitInfo.exposureTime 

311 footprintSet = detectObjectsInExp(exp) 

312 footprints = footprintSet.getFootprints() 

313 bgMean, bgStd = getBackgroundLevel(exp) 

314 

315 dayObs, seqNum, frameNum = dayObsSeqNumFrameNumFromFilename(filename) 

316 

317 sources = [] 

318 if len(footprints) == 0: 

319 sources = [NanSource()] 

320 return sources 

321 

322 for footprint in footprints: 

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

324 source.expTime = expTime 

325 source.nSourcesInImage = len(footprints) 

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

327 

328 centroid = footprint.getCentroid() 

329 bbox = getBboxAround(centroid, boxSize, exp) 

330 source.bbox = bbox 

331 cutout = exp.image[bbox].array 

332 if attachCutouts: 

333 source.cutout = cutout 

334 source.centroidX = centroid[0] 

335 source.centroidY = centroid[1] 

336 source.rawFlux = getFlux(cutout, bgMean) 

337 source.imageBackground = bgMean 

338 source.imageStddev = bgStd 

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

340 

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

342 source.moments = moments 

343 source.hsmFittedFlux = moments.moments_amp 

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

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

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

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

348 sources.append(source) 

349 return sortSourcesByFlux(sources) 

350 

351 

352def checkResultConsistency( 

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

354 maxAllowableShift: float = 5, 

355 silent: bool = False, 

356) -> bool: 

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

358 

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

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

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

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

363 

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

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

366 

367 Parameters 

368 ---------- 

369 results : `dict` of `list` of 

370 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

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

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

373 ``findFastStarTrackerImageSources()``. 

374 maxAllowableShift : `float` 

375 The biggest centroid shift between adjacent images allowable before 

376 something is considered to have gone wrong. 

377 silent : `bool`, optional 

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

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

380 being called by other code rather than users). 

381 

382 Returns 

383 ------- 

384 consistent : `bool` 

385 Are the results nominally consistent? 

386 """ 

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

388 results = list(results) 

389 

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

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

392 if not silent: 

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

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

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

396 return True 

397 

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

399 if not silent: 

400 print( 

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

402 " and other checks cannot be run" 

403 ) 

404 return False 

405 

406 consistent = True 

407 toPrint = [] 

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

409 if len(nSources) != 1: 

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

411 consistent = False 

412 else: 

413 n = nSources.pop() 

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

415 

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

417 if len(nSourcesCounted) != 1: 

418 toPrint.append( 

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

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

421 ) 

422 consistent = False 

423 else: 

424 n = nSourcesCounted.pop() 

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

426 

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

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

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

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

431 consistent = False 

432 else: 

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

434 

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

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

437 # maximum deltas for the primary sources 

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

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

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

441 maxMovementX = np.max(dx) 

442 maxMovementY = np.max(dy) 

443 happyOrSad = "✅" 

444 if max(maxMovementX, maxMovementY) > maxAllowableShift: 

445 consistent = False 

446 happyOrSad = "❌" 

447 

448 toPrint.append( 

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

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

451 ) 

452 

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

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

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

456 

457 if not silent: 

458 for line in toPrint: 

459 print(line) 

460 

461 return consistent 

462 

463 

464def plotSourceMovement( 

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

466 sourceIndex: int = 0, 

467 allowInconsistent: bool = False, 

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

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

470 

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

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

473 the list of sources in each image. 

474 

475 Parameters 

476 ---------- 

477 results : `dict` of `list` of 

478 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

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

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

481 ``findFastStarTrackerImageSources()``. 

482 sourceIndex : `int`, optional 

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

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

485 source by default. 

486 allowInconsistent : `bool`, optional 

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

488 

489 Returns 

490 ------- 

491 figs : `list` of `matplotlib.figure.Figure` 

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

493 image sequence, and the second is a scatter plot of the x and y, with 

494 the color showing the position in the sequence. 

495 """ 

496 opts = { 

497 "marker": "o", 

498 "markersize": 6, 

499 "linestyle": "-", 

500 } 

501 

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

503 if not consistent and not allowInconsistent: 

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

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

506 

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

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

509 sources = list(sourceDict.values()) 

510 

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

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

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

514 raise ValueError( 

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

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

517 ) 

518 dayObs = allDayObs.pop() 

519 seqNum = allSeqNums.pop() 

520 startFrame = min(frameNums) 

521 endFrame = max(frameNums) 

522 

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

524 

525 axisLabelSize = 18 

526 

527 figs = [] 

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

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

530 fig.subplots_adjust(hspace=0) 

531 

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

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

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

535 ax1.set_title(title) 

536 ax1.legend() 

537 

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

539 ax2.plot( 

540 frameNums, 

541 [s.hsmCentroidX for s in sources], 

542 label="Fitted centroid x", 

543 **opts, 

544 ) 

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

546 ax2.legend() 

547 

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

549 ax3.plot( 

550 frameNums, 

551 [s.hsmCentroidY for s in sources], 

552 label="Fitted centroid y", 

553 **opts, 

554 ) 

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

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

557 ax3.legend() 

558 

559 figs.append(fig) 

560 

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

562 ax4 = fig.subplots(1) 

563 

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

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

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

567 axRef = ax4.scatter( 

568 [s.centroidX for s in sources], 

569 [s.centroidY for s in sources], 

570 c=colors, 

571 cmap="gnuplot2", 

572 ) 

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

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

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

576 # move the colorbar 

577 divider = make_axes_locatable(ax4) 

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

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

580 ax4.set_title(title) 

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

582 figs.append(fig) 

583 

584 return figs 

585 

586 

587# -------------- plotting tools 

588 

589 

590def plotSourcesOnImage( 

591 parentFilename: str, 

592 sources: Source | list[Source], 

593) -> None: 

594 """Plot one of more source on top of an image. 

595 

596 Parameters 

597 ---------- 

598 parentFilename : `str` 

599 The full path to the parent (.tif) file. 

600 sources : `list` of 

601 `lsst.summit.extras.fastStarTrackerAnalysis.Source` or 

602 `lsst.summit.extras.fastStarTrackerAnalysis.Source` 

603 The sources found in the image. 

604 """ 

605 exp = openFile(parentFilename) 

606 data = exp.image.array 

607 

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

609 ax = fig.subplots(1) 

610 

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

612 

613 sources = ensure_iterable(sources) 

614 patches = [] 

615 for source in sources: 

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

617 patch = bboxToMatplotlibRectanle(source.bbox) 

618 patches.append(patch) 

619 

620 # move the colorbar 

621 divider = make_axes_locatable(ax) 

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

623 plt.colorbar(cax=cax) 

624 

625 # plot the bboxes on top 

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

627 ax.add_collection(pc) 

628 

629 plt.tight_layout() 

630 

631 

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

633 """Plot a single source. 

634 

635 Parameters 

636 ---------- 

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

638 The source to plot. 

639 """ 

640 if source.cutout is None: 

641 raise RuntimeError( 

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

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

644 ) 

645 

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

647 ax = fig.subplots(1) 

648 

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

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

651 

652 # move the colorbar 

653 divider = make_axes_locatable(ax) 

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

655 plt.colorbar(cax=cax) 

656 

657 plt.tight_layout()