Coverage for python/lsst/analysis/ap/nb_utils.py: 12%

885 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 15:23 +0000

1# This file is part of analysis_ap. 

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 

22from __future__ import annotations 

23 

24__all__ = ["make_simbad_link", "compare_sources", "compare_objects", 

25 "find_objects_sharing_sources", 

26 "classify_association_clusters", 

27 "plot_cutouts_with_object_markers", 

28 "plot_objects_sharing_sources", 

29 "display_images", "display_images_ab", "display_footprints", 

30 "display_coadd_coverage", 

31 "get_xy_from_source_table", "extract_timestamped_messages"] 

32 

33import astropy.coordinates as coord 

34from astroquery.simbad import Simbad 

35import astropy.units as u 

36import astropy.table 

37from dataclasses import dataclass, field 

38from datetime import datetime, timezone 

39import functools 

40import json 

41import numpy as np 

42import os 

43import random 

44import pandas as pd 

45from typing import Any 

46 

47import lsst.afw.display 

48import lsst.afw.table 

49import lsst.geom 

50from lsst.daf.butler import DatasetNotFoundError 

51from lsst.images import Box 

52from lsst.analysis.ap import plotImageSubtractionCutouts 

53from lsst.analysis.ap.compare import match_catalogs 

54from lsst.analysis.ap.imageUtils import ( 

55 _box_edges, _pixel_to_sky, _sky_to_pixel, cutout, get_visit_image, 

56) 

57from lsst.analysis.ap.skymapOverlay import draw_skymap_outlines_afw 

58# Polygon geometry shared with the skymap overlays; private to the 

59# package, not to the module. 

60from lsst.analysis.ap.skymapOverlay import _clip_polygon_to_rect, _polygon_area 

61from IPython.display import display, Image, Markdown 

62 

63 

64# Maps the image_type kwarg used by `display_images` to butler dataset 

65# names. 

66_IMAGE_DATASETS = { 

67 "science": "preliminary_visit_image", 

68 "template": "template_detector", 

69 "difference": "difference_image", 

70} 

71 

72 

73def _apply_fakes_prefix(image_datasets, use_fakes): 

74 """Prepend the fake-source pipeline's prefixes to the science and 

75 template image dataset names when ``use_fakes`` is True: ``fakes_`` 

76 for the science image, ``injectedTemplate_`` for the template. The 

77 difference image and all catalogs keep their non-prefixed names 

78 (the fake-source pipeline injects into science + template but 

79 re-uses the same downstream artifacts). 

80 """ 

81 if not use_fakes: 

82 return image_datasets 

83 return { 

84 "science": f"fakes_{image_datasets['science']}", 

85 "template": f"injectedTemplate_{image_datasets['template']}", 

86 "difference": image_datasets["difference"], 

87 } 

88 

89 

90def _cutout_exists(cpath, dia_source_id): 

91 """Return True if a cutout PNG for this diaSourceId already exists. 

92 

93 Parameters 

94 ---------- 

95 cpath : `~plotImageSubtractionCutouts.CutoutPath` 

96 Path manager for the cutout directory. 

97 dia_source_id : `int` 

98 DiaSourceId whose ``{id}.png`` is checked. 

99 """ 

100 return cpath.exists(dia_source_id, f"{dia_source_id}.png") 

101 

102 

103def make_simbad_link(ra, dec, radius_arcsec=3.0): 

104 """Search Simbad for associated sources within a 3 arcsecond region. 

105 

106 Parameters 

107 ---------- 

108 ra : 'float' 

109 Ra from source. 

110 

111 dec : 'float' 

112 Dec from source. 

113 

114 radius_arcsec : 'float' 

115 Search radius submitted to Simbad in arcseconds. 

116 Default radius is 3 arcseconds. 

117 

118 Returns 

119 ------- 

120 results_table : `astropy.table.table.Table` 

121 A table of Simbad search results. 

122 """ 

123 search_results = f"http://simbad.cds.unistra.fr/simbad/sim-coo?Coord={ra}+{dec}" \ 

124 f"&CooFrame=FK5&CooEpoch=2000&CooEqui=2000&CooDefinedFrames=none&Radius=" \ 

125 f"{radius_arcsec}&Radius.unit=arcsec&submit=submit+query&CoordList=" 

126 display(Markdown(f"[Link to Simbad search]({search_results})")) 

127 

128 source_coords = coord.SkyCoord(ra, dec, frame="icrs", unit=(u.deg, u.deg)) 

129 customSimbad = Simbad() 

130 customSimbad.TIMEOUT = 600 

131 customSimbad.add_votable_fields("otype(V)") 

132 results_table = customSimbad.query_region( 

133 source_coords, radius=radius_arcsec*u.arcsecond 

134 ) 

135 

136 if results_table is not None: 

137 

138 return results_table 

139 

140 else: 

141 print(f"No matched sources within {radius_arcsec} arcseconds.") 

142 

143 return None 

144 

145 

146def compare_sources(butler1, butler2, query1, query2, 

147 bad_flag_list=None, match_radius=0.5, 

148 make_cutouts=False, display_cutouts=False, 

149 cutout_path1=None, cutout_path2=None, 

150 cutout_config1=None, cutout_config2=None, 

151 njobs=0): 

152 """Compare two APDB datasets by extracting unassociated sources, 

153 spatially crossmatching, and plotting cutouts of the differences. 

154 

155 Parameters 

156 ---------- 

157 butler1 : `lsst.daf.butler` 

158 Initialized Butler repo containing the first dataset. 

159 Could be the same as butler2 but should be initialized with the 

160 appropriate collection name for cutout generation, if doing that. 

161 butler2 : `lsst.daf.butler` 

162 Initialized Butler repo containing the second dataset. 

163 Could be the same as butler1 but should be initialized with the 

164 appropriate collection name for cutout generation, if doing that. 

165 query1 : `lsst.analysis.ap.DbQuery` 

166 DbQuery to first APDB (postgresql or slite file; 

167 NOT created in this function). 

168 query2 : `lsst.analysis.ap.DbQuery` 

169 DbQuery to second APDB (postgresql or slite file; 

170 NOT created in this function). 

171 bad_flag_list : `list`, optional 

172 List of bad flags to exclude (applied to both query1 and query2). 

173 Omit list to skip filtering. 

174 match_radius : `double` 

175 Maximum allowable distance in arcsec between an object in 

176 data1 and data2. 

177 make_cutouts : `bool`, optional 

178 Generate cutouts for sources unique to each dataset; default is False. 

179 display_cutouts: `bool`, optional 

180 Display cutouts for sources present in only one of the DBs to the 

181 screen; default is False. 

182 cutout_path1, cutout_path2 : `str`, optional 

183 Base path to store cutouts for sources unique to the datasets. 

184 Must be supplied if make_cutouts is True. 

185 cutout_config1, cutout_config2 : `dict` [`str`], optional 

186 Config overrides to apply to cutout plotter for the datasets. 

187 See `~plotImageSubtractionCutouts.PlotImageSubtractionCutoutsConfig` 

188 for available options. 

189 njobs : `int`, optional 

190 Number of parallel processes for plotImageSubtractionCutouts. 

191 

192 Returns 

193 ------- 

194 unique1 : `pandas.DataFrame` 

195 Data frame of sources only found in the first dataset. 

196 unique2 : `pandas.DataFrame` 

197 Data frame of sources only found in the second dataset. 

198 matched : `pandas.DataFrame` 

199 Data frame of matched sources; the rows are sources from the first 

200 dataset, with two columns added: ``src2_diaSourceId`` pointing to 

201 the matched diaSourceId in the second dataset, and 

202 ``xmatch_dist_arcsec`` giving the on-sky separation in arcseconds. 

203 """ 

204 

205 if make_cutouts and (cutout_path1 is None or cutout_path2 is None): 

206 errstr = ('You must supply a value for `cutout_path1` and `cutout_path2` if `make_cutouts` is True.') 

207 raise ValueError(errstr) 

208 

209 if bad_flag_list is not None: 

210 # Snapshot and restore so we don't leave the caller's queries with a 

211 # different exclusion list than they started with. 

212 saved_flags1 = list(query1.diaSource_flags_exclude) 

213 saved_flags2 = list(query2.diaSource_flags_exclude) 

214 query1.set_excluded_diaSource_flags(bad_flag_list) 

215 query2.set_excluded_diaSource_flags(bad_flag_list) 

216 try: 

217 goodSrc1 = query1.load_sources(exclude_flagged=True) 

218 goodSrc2 = query2.load_sources(exclude_flagged=True) 

219 finally: 

220 query1.set_excluded_diaSource_flags(saved_flags1) 

221 query2.set_excluded_diaSource_flags(saved_flags2) 

222 else: 

223 goodSrc1 = query1.load_sources(exclude_flagged=True) 

224 goodSrc2 = query2.load_sources(exclude_flagged=True) 

225 

226 if 'reliability' not in goodSrc1.columns: 

227 goodSrc1['reliability'] = None 

228 if 'reliability' not in goodSrc2.columns: 

229 goodSrc2['reliability'] = None 

230 

231 # Cross-match within each (visit, detector) group. 

232 matched, unique1, unique2 = match_catalogs( 

233 goodSrc1, goodSrc2, 

234 radius=match_radius * u.arcsec, 

235 on=("visit", "detector"), 

236 ) 

237 # Preserve the legacy column name on the returned `matched` DataFrame. 

238 matched = matched.rename(columns={"diaSourceId_2": "src2_diaSourceId"}) 

239 

240 print("{} matched sources; {} unique to set 1; {} unique to set 2.".format( 

241 len(matched), len(unique1), len(unique2))) 

242 

243 # Decide if we are doing anything with cutouts or not. If not, just skip. 

244 if make_cutouts: 

245 # Make paths if they don't exist. 

246 if not os.path.exists(cutout_path1): 

247 os.makedirs(cutout_path1) 

248 if not os.path.exists(cutout_path2): 

249 os.makedirs(cutout_path2) 

250 

251 # Make cutouts if they don't already exist 

252 config1 = plotImageSubtractionCutouts.PlotImageSubtractionCutoutsConfig() 

253 config2 = plotImageSubtractionCutouts.PlotImageSubtractionCutoutsConfig() 

254 # default to flat directories for ease of use 

255 config1.chunk_size = None 

256 config2.chunk_size = None 

257 # apply user-specified overrides 

258 if cutout_config1 is not None: 

259 config1.update(**cutout_config1) 

260 if cutout_config2 is not None: 

261 config2.update(**cutout_config2) 

262 

263 cpath1 = plotImageSubtractionCutouts.CutoutPath(cutout_path1, 

264 chunk_size=config1.chunk_size) 

265 cpath2 = plotImageSubtractionCutouts.CutoutPath(cutout_path2, 

266 chunk_size=config2.chunk_size) 

267 

268 plotter1 = plotImageSubtractionCutouts.PlotImageSubtractionCutoutsTask( 

269 output_path=cutout_path1, config=config1) 

270 plotter2 = plotImageSubtractionCutouts.PlotImageSubtractionCutoutsTask( 

271 output_path=cutout_path2, config=config2) 

272 

273 # First figure out which cutouts already exist at the output path. 

274 # Series.apply passes one positional argument (the diaSourceId), but 

275 # _cutout_exists also needs the per-dataset cpath; partial binds it. 

276 unique1['pathexists'] = unique1['diaSourceId'].apply( 

277 functools.partial(_cutout_exists, cpath1)) 

278 pathchk1 = unique1.loc[~unique1['pathexists']] 

279 

280 unique2['pathexists'] = unique2['diaSourceId'].apply( 

281 functools.partial(_cutout_exists, cpath2)) 

282 pathchk2 = unique2.loc[~unique2['pathexists']] 

283 

284 # Only write those that don't exist yet 

285 plotter1.write_images(pathchk1, butler=butler1, njobs=njobs) 

286 plotter2.write_images(pathchk2, butler=butler2, njobs=njobs) 

287 

288 if display_cutouts: 

289 for isrc in unique1.itertuples(): 

290 fpath = cpath1(int(isrc.diaSourceId), f"{int(isrc.diaSourceId)}.png") 

291 print('Unique to dataset 1: {}'.format(int(isrc.diaSourceId))) 

292 display(Image(filename=fpath)) 

293 

294 for isrc in unique2.itertuples(): 

295 fpath = cpath2(int(isrc.diaSourceId), f"{int(isrc.diaSourceId)}.png") 

296 

297 print('Unique to dataset 2: {}'.format(int(isrc.diaSourceId))) 

298 display(Image(filename=fpath)) 

299 

300 # drop pathexists columns to return to original dataframe shape 

301 _ = unique1.pop('pathexists') 

302 _ = unique2.pop('pathexists') 

303 

304 return unique1, unique2, matched 

305 

306 

307def compare_objects(query1, query2, match_radius=0.5): 

308 """Compare two APDB datasets by spatially crossmatching diaObjects. 

309 

310 Parameters 

311 ---------- 

312 query1 : `lsst.analysis.ap.DbQuery` 

313 DbQuery to first APDB (postgresql or slite file; 

314 NOT created in this function). 

315 query2 : `lsst.analysis.ap.DbQuery` 

316 DbQuery to second APDB (postgresql or slite file; 

317 NOT created in this function). 

318 match_radius : `double` 

319 Maximum allowable distance in arcsec between an object in 

320 data1 and data2. 

321 

322 Returns 

323 ------- 

324 unique1 : `pandas.DataFrame` 

325 Data frame of diaObjects only found in the first dataset. 

326 unique2 : `pandas.DataFrame` 

327 Data frame of diaObjects only found in the second dataset. 

328 matched : `pandas.DataFrame` 

329 Data frame of matched diaObjects; the rows are objects from the 

330 first dataset, with two columns added: ``obj2_diaObjectId`` 

331 pointing to the matched diaObjectId in the second dataset, and 

332 ``xmatch_dist_arcsec`` giving the on-sky separation in arcseconds. 

333 """ 

334 obj1 = query1.load_objects() 

335 obj2 = query2.load_objects() 

336 

337 # diaObjects aren't tied to a single (visit, detector); match across 

338 # the full catalog with `on=()`. 

339 matched, unique1, unique2 = match_catalogs( 

340 obj1, obj2, 

341 radius=match_radius * u.arcsec, 

342 on=(), 

343 id_col="diaObjectId", 

344 ) 

345 matched = matched.rename(columns={"diaObjectId_2": "obj2_diaObjectId"}) 

346 

347 print("{} matched objects; {} unique to set 1; {} unique to set 2.".format( 

348 len(matched), len(unique1), len(unique2))) 

349 

350 return unique1, unique2, matched 

351 

352 

353def _match_source_ids(sources1, sources2, match_radius): 

354 """Return the (diaSourceId, diaSourceId_2) correspondence between two 

355 runs' diaSource catalogs, plus run 1's sky position for each pair. 

356 

357 diaSourceIds are not stable across runs -- they end in a per-catalog 

358 counter assigned in detection order, which any change to detection 

359 or measurement renumbers -- so the same detection must be identified 

360 by position within a single (visit, detector). 

361 

362 Parameters 

363 ---------- 

364 sources1, sources2 : `pandas.DataFrame` 

365 diaSource catalogs, each with `diaSourceId`, `diaObjectId`, 

366 `ra`, `dec`, `visit` and `detector`. 

367 match_radius : `float` 

368 Maximum separation in arcsec for a pair to count as the same 

369 detection. 

370 

371 Returns 

372 ------- 

373 paired : `pandas.DataFrame` 

374 Columns `diaSourceId`, `diaObjectId`, `ra`, `dec` (all from run 

375 1), `diaSourceId_2` and `diaObjectId_2` (from run 2). One row 

376 per matched pair; unmatched sources are absent. 

377 """ 

378 cols = ["diaSourceId", "diaObjectId", "ra", "dec", "visit", "detector"] 

379 matched, _, _ = match_catalogs(sources1[cols], sources2[cols], 

380 radius=match_radius * u.arcsec, 

381 on=("visit", "detector")) 

382 # match_catalogs gives every run-1 source its nearest run-2 neighbor, so 

383 # two run-1 sources can claim the same run-2 source; keep only the closest 

384 # of those so the pairing stays one-to-one. 

385 matched = matched.sort_values("xmatch_dist_arcsec").drop_duplicates( 

386 subset="diaSourceId_2") 

387 return matched[["diaSourceId", "diaObjectId", "ra", "dec", 

388 "diaSourceId_2"]].merge( 

389 sources2[["diaSourceId", "diaObjectId"]].rename( 

390 columns={"diaSourceId": "diaSourceId_2", 

391 "diaObjectId": "diaObjectId_2"}), 

392 on="diaSourceId_2", how="inner") 

