lightstack 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.
lightstack/datacube.py ADDED
@@ -0,0 +1,493 @@
1
+ import numpy as np
2
+ import os
3
+
4
+ from astropy.io import fits
5
+ from astropy.wcs import WCS
6
+
7
+ from reproject import reproject_interp, reproject_exact
8
+
9
+ from .utils import find_ext, infer_filter
10
+
11
+
12
+ def align_reproject_fits(fits_list, ref_file, method="interp", crop=1):
13
+ """
14
+ Aligns and reprojects FITS images to a common WCS using a reference FITS file.
15
+
16
+ Parameters
17
+ ----------
18
+ fits_list : list of tuples
19
+ List in the form [(fits_path, filter_name), ...].
20
+
21
+ ref_file : str
22
+ Path to the FITS file used as WCS reference.
23
+
24
+ method : str, optional
25
+ Reprojection method:
26
+ - "interp" (default): faster, interpolates values
27
+ - "exact": slower, conserves flux --> but reproject_exact has precision issues with resolutions below ~0.05 arcsec, so the results may not be accurate.
28
+
29
+ crop : int, optional
30
+ Number of pixels to remove from each border after reprojection
31
+ (helps remove edge artifacts from interpolation).
32
+
33
+ Returns
34
+ -------
35
+ aligned_list : list of tuples
36
+ List in the form [(aligned_fits_path, filter_name), ...].
37
+ """
38
+
39
+ # Choose reprojection method
40
+ if method == "exact":
41
+ reproj_func = reproject_exact
42
+ elif method == "interp":
43
+ reproj_func = reproject_interp
44
+ else:
45
+ raise ValueError("method must be 'interp' or 'exact'")
46
+
47
+ # Open reference FITS
48
+ with fits.open(ref_file) as hdul_ref:
49
+ ext_ref = find_ext(hdul_ref)
50
+ if ext_ref is None:
51
+ raise ValueError(f"No valid image data in reference file '{ref_file}'.")
52
+
53
+ ref_header = hdul_ref[ext_ref].header
54
+ ref_wcs = WCS(ref_header)
55
+ shape_out = hdul_ref[ext_ref].data.shape
56
+
57
+ aligned_list = []
58
+
59
+ # Loop over all FITS
60
+ for fpath, filt in fits_list:
61
+
62
+ with fits.open(fpath) as hdul:
63
+ ext = find_ext(hdul)
64
+ if ext is None:
65
+ print(f"No data extension found in {fpath}. Skipping.")
66
+ continue
67
+
68
+ data = hdul[ext].data
69
+ header = hdul[ext].header
70
+ wcs_in = WCS(header)
71
+
72
+ unit = header.get('BUNIT', 'unknown')
73
+ print(f"Filter {filt}: unit = {unit}")
74
+
75
+ # Reproject
76
+ data_aligned, footprint = reproj_func(
77
+ (data, wcs_in),
78
+ ref_wcs,
79
+ shape_out=shape_out)
80
+
81
+ # Crop border (interp method)
82
+ if crop > 0:
83
+ data_aligned = data_aligned[crop:-crop, crop:-crop]
84
+
85
+ # Adjust WCS
86
+ wcs_out = ref_wcs.deepcopy()
87
+ wcs_out.wcs.crpix -= crop
88
+ else:
89
+ wcs_out = ref_wcs
90
+
91
+ # Header
92
+ header_aligned = wcs_out.to_header()
93
+
94
+ ref_filter = infer_filter(ref_file)
95
+ header_aligned.add_history(
96
+ f"Reprojected to {ref_filter} using {method} method (reproject package)")
97
+
98
+ if crop > 0:
99
+ header_aligned.add_history(f"Cropped {crop} pixels from each border")
100
+
101
+ # Save
102
+ suffix = f"_aligned_{method}"
103
+ out_name = os.path.splitext(fpath)[0] + f"{suffix}.fits"
104
+
105
+ fits.PrimaryHDU(data_aligned, header=header_aligned).writeto(
106
+ out_name, overwrite=True)
107
+
108
+ aligned_list.append((out_name, filt))
109
+ print(f"Saved: {out_name}")
110
+
111
+ return aligned_list
112
+
113
+
114
+ def build_datacube(aligned_fits_files, reference_file, output_path):
115
+ """
116
+ Builds a 3D datacube from aligned 2D FITS images.
117
+
118
+ Parameters
119
+ ----------
120
+ aligned_fits_files : list of tuples
121
+ [(filename, filter_name), ...]
122
+ reference_file : str
123
+ FITS file to define WCS and shape.
124
+ output_path : str
125
+ Path to save the 3D datacube.
126
+
127
+ Returns
128
+ -------
129
+ None
130
+ Saves the cut datacube.
131
+ """
132
+
133
+ # Open FITS
134
+ with fits.open(reference_file) as hdul_ref:
135
+ ext_ref = find_ext(hdul_ref)
136
+ if ext_ref is None:
137
+ raise ValueError(f"No 2D data in reference file {reference_file}.")
138
+ ref_header = hdul_ref[ext_ref].header
139
+ ny, nx = hdul_ref[ext_ref].data.shape
140
+ wcs_2d = WCS(ref_header, naxis=2)
141
+
142
+ cube_images = []
143
+ filter_names = []
144
+ units = []
145
+
146
+ # Construct cube
147
+ for file, filt in aligned_fits_files:
148
+ with fits.open(file) as hdul:
149
+ ext = find_ext(hdul)
150
+ if ext is None:
151
+ print(f"No 2D data in {file}. Skipping.")
152
+ continue
153
+ data = hdul[ext].data
154
+ cube_images.append(data)
155
+ filter_names.append(filt)
156
+ units.append(hdul[ext].header.get('BUNIT', 'unknown'))
157
+
158
+ cube = np.array(cube_images)
159
+
160
+ # Write header
161
+ wcs_3d = WCS(naxis=3)
162
+ wcs_3d.wcs.crpix[0] = wcs_2d.wcs.crpix[0]
163
+ wcs_3d.wcs.crpix[1] = wcs_2d.wcs.crpix[1]
164
+ wcs_3d.wcs.crval[0] = wcs_2d.wcs.crval[0]
165
+ wcs_3d.wcs.crval[1] = wcs_2d.wcs.crval[1]
166
+ wcs_3d.wcs.cdelt[0] = wcs_2d.wcs.cdelt[0]
167
+ wcs_3d.wcs.cdelt[1] = wcs_2d.wcs.cdelt[1]
168
+ wcs_3d.wcs.ctype[0] = wcs_2d.wcs.ctype[0]
169
+ wcs_3d.wcs.ctype[1] = wcs_2d.wcs.ctype[1]
170
+ wcs_3d.wcs.cunit[0] = wcs_2d.wcs.cunit[0]
171
+ wcs_3d.wcs.cunit[1] = wcs_2d.wcs.cunit[1]
172
+
173
+ if wcs_2d.wcs.has_cd():
174
+ cd3 = np.zeros((3,3))
175
+ cd3[0:2,0:2] = wcs_2d.wcs.cd.copy()
176
+ cd3[2,2] = 1.0
177
+ wcs_3d.wcs.cd = cd3
178
+
179
+ wcs_3d.wcs.crpix[2] = 1
180
+ wcs_3d.wcs.crval[2] = 0
181
+ wcs_3d.wcs.cdelt[2] = 1
182
+ wcs_3d.wcs.ctype[2] = 'FILTER'
183
+ wcs_3d.wcs.cunit[2] = ''
184
+
185
+ header = wcs_3d.to_header()
186
+ header['NAXIS'] = 3
187
+ header['NAXIS1'] = nx
188
+ header['NAXIS2'] = ny
189
+ header['NAXIS3'] = len(filter_names)
190
+
191
+ with fits.open(aligned_fits_files[0][0]) as hdul0:
192
+ ext0 = find_ext(hdul0)
193
+ header_meta = hdul0[ext0].header
194
+
195
+ if "HISTORY" in header_meta:
196
+ for h in header_meta["HISTORY"]:
197
+ header.add_history(h)
198
+
199
+ if all(u == units[0] for u in units):
200
+ header['BUNIT'] = units[0]
201
+ else:
202
+ header['BUNIT'] = 'unknown'
203
+
204
+ for i, filt in enumerate(filter_names):
205
+ header[f'FILTER{i+1}'] = filt
206
+
207
+ header['NFILTERS'] = len(filter_names)
208
+ header['FILTERS'] = ",".join(filter_names)
209
+
210
+ # Save datacube
211
+ fits.PrimaryHDU(cube, header=header).writeto(output_path, overwrite=True)
212
+ print(f"Datacube saved at {output_path}.")
213
+
214
+
215
+ def cut_region_datacube(cube_fits_file, x_start, x_end, y_start, y_end, output_path):
216
+ """
217
+ Cuts a spatial region from a datacube.
218
+
219
+ Parameters
220
+ ----------
221
+ cube_fits_file : str
222
+ Path to the input 3D fits datacube.
223
+ x_start, x_end : int
224
+ Pixel indices for the x axis.
225
+ y_start, y_end : int
226
+ Pixel indices for the y axis.
227
+ output_filename : str
228
+ Path to the output fits file.
229
+
230
+ Returns
231
+ -------
232
+ None
233
+ Saves the cut datacube.
234
+ """
235
+ # Open datacube
236
+ with fits.open(cube_fits_file) as hdul:
237
+ ext = find_ext(hdul)
238
+ if ext is None:
239
+ raise ValueError(f"No image data found in {cube_fits_file}")
240
+
241
+ cube_data = hdul[ext].data
242
+ cube_header = hdul[ext].header
243
+
244
+ # Cut the data array
245
+ cut_data = cube_data[:, y_start:y_end, x_start:x_end]
246
+
247
+ # Get and update WCS
248
+ wcs_3d = WCS(cube_header)
249
+ wcs_3d.wcs.crpix[0] -= x_start
250
+ wcs_3d.wcs.crpix[1] -= y_start
251
+
252
+ # Create new header with updated size and WCS
253
+ new_header = wcs_3d.to_header()
254
+ new_header['NAXIS'] = 3
255
+ new_header['NAXIS1'] = x_end - x_start
256
+ new_header['NAXIS2'] = y_end - y_start
257
+ new_header['NAXIS3'] = cube_data.shape[0]
258
+
259
+ # Filter info
260
+ for key in cube_header:
261
+ if key.startswith('FILTER'):
262
+ new_header[key] = cube_header[key]
263
+
264
+ # Crop window
265
+ new_header['XMINPIX'] = x_start
266
+ new_header['XMAXPIX'] = x_end
267
+ new_header['YMINPIX'] = y_start
268
+ new_header['YMAXPIX'] = y_end
269
+
270
+ # Save cut datacube
271
+ fits.PrimaryHDU(cut_data, header=new_header).writeto(output_path, overwrite=True)
272
+ print(f"Cut datacube saved to '{output_path}'")
273
+
274
+ def build_valid_datacube(cube_fits_file, output_cube, threshold=0.0, frac_valid=0.01):
275
+ """
276
+ Remove empty filters in a datacube, saves the new datacube and returns the valid filter names.
277
+
278
+ Parameters
279
+ ----------
280
+ cube_fits_file : str
281
+ Path to the input 3D fits datacube.
282
+ output_cube: str
283
+ Path to save the filtered fits.
284
+ threshold : float
285
+ Minimum flux value to consider a pixel valid.
286
+ frac_valid : float
287
+ Minimum fraction of pixels above the threshold to consider the filter valid.
288
+
289
+ Returns
290
+ -------
291
+ cube_filtered : np.ndarray
292
+ Datacube with only valid filters.
293
+ filters_valid : list
294
+ List of valid filter names.
295
+
296
+ Saves the new datacube.
297
+ """
298
+
299
+ # Open FITS
300
+ with fits.open(cube_fits_file) as hdul:
301
+ ext = find_ext(hdul)
302
+ if ext is None:
303
+ raise ValueError(f"No image data found in {cube_fits_file}")
304
+
305
+ cube = hdul[ext].data
306
+ header = hdul[ext].header
307
+
308
+ n_filters = header.get("NAXIS3", cube.shape[0])
309
+ filters = [header[f"FILTER{i+1}"].strip() for i in range(n_filters)]
310
+
311
+ # Keep only valid filters
312
+ valid_indices = []
313
+ for i, img in enumerate(cube):
314
+ valid = np.isfinite(img)
315
+ n_total = valid.sum()
316
+ if n_total == 0:
317
+ continue
318
+ n_above = np.sum(img[valid] > threshold)
319
+ if (n_above / n_total) >= frac_valid:
320
+ valid_indices.append(i)
321
+
322
+ cube_filtered = cube[valid_indices]
323
+ filters_valid = [filters[i] for i in valid_indices]
324
+
325
+ # Write new header
326
+ new_header = header.copy()
327
+ new_header["NAXIS3"] = len(filters_valid)
328
+ new_header['NFILTERS'] = len(filters_valid)
329
+ new_header['FILTERS'] = ",".join(filters_valid)
330
+
331
+ for i, f in enumerate(filters_valid):
332
+ new_header[f"FILTER{i+1}"] = f
333
+
334
+ for i in range(len(filters_valid), n_filters):
335
+ key = f"FILTER{i+1}"
336
+ if key in new_header:
337
+ del new_header[key]
338
+
339
+ # Save filtered datacube
340
+ hdu = fits.PrimaryHDU(cube_filtered, header=new_header)
341
+ hdu.writeto(output_cube, overwrite=True)
342
+ print(f"Filtered datacube saved at '{output_cube}'")
343
+
344
+ return cube_filtered, filters_valid
345
+
346
+
347
+ def remove_filter(cube_fits_file, output_cube, filter_to_remove):
348
+ """
349
+ Removes a specific filter from a 3D datacube.
350
+
351
+ Parameters
352
+ ----------
353
+ cube_fits_file : str
354
+ Path to the original datacube.
355
+ output_cube : str
356
+ Path to save the new datacube.
357
+ filter_to_remove : str
358
+ Name of the filter to be removed (e.g., ‘F115W’).
359
+ """
360
+
361
+ # Open FITS
362
+ with fits.open(cube_fits_file) as hdul:
363
+ ext = find_ext(hdul)
364
+ if ext is None:
365
+ raise ValueError(f"No image data found in {cube_fits_file}")
366
+
367
+ cube = hdul[ext].data
368
+ header = hdul[ext].header
369
+
370
+ n_filters = header.get("NAXIS3", cube.shape[0])
371
+ filters = [header[f"FILTER{i+1}"].strip() for i in range(n_filters)]
372
+
373
+ # Keep filters
374
+ keep_indices = [i for i, f in enumerate(filters) if f != filter_to_remove]
375
+
376
+ if len(keep_indices) == len(filters):
377
+ print(f"This filter '{filter_to_remove}' is not in the datacube.")
378
+ else:
379
+ print(f"Removing filter: {filter_to_remove}")
380
+
381
+ # New cube
382
+ cube_new = cube[keep_indices]
383
+ filters_new = [filters[i] for i in keep_indices]
384
+
385
+ # Write new header
386
+ new_header = header.copy()
387
+ new_header["NAXIS3"] = len(filters_new)
388
+ new_header['NFILTERS'] = len(filters_new)
389
+ new_header['FILTERS'] = ",".join(filters_new)
390
+
391
+ for i, f in enumerate(filters_new):
392
+ new_header[f"FILTER{i+1}"] = f
393
+
394
+ for i in range(len(filters_new), n_filters):
395
+ key = f"FILTER{i+1}"
396
+ if key in new_header:
397
+ del new_header[key]
398
+
399
+ # Save
400
+ hdu = fits.PrimaryHDU(cube_new, header=new_header)
401
+ hdu.writeto(output_cube, overwrite=True)
402
+ print(f"New datacube without '{filter_to_remove}' saved at {output_cube}")
403
+
404
+ return cube_new, filters_new
405
+
406
+ def update_cube_header(
407
+ cube_fits_file,
408
+ output_file=None,
409
+ redshift=None,
410
+ ra_center=None,
411
+ dec_center=None,
412
+ use_brightest_pixel=False,
413
+ overwrite=False):
414
+ """
415
+ Update a datacube header with astrophysical metadata.
416
+
417
+ By default, creates a new file with suffix '_more.fits'
418
+ instead of overwriting the original.
419
+
420
+ Parameters
421
+ ----------
422
+ cube_fits_file : str
423
+ Path to input datacube.
424
+
425
+ output_file : str or None
426
+ Output FITS file. If None, creates '<original>_more.fits'.
427
+
428
+ redshift : float or None
429
+ Galaxy redshift.
430
+
431
+ ra_center, dec_center : float or None
432
+ Galaxy center coordinates (deg).
433
+
434
+ use_brightest_pixel : bool
435
+ If True, estimate center from brightest pixel.
436
+
437
+ overwrite : bool
438
+ Overwrite output file if it already exists.
439
+ """
440
+
441
+ # Define output file
442
+ if output_file is None:
443
+ base, ext = os.path.splitext(cube_fits_file)
444
+ output_file = f"{base}_more{ext}"
445
+
446
+ # Open FITS
447
+ with fits.open(cube_fits_file) as hdul:
448
+ ext = find_ext(hdul)
449
+ if ext is None:
450
+ raise ValueError(f"No valid data extension in {cube_fits_file}")
451
+
452
+ datacube = hdul[ext].data
453
+ header = hdul[ext].header.copy()
454
+
455
+ wcs = WCS(header)
456
+ if wcs.pixel_n_dim > 2:
457
+ wcs = wcs.celestial
458
+
459
+ # Redshift
460
+ if redshift is not None and not np.isnan(redshift):
461
+ header["REDSHIFT"] = (redshift, "Galaxy redshift")
462
+ else:
463
+ header["REDSHIFT"] = ("UNKNOWN", "Galaxy redshift not provided")
464
+
465
+ # Galaxy center
466
+ if use_brightest_pixel:
467
+ collapsed = np.nansum(datacube, axis=0)
468
+
469
+ y_max, x_max = np.unravel_index(
470
+ np.nanargmax(collapsed), collapsed.shape)
471
+
472
+ ra_peak, dec_peak = wcs.wcs_pix2world(x_max, y_max, 0)
473
+
474
+ header["GAL_XCEN"] = (x_max, "Galaxy center X (pixel)")
475
+ header["GAL_YCEN"] = (y_max, "Galaxy center Y (pixel)")
476
+ header["GAL_RA"] = (ra_peak, "Galaxy center RA (deg)")
477
+ header["GAL_DEC"] = (dec_peak, "Galaxy center Dec (deg)")
478
+ header["CEN_TYPE"] = ("BRIGHTEST", "Center definition")
479
+
480
+ elif (ra_center is not None) and (dec_center is not None):
481
+ header["GAL_RA"] = (ra_center, "Galaxy center RA (deg)")
482
+ header["GAL_DEC"] = (dec_center, "Galaxy center Dec (deg)")
483
+ header["CEN_TYPE"] = ("USER", "Center definition")
484
+
485
+ else:
486
+ header["CEN_TYPE"] = ("UNKNOWN", "Center not defined")
487
+
488
+ # Save
489
+ with fits.open(cube_fits_file) as hdul:
490
+ hdul[ext].header = header
491
+ hdul.writeto(output_file, overwrite=overwrite)
492
+
493
+ print(f"Updated header saved to: {output_file}")