lightstack 0.1.6__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lightstack
3
- Version: 0.1.6
3
+ Version: 0.2.0
4
4
  Summary: Tools for building and processing multi-filter astrophysical datacubes
5
5
  Author: Andressa Wille, Thallis Pessi
6
6
  License: MIT License
@@ -41,7 +41,7 @@ Requires-Dist: photutils
41
41
 
42
42
  # Lightstack
43
43
 
44
- Tools for building and processing multi-filter astrophysical datacubes.
44
+ Tools for building and processing multi-filter astrophysical datacubes.
45
45
 
46
46
  ## Features
47
47
 
@@ -49,8 +49,6 @@ Tools for building and processing multi-filter astrophysical datacubes.
49
49
  - Building photometric datacubes
50
50
  - PSF matching
51
51
 
52
- (Currently supports JWST and HST)
53
-
54
52
  ## Installation
55
53
 
56
54
  ```bash
@@ -61,4 +59,4 @@ pip install lightstack
61
59
 
62
60
  https://lightstack.readthedocs.io/en/latest/
63
61
 
64
- See the tutorial notebook!
62
+ See the tutorial notebook (in the "examples" folder)!
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Lightstack
6
6
 
7
- Tools for building and processing multi-filter astrophysical datacubes.
7
+ Tools for building and processing multi-filter astrophysical datacubes.
8
8
 
9
9
  ## Features
10
10
 
@@ -12,8 +12,6 @@ Tools for building and processing multi-filter astrophysical datacubes.
12
12
  - Building photometric datacubes
13
13
  - PSF matching
14
14
 
15
- (Currently supports JWST and HST)
16
-
17
15
  ## Installation
18
16
 
19
17
  ```bash
@@ -24,4 +22,4 @@ pip install lightstack
24
22
 
25
23
  https://lightstack.readthedocs.io/en/latest/
26
24
 
27
- See the tutorial notebook!
25
+ See the tutorial notebook (in the "examples" folder)!
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lightstack"
3
- version = "0.1.6"
3
+ version = "0.2.0"
4
4
  description = "Tools for building and processing multi-filter astrophysical datacubes"
5
5
  readme = "README.md"
6
6
  authors = [{name = "Andressa Wille"}, {name="Thallis Pessi"}]
@@ -1,39 +1,56 @@
1
1
  import numpy as np
2
2
  import os
3
+ import glob
3
4
 
4
5
  from astropy.io import fits
5
6
  from astropy.wcs import WCS
6
7
 
7
8
  from reproject import reproject_interp, reproject_exact
8
9
 
9
- from .utils import find_ext, infer_filter, get_pixel_scale_from_wcs
10
+ from .utils import find_ext, get_filter, get_pixel_scale_from_wcs
11
+
12
+
13
+ def load_image_fits(folder_path):
14
+
15
+ fits_files = []
16
+
17
+ for f in glob.glob(os.path.join(folder_path, "*.fits")):
18
+ try:
19
+ if fits.getval(f, "NAXIS") != 2:
20
+ continue
21
+ fits_files.append(f)
22
+ except Exception:
23
+ continue
24
+
25
+ return fits_files
10
26
 
11
27
 
12
28
  def align_reproject_fits(fits_list, ref_file, method="interp", crop=1):
13
29
  """
14
- Aligns and reprojects FITS images to a common WCS using a reference FITS file.
30
+ Align and optionally reproject FITS images to a common WCS.
31
+ If an image already matches the reference WCS, shape and pixel scale, reprojection is skipped automatically.
32
+ Area correction is only applied when input and output pixel scales differ.
15
33
 
16
34
  Parameters
17
35
  ----------
18
36
  fits_list : list of tuples
19
- List in the form [(fits_path, filter_name), ...].
37
+ [(fits_path, filter_name), ...]
20
38
 
21
39
  ref_file : str
22
- Path to the FITS file used as WCS reference.
40
+ Reference FITS file.
23
41
 
24
42
  method : str, optional
25
43
  Reprojection method:
26
- - "interp" (default): faster, interpolates values
27
- - "exact": slower --> but reproject_exact has precision issues
28
- with resolutions below ~0.05 arcsec.
44
+ - "interp" (default)
45
+ - "exact"
29
46
 
30
47
  crop : int, optional
31
- Number of pixels to remove from each border after reprojection.
48
+ Pixels to crop from borders after reprojection.
32
49
 
33
50
  Returns
34
51
  -------
35
52
  aligned_list : list of tuples
36
- List in the form [(aligned_fits_path, filter_name), ...].
53
+ [(aligned_fits_path, filter_name), ...]
37
54
  """