393 

394 

395def _to_run1_ids(sources2, obj2_ids, id2_to_id1): 

396 """Return the run-1 diaSourceIds of every run-2 diaSource owned by 

397 one of ``obj2_ids``, dropping any with no run-1 counterpart. 

398 """ 

399 ids2 = sources2.loc[sources2["diaObjectId"].isin(obj2_ids), "diaSourceId"] 

400 return {id2_to_id1[i] for i in ids2 if i in id2_to_id1} 

401 

402 

403def find_objects_sharing_sources(diaObjectId, sources1, sources2, 

404 objects1, objects2, 

405 max_distance_arcsec=2, match_radius=0.5): 

406 """For a diaObjectId in run 1, return the full association cluster 

407 of diaSources and diaObjects from both runs. 

408 

409 Treats the (diaSource, run-1-diaObject, run-2-diaObject) links as a 

410 graph -- each diaSource is connected to its owning diaObject in 

411 each run -- and grows the connected component reachable from the 

412 input diaObjectId until no new sources or objects are discovered. 

413 Catches arbitrarily deep merge/split chains across the two runs 

414 (e.g. run 2 merges A+B into Z, then a third source in B is split 

415 into a fourth object in run 2, etc.). 

416 

417 diaSourceIds are *not* stable across runs (they end in a per-catalog 

418 counter assigned in detection order), so the two runs' diaSources 

419 are paired by position via `_match_source_ids`. Only detections 

420 present in both runs carry the graph; a diaSource with no 

421 counterpart within ``match_radius`` cannot link objects across runs. 

422 

423 Parameters 

424 ---------- 

425 diaObjectId : `int` 

426 A diaObjectId, typically from `unique1` returned by 

427 `compare_objects`. 

428 sources1, sources2 : `pandas.DataFrame` 

429 Full diaSources catalogs from runs 1 and 2 (e.g. from 

430 ``query.load_sources()``). Each must contain `diaSourceId`, 

431 `diaObjectId`, `ra`, and `dec` columns. 

432 objects1, objects2 : `pandas.DataFrame` 

433 Full diaObjects catalogs from runs 1 and 2 (e.g. from 

434 ``query.load_objects()``). Each must contain `diaObjectId`, 

435 `ra`, and `dec` columns. 

436 max_distance_arcsec : `float`, optional 

437 If given, only include diaSources within this distance of the input 

438 diaObject's (ra, dec) in the search. All diaSources of the final 

439 diaObjects will still be returned, even if outside this distance. 

440 match_radius : `float`, optional 

441 Maximum separation in arcsec for a run-1 and a run-2 diaSource to 

442 be treated as the same detection. 

443 

444 Returns 

445 ------- 

446 sources : `pandas.DataFrame` 

447 Rows of `sources1` for every diaSource belonging to any of the 

448 found run-2 diaObjects (in run 2's view). 

449 related_objects1 : `pandas.DataFrame` 

450 Rows of `objects1` for every run-1 diaObject containing any of 

451 those diaSources in run 1. 

452 related_objects2 : `pandas.DataFrame` 

453 Rows of `objects2` for every run-2 diaObject containing any of 

454 those diaSources in run 2. 

455 """ 

456 # Pair the two runs' diaSources up front; the search below runs in 

457 # run-1 id space and translates run-2 sources through this map. 

458 paired = _match_source_ids(sources1, sources2, match_radius) 

459 id2_to_id1 = dict(zip(paired["diaSourceId_2"], paired["diaSourceId"])) 

460 id1_to_id2 = dict(zip(paired["diaSourceId"], paired["diaSourceId_2"])) 

461 

462 if max_distance_arcsec is not None: 

463 ref_match = objects1[objects1["diaObjectId"] == diaObjectId] 

464 if len(ref_match) == 0: 

465 raise ValueError( 

466 f"diaObjectId={diaObjectId} not found in objects1") 

467 ref_row = ref_match.iloc[0] 

468 ref = coord.SkyCoord(ra=ref_row["ra"] * u.deg, 

469 dec=ref_row["dec"] * u.deg) 

470 # The search space is run-1 diaSourceIds, so filter against 

471 # sources1; positions agree between paired sources to within 

472 # match_radius. 

473 sep = ref.separation( 

474 coord.SkyCoord(ra=sources1["ra"].values * u.deg, 

475 dec=sources1["dec"].values * u.deg) 

476 ).to_value(u.arcsec) 

477 allowed_src_ids = set( 

478 sources1.loc[sep <= max_distance_arcsec, "diaSourceId"]) 

479 else: 

480 allowed_src_ids = None 

481 

482 # Breadth-first search for the connected component: 

483 # alternately expand sources from the currently-known objects, 

484 # then expand objects from the sources. 

485 # Terminates because every iteration adds at least one source 

486 # before the fixed-point check fires, and the source pool is finite. 

487 src_ids = set() 

488 obj1_ids = {diaObjectId} 

489 obj2_ids = set() 

490 

491 while True: 

492 new_src_ids = set( 

493 sources1.loc[sources1["diaObjectId"].isin(obj1_ids), 

494 "diaSourceId"]) 

495 new_src_ids.update(_to_run1_ids(sources2, obj2_ids, id2_to_id1)) 

496 if allowed_src_ids is not None: 

497 new_src_ids &= allowed_src_ids 

498 if new_src_ids <= src_ids: 

499 break 

500 src_ids |= new_src_ids 

501 obj1_ids |= set( 

502 sources1.loc[sources1["diaSourceId"].isin(src_ids), 

503 "diaObjectId"]) 

504 run2_ids = {id1_to_id2[i] for i in src_ids if i in id1_to_id2} 

505 obj2_ids |= set( 

506 sources2.loc[sources2["diaSourceId"].isin(run2_ids), 

507 "diaObjectId"]) 

508 

509 # Expand the final diaSource list to every source owned by any 

510 # surviving diaObject. 

511 final_src_ids = set( 

512 sources1.loc[sources1["diaObjectId"].isin(obj1_ids), "diaSourceId"]) 

513 final_src_ids |= _to_run1_ids(sources2, obj2_ids, id2_to_id1) 

514 

515 sources = sources1[sources1["diaSourceId"].isin(final_src_ids)] 

516 related_objects1 = objects1[objects1["diaObjectId"].isin(obj1_ids)] 

517 related_objects2 = objects2[objects2["diaObjectId"].isin(obj2_ids)] 

518 

519 return sources, related_objects1, related_objects2 

520 

521 

522class _UnionFind: 

523 """Disjoint-set with path compression and union-by-rank. 

524 

525 Used by `classify_association_clusters` to quickly find connected 

526 components of the (run-1 diaObject, run-2 diaObject) graph. 

527 """ 

528 

529 def __init__(self): 

530 self._parent = {} 

531 self._rank = {} 

532 

533 def add(self, x): 

534 if x not in self._parent: 

535 self._parent[x] = x 

536 self._rank[x] = 0 

537 

538 def find(self, x): 

539 # Two-pass iterative find with path compression. 

540 root = x 

541 while self._parent[root] != root: 

542 root = self._parent[root] 

543 while self._parent[x] != root: 

544 self._parent[x], x = root, self._parent[x] 

545 return root 

546 

547 def union(self, x, y): 

548 rx, ry = self.find(x), self.find(y) 

549 if rx == ry: 

550 return 

551 if self._rank[rx] < self._rank[ry]: 

552 rx, ry = ry, rx 

553 self._parent[ry] = rx 

554 if self._rank[rx] == self._rank[ry]: 

555 self._rank[rx] += 1 

556 

557 

558def classify_association_clusters(sources1, sources2, match_radius=0.5): 

559 """Enumerate and classify every association-disagreement cluster 

560 between two APDBs that share input diaSources. 

561 

562 Builds the bipartite graph whose edges are 

563 ``(diaSource -> its run-1 diaObject, diaSource -> its run-2 

564 diaObject)`` over all diaSources the two runs have in common, runs 

565 union-find over the diaObjectIds to extract every connected 

566 component, and labels each cluster: 

567 

568 * ``matched`` -- one run-1 obj <-> one run-2 obj. 

569 * ``split`` -- one run-1 obj split into multiple run-2 objs. 

570 * ``merged`` -- multiple run-1 objs merged into one run-2 obj. 

571 * ``tangled`` -- M run-1 objs <-> N run-2 objs, both > 1. 

572 

573 diaSourceIds are *not* stable across runs: they carry a per-catalog 

574 counter assigned in detection order, so any change to detection or 

575 measurement renumbers them. The common diaSources are therefore 

576 identified by position (nearest neighbor within ``match_radius``, 

577 inside a single (visit, detector)) rather than by id. Sources with 

578 no counterpart in the other run are skipped. 

579 

580 Parameters 

581 ---------- 

582 sources1, sources2 : `pandas.DataFrame` 

583 Full diaSources catalogs from runs 1 and 2 (e.g. from 

584 ``query.load_sources()``). Each must contain `diaSourceId`, 

585 `diaObjectId`, `ra`, `dec`, `visit`, and `detector` columns. 

586 match_radius : `float`, optional 

587 Maximum separation in arcsec for two diaSources to be considered 

588 the same detection in both runs. 

589 

590 Returns 

591 ------- 

592 clusters : `pandas.DataFrame` 

593 One row per cluster, with columns: 

594 - ``kind``: matched / split / merged / tangled. 

595 - ``n_obj1``, ``n_obj2``: distinct diaObject counts per run. 

596 - ``n_sources``: matched diaSource pairs in the cluster. 

597 - ``obj1_ids``, ``obj2_ids``: tuples of diaObjectIds. 

598 - ``ra``, ``dec``: mean sky position of the cluster's 

599 diaSources (degrees). 

600 """ 

601 # Pre-define the types so that value_counts() and groupby() 

602 # include unused kinds with a count of 0. 

603 kind_dtype = pd.CategoricalDtype( 

604 categories=["matched", "split", "merged", "tangled"], ordered=True) 

605 

606 paired = _match_source_ids(sources1, sources2, match_radius) 

607 

608 if len(paired) == 0: 

609 empty = pd.DataFrame(columns=[ 

610 "kind", "n_obj1", "n_obj2", "n_sources", 

611 "obj1_ids", "obj2_ids", "ra", "dec"]) 

612 empty["kind"] = empty["kind"].astype(kind_dtype) 

613 return empty 

614 

615 # Define a namespace for the two runs so identical numeric ids in run 1 and 

616 # run 2 don't collide as keys. 

617 keys1 = [("r1", int(i)) for i in paired["diaObjectId"].to_numpy()] 

618 keys2 = [("r2", int(i)) for i in paired["diaObjectId_2"].to_numpy()] 

619 

620 uf = _UnionFind() 

621 for k in set(keys1): 

622 uf.add(k) 

623 for k in set(keys2): 

624 uf.add(k) 

625 for k1, k2 in zip(keys1, keys2): 

626 uf.union(k1, k2) 

627 

628 paired = paired.assign(_cluster=[uf.find(k) for k in keys1]) 

629 

630 rows = [] 

631 for _, grp in paired.groupby("_cluster", sort=False): 

632 ids_a = tuple(sorted(int(i) for i in grp["diaObjectId"].unique())) 

633 ids_b = tuple(sorted(int(i) for i in grp["diaObjectId_2"].unique())) 

634 n1, n2 = len(ids_a), len(ids_b) 

635 if n1 == 1 and n2 == 1: 

636 kind = "matched" 

637 elif n1 == 1: 

638 kind = "split" 

639 elif n2 == 1: 

640 kind = "merged" 

641 else: 

642 kind = "tangled" 

643 rows.append({ 

644 "kind": kind, 

645 "n_obj1": n1, "n_obj2": n2, 

646 "n_sources": grp["diaSourceId"].nunique(), 

647 "obj1_ids": ids_a, "obj2_ids": ids_b, 

648 "ra": float(grp["ra"].mean()), 

649 "dec": float(grp["dec"].mean()), 

650 }) 

651 

652 result = pd.DataFrame(rows) 

653 result["kind"] = result["kind"].astype(kind_dtype) 

654 return result 

655 

656 

657# Colors used by the cutout plotters to give each distinct 

658# diaObjectId its own marker color. 

659_OBJECT_PALETTE = ("lime", "red", "cyan", "magenta", "yellow", "orange", 

660 "deepskyblue", "pink", "white", "violet", "gold", 

661 "lightgreen") 

662 

663 

664def _prepare_object_overlays(objects, palette): 

665 """Deduplicate `objects` by diaObjectId and assign one palette color 

666 per distinct id, returning the parallel arrays the cutout renderer 

667 needs: ``(ids, ras, decs, colors)``. Done once per call so the same 

668 color identifies the same diaObject across every cutout. 

669 """ 

670 obj_unique = objects.drop_duplicates(subset="diaObjectId") 

671 # Prefer the run-2 id when present (matched rows carry both); pandas 

672 # concat promotes the column to float64 if any rows lack it, so cast 

673 # back to int64 after filling. 

674 if "obj2_diaObjectId" in obj_unique.columns: 

675 obj_ids = obj_unique["obj2_diaObjectId"].combine_first( 

676 obj_unique["diaObjectId"]).astype(np.int64).to_numpy() 

677 else: 

678 obj_ids = obj_unique["diaObjectId"].astype(np.int64).to_numpy() 

679 obj_ras = np.asarray(obj_unique["ra"]) 

680 obj_decs = np.asarray(obj_unique["dec"]) 

681 obj_colors = [palette[i % len(palette)] for i in range(len(obj_ids))] 

682 return obj_ids, obj_ras, obj_decs, obj_colors 

683 

684 

685def _load_cutout(butler, row, *, size, image_type, image_datasets): 

686 """Fetch the requested image dataset for this row's (visit, 

687 detector) and return a small dict with everything the renderer 

688 needs: pixel data, dimensions, cutout origin, WCS, an 

689 ImageNormalize tuned to the central source, and the `image_type` 

690 label used in the cutout title. 

691 

692 Loading is separated from rendering so callers that need to draw 

693 the same cutout into multiple Axes can pay the read and cutout cost 

694 once. 

695 """ 

696 import astropy.visualization as aviz 

697 

698 data_id = {"visit": int(row.visit), "detector": int(row.detector)} 

699 image = _get_image(butler, image_datasets, image_type, data_id) 

700 stamp = cutout(image, coord.SkyCoord(row.ra, row.dec, unit="deg"), size) 

701 data = stamp.array 

702 ny, nx = data.shape 

703 

704 if image_type == "difference": 704 ↛ 711line 704 didn't jump to line 711 because the condition on line 704 was always true

705 # Normalize on a small central window so the source dominates 

706 # the dynamic range. 

707 cy, cx = ny // 2, nx // 2 

708 half = min(7, cy, cx) 

709 norm_data = data[cy - half:cy + half + 1, cx - half:cx + half + 1] 

710 else: 

711 norm_data = data 

712 norm = aviz.ImageNormalize( 

713 norm_data, interval=aviz.MinMaxInterval(), 

714 stretch=aviz.AsinhStretch(a=0.1)) 

715 

716 return { 

717 "data": data, "ny": ny, "nx": nx, 

718 "sky_projection": image.sky_projection, 

719 "x0": stamp.bbox.x.start, "y0": stamp.bbox.y.start, 

720 "norm": norm, 

721 "image_type": image_type, 

722 } 

723 

724 

725def _render_cutout_axes(ax, row, cutout_data, sources, 

726 obj_ids, obj_ras, obj_decs, obj_colors, *, 

727 marker_size, marker_symbol, 

728 source_marker_size, current_source_marker_size, 

729 current_source_color, 

730 source_match_ids=None, 

731 title=None, subtitle=""): 

732 """Render one diaSource cutout onto an existing matplotlib Axes 

733 using preloaded data from `_load_cutout`. 

734 

735 Internal helper shared by `plot_cutouts_with_object_markers` and 

736 `plot_objects_sharing_sources`. The caller owns figure creation, 

737 layout, saving, and displaying. 

738 

739 By default the axes title is built from `row` as 

740 ``"diaSourceId=... (image_type, visit=..., det=...)"``. Pass 

741 `title=` explicitly (including ``""``) to override or suppress 

742 that line -- useful when a parent figure or subfigure already 

743 carries the shared header. If `subtitle` is non-empty it is drawn 

744 on a second title line. 

745 """ 

746 from matplotlib import cm 

747 

748 data = cutout_data["data"] 

749 ny = cutout_data["ny"] 

750 nx = cutout_data["nx"] 

