Coverage for python / lsst / summit / extras / animation.py: 13%

232 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 08:54 +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 gc 

23import logging 

24import math 

25import os 

26import shutil 

27import subprocess 

28import uuid 

29from typing import Any 

30 

31import matplotlib.pyplot as plt 

32 

33import lsst.afw.display as afwDisplay 

34import lsst.afw.image as afwImage 

35import lsst.afw.math as afwMath 

36import lsst.daf.butler as dafButler 

37import lsst.meas.algorithms as measAlg 

38from lsst.atmospec.utils import airMassFromRawMetadata, getTargetCentroidFromWcs 

39from lsst.pipe.tasks.quickFrameMeasurement import QuickFrameMeasurementTask, QuickFrameMeasurementTaskConfig 

40from lsst.summit.utils.butlerUtils import ( 

41 getDayObs, 

42 getExpRecordFromDataId, 

43 getLatissOnSkyDataIds, 

44 getSeqNum, 

45 makeDefaultLatissButler, 

46 updateDataIdOrDataCord, 

47) 

48from lsst.summit.utils.dateTime import dayObsIntToString 

49from lsst.summit.utils.utils import setupLogging 

50 

51logger = logging.getLogger("lsst.summit.extras.animation") 

52setupLogging() 

53 

54 

55class Animator: 

56 """Animate a list of dataIds into an mp4 movie. 

57 

58 Iterates over the dataIds in the order supplied, renders each 

59 exposure for the specified data product as a PNG (caching PNGs in 

60 ``outputPath/pngs/``), stages them in a temporary numbered 

61 directory, and assembles them into an mp4 with ``ffmpeg``. 

62 

63 Parameters 

64 ---------- 

65 butler : `lsst.daf.butler.Butler` 

66 Butler with access to the specified data product. 

67 dataIdList : `list` [`dict`] 

68 DataIds to animate, in display order. 

69 outputPath : `str` 

70 Directory to write the mp4 and the ``pngs/`` cache directory 

71 to. Created if it does not exist. 

72 outputFilename : `str` 

73 Base name of the output mp4. The ``.mp4`` suffix is appended 

74 if missing. 

75 remakePngs : `bool`, optional 

76 If `True`, regenerate PNGs even when cached copies exist. 

77 clobberVideoAndGif : `bool`, optional 

78 If `True`, overwrite any existing output mp4. Otherwise raise 

79 if the file already exists. 

80 keepIntermediateGif : `bool`, optional 

81 Retained for backwards compatibility. Currently unused; 

82 intermediate gif generation is disabled. 

83 smoothImages : `bool`, optional 

84 If `True`, smooth the displayed image with a small Gaussian 

85 (for display only; centroiding still uses the raw exposure). 

86 plotObjectCentroids : `bool`, optional 

87 If `True`, overlay the target star centroid on each frame. 

88 useQfmForCentroids : `bool`, optional 

89 If `True`, use QuickFrameMeasurement for the overlay centroid 

90 instead of the WCS-based target lookup. 

91 dataProductToPlot : `str`, optional 

92 The butler dataset type to retrieve for each dataId. 

93 debug : `bool`, optional 

94 If `True`, emit extra debug logging. 

95 """ 

96 

97 def __init__( 

98 self, 

99 butler: dafButler.Butler, 

100 dataIdList: list[dict], 

101 outputPath: str, 

102 outputFilename: str, 

103 *, 

104 remakePngs: bool = False, 

105 clobberVideoAndGif: bool = False, 

106 keepIntermediateGif: bool = False, 

107 smoothImages: bool = True, 

108 plotObjectCentroids: bool = True, 

109 useQfmForCentroids: bool = False, 

110 dataProductToPlot: str = "calexp", 

111 debug: bool = False, 

112 ): 

113 self.butler = butler 

114 self.dataIdList = dataIdList 

115 self.outputPath = outputPath 

116 self.outputFilename = os.path.join(outputPath, outputFilename) 

117 if not self.outputFilename.endswith(".mp4"): 

118 self.outputFilename += ".mp4" 

119 self.pngPath = os.path.join(outputPath, "pngs/") 

120 

121 self.remakePngs = remakePngs 