38
55
 
39
56
  # Choose reprojection method
@@ -56,12 +73,15 @@ def align_reproject_fits(fits_list, ref_file, method="interp", crop=1):
56
73
  ref_wcs = WCS(ref_header)
57
74
  shape_out = hdul_ref[ext_ref].data.shape
58
75
 
59
- # Pixel scale of reference
60
76
  scale_out = get_pixel_scale_from_wcs(ref_wcs)
61
-
62
77
  aligned_list = []
63
78
 
64
- # Loop over all FITS
79
+ # Reference filter name
80
+ ref_filter_name = next(
81
+ filt for f, filt in fits_list if f == ref_file
82
+ )
83
+
84
+ # Loop through files
65
85
  for fpath, filt in fits_list:
66
86
 
67
87
  with fits.open(fpath) as hdul:
@@ -74,50 +94,67 @@ def align_reproject_fits(fits_list, ref_file, method="interp", crop=1):
74
94
  header = hdul[ext].header
75
95
  wcs_in = WCS(header)
76
96
 
77
- unit = header.get('BUNIT', 'unknown')
78
- print(f"Filter {filt}: unit = {unit}")
97
+ unit = header.get("BUNIT", "unknown")
98
+ print(f"\nFilter {filt}: unit = {unit}")
79
99
 
80
- # Pixel scale of input
81
100
  scale_in = get_pixel_scale_from_wcs(wcs_in)
101
+ print(f"Pixel scale in/out: {scale_in:.4f} -> {scale_out:.4f}")
82
102
 
83
- # Reproject
84
- data_aligned, footprint = reproj_func(
85
- (data, wcs_in),
86
- ref_wcs,
87
- shape_out=shape_out
103
+ # Check whether reprojection is really needed
104
+ same_scale = np.isclose(scale_in, scale_out, rtol=1e-6)
105
+ same_shape = data.shape == shape_out
106
+ same_wcs = (
107
+ wcs_in.to_header_string()
108
+ == ref_wcs.to_header_string()
88
109
  )
89
110
 
90
- print(f"Pixel scale in/out: {scale_in:.4f} -> {scale_out:.4f}")
111
+ if same_scale and same_shape and same_wcs:
112
+ print("Already aligned with reference: skipping reprojection.")
91
113
 
92
- # Apply area correction only if scales differ
93
- if not np.isclose(scale_in, scale_out, rtol=1e-6):
94
- area_ratio = (scale_out / scale_in) ** 2
95
- data_aligned *= area_ratio
96
- print(f"Applied area correction: {area_ratio:.4f}")
97
- area_corrected = True
98
- else:
99
- print("Pixel scales identical: no area correction applied.")
114
+ data_aligned = data.copy()
115
+ wcs_out = wcs_in.deepcopy()
116
+ reprojected = False
100
117
  area_corrected = False
101
118
 
102
- # Crop border
103
- if crop > 0:
104
- data_aligned = data_aligned[crop:-crop, crop:-crop]
119
+ else:
120
+ data_aligned, footprint = reproj_func(
121
+ (data, wcs_in),
122
+ ref_wcs,
123
+ shape_out=shape_out
124
+ )
105
125
 
106
- # Adjust WCS
107
126
  wcs_out = ref_wcs.deepcopy()
127
+ reprojected = True
128
+
129
+ # Area correction only if pixel scales differ
130
+ if not same_scale:
131
+ area_ratio = (scale_out / scale_in) ** 2
132
+ data_aligned *= area_ratio
133
+ print(f"Applied area correction: {area_ratio:.4f}")
134
+ area_corrected = True
135
+ else:
136
+ print("Pixel scales identical: no area correction applied.")
137
+ area_corrected = False
138
+
139
+ # Crop borders
140
+ if crop > 0:
141
+ data_aligned = data_aligned[crop:-crop, crop:-crop]
108
142
  wcs_out.wcs.crpix -= crop
109
- else:
110
- wcs_out = ref_wcs
111
143
 
112
- # Header
144
+ # Build output header
113
145
  header_aligned = wcs_out.to_header()
114
- header_aligned['BUNIT'] = unit
146
+ header_aligned["BUNIT"] = unit
115
147
 
116
- ref_filter = infer_filter(ref_file)
117
- header_aligned.add_history(
118
- f"Reprojected to {ref_filter} using {method} method "
119
- "(reproject package)"
120
- )
148
+ if reprojected:
149
+ header_aligned.add_history(
150
+ f"Reprojected to {ref_filter_name} using "
151
+ f"{method} method (reproject package)"
152
+ )
153
+ else:
154
+ header_aligned.add_history(
155
+ f"No reprojection needed "
156
+ f"(already aligned to {ref_filter_name})"
157
+ )
121
158
 
122
159
  if area_corrected:
123
160
  header_aligned.add_history(
@@ -125,8 +162,7 @@ def align_reproject_fits(fits_list, ref_file, method="interp", crop=1):
125
162
  )
126
163
  else:
127
164
  header_aligned.add_history(
128
- "No area correction applied "
129
- "(identical pixel scale)"
165
+ "No area correction applied"
130
166
  )
131
167
 
132
168
  if crop > 0:
@@ -134,7 +170,7 @@ def align_reproject_fits(fits_list, ref_file, method="interp", crop=1):
134
170
  f"Cropped {crop} pixels from each border"
135
171
  )