751 x0 = cutout_data["x0"] 

752 y0 = cutout_data["y0"] 

753 sky_projection = cutout_data["sky_projection"] 

754 norm = cutout_data["norm"] 

755 image_type = cutout_data["image_type"] 

756 

757 ax.imshow(data, cmap=cm.bone, interpolation="none", norm=norm, 

758 origin="lower", aspect="equal", 

759 extent=(0, nx, 0, ny)) 

760 

761 this_id = int(row.diaSourceId) 

762 

763 # Project every supplied diaSource into the cutout frame once, 

764 # then split into "this cutout's diaSource" vs every other 

765 # diaSource whose sky position falls inside this cutout 

766 # (regardless of which image it was detected on). 

767 if len(sources) > 0: 

768 src_xs, src_ys = _sky_to_pixel(sky_projection, sources["ra"], sources["dec"]) 

769 src_xs = src_xs - x0 

770 src_ys = src_ys - y0 

771 src_in_frame = ( 

772 (src_xs >= 0) & (src_xs < nx) 

773 & (src_ys >= 0) & (src_ys < ny)) 

774 id_arr = sources["diaSourceId"].to_numpy() 

775 other_src_mask = src_in_frame & (id_arr != this_id) 

776 current_src_mask = src_in_frame & (id_arr == this_id) 

777 else: 

778 src_xs = src_ys = None 

779 other_src_mask = current_src_mask = None 

780 

781 # Per-diaSource color resolution: map each diaSource to its 

782 # owning diaObject (in this panel's view), then to that 

783 # diaObject's palette color. Falls back to `current_source_color` 

784 # for sources whose owner is not present in `obj_ids`. 

785 if source_match_ids is None: 

786 match_ids_list = sources["diaObjectId"].tolist() 

787 else: 

788 match_ids_list = list(source_match_ids) 

789 src_to_match = dict(zip(sources["diaSourceId"].tolist(), 

790 match_ids_list)) 

791 id_to_color = dict(zip(obj_ids.tolist(), obj_colors)) 

792 

793 def _color_for(diaSourceId): 

794 return id_to_color.get( 

795 src_to_match.get(int(diaSourceId)), current_source_color) 

796 

797 if other_src_mask is not None and other_src_mask.any(): 

798 other_indices = np.flatnonzero(other_src_mask) 

799 other_colors = [_color_for(int(id_arr[i])) for i in other_indices] 

800 ax.scatter(src_xs[other_src_mask], src_ys[other_src_mask], 

801 s=source_marker_size, marker="+", 

802 c=other_colors, linewidths=1.0, 

803 label="other diaSource") 

804 

805 if len(obj_ids) > 0: 

806 xs, ys = _sky_to_pixel(sky_projection, obj_ras, obj_decs) 

807 xs = xs - x0 

808 ys = ys - y0 

809 in_bounds = (xs >= 0) & (xs < nx) & (ys >= 0) & (ys < ny) 

810 else: 

811 in_bounds = np.zeros(0, dtype=bool) 

812 

813 for i in np.flatnonzero(in_bounds): 

814 ax.scatter(xs[i], ys[i], 

815 s=marker_size, marker=marker_symbol, 

816 facecolors="none", edgecolors=obj_colors[i], 

817 linewidths=1.5, 

818 label=f"diaObjectId={int(obj_ids[i])}") 

819 

820 # Current diaSource last so it stays on top of any overlapping 

821 # diaObject marker at the cutout center. 

822 if current_src_mask is not None and current_src_mask.any(): 

823 ax.scatter(src_xs[current_src_mask], src_ys[current_src_mask], 

824 s=current_source_marker_size, marker="x", 

825 c=_color_for(this_id), linewidths=2.0, 

826 label=f"current diaSourceId={this_id}") 

827 

828 if title is None: 

829 title = (f"diaSourceId={this_id} " 

830 f"({image_type}, visit={int(row.visit)}, " 

831 f"det={int(row.detector)})") 

832 if subtitle: 

833 title = f"{title}\n{subtitle}" if title else subtitle 

834 if title: 

835 ax.set_title(title, fontsize="small") 

836 ax.set_xticks([]) 

837 ax.set_yticks([]) 

838 if ax.get_legend_handles_labels()[0]: 

839 ax.legend(loc="upper right", fontsize="x-small", framealpha=0.7) 

840 

841 

842def plot_cutouts_with_object_markers(sources, butler, objects, *, 

843 output_path=None, 

844 display_cutouts=False, 

845 size=51, 

846 image_type="difference", 

847 image_datasets=_IMAGE_DATASETS, 

848 marker_size=80, 

849 marker_symbol="o", 

850 palette=_OBJECT_PALETTE, 

851 source_marker_size=80, 

852 current_source_marker_size=180, 

853 current_source_color="yellow"): 

854 """Plot per-diaSource cutouts with overlaid markers at given diaObject 

855 sky positions. 

856 

857 For each diaSource in `sources`, fetch a square cutout from `butler` 

858 centered on the source's (ra, dec). On each cutout draw: 

859 

860 * A small ``+`` marker at every other diaSource in `sources` 

861 whose sky position lands inside the cutout, regardless of which 

862 (visit, detector) it was detected on. 

863 * A distinct ``x`` marker for the diaSource the cutout is 

864 centered on (the "current" diaSource). 

865 * One color-coded marker per distinct diaObjectId in `objects`, 

866 cycling through `palette`; the same color identifies the same 

867 diaObject across every cutout in the run. 

868 

869 Markers that fall outside the cutout bounds are skipped. 

870 

871 Typical use: visualize how a group of diaSources (all originally 

872 associated with one diaObjectId in run 1) got redistributed across 

873 diaObjects in run 2. `sources` and `objects` are usually built from 

874 the output of `find_objects_sharing_sources`:: 

875 

876 sources, ro1, ro2 = find_objects_sharing_sources( 

877 diaObjectId, sources1, sources2, objects1, objects2) 

878 objects = pd.concat([ro1, ro2]) 

879 plot_cutouts_with_object_markers( 

880 sources, butler1, objects, display_cutouts=True, 

881 ) 

882 

883 Parameters 

884 ---------- 

885 sources : `pandas.DataFrame` 

886 DiaSources to cut out. Must contain `diaSourceId`, `ra`, `dec`, 

887 `visit`, and `detector` columns. 

888 butler : `lsst.daf.butler.Butler` 

889 Butler containing the image datasets for these (visit, detector) 

890 pairs. 

891 objects : `pandas.DataFrame` 

892 DiaObjects to mark. Must contain `diaObjectId`, `ra`, and `dec` 

893 columns. Duplicate diaObjectIds are dropped (first row wins). 

894 If an `obj2_diaObjectId` column is present (e.g. for rows from 

895 a `matched` DataFrame returned by `compare_objects`), the run-2 

896 id is shown in the legend in preference to the run-1 

897 `diaObjectId`. 

898 output_path : `str`, optional 

899 Directory to write ``{diaSourceId}.png`` files to. Created if 

900 missing. Pass None to skip writing. 

901 display_cutouts : `bool`, optional 

902 If True, display each cutout inline (notebook). 

903 size : `int`, optional 

904 Cutout side length in pixels. 

905 image_type : {"science", "template", "difference"}, optional 

906 Which image to render. 

907 image_datasets : `dict` [`str`, `str`], optional 

908 Mapping from image-type key to butler dataset name. 

909 marker_size : `int`, optional 

910 matplotlib scatter ``s`` parameter for diaObject markers. 

911 marker_symbol : `str`, optional 

912 matplotlib scatter ``marker`` parameter for diaObject markers. 

913 palette : sequence of `str`, optional 

914 Color cycle used to assign one color per diaObjectId. 

915 source_marker_size : `int`, optional 

916 Scatter ``s`` parameter for the small ``+`` markers drawn at 

917 the positions of the *other* diaSources in `sources`. 

918 current_source_marker_size : `int`, optional 

919 Scatter ``s`` parameter for the distinct marker drawn at the 

920 diaSource the cutout is centered on. 

921 current_source_color : `str`, optional 

922 Color of the current-diaSource marker. 

923 """ 

924 import matplotlib.pyplot as plt 

925 

926 if image_type not in image_datasets: 

927 raise ValueError( 

928 f"image_type must be one of {sorted(image_datasets)}, " 

929 f"got {image_type!r}") 

930 

931 if output_path is not None: 

932 os.makedirs(output_path, exist_ok=True) 

933 

934 overlays = _prepare_object_overlays(objects, palette) 

935 

936 for row in sources.itertuples(index=False): 

937 cutout_data = _load_cutout(butler, row, size=size, 

938 image_type=image_type, 

939 image_datasets=image_datasets) 

940 fig, ax = plt.subplots() 

941 _render_cutout_axes( 

942 ax, row, cutout_data, sources, *overlays, 

943 marker_size=marker_size, marker_symbol=marker_symbol, 

944 source_marker_size=source_marker_size, 

945 current_source_marker_size=current_source_marker_size, 

946 current_source_color=current_source_color) 

947 

948 if output_path is not None: 

949 fpath = os.path.join(output_path, f"{int(row.diaSourceId)}.png") 

950 fig.savefig(fpath, bbox_inches="tight") 

951 if display_cutouts: 

952 display(fig) 

953 plt.close(fig) 

954 

955 

956def plot_objects_sharing_sources(diaObjectId, sources1, sources2, 

957 objects1, objects2, butler, *, 

958 max_distance_arcsec=None, 

959 output_path=None, 

960 display_figure=True, 

961 column_labels=("run 1", "run 2"), 

962 figsize_per_row=4.0, 

963 size=51, 

964 image_type="difference", 

965 image_datasets=_IMAGE_DATASETS, 

966 marker_size=80, 

967 marker_symbol="o", 

968 palette=_OBJECT_PALETTE, 

969 source_marker_size=80, 

970 current_source_marker_size=180, 

971 current_source_color="yellow"): 

972 """Two-column cutout figure comparing the run-1 and run-2 views of 

973 an association cluster. 

974 

975 Calls `find_objects_sharing_sources` internally to identify the 

976 cluster of diaSources and diaObjects reachable from the input 

977 `diaObjectId`, then renders one row per diaSource in the cluster. 

978 The same cutout image is loaded once per row and drawn into both 

979 columns: the left panel is overlaid with run-1 diaObject markers 

980 (from `objects1`) and the right panel with run-2 diaObject markers 

981 (from `objects2`). Each column's diaObjects get their own palette 

982 mapping, so the same color in the left and right columns does 

983 *not* imply the same diaObject. 

984 

985 Parameters 

986 ---------- 

987 diaObjectId : `int` 

988 Starting diaObjectId for the cluster walk. 

989 sources1, sources2, objects1, objects2 : `pandas.DataFrame` 

990 Forwarded to `find_objects_sharing_sources`. 

991 butler : `lsst.daf.butler.Butler` 

992 Butler used to fetch the cutout images. The same image backs 

993 both panels of a given row. 

994 max_distance_arcsec : `float`, optional 

995 Forwarded to `find_objects_sharing_sources`. 

996 output_path : `str`, optional 

997 Filename to write the combined figure to as a PNG. Parent 

998 directories are created if missing. 

999 display_figure : `bool`, optional 

1000 If True, display the figure inline (notebook). 

1001 column_labels : pair of `str`, optional 

1002 Labels appended to each cutout's title to identify the column. 

1003 figsize_per_row : `float`, optional 

1004 Height in inches allocated to each cutout row. 

1005 All other kwargs: 

1006 Forwarded to the cutout renderer; same meaning as in 

1007 `plot_cutouts_with_object_markers`. 

1008 

1009 Returns 

1010 ------- 

1011 sources, related_objects1, related_objects2 : `pandas.DataFrame` 

1012 The catalogs returned by `find_objects_sharing_sources`. 

1013 """ 

1014 import matplotlib.pyplot as plt 

1015 

1016 if image_type not in image_datasets: 

1017 raise ValueError( 

1018 f"image_type must be one of {sorted(image_datasets)}, " 

1019 f"got {image_type!r}") 

1020 

1021 sources, ro1, ro2 = find_objects_sharing_sources( 

1022 diaObjectId, sources1, sources2, objects1, objects2, 

1023 max_distance_arcsec=max_distance_arcsec) 

1024 

1025 if len(sources) == 0: 

1026 print(f"No diaSources in the cluster for " 

1027 f"diaObjectId={diaObjectId}") 

1028 return sources, ro1, ro2 

1029 

1030 left_overlays = _prepare_object_overlays(ro1, palette) 

1031 right_overlays = _prepare_object_overlays(ro2, palette) 

1032 

1033 # diaSourceId -> run-2 diaObjectId, so the right panel can color 

1034 # each diaSource by its run-2 owner. The left panel uses the 

1035 # default (sources["diaObjectId"], the run-1 owner) since 

1036 # `sources` is a slice of `sources1`. 

1037 src_to_obj2 = dict(zip( 

1038 sources2["diaSourceId"].to_numpy(), 

1039 sources2["diaObjectId"].to_numpy())) 

1040 right_match_ids = [src_to_obj2.get(int(sid)) 

1041 for sid in sources["diaSourceId"]] 

1042 

1043 n_rows = len(sources) 

1044 # One subfigure per row so each row can carry a single shared 

1045 # suptitle above both panels; the per-axes title is then just the 

1046 # column label. 

1047 fig = plt.figure(figsize=(8, figsize_per_row * n_rows), 

1048 constrained_layout=True) 

1049 subfigs = np.atleast_1d(fig.subfigures(n_rows, 1, squeeze=False).ravel()) 

1050 

1051 common_kw = dict( 

1052 marker_size=marker_size, marker_symbol=marker_symbol, 

1053 source_marker_size=source_marker_size, 

1054 current_source_marker_size=current_source_marker_size, 

1055 current_source_color=current_source_color) 

1056 

1057 for i, row in enumerate(sources.itertuples(index=False)): 

1058 sf = subfigs[i] 

1059 sf.suptitle( 

1060 f"diaSourceId={int(row.diaSourceId)} " 

1061 f"({image_type}, visit={int(row.visit)}, " 

1062 f"det={int(row.detector)})", 

1063 fontsize="small") 

1064 ax_left, ax_right = sf.subplots(1, 2) 

1065 # Load once; both panels in this row use the same image. 

1066 cutout_data = _load_cutout(butler, row, size=size, 

1067 image_type=image_type, 

1068 image_datasets=image_datasets) 

1069 _render_cutout_axes(ax_left, row, cutout_data, sources, 

1070 *left_overlays, 

1071 title="", subtitle=column_labels[0], 

1072 **common_kw) 

1073 _render_cutout_axes(ax_right, row, cutout_data, sources, 

1074 *right_overlays, 

1075 title="", subtitle=column_labels[1], 

1076 source_match_ids=right_match_ids, 

1077 **common_kw) 

1078 

1079 if output_path is not None: 

1080 out_dir = os.path.dirname(output_path) 

1081 if out_dir: 

1082 os.makedirs(out_dir, exist_ok=True) 

1083 fig.savefig(output_path, bbox_inches="tight") 

1084 if display_figure: 

1085 display(fig) 

1086 plt.close(fig) 

1087 

1088 return sources, ro1, ro2 

1089 

1090 

1091def get_xy_from_source_table(table, wcs, degrees=None): 

1092 """Convert ra/dec coordinates in an astropy table/pandas data frame to 

1093 pixel x/y positions. 

1094 

1095 Parameters 

1096 ---------- 

1097 table : `astropy.table.Table` or `pandas.DataFrame` 

1098 Sources with ``ra``/``dec`` (degrees) or ``coord_ra``/``coord_dec`` 

1099 (radians) columns. 

1100 wcs : `lsst.images.SkyProjection` or `lsst.afw.geom.SkyWcs` 

1101 Transform from sky to pixel coordinates. 

1102 degrees : `bool`, optional 

1103 Whether the coordinates are in degrees; inferred from the column 

1104 names if not given. 

1105 

1106 Returns 

1107 ------- 

1108 xy : `astropy.table.Table` 

1109 Pixel positions, in columns ``x`` and ``y``. 

1110 """ 

1111 try: 

1112 ra = table['ra'] 

1113 dec = table['dec'] 

1114 inferred_degrees = True 

1115 except KeyError: 

1116 ra = table['coord_ra'] 

1117 dec = table['coord_dec'] 

1118 inferred_degrees = False 

1119 if degrees is None: 

1120 degrees = inferred_degrees 

1121 

1122 if not degrees: 

1123 ra, dec = np.degrees(ra), np.degrees(dec) 

