slide-lsst 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
slide_lsst/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ SLIDE: Subtracting LSST Images with DECam Exposures
3
+
4
+ This package provides tools for performing image subtraction on LSST images using DECam templates,
5
+ including PSF modeling, reference image downloading, and image subtraction.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __author__ = "Yize Dong"
10
+
11
+ from .lsst_utils import query_lsst_visits, lsst_visit_to_ccddata, lsst_visit_to_psf
12
+ from .image_processing import (
13
+ read_with_datasec,
14
+ get_ccd_bbox,
15
+ make_psf,
16
+ refine_wcs,
17
+ assemble_reference
18
+ )
19
+ from .reference_download import (
20
+ download_des_reference,
21
+ download_decals_reference,
22
+ gaia3cat,
23
+ )
24
+ from .subtraction import perform_image_subtraction, lsst_decam_data_load, load_usesr_decam
25
+
26
+ __all__ = [
27
+ 'gaia3cat',
28
+ 'query_lsst_visits',
29
+ 'read_with_datasec',
30
+ 'get_ccd_bbox',
31
+ 'make_psf',
32
+ 'refine_wcs',
33
+ 'assemble_reference',
34
+ 'download_des_reference',
35
+ 'download_decals_reference',
36
+ 'lsst_visit_to_ccddata',
37
+ 'lsst_visit_to_psf',
38
+ 'perform_image_subtraction',
39
+ 'lsst_decam_data_load',
40
+ 'load_usesr_decam'
41
+ ]
slide_lsst/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ __version__ = version = '0.1.dev45+gfc69713'
4
+ __version_tuple__ = version_tuple = (0, 1, 'dev45', 'gfc69713')
@@ -0,0 +1,344 @@
1
+ """
2
+ Image processing utilities.
3
+ """
4
+
5
+ import numpy as np
6
+ import matplotlib.pyplot as plt
7
+ from reproject import reproject_interp, reproject_adaptive
8
+ from astropy.io import fits
9
+ from astropy.nddata import CCDData, NDData
10
+ from astropy.wcs import WCS, _wcs
11
+ from astropy.visualization import PercentileInterval, ImageNormalize
12
+ from photutils.psf import EPSFBuilder, extract_stars
13
+ from astropy.coordinates import SkyCoord
14
+ from astropy.wcs.utils import skycoord_to_pixel
15
+ import scipy.optimize
16
+ import warnings
17
+ from astropy.wcs.utils import fit_wcs_from_points
18
+ import sep
19
+ from .lsst_utils import astropy_world_to_pixel, lsst_world_to_pixel, lsst_pixel_to_world
20
+
21
+ def read_with_datasec(filename, hdu=0):
22
+ """
23
+ Read FITS file with optional DATASEC handling.
24
+
25
+ Parameters
26
+ ----------
27
+ filename : str
28
+ Path to FITS file
29
+ hdu : int, optional
30
+ HDU index to read (default: 0)
31
+
32
+ Returns
33
+ -------
34
+ CCDData
35
+ Image data with WCS information
36
+ """
37
+ ccddata = CCDData.read(filename, format='fits', unit='adu', hdu=hdu)
38
+ if 'datasec' in ccddata.meta:
39
+ jmin, jmax, imin, imax = eval(ccddata.meta['datasec'].replace(':', ','))
40
+ ccddata = ccddata[imin-1:imax, jmin-1:jmax]
41
+ return ccddata
42
+
43
+
44
+ def get_ccd_bbox(ccddata):
45
+ """
46
+ Get bounding box coordinates for a CCD image.
47
+
48
+ Parameters
49
+ ----------
50
+ ccddata : CCDData
51
+ Image data with WCS
52
+
53
+ Returns
54
+ -------
55
+ tuple
56
+ (ra_min, dec_min, ra_max, dec_max) in degrees
57
+ """
58
+ corners = [[0.], [0.5], [1.]] * np.array(ccddata.shape)[::-1]
59
+ (ra_min, dec_min), (ra_ctr, dec_ctr), (ra_max, dec_max) = ccddata.wcs.all_pix2world(corners, 0.)
60
+ if ra_min > ra_max:
61
+ ra_min, ra_max = ra_max, ra_min
62
+ if dec_min > dec_max:
63
+ dec_min, dec_max = dec_max, dec_min
64
+ max_size_dec = 0.199
65
+ if dec_max - dec_min > max_size_dec:
66
+ dec_min = dec_ctr - max_size_dec / 2.
67
+ dec_max = dec_ctr + max_size_dec / 2.
68
+ return ra_min, dec_min, ra_max, dec_max
69
+
70
+
71
+ def make_psf(data, catalog, show=False, boxsize=25, oversampling=1, center_accuracy=0.001):
72
+ """
73
+ Create PSF model from catalog stars.
74
+
75
+ Parameters
76
+ ----------
77
+ data : CCDData
78
+ Image data
79
+ catalog : astropy.table.Table
80
+ Star catalog with ra, dec columns
81
+ show : bool, optional
82
+ Show PSF visualization (default: False)
83
+ boxsize : int, optional
84
+ Size of star cutouts (default: 25)
85
+
86
+ Returns
87
+ -------
88
+ tuple
89
+ (epsf, fitted_stars)
90
+ """
91
+ _catalog = catalog.copy()
92
+ coords = SkyCoord(_catalog['ra'], _catalog['dec'], unit='deg')
93
+ _catalog['x'], _catalog['y'] = skycoord_to_pixel(coords, data.wcs, origin=0)
94
+
95
+ good_mask = []
96
+ for x, y in zip(_catalog['x'], _catalog['y']):
97
+ x = int(np.round(x))
98
+ y = int(np.round(y))
99
+ # If any pixel in the cutout is masked, reject it
100
+ if data.mask is not None and np.any(data.mask[y-1:y+1,
101
+ x-1:x+1]):
102
+ good_mask.append(False)
103
+ else:
104
+ good_mask.append(True)
105
+ _catalog = _catalog[good_mask]
106
+
107
+ bkg = np.nanmedian(data.data)
108
+ nddata = NDData(data.data - bkg)
109
+
110
+ stars = extract_stars(nddata, _catalog, size=boxsize)
111
+ epsf_builder = EPSFBuilder(oversampling=oversampling, center_accuracy=center_accuracy)
112
+ epsf, fitted_stars = epsf_builder(stars)
113
+
114
+ if show:
115
+ plt.figure()
116
+ plt.imshow(epsf.data)
117
+ plot_stars(fitted_stars)
118
+
119
+ return epsf, fitted_stars
120
+
121
+ def find_catalog_stars(image, wcs, catalog):
122
+ try:
123
+ data = image.astype(np.float32)
124
+ except:
125
+ data = image.byteswap().newbyteorder()
126
+ bkg = sep.Background(data)
127
+ data_sub = data - bkg
128
+
129
+ # 2. Extract all sources
130
+ sources = sep.extract(data_sub, thresh=3, err=bkg.globalrms)
131
+
132
+ known_x, known_y = astropy_world_to_pixel(catalog['ra'], catalog['dec'], wcs)
133
+
134
+ matched_sources = []
135
+ for x0, y0 in zip(known_x, known_y):
136
+ distances = np.hypot(sources['x'] - x0, sources['y'] - y0)
137
+ closest_idx = np.argmin(distances)
138
+ matched_sources.append(sources[closest_idx])
139
+
140
+ matched_sources = np.array(matched_sources)
141
+ return matched_sources
142
+
143
+ def plot_stars(stars):
144
+ """
145
+ Plot extracted stars for visualization.
146
+
147
+ Parameters
148
+ ----------
149
+ stars : list
150
+ List of star cutouts
151
+ """
152
+ nrows = int(np.ceil(len(stars) ** 0.5))
153
+ fig, axarr = plt.subplots(nrows, nrows, figsize=(20, 20), squeeze=True)
154
+ for ax, star in zip(axarr.ravel(), stars):
155
+ ax.imshow(star)
156
+ ax.plot(star.cutout_center[0], star.cutout_center[1], 'r+')
157
+
158
+
159
+ def update_wcs(wcs, p):
160
+ """
161
+ Update WCS with transformation parameters.
162
+
163
+ Parameters
164
+ ----------
165
+ wcs : WCS
166
+ WCS object to update
167
+ p : array-like
168
+ Transformation parameters [dx, dy, rotation, scale]
169
+ """
170
+ wcs.wcs.crval += p[:2]
171
+ c, s = np.cos(p[2]), np.sin(p[2])
172
+ if wcs.wcs.has_cd():
173
+ wcs.wcs.cd = wcs.wcs.cd @ np.array([[c, -s], [s, c]]) * p[3]
174
+ if wcs.wcs.has_pc():
175
+ wcs.wcs.pc = wcs.wcs.pc @ np.array([[c, -s], [s, c]]) * p[3]
176
+
177
+
178
+
179
+ def wcs_offset(p, radec, xy, origwcs):
180
+ """
181
+ Calculate RMS offset between catalog and detected positions.
182
+
183
+ Parameters
184
+ ----------
185
+ p : array-like
186
+ Transformation parameters
187
+ radec : array-like
188
+ Catalog coordinates
189
+ xy : array-like
190
+ Detected pixel coordinates
191
+ origwcs : WCS
192
+ Original WCS
193
+
194
+ Returns
195
+ -------
196
+ float
197
+ RMS offset in pixels
198
+ """
199
+ wcs = origwcs.deepcopy()
200
+ update_wcs(wcs, p)
201
+ sky = SkyCoord(radec[:, 0], radec[:, 1], unit='deg')
202
+ test_xy = np.column_stack(skycoord_to_pixel(sky, wcs))
203
+ rms = (np.sum((test_xy - xy)**2) / len(radec))**0.5
204
+ return rms
205
+
206
+
207
+ def refine_wcs(wcs, stars, catalog, use_sep=False):
208
+ """
209
+ Refine WCS using star positions.
210
+
211
+ Parameters
212
+ ----------
213
+ wcs : WCS
214
+ WCS to refine
215
+ stars : object
216
+ Detected stars
217
+ catalog : astropy.table.Table
218
+ Reference catalog
219
+ use_sep : bool, optional
220
+ Use SEP detection format (default: False)
221
+ """
222
+ if use_sep:
223
+ xy = np.array([[star['x'], star['y']] for star in stars])
224
+ t_match = catalog[stars['i']]
225
+ else:
226
+ xy = np.array([star.center for star in stars.all_good_stars])
227
+ t_match = catalog[[star.id_label - 1 for star in stars.all_good_stars]]
228
+ radec = np.array([t_match['raMean'], t_match['decMean']]).T
229
+
230
+ res = scipy.optimize.minimize(wcs_offset, [0., 0., 0., 1.], args=(radec, xy, wcs),
231
+ bounds=[(-0.01, 0.01), (-0.01, 0.01), (-0.1, 0.1), (0.9, 1.1)])
232
+
233
+ orig_rms = wcs_offset([0., 0., 0., 1.], radec, xy, wcs)
234
+ print(' orig_fun: {}'.format(orig_rms))
235
+ print(res)
236
+ update_wcs(wcs, res.x)
237
+
238
+ def refine_multiple_stars(image, wcs, catalog, box_size=25, fwhm=5.0, threshold=5):
239
+ from astropy.stats import sigma_clipped_stats
240
+ from astropy.nddata import Cutout2D
241
+ from photutils.detection import DAOStarFinder
242
+
243
+ known_x, known_y = astropy_world_to_pixel(catalog['ra'], catalog['dec'], wcs)
244
+ refined_xy = {}
245
+ refined_xy['x'] = []
246
+ refined_xy['y'] = []
247
+
248
+
249
+ for (rough_x, rough_y) in zip(known_x, known_y):
250
+ try:
251
+ cutout = Cutout2D(image, (rough_x, rough_y), size=box_size)
252
+ mean, median, std = sigma_clipped_stats(cutout.data)
253
+ finder = DAOStarFinder(fwhm=fwhm, threshold=threshold*std)
254
+ sources = finder(cutout.data - median)
255
+
256
+ if sources is None or len(sources) == 0:
257
+ #refined_xy_list.append((np.nan, np.nan))
258
+ refined_xy['x'].append(np.nan)
259
+ refined_xy['y'].append(np.nan)
260
+ continue
261
+
262
+ # Choose nearest star to center
263
+ dx = sources['xcentroid'] - box_size/2
264
+ dy = sources['ycentroid'] - box_size/2
265
+ distances = np.hypot(dx, dy)
266
+ idx = np.argmin(distances)
267
+
268
+ x_refined = sources['xcentroid'][idx] + cutout.origin_original[0]
269
+ y_refined = sources['ycentroid'][idx] + cutout.origin_original[1]
270
+ #refined_xy_list.append((x_refined, y_refined))
271
+ refined_xy['x'].append(x_refined)
272
+ refined_xy['y'].append(y_refined)
273
+ except Exception as e:
274
+ print(f"Warning: failed to refine star at ({rough_x:.1f}, {rough_y:.1f}): {e}")
275
+ #refined_xy_list.append((np.nan, np.nan))
276
+ refined_xy['x'].append(np.nan)
277
+ refined_xy['y'].append(np.nan)
278
+
279
+ return refined_xy
280
+
281
+ def refine_wcs_astropy(image, wcs, catalog, fwhm=5.0, projection='TAN', fit_distortion=None):
282
+ #matched_sources = find_catalog_stars(image, wcs, catalog)
283
+ matched_sources = refine_multiple_stars(image, wcs, catalog, box_size=25, fwhm=fwhm)
284
+
285
+ valid_mask = np.isfinite(matched_sources['x']) & np.isfinite(matched_sources['y'])
286
+ matched_x = np.array(matched_sources['x'])[valid_mask]
287
+ matched_y = np.array(matched_sources['y'])[valid_mask]
288
+
289
+ catalog = catalog[valid_mask]
290
+
291
+ sky_coords = SkyCoord(ra=catalog['ra'], dec=catalog['dec'], unit='deg')
292
+ # matched pixel positions and sky positions
293
+ new_wcs = fit_wcs_from_points(xy=(matched_x, matched_y), world_coords=sky_coords, projection=projection, sip_degree=fit_distortion)
294
+ return new_wcs, catalog
295
+
296
+
297
+ def assemble_reference(refdatas, wcs, shape, ref_global_bkg=0, order='bicubic'):
298
+ """
299
+ Reproject and stack reference images to match science image.
300
+
301
+ Parameters
302
+ ----------
303
+ refdatas : list
304
+ List of reference CCDData objects
305
+ wcs : WCS
306
+ Target WCS
307
+ shape : tuple
308
+ Target image shape
309
+ ref_global_bkg : float, optional
310
+ Background value for masked pixels (default: 0)
311
+ order : str, optional
312
+ Interpolation order (default: 'bicubic')
313
+
314
+ Returns
315
+ -------
316
+ CCDData
317
+ Assembled reference image
318
+ """
319
+ refdatas_reprojected = []
320
+ mask_stack = []
321
+ refdata_foot = np.zeros(shape, float)
322
+ for data in refdatas:
323
+ #reprojected, foot = reproject_interp((data.data, data.wcs), wcs, shape, order=order)
324
+ reprojected, foot = reproject_adaptive((data.data, data.wcs), wcs, shape, conserve_flux=True)
325
+
326
+ refdatas_reprojected.append(reprojected)
327
+ refdata_foot += foot
328
+
329
+ if data.mask is not None:
330
+ # Use nearest-neighbor interpolation for boolean mask
331
+ mask_reproj, _ = reproject_interp((data.mask.astype(float), data.wcs), wcs, shape)
332
+ mask_stack.append(mask_reproj > 0.5)
333
+ # Combine masks if available
334
+ if mask_stack:
335
+ combined_mask = np.any(mask_stack, axis=0)
336
+ else:
337
+ combined_mask = np.zeros(shape, dtype=bool)
338
+ refdata_reproj = np.nanmedian(refdatas_reprojected, axis=0)
339
+ if np.all(np.isnan(refdata_reproj)):
340
+ raise ValueError("All reprojected reference data is NaN.")
341
+ refdata_reproj[np.isnan(refdata_reproj)] = ref_global_bkg
342
+ final_mask = (refdata_foot == 0.) | combined_mask
343
+ refdata = CCDData(refdata_reproj, wcs=wcs, mask=final_mask, unit='adu')
344
+ return refdata
@@ -0,0 +1,312 @@
1
+ from lsst.daf.butler import Butler, Timespan
2
+ from lsst.rsp import get_tap_service
3
+ import lsst.geom
4
+ import lsst.afw.display as afwDisplay
5
+ from astropy.time import Time
6
+ import numpy as np
7
+ import lsst.geom as geom
8
+ from astroquery.gaia import Gaia
9
+ from astropy.coordinates import SkyCoord
10
+ from astropy.wcs.utils import skycoord_to_pixel, pixel_to_skycoord
11
+ from astropy.nddata import CCDData
12
+ from astropy.io import fits
13
+ from astropy.wcs import WCS
14
+ from photutils.aperture import SkyCircularAnnulus, SkyCircularAperture, ApertureStats
15
+ from photutils.background import Background2D
16
+ from photutils.psf import ImagePSF
17
+ from astropy.table import QTable
18
+ from photutils.psf import IterativePSFPhotometry
19
+ from photutils.background import LocalBackground, MMMBackground
20
+ from photutils.detection import DAOStarFinder
21
+ from photutils.psf import PSFPhotometry
22
+ from astropy.nddata import Cutout2D
23
+ from astropy.wcs.utils import fit_wcs_from_points
24
+
25
+
26
+ def query_lsst_visits(butler, ra, dec, band, time1, time2):
27
+ """
28
+ Query LSST Butler for visit images within a time range and sky region.
29
+
30
+ Parameters
31
+ ----------
32
+ butler : lsst.daf.butler.Butler
33
+ Butler instance for data access
34
+ ra : float
35
+ Right ascension in degrees
36
+ dec : float
37
+ Declination in degrees
38
+ band : str
39
+ Filter band name
40
+ time1 : astropy.time.Time
41
+ Start time
42
+ time2 : astropy.time.Time
43
+ End time
44
+
45
+ Returns
46
+ -------
47
+ list
48
+ Dataset references matching the criteria
49
+ """
50
+ #butler = Butler("dp1", collections="LSSTComCam/DP1")
51
+ time1 = Time(time1, format="isot", scale="tai")
52
+ time2 = Time(time2, format="isot", scale="tai")
53
+ timespan = Timespan(time1, time2)
54
+
55
+ dataset_refs = butler.query_datasets("visit_image",
56
+ where="band.name = :band AND \
57
+ visit.timespan OVERLAPS :timespan AND \
58
+ visit_detector_region.region OVERLAPS POINT(:ra, :dec)",
59
+ bind={"band": band, "timespan": timespan,
60
+ "ra": ra, "dec": dec},
61
+ order_by=["visit.timespan.begin"])
62
+ return dataset_refs
63
+
64
+ def lsst_bad_mask(visit_image, mask_type = ['']):
65
+ mask = visit_image.mask.array
66
+ if len(mask_type) == 0:
67
+ bitmask = visit_image.mask.getPlaneBitMask([
68
+ "BAD", # detector bad pixel
69
+ "SAT", # saturated pixel
70
+ "INTRP", # interpolated pixel
71
+ "CR", # cosmic ray
72
+ "EDGE", # edge of the detector
73
+ #"DETECTED", # source detected
74
+ #"DETECTED_NEGATIVE", # negative source detected
75
+ "SUSPECT", # suspicious pixel
76
+ "NO_DATA", # no data available
77
+ "SENSOR_EDGE", # sensor edge
78
+ "CLIPPED", # pixel in clipped area
79
+ "CROSSTALK", # crosstalk
80
+ #"NOT_DEBLENDED",# not deblended
81
+ "UNMASKEDNAN", # unmasked NaN
82
+ #"VIGNETTED", # vignetting
83
+ "STREAK", # streak from satellite or airplane
84
+ ])
85
+ else:
86
+ bitmask = visit_image.mask.getPlaneBitMask(mask_type)
87
+ image_mask = (visit_image.mask.array & bitmask) != 0
88
+ return image_mask
89
+
90
+ def lsst_visit_to_ccddata(visit_image, mask_type = ["BAD", "SAT", "INTRP", "CR", "EDGE", "SUSPECT", "NO_DATA", "SENSOR_EDGE", "CLIPPED", "CROSSTALK", "UNMASKEDNAN", "STREAK"], saveas=None):
91
+ data = visit_image.image.array
92
+ #header = fits.Header(visit_image.getWcs().getFitsMetadata().toDict())
93
+ #wcs = WCS(header)
94
+ wcs = lsst_refine_wcs_astropy(visit_image, n_points=10)
95
+ mask = lsst_bad_mask(visit_image, mask_type=mask_type)
96
+
97
+ sat_mask = lsst_bad_mask(visit_image, mask_type = ['SAT'])
98
+ masked_values = data[sat_mask]
99
+ finite_values = masked_values[np.isfinite(masked_values)]
100
+ SATURATE = np.nanmax(finite_values) if finite_values.size > 0 else 60000
101
+
102
+ ccddata = CCDData(data, wcs=wcs, unit='adu')
103
+ ccddata.mask = mask
104
+ ccddata.meta['SATURATE'] = SATURATE
105
+ if saveas is not None:
106
+ visit_image.writeFits(saveas)
107
+ return ccddata
108
+
109
+ def get_visit_fwhm(visit_image, ra, dec):
110
+ SIGMA_TO_FWHM = 2.0*np.sqrt(2.0*np.log(2.0))
111
+ x, y = lsst_world_to_pixel(ra, dec, visit_image)
112
+
113
+ info_calexp = visit_image.getInfo()
114
+ psf_calexp = info_calexp.getPsf()
115
+
116
+ point_image = lsst.geom.Point2D(x, y)
117
+ psf_shape = psf_calexp.computeShape(point_image)
118
+ psf_sigma = psf_shape.getDeterminantRadius()
119
+ psf_fwhm = psf_sigma * SIGMA_TO_FWHM
120
+ return psf_fwhm
121
+
122
+ def lsst_visit_to_psf(visit_image, ra, dec):
123
+ x, y = lsst_world_to_pixel(ra, dec, visit_image)
124
+ psf = visit_image.getPsf()
125
+ xy = lsst.geom.Point2D(x, y)
126
+ psf_image = psf.computeImage(xy)
127
+ ccddata = CCDData(psf_image.array, unit='adu')
128
+ return ccddata
129
+
130
+
131
+ def lsst_visit_to_psf_median(visit_image, ra, dec, cutout_size=(2000, 2000), sample_number=20):
132
+ if isinstance(cutout_size, (int, float)):
133
+ cutout_size = (int(cutout_size), int(cutout_size))
134
+ else:
135
+ cutout_size = (int(cutout_size[0]), int(cutout_size[1]))
136
+
137
+ data = visit_image.image.array
138
+ ny, nx = data.shape
139
+
140
+ # Get WCS and center in pixel coordinates
141
+ header = fits.Header(visit_image.getWcs().getFitsMetadata().toDict())
142
+ wcs = WCS(header)
143
+ xcen, ycen = astropy_world_to_pixel(ra, dec, wcs)
144
+
145
+ # Shift center inward if too close to edge
146
+ half_x, half_y = cutout_size[0] // 2, cutout_size[1] // 2
147
+ xcen = np.clip(xcen, half_x, nx - half_x)
148
+ ycen = np.clip(ycen, half_y, ny - half_y)
149
+
150
+ # Sample PSFs
151
+ psf = visit_image.getPsf()
152
+ psf_stars = []
153
+
154
+ for dx in np.linspace(-cutout_size[0] / 2, cutout_size[0] / 2, sample_number):
155
+ for dy in np.linspace(-cutout_size[1] / 2, cutout_size[1] / 2, sample_number):
156
+ x_sample, y_sample = xcen + dx, ycen + dy
157
+ xy = lsst.geom.Point2D(x_sample, y_sample)
158
+ psf_image = psf.computeImage(xy).getArray()
159
+ psf_stars.append(psf_image)
160
+
161
+ if len(psf_stars) == 0:
162
+ raise RuntimeError("No valid PSFs found in the region.")
163
+
164
+ # Stack and normalize
165
+ stacked = np.median(np.stack(psf_stars, axis=0), axis=0)
166
+ psf_final = stacked / np.sum(stacked)
167
+
168
+ ccddata = CCDData(psf_final, unit='adu')
169
+
170
+ return ccddata
171
+
172
+
173
+
174
+ def lsst_world_to_pixel(ra, dec, visit_image):
175
+ wcs = visit_image.wcs
176
+ if isinstance(ra, (int, float)):
177
+ spherePoint = lsst.geom.SpherePoint(ra*geom.degrees, dec*geom.degrees)
178
+ pixel_coord = wcs.skyToPixel(spherePoint)
179
+ x = pixel_coord.getX()
180
+ y = pixel_coord.getY()
181
+ else:
182
+ sphere_points = [geom.SpherePoint(_ra, _dec, geom.degrees) for _ra, _dec in zip(ra, dec)]
183
+ pixel_coords = [wcs.skyToPixel(sp) for sp in sphere_points]
184
+ x = [pt.getX() for pt in pixel_coords]
185
+ y = [pt.getY() for pt in pixel_coords]
186
+ return x, y
187
+
188
+ def lsst_pixel_to_world(x, y, visit_image):
189
+ wcs = visit_image.wcs
190
+ if isinstance(x, (int, float)):
191
+ pixel = geom.Point2D(x, y)
192
+ sky_coord = wcs.pixelToSky(pixel)
193
+ ra = sky_coord.getRa().asDegrees()
194
+ dec = sky_coord.getDec().asDegrees()
195
+ else:
196
+ pixel_coords = [geom.Point2D(px, py) for px, py in zip(x, y)]
197
+ sky_coords = [wcs.pixelToSky(p) for p in pixel_coords]
198
+ ra = [c.getRa().asDegrees() for c in sky_coords]
199
+ dec = [c.getDec().asDegrees() for c in sky_coords]
200
+ return ra, dec
201
+
202
+ def astropy_world_to_pixel(ra, dec, wcs, origin = 0):
203
+ coord = SkyCoord(ra, dec, unit='deg')
204
+ x, y = skycoord_to_pixel(coord, wcs, origin=origin)
205
+ return x, y
206
+
207
+ def astropy_pixel_to_world(x, y, wcs, origin=0):
208
+ skycoord = pixel_to_skycoord(x, y, wcs, origin=origin)
209
+ return skycoord.ra.deg, skycoord.dec.deg
210
+
211
+ def lsst_refine_wcs_astropy(visit_image, n_points=10, projection='TAN', fit_distortion=None):
212
+
213
+ ny, nx = visit_image.image.array.shape
214
+ # Sample pixel grid
215
+ x_vals = np.linspace(25, nx - 25, n_points)
216
+ y_vals = np.linspace(25, ny - 25, n_points)
217
+ xx, yy = np.meshgrid(x_vals, y_vals)
218
+ matched_x = xx.ravel()
219
+ matched_y = yy.ravel()
220
+
221
+ ra, dec = lsst_pixel_to_world(matched_x, matched_y, visit_image)
222
+
223
+ sky_coords = SkyCoord(ra=ra, dec=dec, unit='deg')
224
+ #matched_x, matched_y = lsst_world_to_pixel(catalog['ra'], catalog['dec'], visit_image)
225
+ #matched_x = np.asarray(matched_x)
226
+ #matched_y = np.asarray(matched_y)
227
+ new_wcs = fit_wcs_from_points(xy=(matched_x, matched_y), world_coords=sky_coords, projection=projection, sip_degree=fit_distortion)
228
+ return new_wcs
229
+
230
+ def safe_cutout2d(visit_image, ra, dec, cutout_size=(2000, 2000), mask_type = ["BAD", "SAT", "INTRP", "CR", "EDGE", "SUSPECT", "NO_DATA", "SENSOR_EDGE", "CLIPPED", "CROSSTALK", "UNMASKEDNAN", "STREAK"]):
231
+ """
232
+ Create a Cutout2D that contains the original RA/Dec, shifting inward if near the edge.
233
+
234
+ Parameters:
235
+ data (2D ndarray): Image array.
236
+ wcs (astropy.wcs.WCS): WCS for the image.
237
+ ra, dec (float): Center coordinates in degrees.
238
+ cutout_size (tuple): Desired cutout size in pixels.
239
+
240
+ Returns:
241
+ Cutout2D object.
242
+ """
243
+ if isinstance(cutout_size, (int, float)):
244
+ cutout_size = (int(cutout_size), int(cutout_size))
245
+ else:
246
+ cutout_size = (int(cutout_size[0]), int(cutout_size[1]))
247
+
248
+ data = visit_image.image.array
249
+ mask = lsst_bad_mask(visit_image, mask_type = mask_type)
250
+ sat_mask = lsst_bad_mask(visit_image, mask_type = ['SAT'])
251
+ masked_values = data[sat_mask]
252
+ finite_values = masked_values[np.isfinite(masked_values)]
253
+ SATURATE = np.nanmax(finite_values) if finite_values.size > 0 else 60000
254
+ #SATURATE = np.max(data[sat_mask==True])
255
+ #header = fits.Header(visit_image.getWcs().getFitsMetadata().toDict())
256
+ #wcs = WCS(header)
257
+ wcs = lsst_refine_wcs_astropy(visit_image, n_points=10)
258
+
259
+ xcen, ycen = astropy_world_to_pixel(ra, dec, wcs)
260
+
261
+ # Half size
262
+ half_x = cutout_size[0] // 2
263
+ half_y = cutout_size[1] // 2
264
+ ny, nx = data.shape
265
+
266
+ # Shift center inward if near edges
267
+ xcen = np.clip(xcen, half_x, nx - half_x)
268
+ ycen = np.clip(ycen, half_y, ny - half_y)
269
+
270
+ pixel_center = (xcen, ycen)
271
+ cutout = Cutout2D(data, position=pixel_center, size=cutout_size, wcs=wcs, mode='trim')
272
+ cutout_mask = Cutout2D(mask, position=pixel_center, size=cutout_size, wcs=wcs, mode='trim')
273
+
274
+ ccddata = CCDData(cutout.data, wcs=cutout.wcs, unit='adu')
275
+ ccddata.mask = cutout_mask.data
276
+ ccddata.meta['SATURATE'] = SATURATE
277
+ return ccddata
278
+
279
+ def lsst_cutout_to_ccddata(cutout, saveas=None):
280
+ ccddata = CCDData(cutout.data, wcs=cutout.wcs, unit='adu')
281
+ return ccddata
282
+
283
+ def forced_phot(ra, dec, image, wcs, psf_data):
284
+ xx, yy = np.mgrid[:psf_data.shape[0], :psf_data.shape[1]]
285
+ psf_model = ImagePSF(psf_data/np.sum(psf_data), x_0=psf_data.shape[0]/2, y_0=psf_data.shape[1]/2, flux=1)
286
+ bkgstat = MMMBackground()
287
+ localbkg_estimator = LocalBackground(5, 10, bkgstat)
288
+ init_params = QTable()
289
+ _x, _y = astropy_world_to_pixel(ra, dec, wcs)
290
+ init_params['x'] = [_x]
291
+ init_params['y'] = [_y]
292
+ fit_shape = (5,5)
293
+ #bkgstat = MMMBackground()
294
+ #localbkg_estimator = LocalBackground(5, 10, bkgstat)
295
+ psfphot = PSFPhotometry(psf_model, fit_shape,
296
+ aperture_radius=7,
297
+ localbkg_estimator=None)
298
+ phot = psfphot(image.data, init_params=init_params)
299
+ if phot[0]['flux_fit'] / phot[0]['flux_err'] <= 3 or phot[0]['flux_err'] == 0:
300
+ psf_model.x_0.fixed = True
301
+ psf_model.y_0.fixed = True
302
+ psfphot = PSFPhotometry(psf_model, fit_shape,
303
+ aperture_radius=7,
304
+ localbkg_estimator=None)
305
+ phot = psfphot(image.data, init_params=init_params)
306
+ flux_njy = phot[0]['flux_fit']
307
+ flux_err = phot[0]['flux_err']
308
+ mag = -2.5 * np.log10(flux_njy / 3631e9)
309
+ magerr = (2.5 / np.log(10)) * (flux_err / flux_njy)
310
+ upper_limit = -2.5 * np.log10(flux_err * 5 / 3631e9)
311
+
312
+ return flux_njy, flux_err, mag, magerr, upper_limit