122 self.clobberVideoAndGif = clobberVideoAndGif 

123 self.keepIntermediateGif = keepIntermediateGif 

124 self.smoothImages = smoothImages 

125 self.plotObjectCentroids = plotObjectCentroids 

126 self.useQfmForCentroids = useQfmForCentroids 

127 self.dataProductToPlot = dataProductToPlot 

128 self.debug = debug 

129 

130 # zfilled at the start as animation is alphabetical 

131 # if you're doing more than 1e6 files you've got bigger problems 

132 self.toAnimateTemplate = "%06d-%s-%s.png" 

133 self.basicTemplate = "%s-%s.png" 

134 

135 qfmTaskConfig = QuickFrameMeasurementTaskConfig() 

136 self.qfmTask = QuickFrameMeasurementTask(config=qfmTaskConfig) 

137 

138 afwDisplay.setDefaultBackend("matplotlib") 

139 self.fig = plt.figure(figsize=(15, 15)) 

140 self.disp = afwDisplay.Display(self.fig) 

141 self.disp.setImageColormap("gray") 

142 self.disp.scale("asinh", "zscale") 

143 

144 self.pngsToMakeDataIds: list[dict] = [] 

145 

146 self.preRun() # sets the above list 

147 

148 @staticmethod 

149 def _strDataId(dataId: dict) -> str: 

150 """Make a dataId into a string suitable for use as a filename. 

151 

152 Parameters 

153 ---------- 

154 dataId : `dict` 

155 The data id. 

156 

157 Returns 

158 ------- 

159 strId : `str` 

160 The data id as a string. 

161 """ 

162 if (dayObs := getDayObs(dataId)) and (seqNum := getSeqNum(dataId)): # nicely ordered if easy 

163 return f"{dayObsIntToString(dayObs)}-{seqNum:05d}" 

164 

165 # General case (and yeah, I should probably learn regex someday) 

166 dIdStr = str(dataId) 

167 dIdStr = dIdStr.replace(" ", "") 

168 dIdStr = dIdStr.replace("{", "") 

169 dIdStr = dIdStr.replace("}", "") 

170 dIdStr = dIdStr.replace("'", "") 

171 dIdStr = dIdStr.replace(":", "-") 

172 dIdStr = dIdStr.replace(",", "-") 

173 return dIdStr 

174 

175 def dataIdToFilename(self, dataId: dict, includeNumber: bool = False, imNum: int | None = None) -> str: 

176 """Convert a dataId to a PNG filename. 

177 

178 Parameters 

179 ---------- 

180 dataId : `dict` 

181 The dataId to render. 

182 includeNumber : `bool`, optional 

183 If `True`, prepend a zero-padded frame number so that 

184 alphabetical filename ordering matches the animation 

185 order. In this mode the returned value is a bare filename 

186 suitable for use inside the temporary staging directory. 

187 Otherwise a full path inside ``self.pngPath`` is returned. 

188 imNum : `int`, optional 

189 Frame number used when ``includeNumber`` is `True`. Must 

190 be provided in that case. 

191 

192 Returns 

193 ------- 

194 filename : `str` 

195 The PNG filename or full path, as described above. 

196 """ 

197 if includeNumber: 

198 assert imNum is not None 

199 

200 dIdStr = self._strDataId(dataId) 

201 

202 if includeNumber: # for use in temp dir, so not full path 

203 filename = self.toAnimateTemplate % (imNum, dIdStr, self.dataProductToPlot) 

204 return os.path.join(filename) 

205 else: 

206 filename = self.basicTemplate % (dIdStr, self.dataProductToPlot) 

207 return os.path.join(self.pngPath, filename) 

208 

209 def exists(self, obj: Any) -> bool: 

210 """Check whether a filesystem object exists. 

211 

212 Parameters 

213 ---------- 

214 obj : `str` 

215 The path to check. 

216 

217 Returns 

218 ------- 

219 exists : `bool` 

220 `True` if the path exists on disk. 

221 

222 Raises 

223 ------ 

224 RuntimeError 

225 Raised if ``obj`` is not a string; other existence checks 

226 are not yet implemented. 

227 """ 

228 if isinstance(obj, str): 

229 return os.path.exists(obj) 