1124 x, y = _sky_to_pixel(wcs, ra, dec) 

1125 return astropy.table.Table.from_pandas(pd.DataFrame({'x': x, 'y': y})) 

1126 

1127 

1128# Palette used by the `color_by` flag-bucketing mode. Sources with none of 

1129# the requested flags set get the residual color "white", which is kept out 

1130# of this palette so it never collides with a flagged bucket. 

1131_FLAG_PALETTE = ("red", "orange", "yellow", "magenta", "cyan", "green") 

1132 

1133 

1134def _group_sources_by_flag(table, flag_names, palette=_FLAG_PALETTE): 

1135 """Split a source table into per-flag buckets for color-coded overlay. 

1136 

1137 Each row is assigned to the first flag in ``flag_names`` whose column 

1138 is True; remaining rows go into a residual "no flag" bucket. Names that 

1139 aren't present as columns in ``table`` are silently skipped. 

1140 

1141 Parameters 

1142 ---------- 

1143 table : table-like 

1144 Anything that supports ``len(table)``, ``table[name]`` returning a 

1145 boolean-coercible column, and ``table[bool_array]`` row selection. 

1146 flag_names : sequence of str 

1147 Column names to group on. Order determines color *and* priority 

1148 when a row has multiple flags set. 

1149 palette : sequence of str, optional 

1150 Cycle of display ``ctype`` values to assign in order. 

1151 

1152 Returns 

1153 ------- 

1154 buckets : list of ``(subset_table, ctype, legend)`` tuples. 

1155 """ 

1156 n = len(table) 

1157 if n == 0: 

1158 return [] 

1159 remaining = np.ones(n, dtype=bool) 

1160 buckets = [] 

1161 for i, flag in enumerate(flag_names): 

1162 try: 

1163 col = table[flag] 

1164 except KeyError: 

1165 continue 

1166 mask = np.asarray(col, dtype=bool) & remaining 

1167 if mask.any(): 

1168 buckets.append((table[mask], palette[i % len(palette)], flag)) 

1169 remaining = remaining & ~mask 

1170 if remaining.any(): 

1171 buckets.append((table[remaining], "white", "no flag")) 

1172 return buckets 

1173 

1174 

1175def _line_segments_from(source_table, wcs, *, flag_col, angle_col, ctype, 

1176 length_col=None, fixed_length=None, 

1177 min_length=None, length_scale=1.0, thickness=1.5): 

1178 """Build centered line-segment endpoints from a source catalog. 

1179 

1180 Sources are selected by ``flag_col`` (rows where the boolean column 

1181 is True) when given, then optionally by ``length_col > min_length`` 

1182 (only meaningful when ``length_col`` is set), then always by 

1183 ``sky_source == False`` when that column is present. Endpoints are 

1184 the source centroid ± half the (scaled) length along ``angle_col``. 

1185 

1186 Exactly one of ``length_col`` (per-source measurement, e.g. 

1187 ``ext_trailedSources_Naive_length`` in pixels) or ``fixed_length`` 

1188 (constant, in pixels — used for markers whose real separation is 

1189 below display resolution) must be given. Length units are pixels 

1190 and angle units are radians in the detector frame, matching the 

1191 raw ``ip_diffim`` and ``ext_trailedSources`` measurement outputs 

1192 on ``dia_source_unfiltered``. ``length_scale`` multiplies measured 

1193 lengths after filtering; it is a no-op when ``fixed_length`` is 

1194 used, since a "fixed" length by definition is not magnified. 

1195 

1196 ``thickness`` is the stroke width forwarded to ``afw_display.line``; 

1197 stored as ``size`` in the returned dict. 

1198 

1199 Returns a dict ``{"x1", "y1", "x2", "y2", "ctype", "size"}`` of 

1200 numpy arrays and scalars, or ``None`` if the table is missing/empty 

1201 or any referenced column is absent. 

1202 """ 

1203 if (length_col is None) == (fixed_length is None): 

1204 raise ValueError("_line_segments_from: pass exactly one of " 

1205 "length_col or fixed_length") 

1206 if source_table is None or len(source_table) == 0: 

1207 return None 

1208 try: 

1209 angle = np.asarray(source_table[angle_col], dtype=float) 

1210 mask = (np.asarray(source_table[flag_col], dtype=bool) if flag_col is not None 

1211 else np.ones(len(source_table), dtype=bool)) 

1212 if length_col is not None: 

1213 length = np.asarray(source_table[length_col], dtype=float) 

1214 else: 

1215 length = np.full(len(source_table), fixed_length, dtype=float) 

1216 except KeyError: 

1217 return None 

1218 if length_col is not None and min_length is not None: 

1219 mask = mask & (length > min_length) 

1220 try: 

1221 mask = mask & ~np.asarray(source_table["sky_source"], dtype=bool) 

1222 except KeyError: 

1223 pass 

1224 if not mask.any(): 

1225 return None 

1226 xy = get_xy_from_source_table(source_table[mask], wcs) 

1227 x0 = xy["x"].data 

1228 y0 = xy["y"].data 

1229 effective_scale = length_scale if fixed_length is None else 1.0 

1230 half = length[mask] * effective_scale / 2.0 

1231 dx = half * np.cos(angle[mask]) 

1232 dy = half * np.sin(angle[mask]) 

1233 return {"x1": x0 - dx, "y1": y0 - dy, 

1234 "x2": x0 + dx, "y2": y0 + dy, 

1235 "ctype": ctype, "size": thickness} 

1236 

1237 

1238@dataclass 

1239class _OverlayData: 

1240 """Everything `display_images` / `display_images_ab` draw on one frame. 

1241 

1242 ``unfiltered_footprints`` is populated only when the caller asked for 

1243 footprint-style rendering of the unfiltered catalog (see 

1244 ``unfiltered_as_footprints`` in `_collect_overlays`); it holds 

1245 ``(catalog, color, label)`` buckets ready for `_overlay_footprint_layers`, 

1246 and in that case the unfiltered ``+`` marker is omitted from 

1247 ``overlays``. Otherwise it is ``None`` and the unfiltered catalog is a 

1248 normal marker entry in ``overlays``. 

1249 """ 

1250 overlays: list = field(default_factory=list) 

1251 reliability_labels: dict | None = None 

1252 solar_system_labels: dict | None = None 

1253 dipole_segments: dict | None = None 

1254 trail_segments: dict | None = None 

1255 unfiltered_footprints: list | None = None 

1256 

1257 

1258def _collect_overlays(butler, data_id, wcs, *, 

1259 reliability_threshold, 

1260 show_unfiltered, show_trailed, 

1261 show_rejected, show_standardized, show_marginal, 

1262 show_kernel_sources, show_solar_system, show_apdb, 

1263 show_reliability_labels, show_dipoles, 

1264 show_trail_geometry, line_length_scale, 

1265 color_by, unfiltered_as_footprints=False): 

1266 """Load catalogs from one butler and build the overlay record list. 

1267 

1268 Shared between `display_images` and `display_images_ab`. Catalogs that 

1269 aren't present for this dataId are silently skipped. 

1270 

1271 When ``unfiltered_as_footprints`` is True the ``dia_source_unfiltered`` 

1272 catalog is *not* added to ``overlays`` as ``+`` markers; instead its 

1273 non-sky rows are returned as `_OverlayData.unfiltered_footprints` 

1274 ``(catalog, color, label)`` buckets for `_overlay_footprint_layers` to 

1275 draw as Firefly footprint layers. With ``color_by`` the buckets are the 

1276 flag partition (`_group_sources_by_flag`); otherwise a single red 

1277 bucket. The dipole/trail line segments still come from the same 

1278 unfiltered catalog either way. 

1279 

1280 Returns 

1281 ------- 

1282 data : `_OverlayData` 

1283 ``overlays`` is a list of ``(x_arr, y_arr, symbol, size, ctype, 

1284 legend)`` marker tuples; ``reliability_labels`` / 

1285 ``solar_system_labels`` are ``{"x", "y", ...}`` dicts (or None) for 

1286 text annotations; ``dipole_segments`` / ``trail_segments`` are 

1287 line-segment dicts (or None); ``unfiltered_footprints`` is the 

1288 footprint-bucket list described above (or None). 

1289 """ 

1290 def _try_get(dataset): 

1291 try: 

1292 return butler.get(dataset, data_id) 

1293 except DatasetNotFoundError: 

1294 return None 

1295 

1296 overlays = [] 

1297 

1298 def _add(table, *, symbol, size, ctype, legend, use_radec=True): 

1299 if table is None or len(table) == 0: 

1300 return 

1301 if use_radec: 

1302 xy = get_xy_from_source_table(table, wcs) 

1303 x_arr = xy["x"].data 

1304 y_arr = xy["y"].data 

1305 else: 

1306 x_arr = table["x"].data 

1307 y_arr = table["y"].data 

1308 overlays.append((x_arr, y_arr, symbol, size, ctype, legend)) 

1309 

1310 # Catalogs are added in AP-pipeline-creation order 

1311 if show_kernel_sources: 

1312 _add(_try_get("difference_kernel_sources"), 

1313 symbol="o", size=12, ctype="green", 

1314 legend="psf-matching kernel source") 

1315 

1316 # Load dia_source_unfiltered once — it backs the unfiltered marker 

1317 # overlay AND the dipole/trail line-segment overlays below (which 

1318 # always read from the unfiltered catalog because dipoles and long 

1319 # trails are filtered out of the downstream catalogs). 

1320 unfiltered = None 

1321 if show_unfiltered or show_dipoles or show_trail_geometry: 

1322 unfiltered = _try_get("dia_source_unfiltered") 

1323 

1324 unfiltered_footprints = None 

1325 if show_unfiltered and unfiltered is not None and len(unfiltered) > 0: 

1326 non_sky = unfiltered[~unfiltered["sky_source"]] 

1327 if unfiltered_as_footprints: 

1328 # Draw as footprints instead of markers: build one 

1329 # (catalog, color, label) bucket per color for 

1330 # `_overlay_footprint_layers`. color_by splits by flag 

1331 # (deterministic); otherwise a single red bucket. 

1332 if color_by: 

1333 unfiltered_footprints = [ 

1334 (sub, ctype, f"unfiltered: {flag}") 

1335 for sub, ctype, flag 

1336 in _group_sources_by_flag(non_sky, color_by)] 

1337 else: 

1338 unfiltered_footprints = [(non_sky, "red", "unfiltered candidate")] 

1339 elif color_by: 

1340 for sub, ctype, flag in _group_sources_by_flag(non_sky, color_by): 

1341 _add(sub, symbol="+", size=10, ctype=ctype, 

1342 legend=f"unfiltered: {flag}") 

1343 else: 

1344 _add(non_sky, symbol="+", size=10, ctype="red", 

1345 legend="unfiltered candidate") 

1346 if show_rejected: 

1347 _add(_try_get("rejected_dia_source"), 

1348 symbol="+", size=10, ctype="orange", legend="rejected diaSource") 

1349 if show_trailed: 

1350 _add(_try_get("long_trailed_source_detector"), 

1351 symbol="x", size=30, ctype="magenta", legend="long-trailed source") 

1352 

1353 # Stash the standardized catalog + projected xy for reuse by the 

1354 # geometry overlays below. `standardizeDiaSource` runs between 

1355 # filterDiaSource and associateApdb; when the pipeline stops before 

1356 # the APDB ingest, dia_source_detector is the last diaSource catalog 

1357 # available. 

1358 standardized_data = None 

1359 if show_standardized: 

1360 standardized = _try_get("dia_source_detector") 

1361 if standardized is not None and len(standardized) > 0: 

1362 xy = get_xy_from_source_table(standardized, wcs) 

1363 x_arr = xy["x"].data 

1364 y_arr = xy["y"].data 

1365 overlays.append((x_arr, y_arr, "+", 10, "blue", "standardized diaSource")) 

1366 standardized_data = {"catalog": standardized, "x": x_arr, "y": y_arr} 

1367 

1368 # Load dia_source_apdb once: it backs the APDB reliability overlay and 

1369 # also supplies pixel x/y for the solar-system overlay (ss_source_detector 

1370 # carries only the matched diaSourceId, not coordinates). 

1371 dia_apdb = None 

1372 if show_solar_system or show_apdb: 

1373 dia_apdb = _try_get("dia_source_apdb") 

1374 

1375 if show_apdb and dia_apdb is not None and len(dia_apdb) > 0: 

1376 good_mask = dia_apdb["reliability"] > reliability_threshold 

1377 _add(dia_apdb[good_mask], symbol="o", size=14, ctype="blue", use_radec=False, 

1378 legend=f"APDB, reliability > {reliability_threshold:g}") 

1379 _add(dia_apdb[~good_mask], symbol="o", size=14, ctype="red", use_radec=False, 

1380 legend=f"APDB, reliability <= {reliability_threshold:g}") 

1381 

1382 solar_system_labels = None 

1383 if show_solar_system: 

1384 ss = _try_get("ss_source_detector") 

1385 if (ss is not None and len(ss) > 0 

1386 and dia_apdb is not None and len(dia_apdb) > 0): 

1387 # ss_source_detector lacks coords; match each diaSourceId to 

1388 # the APDB row to recover its pixel x/y. 

1389 ss_ids = np.asarray(ss["diaSourceId"]) 

1390 apdb_ids = np.asarray(dia_apdb["diaSourceId"]) 

1391 idx_in_apdb = pd.Series(np.arange(len(apdb_ids)), index=apdb_ids).reindex(ss_ids) 

1392 keep = idx_in_apdb.notna().to_numpy() 

1393 if keep.any(): 

1394 apdb_idx = idx_in_apdb.dropna().astype(int).to_numpy() 

1395 x_arr = np.asarray(dia_apdb["x"])[apdb_idx] 

1396 y_arr = np.asarray(dia_apdb["y"])[apdb_idx] 

1397 designation = np.asarray(ss["designation"])[keep] 

1398 overlays.append((x_arr, y_arr, "o", 16, "cyan", "solar-system match")) 

1399 solar_system_labels = {"x": x_arr, "y": y_arr, "designation": designation, } 

1400 

1401 if show_marginal: 

1402 _add(_try_get("marginal_new_dia_source"), 

1403 symbol="+", size=10, ctype="yellow", legend="marginal new diaSource") 

1404 

1405 # Reliability text is drawn at most once per diaSource: prefer APDB 

1406 # good sources when APDB is displayed, and otherwise annotate every 

1407 # standardized row (they've been pipeline-filtered to high 

1408 # reliability already). The unfiltered catalog doesn't carry a final 

1409 # reliability score, so it never provides labels. 

1410 reliability_labels = None 

1411 apdb_shown = show_apdb and dia_apdb is not None and len(dia_apdb) > 0 

1412 if show_reliability_labels: 

1413 if apdb_shown: 

1414 rel = np.asarray(dia_apdb["reliability"]) 

1415 mask = rel > reliability_threshold 

1416 if mask.any(): 

1417 reliability_labels = {"x": np.asarray(dia_apdb["x"])[mask], 

1418 "y": np.asarray(dia_apdb["y"])[mask], 

1419 "reliability": rel[mask]} 

1420 elif standardized_data is not None: 

1421 reliability_labels = { 

1422 "x": standardized_data["x"], 

1423 "y": standardized_data["y"], 

1424 "reliability": np.asarray(standardized_data["catalog"]["reliability"]), 

1425 } 

1426 

1427 # Dipole and trail line segments both come from dia_source_unfiltered: 

1428 # it's the earliest AP-pipeline catalog and thus a superset of the 

1429 # downstream ones that get dipoles/long trails filtered out, and it 

1430 # carries the raw ip_diffim / ext_trailedSources measurement columns 

1431 # in native pixel + detector-radian units — exactly the coordinate 

1432 # system the endpoint math lives in. 

1433 dipole_segments = None 

1434 if show_dipoles: 

1435 # Fixed 10 px length: the measured ``ip_diffim_DipoleFit_separation`` 

1436 # is sub-pixel for the vast majority of classified dipoles (median 

1437 # ~0.09 px in typical data), so drawing at the measured length 

1438 # would hide them. The line here is a fixed-size *marker* of the 

1439 # dipole's orientation, not a physical extent. 

1440 dipole_segments = _line_segments_from( 

1441 unfiltered, wcs, 

1442 flag_col="ip_diffim_DipoleFit_classification", 

1443 angle_col="ip_diffim_DipoleFit_orientation", 

1444 ctype="white", fixed_length=10.0, thickness=3.0) 

1445 

1446 # 3 px threshold: below this the trail measurement is dominated by 

1447 # noise on point-like sources. Long-trailed sources removed by 

