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
« 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/>.
22import glob
23import os
24import typing
25from dataclasses import dataclass
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
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
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)
63@dataclass(slots=True)
64class Source:
65 """A single fast star tracker source-measurement result.
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 """
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
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
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
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
99 def __repr__(self) -> str:
100 """Return a concise multi-line summary of this `Source`.
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.
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
130class NanSource:
131 """Stand-in for `Source` used when no detections are present.
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 """
138 def __getattribute__(self, name: str) -> float:
139 return np.nan
142def getStreamingSequences(dayObs: int) -> dict[int, list[str]]:
143 """Get the streaming sequences for a dayObs.
145 Note that this will need rewriting very soon once the way the data is
146 organised on disk is changed.
148 Parameters
149 ----------
150 dayObs : `int`
151 The dayObs.
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.
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}")
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}")
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
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")
204 return data
207def getFlux(cutout: np.ndarray, backgroundLevel: float = 0) -> float:
208 """Get the flux inside a cutout, subtracting the image-background.
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.
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.
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
230 return rawFlux - (cutout.size * backgroundLevel)
233def getBackgroundLevel(exp: afwImage.Exposure, nSigma: float = 3) -> tuple[float, float]:
234 """Calculate the clipped image mean and stddev of an exposure.
236 Testing shows on images like this, 2 rounds of sigma clipping is more than
237 enough so this is left fixed here.
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.
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
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'.
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.
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.
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])
292def sortSourcesByFlux(sources: list[Source], reverse: bool = False) -> list[Source]:
293 """Sort the sources by flux, returning the brightest first.
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``.
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)
315def findFastStarTrackerImageSources(
316 filename: str, boxSize: int, attachCutouts: bool = True
317) -> list[Source | NanSource]:
318 """Analyze a single FastStarTracker image.
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.
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)
344 dayObs, seqNum, frameNum = dayObsSeqNumFrameNumFromFilename(filename)
346 sources: list[Source | NanSource] = []
347 if len(footprints) == 0:
348 sources = [NanSource()]
349 return sources
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()
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)
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]
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.
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).
393 Also displays the maximum (x, y) movements between adjacent exposures, and
394 the mean and stddev of the main source's flux.
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).
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)
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
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
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}")
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}")
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")
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 = "❌"
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 )
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")
486 if not silent:
487 for line in toPrint:
488 print(line)
490 return consistent
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.
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.
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?
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.
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 }
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")
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())
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)
559 title = f"dayObs {dayObs}, seqNum {seqNum}, frames {startFrame}-{endFrame}"
561 axisLabelSize = 18
563 figs = []
564 fig = plt.figure(figsize=(10, 16))
565 ax1, ax2, ax3 = fig.subplots(3, sharex=True)
566 fig.subplots_adjust(hspace=0)
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()
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()
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()
595 figs.append(fig)
597 fig = plt.figure(figsize=(10, 10))
598 ax4 = fig.subplots(1)
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)
620 return figs
623# -------------- plotting tools
626def plotSourcesOnImage(
627 parentFilename: str,
628 sources: Source | list[Source],
629) -> None:
630 """Plot one or more sources overlaid on their parent image.
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
643 fig = plt.figure(figsize=(16, 8))
644 ax = fig.subplots(1)
646 plt.imshow(data, interpolation="None", origin="lower")
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)
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)
660 # plot the bboxes on top
661 pc = PatchCollection(patches, edgecolor="r", facecolor="none")
662 ax.add_collection(pc)
664 plt.tight_layout()
667def plotSource(source: Source) -> None:
668 """Plot a single source's cutout with its fitted centroid marked.
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`.
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 )
687 fig = plt.figure(figsize=(16, 8))
688 ax = fig.subplots(1)
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
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)
698 plt.tight_layout()