230 raise RuntimeError("Other type checks not yet implemented") 

231 

232 def preRun(self) -> None: 

233 """Prepare output directories and determine which PNGs to build. 

234 

235 Creates the PNG cache directory if missing, handles the output 

236 mp4's pre-existence according to ``clobberVideoAndGif``, checks 

237 the butler for each dataId's dataset, logs a summary, and 

238 populates ``self.pngsToMakeDataIds`` with the dataIds whose 

239 PNGs still need to be generated. 

240 """ 

241 # check the paths work 

242 if not os.path.exists(self.pngPath): 

243 os.makedirs(self.pngPath) 

244 assert os.path.exists(self.pngPath), f"Failed to create output dir: {self.pngPath}" 

245 

246 if self.exists(self.outputFilename): 

247 if self.clobberVideoAndGif: 

248 os.remove(self.outputFilename) 

249 else: 

250 raise RuntimeError(f"Output file {self.outputFilename} exists and clobber==False") 

251 

252 # make list of found & missing files 

253 dIdsWithPngs = [d for d in self.dataIdList if self.exists(self.dataIdToFilename(d))] 

254 dIdsWithoutPngs = [d for d in self.dataIdList if d not in dIdsWithPngs] 

255 if self.debug: 

256 logger.info(f"dIdsWithPngs = {dIdsWithPngs}") 

257 logger.info(f"dIdsWithoutPngs = {dIdsWithoutPngs}") 

258 

259 # check the datasets exist for the pngs which need remaking 

260 missingData = [ 

261 d for d in dIdsWithoutPngs if not self.butler.exists(self.dataProductToPlot, d, detector=0) 

262 ] 

263 

264 logger.info(f"Of the provided {len(self.dataIdList)} dataIds:") 

265 logger.info(f"{len(dIdsWithPngs)} existing pngs were found") 

266 logger.info(f"{len(dIdsWithoutPngs)} do not yet exist") 

267 

268 if missingData: 

269 for dId in missingData: 

270 msg = f"Failed to find {self.dataProductToPlot} for {dId}" 

271 logger.warning(msg) 

272 self.dataIdList.remove(dId) 

273 logger.info( 

274 f"Of the {len(dIdsWithoutPngs)} dataIds without pngs, {len(missingData)}" 

275 " did not have the corresponding dataset existing" 

276 ) 

277 

278 if self.remakePngs: 

279 self.pngsToMakeDataIds = [d for d in self.dataIdList if d not in missingData] 

280 else: 

281 self.pngsToMakeDataIds = [d for d in dIdsWithoutPngs if d not in missingData] 

282 

283 msg = f"So {len(self.pngsToMakeDataIds)} will be made" 

284 if self.remakePngs and len(dIdsWithPngs) > 0: 

285 msg += " because remakePngs=True" 

286 logger.info(msg) 

287 

288 def run(self) -> str | None: 

289 """Render any missing PNGs and assemble the mp4 movie. 

290 

291 Returns 

292 ------- 

293 outputFilename : `str` or `None` 

294 Path to the produced mp4 file, or `None` if there were no 

295 dataIds to animate. 

296 """ 

297 # make the missing pngs 

298 if self.pngsToMakeDataIds: 

299 logger.info("Creating necessary pngs...") 

300 for i, dataId in enumerate(self.pngsToMakeDataIds): 

301 logger.info(f"Making png for file {i + 1} of {len(self.pngsToMakeDataIds)}") 

302 self.makePng(dataId, self.dataIdToFilename(dataId)) 

303 

304 # stage files in temp dir with numbers prepended to filenames 

305 if not self.dataIdList: 

306 logger.warning("No files to animate - nothing to do") 

307 return None 

308 

309 logger.info("Copying files to ordered temp dir...") 

310 pngFilesOriginal = [self.dataIdToFilename(d) for d in self.dataIdList] 

311 for filename in pngFilesOriginal: # these must all now exist, but let's assert just in case 

312 assert self.exists(filename) 

313 tempDir = os.path.join(self.pngPath, uuid.uuid1().hex[:8]) 

314 os.makedirs(tempDir) 

315 pngFileList = [] # list of number-prepended files in the temp dir 

316 for i, dId in enumerate(self.dataIdList): 