1448 # filterDiaSource still show up via ``show_trailed`` as ``x`` markers. 

1449 trail_segments = None 

1450 if show_trail_geometry: 

1451 trail_segments = _line_segments_from( 

1452 unfiltered, wcs, flag_col=None, 

1453 length_col="ext_trailedSources_Naive_length", 

1454 angle_col="ext_trailedSources_Naive_angle", 

1455 ctype="magenta", min_length=3.0, 

1456 length_scale=line_length_scale) 

1457 

1458 return _OverlayData( 

1459 overlays=overlays, 

1460 reliability_labels=reliability_labels, 

1461 solar_system_labels=solar_system_labels, 

1462 dipole_segments=dipole_segments, 

1463 trail_segments=trail_segments, 

1464 unfiltered_footprints=unfiltered_footprints, 

1465 ) 

1466 

1467 

1468def _print_overlay_legend(overlays, header, indent=""): 

1469 """Print a one-line-per-overlay legend for a single panel.""" 

1470 print(f"{indent}{header}") 

1471 for x_arr, _, symbol, _, ctype, legend in overlays: 

1472 print(f"{indent} {len(x_arr):5d} {ctype:>8s} {symbol} {legend}") 

1473 

1474 

1475def _print_footprint_legend(buckets, indent=""): 

1476 """Print a one-line-per-color summary of the unfiltered footprint layers. 

1477 

1478 ``buckets`` is the `_OverlayData.unfiltered_footprints` list of 

1479 ``(catalog, color, label)`` triples; does nothing when it is empty or 

1480 None (i.e. the unfiltered catalog was drawn as markers). 

1481 """ 

1482 if not buckets: 

1483 return 

1484 total = sum(len(cat) for cat, _, _ in buckets) 

1485 print(f"{indent}{total:5d} footprints unfiltered ({len(buckets)} colors):") 

1486 for cat, color, label in buckets: 

1487 print(f"{indent} {len(cat):5d} {color:>11s} {label}") 

1488 

1489 

1490def _resolve_unfiltered_footprints(unfiltered_style, backend, color_by): 

1491 """Decide whether to draw the unfiltered catalog as footprints, warning 

1492 about the caveats. 

1493 

1494 Footprints need Firefly's native overlay, so a non-firefly backend 

1495 falls back to ``+`` markers. When footprints are combined with 

1496 ``color_by``, stale layers from a previous call are not auto-erased 

1497 (Firefly exposes no API to delete or enumerate footprint layers), so 

1498 warn that clearing them before re-running is the caller's 

1499 responsibility. 

1500 """ 

1501 if unfiltered_style not in ("footprint", "marker"): 

1502 raise ValueError("unfiltered_style must be 'footprint' or 'marker', " 

1503 f"got {unfiltered_style!r}") 

1504 use_footprints = unfiltered_style == "footprint" and backend == "firefly" 

1505 if unfiltered_style == "footprint" and backend != "firefly": 

1506 print(f"WARNING: unfiltered_style='footprint' needs the 'firefly' " 

1507 f"backend; falling back to '+' markers for backend={backend!r}.") 

1508 if use_footprints and color_by: 

1509 print("WARNING: color_by footprint layers are not auto-erased; " 

1510 "re-running may leave stale footprints from a previous call. " 

1511 "Clear the frame's footprint layers before re-running.") 

1512 return use_footprints 

1513 

1514 

1515def _draw_line_segments(afw_display, segments): 

1516 """Draw one batch of centered line segments on the active frame.""" 

1517 if segments is None: 

1518 return 

1519 ctype = segments["ctype"] 

1520 size = segments["size"] 

1521 for x1, y1, x2, y2 in zip(segments["x1"], segments["y1"], 

1522 segments["x2"], segments["y2"]): 

1523 afw_display.line([(float(x1), float(y1)), (float(x2), float(y2))], 

1524 ctype=ctype, size=size) 

1525 

1526 

1527def _draw_overlays_on_current_frame(afw_display, overlays, 

1528 reliability_labels, solar_system_labels, 

1529 dipole_segments=None, 

1530 trail_segments=None, 

1531 label_size=3): 

1532 """Stamp one set of overlays + optional reliability and solar-system 

1533 designation labels onto the active frame. 

1534 

1535 ``label_size`` is the text size (in pixels) used for both label sets. 

1536 ``dipole_segments`` and ``trail_segments`` are optional line-segment 

1537 dicts (see `_line_segments_from`). 

1538 """ 

1539 # Scale the text offset with the size so larger labels still clear the 

1540 # circle markers they annotate. 

1541 label_offset = max(14, 2 * label_size) 

1542 with afw_display.Buffering(): 

1543 for x_arr, y_arr, symbol, size, ctype, _ in overlays: 

1544 for x, y in zip(x_arr, y_arr): 

1545 afw_display.dot(symbol, x, y, size=size, ctype=ctype) 

1546 # Trails first, dipoles on top: a source flagged as a dipole is 

1547 # the more actionable pipeline-quality issue, so it wins any 

1548 # pixel overlap with the trail line. 

1549 _draw_line_segments(afw_display, trail_segments) 

1550 _draw_line_segments(afw_display, dipole_segments) 

1551 if reliability_labels is not None: 

1552 # Offset the score text so it doesn't sit on top of the marker. 

1553 for r, x, y in zip(reliability_labels["reliability"], 

1554 reliability_labels["x"], 

1555 reliability_labels["y"]): 

1556 afw_display.dot(f"{r:.2f}", x + label_offset, y, 

1557 size=label_size, ctype="cyan") 

1558 if solar_system_labels is not None: 

1559 # Offset SS designations *below* the marker so they don't 

1560 # overplot any reliability score drawn to the right. 

1561 for desig, x, y in zip(solar_system_labels["designation"], 

1562 solar_system_labels["x"], 

1563 solar_system_labels["y"]): 

1564 afw_display.dot(str(desig), x, y + label_offset, 

1565 size=label_size, ctype="cyan") 

1566 

1567 

1568def _get_image(butler, image_datasets, image_type, data_id): 

1569 """Read one AP image in `lsst.images` form. 

1570 

1571 Parameters 

1572 ---------- 

1573 butler : `lsst.daf.butler.Butler` 

1574 Butler to read from. 

1575 image_datasets : `dict` [`str`, `str`] 

1576 Mapping from image type to dataset name. 

1577 image_type : {"science", "template", "difference"} 

1578 Image to read. 

1579 data_id : `dict` 

1580 Data ID of the image. 

1581 

1582 Returns 

1583 ------- 

1584 image : `lsst.images.VisitImage` 

1585 The image; a legacy template is given the science image's 

1586 detector, which it lacks. 

1587 """ 

1588 detector = None 

1589 if image_type == "template": 1589 ↛ 1590line 1589 didn't jump to line 1590 because the condition on line 1589 was never true

1590 detector = butler.get(f"{image_datasets['science']}.detector", data_id) 

1591 return get_visit_image(butler, image_datasets[image_type], data_id, image_type=image_type, 

1592 detector=detector) 

1593 

1594 

1595def _science_bbox(butler, image_datasets, data_id): 

1596 """Read the bounding box of a science image as an `lsst.images.Box`.""" 

1597 bbox = butler.get(f"{image_datasets['science']}.bbox", data_id) 

1598 return Box.from_legacy(bbox) if isinstance(bbox, lsst.geom.Box2I) else bbox 

1599 

1600 

1601def _legacy_for_display(image): 

1602 """Return the legacy form of an image, which afw displays take.""" 

1603 return image.to_legacy() if hasattr(image, "to_legacy") else image 

1604 

1605 

1606def _show_image(afw_display, image, title, strip_metadata): 

1607 """Show an image on the current frame of an afw display. 

1608 

1609 Parameters 

1610 ---------- 

1611 afw_display : `lsst.afw.display.Display` 

1612 Display to draw on. 

1613 image : `lsst.images.MaskedImage`, `lsst.images.cells.CellCoadd`, or \ 

1614 `lsst.afw.image.Exposure` 

1615 Image to show; afw displays take only the legacy form, so an 

1616 `lsst.images` image is converted. 

1617 title : `str` 

1618 Frame title. 

1619 strip_metadata : `bool` 

1620 Drop ``LTV1``/``LTV2`` from the shown exposure's metadata. 

1621 

1622 Returns 

1623 ------- 

1624 exposure : `lsst.afw.image.Exposure` 

1625 The exposure sent to the display. 

1626 """ 

1627 exposure = _legacy_for_display(image) 

1628 if strip_metadata: 

1629 _strip_ds9_metadata(exposure) 

1630 afw_display.image(exposure, title=title) 

1631 return exposure 

1632 

1633 

1634def _strip_ds9_metadata(*exposures): 

1635 """Drop LTV1/LTV2 keys from each exposure's metadata in place.""" 

1636 for exp in exposures: 

1637 md = exp.metadata 

1638 for k in ("LTV1", "LTV2"): 

1639 if md.exists(k): 

1640 md.remove(k) 

1641 

1642 

1643def _erase_current_frame_regions(afw_display): 

1644 """Clear all region markers on the currently-selected frame. 

1645 

1646 The firefly backend caches ``_regionLayerId`` on its impl and only 

1647 refreshes it inside ``_flush()``. Calling ``erase()`` right after 

1648 switching frames therefore issues ``delete_region_layer`` with the 

1649 *previous* frame's layer id under the current frame's plot id, so 

1650 nothing gets removed. Refreshing the cached id from the current 

1651 frame before erasing fixes it. Backends that don't cache a layer 

1652 id (e.g. ds9) fall through to a plain ``erase()``. 

1653 """ 

1654 impl = getattr(afw_display, "_impl", None) 

1655 if impl is not None and hasattr(impl, "_getRegionLayerId"): 

1656 impl._regionLayerId = impl._getRegionLayerId() 

1657 afw_display.erase() 

1658 

1659 

1660def display_images(butler, visit, detector, backend="firefly", *, 

1661 reliability_threshold=0.1, 

1662 show_unfiltered=True, 

1663 show_trailed=True, 

1664 show_rejected=True, 

1665 show_standardized=True, 

1666 show_marginal=True, 

1667 show_kernel_sources=True, 

1668 show_solar_system=True, 

1669 show_apdb=True, 

1670 show_reliability_labels=True, 

1671 show_dipoles=True, 

1672 show_trail_geometry=True, 

1673 line_length_scale=1.0, 

1674 label_size=3, 

1675 color_by=None, 

1676 unfiltered_style="footprint", 

1677 mask_transparency=80, 

1678 strip_metadata=True, 

1679 skymap=None, 

1680 skymap_ctype="green", 

1681 skymap_label_size=1.5, 

1682 image_datasets=_IMAGE_DATASETS, 

1683 use_fakes=False, 

1684 dry_run=False): 

1685 """Display the science, template, and difference images for a given 

1686 visit+detector with diagnostic catalog markers overlaid. 

1687 

1688 Three frames are produced (science, template, difference) and the same 

1689 overlays are drawn on each. Catalogs that are missing from the butler 

1690 are silently skipped, so the same call works against partial outputs. 

1691 

1692 Default overlay key. Rows are in AP-pipeline creation order, so the 

1693 last marker drawn at any pixel reflects the latest classification the 

1694 pipeline assigned. Circle sizes step by 2 so successive ``o`` markers 

1695 nest rather than stack. 

1696 

1697 ============================= ========= ==== ========================== 

1698 catalog symbol size color 

1699 ============================= ========= ==== ========================== 

1700 psf-matching kernel sources ``o`` 12 green 

1701 unfiltered candidates footprint -- red (``unfiltered_style``) 

1702 rejected diaSources ``+`` 10 orange 

1703 long-trailed sources ``x`` 30 magenta 

1704 standardized diaSources ``+`` 10 blue 

1705 APDB, reliability > threshold ``o`` 14 blue (+ score text) 

1706 APDB, reliability ≤ threshold ``o`` 14 red 

1707 solar-system matches ``o`` 16 cyan 

1708 marginal new diaSources ``+`` 10 yellow 

1709 ============================= ========= ==== ========================== 

1710 

1711 By default the ``dia_source_unfiltered`` catalog is drawn as Firefly 

1712 footprint outlines rather than ``+`` markers; see ``unfiltered_style``. 

1713 

1714 Parameters 

1715 ---------- 

1716 butler : `lsst.daf.butler.Butler` 

1717 Butler to load data from. 

1718 visit, detector : `int` 

1719 Visit and detector ids to load data for. 

1720 backend : `str`, optional 

1721 afw display backend (typically "firefly" or "ds9"). 

1722 reliability_threshold : `float`, optional 

1723 APDB diaSources with reliability strictly greater than this are 

1724 drawn as "good" (blue); the rest as "bad" (red). 

1725 show_unfiltered, show_trailed, show_rejected, show_standardized, 

1726 show_marginal, show_kernel_sources, show_solar_system, 

1727 show_apdb : `bool`, optional 

1728 Toggle individual catalog overlays. ``show_kernel_sources`` 

1729 loads ``difference_kernel_sources``, the PSF-matching constraint 

1730 sources from image subtraction — useful for seeing where the 

1731 kernel was actually anchored vs extrapolated. 

1732 show_reliability_labels : `bool`, optional 

1733 If True, annotate each good APDB diaSource with its reliability score. 

1734 show_dipoles : `bool`, optional 

1735 If True, draw a 10-px white line segment through each source in 

1736 ``dia_source_unfiltered`` with ``ip_diffim_DipoleFit_classification`` 

1737 set, oriented along ``ip_diffim_DipoleFit_orientation`` (radians, 

1738 detector-frame). The length is fixed rather than measured because 

1739 ``ip_diffim_DipoleFit_separation`` is sub-pixel for the vast 

1740 majority of classified dipoles; the line is a fixed-size marker 

1741 of orientation rather than a physical extent. Sourced from the 

1742 unfiltered catalog rather than the standardized or APDB catalogs 

1743 because ``filterDiaSource`` removes dipoles before those stages. 

1744 show_trail_geometry : `bool`, optional 

1745 If True, draw a magenta line segment along 

1746 ``ext_trailedSources_Naive_angle`` with length 

1747 ``ext_trailedSources_Naive_length`` for every source in 

1748 ``dia_source_unfiltered`` whose trail length exceeds 3 px. 

1749 Long-trailed sources removed by ``filterDiaSource`` still show 

1750 up separately under ``show_trailed`` as ``x`` markers. 

1751 line_length_scale : `float`, optional 

1752 Multiplicative factor applied to the drawn length of the trail 

1753 line segments *after* the 3 px trail filter, so a below-threshold 

1754 trail stays hidden regardless of the scale. Does not affect the 

1755 dipole marker, which is drawn at a fixed 10 px length by design. 

1756 Default 1.0 (draw trails at their measured length); use larger 

1757 values to make short trails easier to see against the image. 

1758 label_size : `int`, optional 

1759 Text size (in pixels) for the reliability score and solar-system 

1760 designation annotations. 

1761 color_by : sequence of `str`, optional 

1762 Flag column names from ``dia_source_unfiltered``. When supplied, 

1763 the unfiltered-candidate overlay is split into buckets colored by 

1764 which named flag fires first (list order = color *and* priority), 

1765 with a residual white bucket for rows that match none of them. 

1766 Unknown column names are silently skipped. Applies whether the 

1767 unfiltered catalog is drawn as footprints or markers. Example:: 

1768 

1769 color_by=["pixelFlags_bad", "pixelFlags_edge", 

1770 "ip_diffim_DipoleFit_classification", 

1771 "pixelFlags_saturated"] 

1772 unfiltered_style : {"footprint", "marker"}, optional 

1773 How to draw ``dia_source_unfiltered``. ``"footprint"`` (default) 

1774 overlays each source's `Footprint` outline using Firefly's native 

1775 footprint rendering; ``"marker"`` draws the old red ``+`` markers. 

1776 Footprints need the ``"firefly"`` backend, so any other backend 

1777 (e.g. ds9) silently falls back to markers. Two caveats with 

1778 footprints, both warned about at call time: (1) when combined with 

1779 ``color_by``, footprint layers from a previous call are *not* 

1780 auto-erased (Firefly offers no way to delete or enumerate them), so 

1781 clearing them before re-running is your responsibility; (2) 

1782 switching ``unfiltered_style`` from ``"footprint"`` to ``"marker"`` 

1783 leaves the previous footprints on the frame — re-run in footprint 

1784 mode or reset the frame to clear them. Re-running in the default 

1785 (no ``color_by``) footprint mode overwrites the single layer in 

1786 place, so it is unaffected. 

1787 mask_transparency : `int` or `None`, optional 

1788 Mask-plane transparency forwarded to the display (0 = opaque, 

1789 100 = fully transparent). Pass ``None`` to leave the backend's 

1790 current setting untouched. 

1791 strip_metadata : `bool`, optional 

1792 Drop ``LTV1``/``LTV2`` keywords from each exposure's metadata 

1793 before sending to the backend. Needed for ds9 to align frames. 

1794 skymap : `lsst.skymap.BaseSkyMap`, optional 

1795 If supplied, overlay the boundaries of every tract/patch that 

1796 touches each frame, labeled ``tract,patch``. 

1797 skymap_ctype : `str`, optional 

1798 Display color for the tract/patch outlines and labels. 

1799 skymap_label_size : `float`, optional 

1800 Text size for the ``tract,patch`` labels. 

1801 image_datasets : `dict` [`str`, `str`], optional 

1802 Mapping from image-type key (``"science"``, ``"template"``, 

1803 ``"difference"``) to butler dataset name. Override to point at 

1804 alternate dataset types. 

1805 use_fakes : `bool`, optional 

1806 If True, load the fake-source-injected versions of the science 

1807 and template images: ``fakes_`` prefix on the science dataset 

1808 and ``injectedTemplate_`` prefix on the template dataset. The 

1809 difference image and every catalog keep their non-prefixed 

1810 names, per the fake-source pipeline's output convention. 

1811 Default False. 

1812 dry_run : `bool`, optional 

1813 If True, load every requested image and catalog and print the 

1814 overlay/footprint legends, but skip constructing the afw display 

1815 and drawing anything. Useful for sanity-checking which datasets 

1816 are available for a (visit, detector) without opening a viewer. 

1817 Default False. 

1818 """ 