136
172
 
137
- # Save
173
+ # Save output
138
174
  suffix = f"_aligned_{method}"
139
175
  out_name = os.path.splitext(fpath)[0] + f"{suffix}.fits"
140
176
 
@@ -143,41 +179,54 @@ def align_reproject_fits(fits_list, ref_file, method="interp", crop=1):
143
179
  header=header_aligned
144
180
  ).writeto(out_name, overwrite=True)
145
181
 
146
- # Flux sanity check
182
+ # Flux sanity check (only if reprojection happened and input not empty)
147
183
  sum_in = np.nansum(data)
148
184
  sum_out = np.nansum(data_aligned)
149
- print(f"Flux ratio (out/in): {sum_out/sum_in:.4f}")
185
+
186
+ if reprojected and sum_in != 0:
187
+ print(f"Flux ratio (out/in): {sum_out / sum_in:.4f}")
188
+ elif sum_in == 0:
189
+ print("Input image is empty: skipping flux ratio check.")
150
190
 
151
191
  aligned_list.append((out_name, filt))
152
192
  print(f"Saved: {out_name}")
153
193
 
154
194
  return aligned_list
155
-
195
+
156
196
 
157
197
  def build_datacube(aligned_fits_files, reference_file, output_path):
158
198
  """
159
- Builds a 3D datacube from aligned 2D FITS images.
199
+ Build a 3D datacube from aligned 2D FITS images.
160
200
 
161
201
  Parameters
162
202
  ----------
163
203
  aligned_fits_files : list of tuples
164
- [(filename, filter_name), ...]
204
+ List in format [(filename, filter_name), ...].
205
+
165
206
  reference_file : str
166
- FITS file to define WCS and shape.
207
+ FITS file used to define WCS and output shape.
208
+
167
209
  output_path : str
168
- Path to save the 3D datacube.
169
-
210
+ Path to save the output datacube.
211
+
170
212
  Returns
171
213
  -------
172
214
  None
173
- Saves the cut datacube.
215
+ Saves the datacube to disk.
174
216
  """
175
-
176
- # Open FITS
217
+
218
+ # Safety check
219
+ if len(aligned_fits_files) == 0:
220
+ raise ValueError("No aligned FITS files provided.")
221
+
222
+ # Reference WCS
177
223
  with fits.open(reference_file) as hdul_ref:
178
224
  ext_ref = find_ext(hdul_ref)
179
225
  if ext_ref is None:
180
- raise ValueError(f"No 2D data in reference file {reference_file}.")
226
+ raise ValueError(
227
+ f"No valid 2D data in reference file {reference_file}."
228
+ )
229
+
181
230
  ref_header = hdul_ref[ext_ref].header
182
231
  ny, nx = hdul_ref[ext_ref].data.shape
183
232
  wcs_2d = WCS(ref_header, naxis=2)
@@ -186,51 +235,71 @@ def build_datacube(aligned_fits_files, reference_file, output_path):
186
235
  filter_names = []
187
236
  units = []
188
237
 
189
- # Construct cube
238
+ # Read aligned images
190
239
  for file, filt in aligned_fits_files:
240
+
191
241
  with fits.open(file) as hdul:
192
242
  ext = find_ext(hdul)
243
+
193
244
  if ext is None:
194
- print(f"No 2D data in {file}. Skipping.")
245
+ print(f"No valid 2D data in {file}. Skipping.")
195
246
  continue
247
+
196
248
  data = hdul[ext].data
249
+
197
250
  cube_images.append(data)
198
251
  filter_names.append(filt)
199
- units.append(hdul[ext].header.get('BUNIT', 'unknown'))
252
+ units.append(
253
+ hdul[ext].header.get("BUNIT", "unknown")
254
+ )
255
+
256
+ # Safety check after filtering invalid files
257
+ if len(cube_images) == 0:
258
+ raise ValueError("No valid 2D FITS images found.")
200
259
 
201
260
  cube = np.array(cube_images)
202
261
 
203
- # Write header
262
+ # Build 3D WCS
204
263
  wcs_3d = WCS(naxis=3)
264
+
265
+ # Spatial axes
205
266
  wcs_3d.wcs.crpix[0] = wcs_2d.wcs.crpix[0]
206
267
  wcs_3d.wcs.crpix[1] = wcs_2d.wcs.crpix[1]
268
+
207
269
  wcs_3d.wcs.crval[0] = wcs_2d.wcs.crval[0]
208
270
  wcs_3d.wcs.crval[1] = wcs_2d.wcs.crval[1]
271
+
209
272
  wcs_3d.wcs.cdelt[0] = wcs_2d.wcs.cdelt[0]
210
273
  wcs_3d.wcs.cdelt[1] = wcs_2d.wcs.cdelt[1]
274
+
211
275
  wcs_3d.wcs.ctype[0] = wcs_2d.wcs.ctype[0]
212
276
  wcs_3d.wcs.ctype[1] = wcs_2d.wcs.ctype[1]
277
+
213
278
  wcs_3d.wcs.cunit[0] = wcs_2d.wcs.cunit[0]
214
279
  wcs_3d.wcs.cunit[1] = wcs_2d.wcs.cunit[1]
215
280
 
281
+ # Preserve CD matrix if present
216
282
  if wcs_2d.wcs.has_cd():
217
- cd3 = np.zeros((3,3))
218
- cd3[0:2,0:2] = wcs_2d.wcs.cd.copy()
219
- cd3[2,2] = 1.0
283
+ cd3 = np.zeros((3, 3))
284
+ cd3[0:2, 0:2] = wcs_2d.wcs.cd.copy()
285
+ cd3[2, 2] = 1.0
220
286
  wcs_3d.wcs.cd = cd3
221
287
 
288
+ # Filter axis
222
289
  wcs_3d.wcs.crpix[2] = 1
223
290
  wcs_3d.wcs.crval[2] = 0
224
291
  wcs_3d.wcs.cdelt[2] = 1
225
- wcs_3d.wcs.ctype[2] = 'FILTER'
226
- wcs_3d.wcs.cunit[2] = ''
292
+ wcs_3d.wcs.ctype[2] = "FILTER"
293
+ wcs_3d.wcs.cunit[2] = ""
227
294
 
228
295
  header = wcs_3d.to_header()
229
- header['NAXIS'] = 3
230
- header['NAXIS1'] = nx
231
- header['NAXIS2'] = ny
232
- header['NAXIS3'] = len(filter_names)
233
-
296
+
297
+ header["NAXIS"] = 3
298
+ header["NAXIS1"] = nx
299
+ header["NAXIS2"] = ny
300
+ header["NAXIS3"] = len(filter_names)
301
+
302
+ # Copy HISTORY from first valid file
234
303
  with fits.open(aligned_fits_files[0][0]) as hdul0:
235
304
  ext0 = find_ext(hdul0)
236
305
  header_meta = hdul0[ext0].header
@@ -239,20 +308,26 @@ def build_datacube(aligned_fits_files, reference_file, output_path):
239
308
  for h in header_meta["HISTORY"]:
240
309
  header.add_history(h)
241
310
 
311
+ # Units
242
312
  if all(u == units[0] for u in units):
243
- header['BUNIT'] = units[0]
313
+ header["BUNIT"] = units[0]
244
314
  else:
245
- header['BUNIT'] = 'unknown'
315
+ header["BUNIT"] = "unknown"
246
316
 
317
+ # Filter metadata
247
318
  for i, filt in enumerate(filter_names):
248
- header[f'FILTER{i+1}'] = filt
249
-
250
- header['NFILTERS'] = len(filter_names)
251
- header['FILTERS'] = ",".join(filter_names)
319
+ header[f"FILTER{i+1}"] = filt
252
320
 
253
- # Save datacube
254
- fits.PrimaryHDU(cube, header=header).writeto(output_path, overwrite=True)
255
- print(f"Datacube saved at {output_path}.")
321
+ header["NFILTERS"] = len(filter_names)
322
+ header["FILTERS"] = ",".join(filter_names)
323
+
324
+ # Save cube
325
+ fits.PrimaryHDU(
326
+ cube,
327
+ header=header
328
+ ).writeto(output_path, overwrite=True)
329
+
330
+ print(f"Datacube saved at {output_path}")
256
331
 
257
332
 
258
333
  def cut_region_datacube(cube_fits_file, x_start, x_end, y_start, y_end, output_path):