317 srcFile = self.dataIdToFilename(dId) 

318 destFile = os.path.join(tempDir, self.dataIdToFilename(dId, includeNumber=True, imNum=i)) 

319 shutil.copy(srcFile, destFile) 

320 pngFileList.append(destFile) 

321 

322 # # create gif in temp dir 

323 # outputGifFilename = os.path.join(tempDir, 'animation.gif') 

324 # self.pngsToGif(pngFileList, outputGifFilename) 

325 

326 # # gif turn into mp4, optionally keep gif by moving up to output dir 

327 # logger.info('Turning gif into mp4...') 

328 # outputMp4Filename = self.outputFilename 

329 # self.gifToMp4(outputGifFilename, outputMp4Filename) 

330 

331 # self.tidyUp(tempDir) 

332 # logger.info('Finished!') 

333 

334 # create gif in temp dir 

335 

336 logger.info("Making mp4 of pngs...") 

337 self.pngsToMp4(tempDir, self.outputFilename, 10, verbose=False) 

338 self.tidyUp(tempDir) 

339 logger.info(f"Finished! Output at {self.outputFilename}") 

340 return self.outputFilename 

341 

342 def _titleFromExp(self, exp: afwImage.Exposure, dataId: dict) -> str: 

343 """Build the plot title line for a single exposure. 

344 

345 Parameters 

346 ---------- 

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

348 The exposure being plotted (unused except to mirror the 

349 display path). 

350 dataId : `dict` 

351 The dataId for which to look up observing metadata. 

352 

353 Returns 

354 ------- 

355 title : `str` 

356 Human-readable title including seqNum, timestamp, target, 

357 exposure time, filter, grating, and airmass. 

358 """ 

359 expRecord = getExpRecordFromDataId(self.butler, dataId) 

360 obj = expRecord.target_name 

361 expTime = expRecord.exposure_time 

362 filterCompound = expRecord.physical_filter 

363 filt, grating = filterCompound.split("~") 

364 rawMd = self.butler.get("raw.metadata", dataId) 

365 airmass = airMassFromRawMetadata(rawMd) # XXX this could be improved a lot 

366 if not airmass: 

367 airmass = -1 

368 dayObsInt = getDayObs(dataId) 

369 assert dayObsInt is not None 

370 dayObs = dayObsIntToString(dayObsInt) 

371 timestamp = expRecord.timespan.begin.to_datetime().strftime("%H:%M:%S") # no microseconds 

372 ms = expRecord.timespan.begin.to_datetime().strftime("%f") # always 6 chars long, 000000 if zero 

373 timestamp += f".{ms[0:2]}" 

374 title = f"seqNum {getSeqNum(dataId)} - {dayObs} {timestamp}TAI - " 

375 title += f"Object: {obj} expTime: {expTime}s Filter: {filt} Grating: {grating} Airmass: {airmass:.3f}" 

376 return title 

377 

378 def getStarPixCoord( 

379 self, exp: Any, doMotionCorrection: bool = True, useQfm: bool = False 

380 ) -> tuple[float, float] | None: 

381 """Return the main star's pixel centroid for an exposure. 

382 

383 If ``self.useQfmForCentroids`` is set, this runs 

384 QuickFrameMeasurement on the exposure; otherwise the target 

385 position is derived from the WCS and the named target. 

386 

387 Parameters 

388 ---------- 

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

390 The exposure to measure. 

391 doMotionCorrection : `bool`, optional 

392 If `True`, apply proper-motion correction when looking up 

393 the target in the catalog. 

394 useQfm : `bool`, optional 

395 Unused; retained for API stability. Centroid source is 

396 controlled by ``self.useQfmForCentroids``. 

397 

398 Returns 

399 ------- 

400 pixCoord : `tuple` [`float`, `float`] or `None` 

401 The ``(x, y)`` pixel coordinate of the main star, or 

402 `None` if the measurement failed. 

403 """ 

404 target = exp.visitInfo.object 

405 

406 if self.useQfmForCentroids: 

407 try: 

408 result = self.qfmTask.run(exp) 

409 pixCoord = result.brightestObjCentroid 

410 expId = exp.info.id 

411 logger.info(f"expId {expId} has centroid {pixCoord}") 