1819 data_id = {"visit": visit, "detector": detector} 

1820 image_datasets = _apply_fakes_prefix(image_datasets, use_fakes) 

1821 use_footprints = _resolve_unfiltered_footprints( 

1822 unfiltered_style, backend, color_by) 

1823 

1824 diffim = _get_image(butler, image_datasets, "difference", data_id) 

1825 science = _get_image(butler, image_datasets, "science", data_id) 

1826 template = get_visit_image(butler, image_datasets["template"], data_id, image_type="template", 

1827 detector=science.detector) 

1828 template = template[science.bbox] 

1829 images = {"science": science, "template": template, "difference": diffim} 

1830 

1831 data = _collect_overlays( 

1832 butler, data_id, diffim.sky_projection, 

1833 reliability_threshold=reliability_threshold, 

1834 show_unfiltered=show_unfiltered, 

1835 show_trailed=show_trailed, show_rejected=show_rejected, 

1836 show_standardized=show_standardized, 

1837 show_marginal=show_marginal, 

1838 show_kernel_sources=show_kernel_sources, 

1839 show_solar_system=show_solar_system, 

1840 show_apdb=show_apdb, 

1841 show_reliability_labels=show_reliability_labels, 

1842 show_dipoles=show_dipoles, 

1843 show_trail_geometry=show_trail_geometry, 

1844 line_length_scale=line_length_scale, 

1845 color_by=color_by, 

1846 unfiltered_as_footprints=use_footprints, 

1847 ) 

1848 _print_overlay_legend( 

1849 data.overlays, f"visit={visit}, detector={detector} -- overlay legend:") 

1850 _print_footprint_legend(data.unfiltered_footprints, indent=" ") 

1851 

1852 if dry_run: 

1853 return 

1854 

1855 afw_display = lsst.afw.display.Display(backend=backend) 

1856 if mask_transparency is not None: 

1857 afw_display.setMaskTransparency(mask_transparency) 

1858 for frame, image_name in enumerate(("science", "template", "difference")): 

1859 afw_display.frame = frame 

1860 # Wipe any markers left over from a previous call — `image()` 

1861 # only replaces the pixel data, region overlays persist otherwise. 

1862 _erase_current_frame_regions(afw_display) 

1863 exposure = _show_image(afw_display, images[image_name], image_name, strip_metadata) 

1864 _draw_overlays_on_current_frame( 

1865 afw_display, data.overlays, data.reliability_labels, 

1866 data.solar_system_labels, 

1867 dipole_segments=data.dipole_segments, 

1868 trail_segments=data.trail_segments, 

1869 label_size=label_size) 

1870 if data.unfiltered_footprints: 

1871 # Per-frame layer prefix so the same catalog drawn on all three 

1872 # frames gets distinct Firefly layer ids. 

1873 _overlay_footprint_layers( 

1874 afw_display, data.unfiltered_footprints, 

1875 style="outline", layer_prefix=f"{image_name} unfiltered") 

1876 if skymap is not None: 

1877 draw_skymap_outlines_afw(afw_display, skymap, exposure.wcs, exposure.getBBox(), 

1878 ctype=skymap_ctype, label_size=skymap_label_size) 

1879 

1880 try: 

1881 afw_display.alignImages(match_type="Pixel") 

1882 except NotImplementedError: 

1883 print(f"WARNING: cannot automatically align and lock images with backend={backend!r}.") 

1884 

1885 

1886def display_images_ab(butler_a, butler_b, visit, detector, *, 

1887 image_type="difference", 

1888 labels=("A", "B"), 

1889 backend="firefly", 

1890 reliability_threshold=0.1, 

1891 show_unfiltered=True, 

1892 show_trailed=True, 

1893 show_rejected=True, 

1894 show_standardized=True, 

1895 show_marginal=True, 

1896 show_kernel_sources=True, 

1897 show_solar_system=True, 

1898 show_apdb=True, 

1899 show_reliability_labels=True, 

1900 show_dipoles=True, 

1901 show_trail_geometry=True, 

1902 line_length_scale=1.0, 

1903 label_size=3, 

1904 color_by=None, 

1905 unfiltered_style="footprint", 

1906 mask_transparency=80, 

1907 strip_metadata=True, 

1908 skymap=None, 

1909 skymap_ctype="green", 

1910 skymap_label_size=1.5, 

1911 image_datasets=_IMAGE_DATASETS, 

1912 use_fakes=False, 

1913 dry_run=False): 

1914 """Display one image type side-by-side from two butlers, with overlays. 

1915 

1916 Loads the same (visit, detector) from ``butler_a`` and ``butler_b``, 

1917 places them in two frames, and draws each butler's catalog overlays on 

1918 its own frame. Intended for A/B-testing pipeline-config changes that affect 

1919 detection or subtraction quality. 

1920 

1921 Parameters 

1922 ---------- 

1923 butler_a, butler_b : `lsst.daf.butler.Butler` 

1924 Two butlers, typically from different pipeline runs of the same data. 

1925 visit, detector : `int` 

1926 Visit and detector ids to load data for. 

1927 image_type : {"science", "template", "difference"}, optional 

1928 Which image dataset to compare. Default ``"difference"``. 

1929 labels : pair of `str`, optional 

1930 Short tags for the two frames; appear in the image title and the 

1931 legend header. Default ``("A", "B")``. 

1932 backend : `str`, optional 

1933 afw display backend (typically "firefly" or "ds9"). 

1934 reliability_threshold, show_unfiltered, show_trailed, show_rejected, 

1935 show_standardized, show_marginal, show_kernel_sources, 

1936 show_solar_system, show_apdb, show_reliability_labels, show_dipoles, 

1937 show_trail_geometry, line_length_scale, label_size, color_by, 

1938 unfiltered_style, mask_transparency, strip_metadata, skymap, 

1939 skymap_ctype, skymap_label_size, image_datasets, use_fakes, dry_run 

1940 Same meaning as in `display_images`. Applied to both frames; the 

1941 tract/patch overlay uses each frame's own exposure WCS. Each frame's 

1942 unfiltered footprints get their own per-frame Firefly layers (keyed 

1943 by ``labels``), so the two frames don't share layers. 

1944 """ 

1945 if image_type not in image_datasets: 

1946 raise ValueError( 

1947 f"image_type must be one of {sorted(image_datasets)}, got {image_type!r}") 

1948 image_datasets = _apply_fakes_prefix(image_datasets, use_fakes) 

1949 data_id = {"visit": visit, "detector": detector} 

1950 

1951 image_a = _get_image(butler_a, image_datasets, image_type, data_id) 

1952 image_b = _get_image(butler_b, image_datasets, image_type, data_id) 

1953 if image_type == "template": 

1954 # Templates are usually larger than the science footprint; clip them 

1955 # to the science bbox so the two frames have matching extents. 

1956 image_a = image_a[_science_bbox(butler_a, image_datasets, data_id)] 

1957 image_b = image_b[_science_bbox(butler_b, image_datasets, data_id)] 

1958 

1959 use_footprints = _resolve_unfiltered_footprints( 

1960 unfiltered_style, backend, color_by) 

1961 common = dict( 

1962 reliability_threshold=reliability_threshold, 

1963 show_unfiltered=show_unfiltered, 

1964 show_trailed=show_trailed, show_rejected=show_rejected, 

1965 show_standardized=show_standardized, 

1966 show_marginal=show_marginal, 

1967 show_kernel_sources=show_kernel_sources, 

1968 show_solar_system=show_solar_system, 

1969 show_apdb=show_apdb, show_reliability_labels=show_reliability_labels, 

1970 show_dipoles=show_dipoles, 

1971 show_trail_geometry=show_trail_geometry, 

1972 line_length_scale=line_length_scale, 

1973 color_by=color_by, 

1974 unfiltered_as_footprints=use_footprints, 

1975 ) 

1976 data_a = _collect_overlays(butler_a, data_id, image_a.sky_projection, **common) 

1977 data_b = _collect_overlays(butler_b, data_id, image_b.sky_projection, **common) 

1978 

1979 label_a, label_b = labels 

1980 print(f"visit={visit}, detector={detector}: A/B comparison of {image_type!r}") 

1981 _print_overlay_legend(data_a.overlays, f"-- {label_a} overlay legend:", indent=" ") 

1982 _print_footprint_legend(data_a.unfiltered_footprints, indent=" ") 

1983 _print_overlay_legend(data_b.overlays, f"-- {label_b} overlay legend:", indent=" ") 

1984 _print_footprint_legend(data_b.unfiltered_footprints, indent=" ") 

1985 

1986 if dry_run: 

1987 return 

1988 

1989 afw_display = lsst.afw.display.Display(backend=backend) 

1990 if mask_transparency is not None: 

1991 afw_display.setMaskTransparency(mask_transparency) 

1992 for frame, (tag, image, data) in enumerate(( 

1993 (label_a, image_a, data_a), 

1994 (label_b, image_b, data_b))): 

1995 afw_display.frame = frame 

1996 # Wipe any markers left over from a previous call — `image()` 

1997 # only replaces the pixel data, region overlays persist otherwise. 

1998 _erase_current_frame_regions(afw_display) 

1999 exposure = _show_image(afw_display, image, f"{image_type} ({tag})", strip_metadata) 

2000 _draw_overlays_on_current_frame(afw_display, data.overlays, 

2001 data.reliability_labels, 

2002 data.solar_system_labels, 

2003 dipole_segments=data.dipole_segments, 

2004 trail_segments=data.trail_segments, 

2005 label_size=label_size) 

2006 if data.unfiltered_footprints: 

2007 # Per-frame layer prefix (the A/B tag) so the two frames' 

2008 # footprints get distinct Firefly layer ids. 

2009 _overlay_footprint_layers( 

2010 afw_display, data.unfiltered_footprints, 

2011 style="outline", layer_prefix=f"{tag} unfiltered") 

2012 if skymap is not None: 

2013 draw_skymap_outlines_afw(afw_display, skymap, exposure.wcs, exposure.getBBox(), 

2014 ctype=skymap_ctype, label_size=skymap_label_size) 

2015 

2016 try: 

2017 afw_display.alignImages(match_type="Pixel") 

2018 except NotImplementedError: 

2019 print(f"WARNING: cannot automatically align and lock images with backend={backend!r}.") 

2020 

2021 

2022@dataclass 

2023class _PatchCoverage: 

2024 """One coadd patch that contributed to a template.""" 

2025 

2026 tract: int 

2027 patch: int 

2028 ref: Any 

2029 """`lsst.daf.butler.DatasetRef` of the coadd, or None if not found.""" 

2030 corners_xy: list 

2031 """Patch outline projected into difference-image pixels.""" 

2032 overlap: float 

2033 """Fraction of the difference image this patch covers.""" 

2034 color: str 

2035 frame: int | None 

2036 """Display frame showing this coadd; None when the coadd is missing.""" 

2037 

2038 

2039def _coadd_input_patches(butler, template_dataset, data_id): 

2040 """The coadd patches recorded as a template's inputs. 

2041 

2042 Read as a butler component, so the template's pixels are never 

2043 loaded. 

2044 

2045 Parameters 

2046 ---------- 

2047 butler : `lsst.daf.butler.Butler` 

2048 Butler to load from. 

2049 template_dataset : `str` 

2050 Dataset name of the template (e.g. ``"template_detector"``). 

2051 data_id : `dict` 

2052 Data id of the template. 

2053 

2054 Returns 

2055 ------- 

2056 patches : `dict` [`tuple` [`int`, `int`], `lsst.afw.table.ExposureRecord`] 

2057 Exposure record of each contributing patch, keyed on 

2058 ``(tract, patch)`` and sorted. Empty if the template carries no 

2059 coadd inputs. 

2060 """ 

2061 coadd_inputs = butler.get(f"{template_dataset}.coaddInputs", data_id) 

2062 ccds = None if coadd_inputs is None else coadd_inputs.ccds 

2063 if ccds is None or len(ccds) == 0: 

2064 return {} 

2065 if not {"tract", "patch"} <= ccds.schema.getNames(): 

2066 raise RuntimeError( 

2067 f"{template_dataset}.coaddInputs has no 'tract'/'patch' fields, so " 

2068 "the contributing coadds cannot be identified; this template " 

2069 "predates GetTemplateTask recording its input patches.") 

2070 patches = {} 

2071 for record in ccds: 

2072 # Patch ids repeat across tracts, so key on the pair. One record 

2073 # per patch is expected; keep the first if that ever changes. 

2074 patches.setdefault((int(record["tract"]), int(record["patch"])), record) 

2075 return dict(sorted(patches.items())) 

2076 

2077 

2078def _query_coadd_refs(butler, coadd_dataset, band, keys, skymap_name=None): 

2079 """Resolve one dataset ref per ``(tract, patch)`` key, where one exists. 

2080 

2081 A single constrained query both resolves the ``skymap`` dimension -- 

2082 which a (visit, detector) data id doesn't carry -- and reports which 

2083 patches are absent from the butler's collections. 

2084 

2085 Parameters 

2086 ---------- 

2087 butler : `lsst.daf.butler.Butler` 

2088 Butler to query; its default collections are used. 

2089 coadd_dataset : `str` 

2090 Dataset name of the coadds (e.g. ``"template_coadd"``). 

2091 band : `str` 

2092 Band to restrict the query to. 

2093 keys : `collections.abc.Container` [`tuple` [`int`, `int`]] 

2094 The ``(tract, patch)`` pairs to look for. 

2095 skymap_name : `str`, optional 

2096 Skymap to restrict the query to. Only needed when the coadds 

2097 match more than one skymap. 

2098 

2099 Returns 

2100 ------- 

2101 refs : `dict` [`tuple` [`int`, `int`], `lsst.daf.butler.DatasetRef`] 

2102 Refs of the coadds that exist, keyed on ``(tract, patch)``. 

2103 """ 

2104 where = "band = :band AND tract IN (:tracts) AND patch IN (:patches)" 

2105 bind = {"band": band, 

2106 "tracts": sorted({tract for tract, _ in keys}), 

2107 "patches": sorted({patch for _, patch in keys})} 

2108 if skymap_name is not None: 

2109 where += " AND skymap = :skymap" 

2110 bind["skymap"] = skymap_name 

2111 

2112 refs = {} 

2113 skymaps = set() 

2114 for ref in butler.query_datasets(coadd_dataset, where=where, bind=bind, 

2115 limit=None, explain=False): 

2116 key = (ref.dataId["tract"], ref.dataId["patch"]) 

2117 # tract and patch are constrained independently, so the query 

2118 # returns their cross product; drop the pairs we didn't ask for. 

2119 if key not in keys: 

2120 continue 

2121 skymaps.add(ref.dataId["skymap"]) 

2122 refs[key] = ref 

2123 if len(skymaps) > 1: 

2124 raise ValueError(f"{coadd_dataset} matched more than one skymap " 

2125 f"({sorted(skymaps)}); pass skymap_name to pick one.") 