@@ -267,7 +342,7 @@ def cut_region_datacube(cube_fits_file, x_start, x_end, y_start, y_end, output_p
267
342
  Pixel indices for the x axis.
268
343
  y_start, y_end : int
269
344
  Pixel indices for the y axis.
270
- output_filename : str
345
+ output_path : str
271
346
  Path to the output fits file.
272
347
 
273
348
  Returns
@@ -354,33 +429,62 @@ def build_valid_datacube(cube_fits_file, output_cube, threshold=0.0, frac_valid=
354
429
  for i, img in enumerate(cube):
355
430
  valid = np.isfinite(img)
356
431
  n_total = valid.sum()
432
+
357
433
  if n_total == 0:
358
434
  continue
435
+
359
436
  n_above = np.sum(img[valid] > threshold)
437
+
360
438
  if (n_above / n_total) >= frac_valid:
361
439
  valid_indices.append(i)
362
-
440
+
441
+ # Stop if all filters are invalid
442
+ if len(valid_indices) == 0:
443
+ raise ValueError(
444
+ f"No valid filters found in {cube_fits_file}. "
445
+ "Try lowering threshold or frac_valid."
446
+ )
447
+
448
+ # Report removed filters
449
+ removed_filters = [
450
+ filters[i] for i in range(n_filters)
451
+ if i not in valid_indices
452
+ ]
453
+
454
+ if removed_filters:
455
+ print(f"Removed empty/invalid filters: {removed_filters}")
456
+
457
+ # Build filtered cube + valid filter names
363
458
  cube_filtered = cube[valid_indices]
364
459
  filters_valid = [filters[i] for i in valid_indices]
365
-
460
+
366
461
  # Write new header
367
462
  new_header = header.copy()
368
463
  new_header["NAXIS3"] = len(filters_valid)
369
- new_header['NFILTERS'] = len(filters_valid)
370
- new_header['FILTERS'] = ",".join(filters_valid)
464
+ new_header["NFILTERS"] = len(filters_valid)
465
+ new_header["FILTERS"] = ",".join(filters_valid)
371
466
 
372
467
  for i, f in enumerate(filters_valid):
373
468
  new_header[f"FILTER{i+1}"] = f
374
469
 
470
+ # Remove obsolete FILTER keywords
375
471
  for i in range(len(filters_valid), n_filters):
376
472
  key = f"FILTER{i+1}"
377
473
  if key in new_header:
378
474
  del new_header[key]
379
475
 
380
- # Save filtered datacube
476
+ # Add history entry
477
+ new_header.add_history(
478
+ f"Removed invalid filters "
479
+ f"(threshold={threshold}, frac_valid={frac_valid})"
480
+ )
481
+
482
+ # Save filtered datacube
381
483
  hdu = fits.PrimaryHDU(cube_filtered, header=new_header)
382
484
  hdu.writeto(output_cube, overwrite=True)
485
+
383
486
  print(f"Filtered datacube saved at '{output_cube}'")
487
+ print(f"Valid filters: {filters_valid}")
384
488
 
385
489
  return cube_filtered, filters_valid
386
490
 
@@ -396,7 +500,7 @@ def remove_filter(cube_fits_file, output_cube, filter_to_remove):
396
500
  output_cube : str
397
501
  Path to save the new datacube.
398
502
  filter_to_remove : str
399
- Name of the filter to be removed (e.g., ‘F115W’).
503
+ Name of the filter to be removed.
400
504
  """
401
505
 
402
506
  # Open FITS
@@ -411,13 +515,20 @@ def remove_filter(cube_fits_file, output_cube, filter_to_remove):
411
515
  n_filters = header.get("NAXIS3", cube.shape[0])
412
516
  filters = [header[f"FILTER{i+1}"].strip() for i in range(n_filters)]
413
517
 
414
- # Keep filters
415
- keep_indices = [i for i, f in enumerate(filters) if f != filter_to_remove]
518
+ # Keep filters except selected one
519
+ keep_indices = [i for i, f in enumerate(filters)
520
+ if f != filter_to_remove]
416
521
 
417
522
  if len(keep_indices) == len(filters):
418
- print(f"This filter '{filter_to_remove}' is not in the datacube.")
419
- else:
420
- print(f"Removing filter: {filter_to_remove}")
523
+ print(f"Filter '{filter_to_remove}' not found in datacube.")
524
+ return cube, filters
525
+
526
+ if len(keep_indices) == 0:
527
+ raise ValueError(
528
+ "Cannot remove all filters. Datacube would be empty."
529
+ )
530
+
531
+ print(f"Removing filter: {filter_to_remove}")
421
532
 
422
533
  # New cube
423
534
  cube_new = cube[keep_indices]
@@ -426,23 +537,33 @@ def remove_filter(cube_fits_file, output_cube, filter_to_remove):
426
537
  # Write new header
427
538
  new_header = header.copy()
428
539
  new_header["NAXIS3"] = len(filters_new)
429
- new_header['NFILTERS'] = len(filters_new)
430
- new_header['FILTERS'] = ",".join(filters_new)
540
+ new_header["NFILTERS"] = len(filters_new)
541
+ new_header["FILTERS"] = ",".join(filters_new)
431
542
 
432
543
  for i, f in enumerate(filters_new):
433
544
  new_header[f"FILTER{i+1}"] = f
434
545
 
546
+ # Remove old FILTER keywords
435
547
  for i in range(len(filters_new), n_filters):
436
548
  key = f"FILTER{i+1}"
437
549
  if key in new_header:
438
550
  del new_header[key]
439
551
 
552
+ # Add history
553
+ new_header.add_history(
554
+ f"Removed filter {filter_to_remove}"
555
+ )
556
+
440
557
  # Save
441
- hdu = fits.PrimaryHDU(cube_new, header=new_header)
442
- hdu.writeto(output_cube, overwrite=True)
443
- print(f"New datacube without '{filter_to_remove}' saved at {output_cube}")
444
-
558
+ fits.PrimaryHDU(cube_new, header=new_header).writeto(
559
+ output_cube,
560
+ overwrite=True
561
+ )
562
+
563
+ print(f"New datacube saved at {output_cube}")
564
+
445
565
  return cube_new, filters_new
566
+
446
567
 
447
568
  def update_cube_header(
448
569
  cube_fits_file,
@@ -532,3 +653,4 @@ def update_cube_header(
532
653
  hdul.writeto(output_file, overwrite=overwrite)
533
654
 
534
655
  print(f"Updated header saved to: {output_file}")
656
+