412 except Exception: 

413 return None 

414 else: 

415 pixCoord = getTargetCentroidFromWcs(exp, target, doMotionCorrection=doMotionCorrection) 

416 return pixCoord 

417 

418 def makePng(self, dataId: dict, saveFilename: str) -> None: 

419 """Render a single dataId to a PNG on disk. 

420 

421 Parameters 

422 ---------- 

423 dataId : `dict` 

424 The dataId to render. 

425 saveFilename : `str` 

426 Full path of the PNG to write. 

427 """ 

428 if self.exists(saveFilename) and not self.remakePngs: # should not be possible due to prerun 

429 raise RuntimeError(f"Almost overwrote {saveFilename} - how is this possible?") 

430 

431 if self.debug: 

432 logger.info(f"Creating {saveFilename}") 

433 

434 self.fig.clear() 

435 

436 # must always keep exp unsmoothed for the centroiding via qfm 

437 try: 

438 exp = self.butler.get(self.dataProductToPlot, dataId) 

439 except Exception: 

440 # oh no, that should never happen, but it does! Let's just skip 

441 logger.warning(f"Skipped {dataId}, because {self.dataProductToPlot} retrieval failed!") 

442 return 

443 toDisplay = exp 

444 if self.smoothImages: 

445 toDisplay = exp.clone() 

446 toDisplay = self._smoothExp(toDisplay, 2) 

447 

448 try: 

449 self.disp.mtv(toDisplay.image, title=self._titleFromExp(exp, dataId)) 

450 self.disp.scale("asinh", "zscale") 

451 except RuntimeError: # all-nan images slip through and don't display 

452 self.disp.scale("linear", 0, 1) 

453 self.disp.mtv(toDisplay.image, title=self._titleFromExp(exp, dataId)) 

454 self.disp.scale("asinh", "zscale") # set back for next image 

455 pass 

456 

457 if self.plotObjectCentroids: 

458 try: 

459 pixCoord = self.getStarPixCoord(exp) 

460 if pixCoord: 

461 self.disp.dot("x", *pixCoord, ctype="C1", size=50) 

462 self.disp.dot("o", *pixCoord, ctype="C1", size=50) 

463 else: 

464 self.disp.dot("x", 2000, 2000, ctype="red", size=2000) 

465 except Exception: 

466 logger.warning(f"Failed to find OBJECT location for {dataId}") 

467 

468 deltaH = -0.05 

469 deltaV = -0.05 

470 plt.subplots_adjust(right=1 + deltaH, left=0 - deltaH, top=1 + deltaV, bottom=0 - deltaV) 

471 self.fig.savefig(saveFilename) 

472 logger.info(f"Saved png for {dataId} to {saveFilename}") 

473 

474 del toDisplay 

475 del exp 

476 gc.collect() 

477 

478 def pngsToMp4(self, indir: str, outfile: str, framerate: float, verbose: bool = False) -> None: 

479 """Assemble all PNGs in ``indir`` into an mp4 file via ffmpeg. 

480 

481 Parameters 

482 ---------- 

483 indir : `str` 

484 Directory whose ``*.png`` files will be globbed and 

485 animated in alphabetical order. 

486 outfile : `str` 

487 Path of the mp4 file to write. 

488 framerate : `float` 

489 Frames per second for the output movie. 

490 verbose : `bool`, optional 

491 If `True`, run ffmpeg at ``info`` verbosity; otherwise 

492 only errors are printed. 

493 """ 

494 # NOTE: the order of ffmpeg arguments *REALLY MATTERS*. 

495 # Reorder them at your own peril! 

496 pathPattern = f'"{os.path.join(indir, "*.png")}"' 

497 if verbose: 

498 ffmpeg_verbose = "info" 

499 else: 

500 ffmpeg_verbose = "error" 

501 cmd = [ 

502 "ffmpeg", 

503 "-v", 

504 ffmpeg_verbose, 

505 "-f", 

506 "image2", 

507 "-y", 

508 "-pattern_type glob", 

509 "-framerate", 

510 f"{framerate}", 

511 "-i", 

512 pathPattern, 

513 "-vcodec", 

514 "libx264", 

515 "-b:v", 

516 "20000k", 

517 "-profile:v", 

518 "main", 

519 "-pix_fmt", 

520 "yuv420p", 

521 "-threads", 

522 "10", 

523 "-r", 

524 f"{framerate}", 

525 os.path.join(outfile), 

526 ] 