2126 return refs 

2127 

2128 

2129def _coadd_geometry(butler, ref): 

2130 """Read the bounding box and WCS of a coadd without its pixels. 

2131 

2132 Parameters 

2133 ---------- 

2134 butler : `lsst.daf.butler.Butler` 

2135 Butler to read from. 

2136 ref : `lsst.daf.butler.DatasetRef` 

2137 Reference to the coadd, stored as an afw exposure or an 

2138 `lsst.images.cells.CellCoadd`. 

2139 

2140 Returns 

2141 ------- 

2142 bbox : `lsst.geom.Box2I` 

2143 Bounding box of the coadd. 

2144 wcs : `lsst.afw.geom.SkyWcs` 

2145 WCS of the coadd. 

2146 """ 

2147 components = ref.datasetType.storageClass.allComponents() 

2148 wcs_component = "wcs" if "wcs" in components else "sky_projection" 

2149 wcs = butler.get(ref.makeComponentRef(wcs_component), storageClass="Wcs") 

2150 bbox = butler.get(ref.makeComponentRef("bbox"), storageClass="Box2I") 

2151 return bbox, wcs 

2152 

2153 

2154def _template_patch_outlines(butler, template_dataset, data_id, wcs, *, 

2155 coadd_dataset, band, skymap_name=None): 

2156 """Outline and coadd of each patch recorded as a template input. 

2157 

2158 A template stored as an `lsst.images.DifferenceImage` records the 

2159 butler ID of each coadd it used, in its ``templates`` component. A 

2160 legacy template records only the tract and patch, in its 

2161 ``coaddInputs`` component, so its coadds are looked up by data ID. 

2162 

2163 Parameters 

2164 ---------- 

2165 butler : `lsst.daf.butler.Butler` 

2166 Butler to load from. 

2167 template_dataset : `str` 

2168 Dataset name of the template (e.g. ``"template_detector"``). 

2169 data_id : `dict` 

2170 Data id of the template. 

2171 wcs : `lsst.images.SkyProjection` 

2172 Sky projection of the difference image the outlines are drawn on. 

2173 coadd_dataset : `str` 

2174 Dataset name of the coadds; used only for a legacy template. 

2175 band : `str` 

2176 Band of the coadds; used only for a legacy template. 

2177 skymap_name : `str`, optional 

2178 Skymap of the coadds; used only for a legacy template. 

2179 

2180 Returns 

2181 ------- 

2182 outlines : `dict` [`tuple` [`int`, `int`], `list` [`tuple`]] 

2183 ``(x, y)`` corners of each patch in difference-image pixels, keyed 

2184 on ``(tract, patch)`` and sorted. For a coadd missing from the 

2185 butler, the outline of an `lsst.images` template covers only the 

2186 part of the patch that overlaps the difference image. 

2187 refs : `dict` [`tuple` [`int`, `int`], `lsst.daf.butler.DatasetRef`] 

2188 Refs of the coadds that exist, keyed on ``(tract, patch)``. 

2189 """ 

2190 components = butler.get_dataset_type(template_dataset).storageClass.allComponents() 

2191 if "templates" not in components: 2191 ↛ 2192line 2191 didn't jump to line 2192 because the condition on line 2191 was never true

2192 patches = _coadd_input_patches(butler, template_dataset, data_id) 

2193 outlines = {key: _project_bbox_corners(record.getBBox(), record.getWcs(), wcs) 

2194 for key, record in patches.items()} 

2195 refs = (_query_coadd_refs(butler, coadd_dataset, band, patches, skymap_name) 

2196 if patches else {}) 

2197 return outlines, refs 

2198 

2199 outlines = {} 

2200 refs = {} 

2201 templates = butler.get(f"{template_dataset}.templates", data_id) 

2202 for info in sorted(templates, key=lambda info: (info.tract, info.patch)): 

2203 key = (info.tract, info.patch) 

2204 ref = butler.get_dataset(info.dataset_id) 

2205 if ref is None: 

2206 # The bounds are already in difference-image pixels; drop the 

2207 # closing vertex, which repeats the first. 

2208 corners = list(zip(info.bounds.x_vertices.tolist(), info.bounds.y_vertices.tolist())) 

2209 outlines[key] = corners[:-1] if corners[0] == corners[-1] else corners 

2210 else: 

2211 refs[key] = ref 

2212 outlines[key] = _project_bbox_corners(*_coadd_geometry(butler, ref), wcs) 

2213 return outlines, refs 

2214 

2215 

2216def _project_bbox_corners(bbox, from_wcs, to_wcs): 

2217 """Corners of a bounding box mapped from one pixel grid to another. 

2218 

2219 ``bbox`` (an `lsst.images.Box` or `lsst.geom.Box2I`) is in 

2220 ``from_wcs``'s pixel system; the returned ``(x, y)`` corners, taken at 

2221 the outer pixel edges, are in ``to_wcs``'s parent pixel system. Each 

2222 transform is an `lsst.images.SkyProjection` or `lsst.afw.geom.SkyWcs`. 

2223 """ 

2224 x_min, x_max, y_min, y_max = _box_edges(bbox) 

2225 ra, dec = _pixel_to_sky(from_wcs, [x_min, x_max, x_max, x_min], [y_min, y_min, y_max, y_max]) 

2226 xs, ys = _sky_to_pixel(to_wcs, ra, dec) 

2227 return [(float(x), float(y)) for x, y in zip(xs, ys)] 

2228 

2229 

2230def _rect_from_bbox(bbox): 

2231 """``(xmin, xmax, ymin, ymax)`` clip rectangle of a bounding box. 

2232 

2233 Taken at the box's outer pixel edges so that clipped areas are 

2234 comparable with the box's area; the integer limits would be a half 

2235 pixel short on each side. 

2236 """ 

2237 return _box_edges(bbox) 

2238 

2239 

2240def _draw_outline_on_current_frame(afw_display, corners, ctype, *, label=None, 

2241 label_size=1.5, clip_rect=None): 

2242 """Draw a closed polyline through ``corners`` on the active frame. 

2243 

2244 ``clip_rect`` is the displayed image's ``(xmin, xmax, ymin, ymax)``; 

2245 when supplied, the label is anchored at the centroid of the outline's 

2246 *visible* portion so it stays on-screen for outlines that mostly fall 

2247 outside the frame. 

2248 """ 

2249 afw_display.line(list(corners) + [corners[0]], ctype=ctype) 

2250 if label is None: 

2251 return 

2252 visible = _clip_polygon_to_rect(corners, *clip_rect) if clip_rect else corners 

2253 if not visible: 

2254 visible = corners 

2255 x = sum(p[0] for p in visible)/len(visible) 

2256 y = sum(p[1] for p in visible)/len(visible) 

2257 afw_display.dot(label, x, y, size=label_size, ctype=ctype) 

2258 

2259 

2260def _subset_coadd_to_outline(coadd, corners, margin): 

2261 """Trim a coadd to ``margin`` pixels around a projected outline. 

2262 

2263 Returns the untrimmed coadd if the outline misses it entirely. 

2264 """ 

2265 box = lsst.geom.Box2D() 

2266 for x, y in corners: 

2267 box.include(lsst.geom.Point2D(x, y)) 

2268 box.grow(margin) 

2269 bbox = lsst.geom.Box2I(box) 

2270 bbox.clip(coadd.getBBox()) 

2271 if bbox.isEmpty(): 

2272 return coadd 

2273 return coadd[bbox] 

2274 

2275 

2276def _print_coadd_coverage_legend(header, coverages, indent=" "): 

2277 """Print one row per contributing patch: frame, id, color, coverage.""" 

2278 print(header) 

2279 print(f"{indent}{'frame':>5s} {'tract':>6s} {'patch':>5s} " 

2280 f"{'color':<8s} {'overlap':>7s} coadd") 

2281 for cov in coverages: 

2282 frame = "--" if cov.frame is None else str(cov.frame) 

2283 status = "found" if cov.ref is not None else "MISSING from collections" 

2284 print(f"{indent}{frame:>5s} {cov.tract:6d} {cov.patch:5d} " 

2285 f"{cov.color:<8s} {100*cov.overlap:6.1f}% {status}") 

2286 

2287 

2288def display_coadd_coverage(butler, visit, detector, backend="firefly", *, 

2289 patch_extent="full", 

2290 patch_margin=100, 

2291 show_diffim_outline=True, 

2292 show_patch_outlines=True, 

2293 diffim_ctype="red", 

2294 label_size=1.5, 

2295 mask_transparency=80, 

2296 strip_metadata=True, 

2297 align="Standard", 

2298 skymap_name=None, 

2299 image_datasets=_IMAGE_DATASETS, 

2300 coadd_dataset="template_coadd", 

2301 dry_run=False): 

2302 """Display a difference image alongside every coadd patch that went 

2303 into its template. 

2304 

2305 Frame 0 shows the difference image, with the outline of each 

2306 contributing patch drawn and labeled ``tract,patch``. Frames 1..N show 

2307 the coadd patches themselves, one per frame in ``(tract, patch)`` 

2308 order, each with the difference image's footprint outlined on it. 

2309 

2310 The contributing patches are read from the template's ``templates`` 

2311 component (an `lsst.images.DifferenceImage`) or ``coaddInputs`` 

2312 component (a legacy exposure), which `~lsst.ip.diffim.GetTemplateTask` 

2313 fills with one record per patch that supplied valid pixels. That is a 

2314 stricter set than a skymap 

2315 lookup would give: patches that overlap the detector geometrically but 

2316 contributed nothing were already dropped when the template was built. 

2317 

2318 Parameters 

2319 ---------- 

2320 butler : `lsst.daf.butler.Butler` 

2321 Butler to load data from. 

2322 visit, detector : `int` 

2323 Visit and detector ids to load data for. 

2324 backend : `str`, optional 

2325 afw display backend (typically "firefly" or "ds9"). 

2326 patch_extent : {"full", "overlap"}, optional 

2327 How much of each coadd to display. ``"full"`` (default) shows the 

2328 whole patch, which is what puts the detector's footprint in 

2329 context but sends a full-size coadd to the backend per frame; 

2330 ``"overlap"`` trims each patch to the difference image's footprint 

2331 plus ``patch_margin``, which is much faster to display but makes 

2332 every frame look alike. 

2333 patch_margin : `int`, optional 

2334 Pixels of coadd to keep around the difference image's footprint 

2335 when ``patch_extent="overlap"``. Ignored for ``"full"``. 

2336 show_diffim_outline : `bool`, optional 

2337 If True, outline the difference image's footprint on each coadd 

2338 frame, labeled ``visit,detector``. 

2339 show_patch_outlines : `bool`, optional 

2340 If True, outline each contributing patch on the difference-image 

2341 frame, labeled ``tract,patch``. Outlines are drawn for missing 

2342 coadds too, from the template's records; for an `lsst.images` 

2343 template such an outline covers only the part of the patch that 

2344 overlaps the difference image. 

2345 diffim_ctype : `str`, optional 

2346 Display color for the difference-image outline. The patch outlines 

2347 cycle through a fixed palette instead, so that a patch's color on 

2348 frame 0 identifies its own frame in the printed legend. 

2349 label_size : `float`, optional 

2350 Text size for the outline labels. 

2351 mask_transparency : `int` or `None`, optional 

2352 Mask-plane transparency forwarded to the display (0 = opaque, 

2353 100 = fully transparent). Pass ``None`` to leave the backend's 

2354 current setting untouched. 

2355 strip_metadata : `bool`, optional 

2356 Drop ``LTV1``/``LTV2`` keywords from each exposure's metadata 

2357 before sending to the backend. Needed for ds9 to align frames. 

2358 align : `str` or `None`, optional 

2359 ``match_type`` passed to ``alignImages``. Defaults to 

2360 ``"Standard"`` (align by WCS) rather than the pixel matching 

2361 `display_images` uses, because these frames do not share a pixel 

2362 grid. Pass None to leave the frames unaligned. 

2363 skymap_name : `str`, optional 

2364 Skymap the coadds live in. Only needed when the contributing 

2365 tracts and patches match coadds in more than one skymap, which is 

2366 an error otherwise. 

2367 image_datasets : `dict` [`str`, `str`], optional 

2368 Mapping from image-type key (``"science"``, ``"template"``, 

2369 ``"difference"``) to butler dataset name. Only the ``"template"`` 

2370 and ``"difference"`` entries are used here; only the template's 

2371 record of its inputs is read, so its pixels are never loaded. 

2372 coadd_dataset : `str`, optional 

2373 Dataset name of the coadds the template was built from. The 

2374 default matches what ``ApPipe.yaml`` binds to the template task's 

2375 ``coaddExposures`` input. Ignored for an `lsst.images` template, 

2376 which records the butler ID of each coadd. 

2377 dry_run : `bool`, optional 

2378 If True, work out which patches contributed and print the legend, 

2379 but skip constructing the afw display and loading any pixels. 

2380 Useful for checking a template's provenance without a viewer. 

2381 Default False. 

2382 """ 

2383 if patch_extent not in ("full", "overlap"): 

2384 raise ValueError("patch_extent must be 'full' or 'overlap', " 

2385 f"got {patch_extent!r}") 

2386 data_id = {"visit": visit, "detector": detector} 

2387 header = f"visit={visit}, detector={detector} -- " 

2388 

2389 diffim = _get_image(butler, image_datasets, "difference", data_id) 

2390 bbox = diffim.bbox 

2391 clip_rect = _rect_from_bbox(bbox) 

2392 diffim_area = bbox.area 

2393 band = diffim.band 

2394 outlines, refs = _template_patch_outlines( 

2395 butler, image_datasets["template"], data_id, diffim.sky_projection, 

2396 coadd_dataset=coadd_dataset, band=band, skymap_name=skymap_name) 

2397 if not outlines: 

2398 print(f"{header}template records no coadd inputs; showing the " 

2399 "difference image only.") 

2400 

2401 coverages = [] 

2402 frame = 1 

2403 for i, ((tract, patch), corners) in enumerate(outlines.items()): 

2404 overlap = _polygon_area(_clip_polygon_to_rect(corners, *clip_rect)) 

2405 ref = refs.get((tract, patch)) 

2406 coverages.append(_PatchCoverage( 

2407 tract=tract, patch=patch, ref=ref, 

2408 corners_xy=corners, overlap=overlap/diffim_area, 

2409 color=_FLAG_PALETTE[i % len(_FLAG_PALETTE)], 

2410 frame=None if ref is None else frame)) 

2411 if ref is not None: 

2412 frame += 1 

2413 

2414 if coverages: 

2415 n_tracts = len({cov.tract for cov in coverages}) 

2416 # Overlaps sum to more than 100%: adjacent patches share a border 

2417 # region by construction. 

2418 _print_coadd_coverage_legend( 

2419 f"{header}{len(coverages)} coadd patches from {n_tracts} tract(s), " 

2420 f"band={band}:", coverages) 

2421 

2422 if dry_run: 

2423 return 

2424 

2425 afw_display = lsst.afw.display.Display(backend=backend) 

2426 if mask_transparency is not None: 

2427 afw_display.setMaskTransparency(mask_transparency) 

2428 

2429 afw_display.frame = 0 

2430 # Wipe any markers left over from a previous call — `image()` 

2431 # only replaces the pixel data, region overlays persist otherwise. 

2432 _erase_current_frame_regions(afw_display) 

2433 _show_image(afw_display, diffim, "difference", strip_metadata) 

2434 if show_patch_outlines: 

2435 with afw_display.Buffering(): 

2436 for cov in coverages: 

2437 _draw_outline_on_current_frame( 

2438 afw_display, cov.corners_xy, cov.color, 

2439 label=f"{cov.tract},{cov.patch}", label_size=label_size, 

2440 clip_rect=clip_rect) 

2441 

2442 for cov in coverages: 

2443 if cov.ref is None: 

2444 continue 

2445 # A coadd is only displayed, so it is read in the legacy form that 

2446 # afw displays take. 

2447 coadd = _legacy_for_display(butler.get(cov.ref)) 

2448 # Project after loading rather than reusing the record's wcs, so 

2449 # the outline is tied to the pixels actually on screen. 

2450 corners = _project_bbox_corners(bbox, diffim.sky_projection, coadd.wcs) 

2451 if patch_extent == "overlap": 

2452 coadd = _subset_coadd_to_outline(coadd, corners, patch_margin) 

2453 afw_display.frame = cov.frame 

2454 _erase_current_frame_regions(afw_display) 

2455 _show_image(afw_display, coadd, f"{cov.tract},{cov.patch}", strip_metadata) 

2456 if show_diffim_outline: 

2457 _draw_outline_on_current_frame( 

2458 afw_display, corners, diffim_ctype, 

2459 label=f"{visit},{detector}", label_size=label_size, 

2460 clip_rect=_rect_from_bbox(coadd.getBBox())) 

2461 

2462 if align is not None: 

2463 try: 

2464 afw_display.alignImages(match_type=align) 

2465 except NotImplementedError: 

2466 print(f"WARNING: cannot automatically align and lock images with backend={backend!r}.") 

2467 

2468 

2469def _subset_catalog(catalog, indices): 

2470 """Build a `~lsst.afw.table.SourceCatalog` of the rows at `indices`. 

2471 

2472 The subset shares the input's table and appends the existing records 

2473 (shallow), so each row keeps its attached `Footprint` rather than a 

2474 copy. Used to split a catalog into one sub-catalog per overlay color. 

2475 """ 

2476 subset = lsst.afw.table.SourceCatalog(catalog.table) 

2477 for i in indices: 

2478 subset.append(catalog[i]) 

2479 return subset 

2480 

2481 

2482def _footprint_adjacency(bboxes): 

2483 """Adjacency lists for footprints whose bounding boxes overlap. 

2484 

2485 Two footprints are adjacent when their bounding boxes overlap (share 

2486 at least one pixel). Bounding-box overlap can slightly over-count 

2487 versus true pixel touching, which only makes the coloring more 

2488 conservative (never assigns the same color to two touching footprints). 

2489 

2490 Parameters 

2491 ---------- 

2492 bboxes : `list` [`lsst.geom.Box2I`] 

2493 Footprint bounding boxes, in the order the catalog iterates. 

2494 

2495 Returns 

2496 ------- 

2497 adjacency : `list` [`set` [`int`]] 

2498 ``adjacency[i]`` is the set of indices whose footprints touch 

2499 footprint ``i``. 

2500 """ 

2501 n = len(bboxes) 

2502 adjacency = [set() for _ in range(n)] 

2503 for i in range(n): 

2504 for j in range(i + 1, n): 

2505 if bboxes[i].overlaps(bboxes[j]): 

2506 adjacency[i].add(j) 

2507 adjacency[j].add(i) 

2508 return adjacency 

2509 

2510 

2511def _greedy_color(adjacency, n_colors, rng=None): 

2512 """Greedily color a graph so touching nodes differ, using `n_colors`. 

2513 

2514 Nodes are colored highest-degree first (Welsh--Powell), and each node 

2515 takes a color chosen *at random* from those not already used by an 

2516 adjacent node. Randomizing the choice -- rather than always taking the 

2517 lowest free index -- spreads the coloring across the whole palette and 

2518 varies it between runs, while still guaranteeing adjacent footprints 

2519 differ. Footprint adjacency graphs are essentially planar, so with 12 

2520 colors available a conflict-free coloring is found in practice. In the 

2521 pathological case where a node's neighbors already occupy all 

2522 `n_colors`, it falls back to a color least used among those neighbors 

2523 (ties broken randomly) rather than failing. 

2524 

2525 Parameters 

2526 ---------- 

2527 adjacency : `list` [`set` [`int`]] 

2528 Adjacency lists from `_footprint_adjacency`. 

2529 n_colors : `int` 

2530 Number of available colors (palette length). 

2531 rng : `random.Random`, optional 

2532 Random source, for reproducible colorings in tests. Defaults to a 

2533 fresh unseeded `random.Random`. 

2534 

2535 Returns 

2536 ------- 

2537 colors : `list` [`int`] 

2538 ``colors[i]`` is the palette index assigned to node ``i``. 

2539 """ 

2540 if rng is None: 

2541 rng = random.Random() 

2542 n = len(adjacency) 

2543 colors = [-1] * n 

2544 order = sorted(range(n), key=lambda i: len(adjacency[i]), reverse=True) 

2545 for node in order: 

2546 used = {colors[nbr] for nbr in adjacency[node] if colors[nbr] >= 0} 

2547 available = [c for c in range(n_colors) if c not in used] 

2548 if available: 

2549 chosen = rng.choice(available) 

2550 else: 

2551 # Every color is taken by a neighbor (needs >n_colors mutually 

2552 # touching footprints -- essentially never). Reuse a color that 

2553 # appears least among the neighbors, breaking ties randomly. 

2554 counts = [0] * n_colors 

2555 for nbr in adjacency[node]: 

2556 if colors[nbr] >= 0: 

2557 counts[colors[nbr]] += 1 

2558 fewest = min(counts) 

2559 chosen = rng.choice( 

2560 [c for c in range(n_colors) if counts[c] == fewest]) 

2561 colors[node] = chosen 

2562 return colors 

2563 

2564 

2565def _overlay_footprint_layers(afw_display, buckets, *, style, layer_prefix): 

2566 """Overlay footprint buckets as Firefly layers on the current frame. 

2567 

2568 Firefly's ``overlayFootprints`` takes a single color per call, so a 

2569 multi-color overlay is drawn as one layer per bucket. ``layer_prefix`` 

2570 is prepended to each layer/title string so overlays drawn on different 

2571 frames (e.g. the three frames of `display_images`) get distinct layer 

2572 ids; the backend appends the frame number itself. The per-bucket suffix 

2573 is the bucket's position in ``buckets``, so re-running with the same 

2574 number of buckets overwrites the layers in place. ``overlayFootprints`` 

2575 is a Firefly-impl method reached via `Display`'s attribute delegation, 

2576 the same way `display_images` calls ``alignImages``. 

2577 

2578 Parameters 

2579 ---------- 

2580 afw_display : `lsst.afw.display.Display` 

2581 Display with the target frame already selected. 

2582 buckets : `list` [`tuple`] 

2583 ``(catalog, color, label)`` triples; each ``catalog`` is a 

2584 footprint-bearing `~lsst.afw.table.SourceCatalog` drawn in 

2585 ``color``. Empty buckets are skipped. 

2586 style : {"outline", "fill"} 

2587 Footprint rendering style. 

2588 layer_prefix : `str` 

2589 Prefix for the Firefly layer/title strings; must be unique per 

2590 frame to avoid layers on different frames colliding. 

2591 

2592 Returns 

2593 ------- 

2594 summary : `list` [`tuple`] 

2595 ``(label, color, count)`` per drawn bucket, for the legend. 

2596 """ 

2597 summary = [] 

2598 for i, (catalog, color, label) in enumerate(buckets): 

2599 if catalog is None or len(catalog) == 0: 

2600 continue 

2601 layer = f"{layer_prefix} c{i} " 

2602 afw_display.overlayFootprints(catalog, color=color, style=style, 

2603 layerString=layer, titleString=layer) 

2604 summary.append((label, color, len(catalog))) 

2605 return summary 

2606 

2607 

2608def display_footprints(butler=None, visit=None, detector=None, 

2609 backend="firefly", *, 

2610 exposure=None, catalog=None, 

2611 image_type="difference", 

2612 catalog_dataset="dia_source_unfiltered", 

2613 style="outline", 

2614 palette=_OBJECT_PALETTE, 

2615 frame=0, 

2616 mask_transparency=80, 

2617 strip_metadata=True, 

2618 image_datasets=_IMAGE_DATASETS): 

2619 """Overlay diaSource footprints on an exposure in Firefly, color-cycled 

2620 so that touching footprints get distinct colors. 

2621 

2622 The footprints come from an afw `~lsst.afw.table.SourceCatalog` (the 

2623 diffim detection output, which still carries per-source `Footprint`\\ s 

2624 -- the transformed and APDB diaSource tables are DataFrames with the 

2625 footprints stripped). Supply the data one of two ways: 

2626 

2627 * pass ``butler`` plus ``visit`` and ``detector`` to load the 

2628 exposure (``image_datasets[image_type]``) and catalog 

2629 (``catalog_dataset``) from the butler; or 

2630 * pass ``exposure`` and ``catalog`` directly, skipping the butler. 

2631 

2632 The footprints are then drawn on a single Firefly frame using the 

2633 backend's native footprint overlay. 

2634 

2635 Each footprint is assigned one of the `palette` colors by greedy 

2636 graph coloring over a bounding-box-touch adjacency graph, so no two 

2637 touching footprints share a color (see `_footprint_adjacency` and 

2638 `_greedy_color`). The color chosen for each footprint is randomized 

2639 among those its neighbors are not using, so the palette is spread 

2640 across the frame and re-running produces a different coloring. Because 

2641 Firefly's ``overlayFootprints`` takes a single color per call, the 

2642 catalog is split into one sub-catalog per color and each is overlaid as 

2643 its own Firefly layer. 

2644 

2645 Re-running on the same frame overwrites each color layer in place. 

2646 Color layers left over from a previous run that used *more* colors are 

2647 not cleared automatically. 

2648 

2649 Parameters 

2650 ---------- 

2651 butler : `lsst.daf.butler.Butler`, optional 

2652 Butler to load the exposure and catalog from. Required (with 

2653 ``visit`` and ``detector``) unless ``exposure`` and ``catalog`` are 

2654 given directly. 

2655 visit, detector : `int`, optional 

2656 Visit and detector ids to load data for. Required with ``butler``. 

2657 backend : `str`, optional 

2658 afw display backend. Only ``"firefly"`` is supported, since the 

2659 overlay uses Firefly's native footprint rendering. 

2660 exposure : `lsst.images.VisitImage` or `lsst.afw.image.Exposure`, optional 

2661 Image to draw on, supplied directly instead of via the butler. 

2662 Must be given together with ``catalog``; when set, ``butler``, 

2663 ``visit``, ``detector``, ``catalog_dataset``, ``image_type``, and 

2664 ``image_datasets`` are all ignored. 

2665 catalog : `lsst.afw.table.SourceCatalog`, optional 

2666 Footprint-bearing source catalog, supplied directly instead of via 

2667 the butler. Must be given together with ``exposure``. 

2668 image_type : {"science", "template", "difference"}, optional 

2669 Which image to display the footprints on (butler mode only). 

2670 Default ``"difference"``. 

2671 catalog_dataset : `str`, optional 

2672 Butler dataset of the footprint-bearing afw source catalog (butler 

2673 mode only). Default ``"dia_source_unfiltered"`` (the pre-filter 

2674 detection catalog, which still carries footprints; the 

2675 transformed/standardized diaSource tables have them stripped). 

2676 style : {"outline", "fill"}, optional 

2677 Footprint rendering style. ``"outline"`` (default) keeps the 

2678 color coding legible where footprints overlap; ``"fill"`` shades 

2679 the interior. 

2680 palette : sequence of `str`, optional 

2681 Colors cycled across footprints. Defaults to the 12-color 

2682 ``_OBJECT_PALETTE`` also used by the cutout plotters. 

2683 frame : `int`, optional 

2684 Display frame to draw the image and footprints in. Default ``0``. 

2685 mask_transparency : `int` or `None`, optional 

2686 Mask-plane transparency forwarded to the display (0 = opaque, 

2687 100 = fully transparent). Pass ``None`` to leave it untouched. 

2688 strip_metadata : `bool`, optional 

2689 Drop ``LTV1``/``LTV2`` keywords from the exposure metadata before 

2690 sending to the backend. 

2691 image_datasets : `dict` [`str`, `str`], optional 

2692 Mapping from image-type key to butler dataset name. 

2693 """ 

2694 if backend != "firefly": 

2695 raise ValueError( 

2696 f"display_footprints only supports the 'firefly' backend " 

2697 f"(needs Firefly's native footprint overlay); got {backend!r}") 

2698 if style not in ("outline", "fill"): 

2699 raise ValueError(f"style must be 'outline' or 'fill', got {style!r}") 

2700 

2701 # Two input modes: direct (exposure + catalog) or butler-loaded. 

2702 direct = exposure is not None or catalog is not None 

2703 if direct: 

2704 if exposure is None or catalog is None: 

2705 raise ValueError( 

2706 "supply BOTH exposure and catalog to draw directly") 

2707 title = "footprints" 

2708 location = "" 

2709 else: 

2710 if butler is None or visit is None or detector is None: 

2711 raise ValueError( 

2712 "supply either (butler, visit, detector) or " 

2713 "(exposure, catalog)") 

2714 if image_type not in image_datasets: 

2715 raise ValueError( 

2716 f"image_type must be one of {sorted(image_datasets)}, " 

2717 f"got {image_type!r}") 

2718 data_id = {"visit": visit, "detector": detector} 

2719 exposure = _get_image(butler, image_datasets, image_type, data_id) 

2720 catalog = butler.get(catalog_dataset, data_id) 

2721 title = f"{image_type} footprints" 

2722 location = f"visit={visit}, detector={detector}: " 

2723 

2724 if not isinstance(catalog, lsst.afw.table.SourceCatalog): 

2725 raise TypeError( 

2726 f"catalog is a {type(catalog).__name__}, not an afw " 

2727 "SourceCatalog. Footprints are only carried by the afw detection " 

2728 "catalog (storageClass 'SourceCatalog'); the " 

2729 "transformed/standardized diaSource tables (DataFrame or " 

2730 "ArrowAstropy, e.g. 'dia_source_detector') have them stripped.") 

2731 if len(catalog) > 0 and catalog[0].getFootprint() is None: 

2732 raise ValueError( 

2733 "catalog is an afw SourceCatalog but its records have no " 

2734 "Footprint attached, so there is nothing to draw.") 

2735 

2736 # Color the footprints so touching ones differ, then group indices by 

2737 # assigned color for the per-color Firefly overlay calls below. 

2738 bboxes = [record.getFootprint().getBBox() for record in catalog] 

2739 color_indices = _greedy_color(_footprint_adjacency(bboxes), 

2740 len(palette)) 

2741 groups = {} 

2742 for i, c in enumerate(color_indices): 

2743 groups.setdefault(c, []).append(i) 

2744 

2745 afw_display = lsst.afw.display.Display(backend=backend) 

2746 if mask_transparency is not None: 

2747 afw_display.setMaskTransparency(mask_transparency) 

2748 afw_display.frame = frame 

2749 # image() only replaces pixel data; wipe stale region markers first. 

2750 _erase_current_frame_regions(afw_display) 

2751 _show_image(afw_display, exposure, title, strip_metadata) 

2752 

2753 print(f"{location}{len(catalog)} footprints in {len(groups)} colors") 

2754 buckets = [(_subset_catalog(catalog, groups[c]), palette[c], f"c{c}") 

2755 for c in sorted(groups)] 

2756 _overlay_footprint_layers(afw_display, buckets, style=style, 

2757 layer_prefix="footprints") 

2758 

2759 

2760def extract_timestamped_messages(log: str | dict[str, Any]) -> str: 

2761 """Extract ``records[*].(asctime, message)`` from an LSST-style JSON 

2762 log and format them one per line, as:: 

2763 

2764 2026-02-25T04:15:35.092108Z Preparing execution... 

2765 

2766 Parameters 

2767 ---------- 

2768 log : `str` or `dict` [`str`, `Any`] 

2769 Either the JSON text or an already-parsed dict. 

2770 

2771 Returns 

2772 ------- 

2773 messages : `str` 

2774 The joined ``asctime message`` lines. 

2775 """ 

2776 if isinstance(log, str): 

2777 s = log.strip() 

2778 

2779 # Handle the case where the *JSON itself* is wrapped in quotes, like: 

2780 # '"{...}"' or "'{...}'" 

2781 if (len(s) >= 2) and (s[0] == s[-1]) and s[0] in ("'", '"'): 

2782 s = s[1:-1] 

2783 

2784 try: 

2785 obj = json.loads(s) 

2786 except json.JSONDecodeError: 

2787 # One more attempt: sometimes a quoted-JSON string is itself 

2788 # JSON-encoded e.g. "\"{...}\"" 

2789 obj = json.loads(json.loads(s)) 

2790 else: 

2791 obj = log 

2792 

2793 records = obj.get("records", []) 

2794 if not isinstance(records, list): 

2795 raise TypeError("Expected obj['records'] to be a list.") 

2796 

2797 rows: list[tuple[datetime, str, str]] = [] 

2798 for rec in records: 

2799 if not isinstance(rec, dict): 

2800 continue 

2801 ts = rec.get("asctime") 

2802 msg = rec.get("message") 

2803 if not ts or msg is None: 

2804 continue 

2805 

2806 # Parse ISO-8601 with trailing "Z" 

2807 dt = datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(timezone.utc) 

2808 rows.append((dt, ts, str(msg))) 

2809 

2810 return "\n".join(f"{ts} {msg}" for _, ts, msg in rows)