527 

528 subprocess.check_call(r" ".join(cmd), shell=True) 

529 

530 def tidyUp(self, tempDir: str) -> None: 

531 """Remove the temporary staging directory. 

532 

533 Parameters 

534 ---------- 

535 tempDir : `str` 

536 Directory to remove, typically the staging directory 

537 created inside ``self.pngPath``. 

538 """ 

539 shutil.rmtree(tempDir) 

540 return 

541 

542 def _smoothExp(self, exp: afwImage.Exposure, smoothing: float, kernelSize: int = 7) -> afwImage.Exposure: 

543 """Return a smoothed copy of an exposure. For DISPLAY ONLY. 

544 

545 Convolves ``exp`` with a double-Gaussian kernel of the given 

546 FWHM and kernel size, preserving the original mask plane on 

547 the returned copy. Intended only for making on-screen images 

548 legible; do not use for centroiding or measurement. 

549 

550 Parameters 

551 ---------- 

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

553 The exposure to smooth (not modified). 

554 smoothing : `float` 

555 FWHM of the smoothing kernel, in pixels. 

556 kernelSize : `int`, optional 

557 Size of the square convolution kernel, in pixels. 

558 

559 Returns 

560 ------- 

561 newExp : `lsst.afw.image.Exposure` 

562 A smoothed clone of ``exp`` with the original mask plane. 

563 """ 

564 psf = measAlg.DoubleGaussianPsf(kernelSize, kernelSize, smoothing / (2 * math.sqrt(2 * math.log(2)))) 

565 newExp = exp.clone() 

566 originalMask = exp.mask 

567 

568 kernel = psf.getKernel() 

569 afwMath.convolve(newExp.maskedImage, newExp.maskedImage, kernel, afwMath.ConvolutionControl()) 

570 newExp.mask = originalMask 

571 return newExp 

572 

573 

574def animateDay( 

575 butler: dafButler.Butler, dayObs: int, outputPath: str, dataProductToPlot: str = "quickLookExp" 

576) -> str | None: 

577 """Animate all LATISS on-sky exposures from a single dayObs. 

578 

579 Parameters 

580 ---------- 

581 butler : `lsst.daf.butler.Butler` 

582 Butler with access to the LATISS data. 

583 dayObs : `int` 

584 The dayObs to animate (``YYYYMMDD``). 

585 outputPath : `str` 

586 Directory in which the mp4 and cached PNGs will be written. 

587 dataProductToPlot : `str`, optional 

588 Butler dataset type to render. Defaults to ``quickLookExp``. 

589 

590 Returns 

591 ------- 

592 filename : `str` or `None` 

593 Path to the produced mp4, or `None` if no on-sky dataIds were 

594 found for the day. 

595 """ 

596 outputFilename = f"{dayObs}.mp4" 

597 

598 onSkyIds = getLatissOnSkyDataIds(butler, startDate=dayObs, endDate=dayObs) # type: ignore[arg-type] 

599 logger.info(f"Found {len(onSkyIds)} on sky ids for {dayObs}") 

600 

601 onSkyIds = [updateDataIdOrDataCord(dataId, detector=0) for dataId in onSkyIds] 

602 

603 animator = Animator( 

604 butler, 

605 onSkyIds, # type: ignore[arg-type] 

606 outputPath, 

607 outputFilename, 

608 dataProductToPlot=dataProductToPlot, 

609 remakePngs=False, 

610 debug=False, 

611 clobberVideoAndGif=True, 

612 plotObjectCentroids=True, 

613 useQfmForCentroids=True, 

614 ) 

615 filename = animator.run() 

616 return filename 

617 

618 

619if __name__ == "__main__": 619 ↛ 620line 619 didn't jump to line 620 because the condition on line 619 was never true

620 outputPath = "/home/mfl/animatorOutput/main/" 

621 butler = makeDefaultLatissButler() 

622 

623 day = 20211104 

624 animateDay(butler, day, outputPath)