visualens 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.
Files changed (35) hide show
  1. visualens/__init__.py +5 -0
  2. visualens/catalog.py +512 -0
  3. visualens/fits_image.py +604 -0
  4. visualens/gui.py +313 -0
  5. visualens/lenstool_model/im2source.py +145 -0
  6. visualens/lenstool_model/lenstool_model.py +800 -0
  7. visualens/lenstool_model/simulate_image/simulate_image.py +256 -0
  8. visualens/lenstool_model/simulate_image/utils.py +301 -0
  9. visualens/lenstool_model/utils/file_makers.py +204 -0
  10. visualens/lenstool_model/utils/lenstool_to_lenstronomy.py +102 -0
  11. visualens/lenstool_model/utils/operations.py +32 -0
  12. visualens/lenstool_model/utils/param_extractors.py +622 -0
  13. visualens/lenstool_model/utils/redshift_extractors.py +81 -0
  14. visualens/lenstool_model/utils/utils_general.py +617 -0
  15. visualens/main.py +19 -0
  16. visualens/sliders_TEST.py +213 -0
  17. visualens/source_extraction/source_extract.py +23 -0
  18. visualens/utils/__init__.py +0 -0
  19. visualens/utils/utils_Qt/drag_widgets.py +339 -0
  20. visualens/utils/utils_Qt/selectable_classes.py +268 -0
  21. visualens/utils/utils_Qt/utils_general.py +104 -0
  22. visualens/utils/utils_astro/cat_manip.py +293 -0
  23. visualens/utils/utils_astro/get_cosmology.py +13 -0
  24. visualens/utils/utils_astro/utils_general.py +205 -0
  25. visualens/utils/utils_astro/utils_lensing.py +16 -0
  26. visualens/utils/utils_fits/utils_fits_image.py +111 -0
  27. visualens/utils/utils_general/sort_points.py +293 -0
  28. visualens/utils/utils_general/utils_general.py +222 -0
  29. visualens/utils/utils_plots/plot_utils_general.py +230 -0
  30. visualens/utils/utils_plots/plot_utils_specific.py +48 -0
  31. visualens/utils/utils_plots/plt_framework.py +78 -0
  32. visualens-0.1.0.dist-info/METADATA +24 -0
  33. visualens-0.1.0.dist-info/RECORD +35 -0
  34. visualens-0.1.0.dist-info/WHEEL +5 -0
  35. visualens-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,604 @@
1
+ import os
2
+ import re
3
+ import glob
4
+ import numpy as np
5
+ from matplotlib import pyplot as plt
6
+ from matplotlib.patches import Ellipse, Polygon, Circle, Rectangle
7
+ from astropy.io import fits
8
+ from astropy.wcs import WCS
9
+ import astropy.units as u
10
+ import astropy.constants as c
11
+ from reproject import reproject_interp
12
+ #from astropy.visualization.wcsaxes import *
13
+ from astropy.coordinates import SkyCoord
14
+ from tqdm import tqdm
15
+ from speclite import filters as specfilters
16
+
17
+ import PyQt5
18
+ from PyQt5.QtWidgets import QMainWindow, QSplitter
19
+ import pyqtgraph as pg
20
+ from PyQt5.QtCore import Qt
21
+ from astropy.table import Table
22
+
23
+ from .catalog import catalog
24
+ from .lenstool_model.lenstool_model import lenstool_model
25
+ from .source_extraction.source_extract import source_extract, source_extract_DIM
26
+ from .utils.utils_fits.utils_fits_image import open_image
27
+ from .utils.utils_plots.plot_utils_general import *
28
+ from .utils.utils_Qt.selectable_classes import *
29
+ from .utils.utils_Qt.drag_widgets import DragWidget
30
+ from .utils.utils_Qt.utils_general import *
31
+ from .utils.utils_astro.get_cosmology import get_cosmo
32
+
33
+
34
+
35
+
36
+ pg.setConfigOption('imageAxisOrder', 'row-major')
37
+
38
+
39
+
40
+ class fits_image :
41
+ def __init__(self, image_path, main_window=None, plot_image=True) :
42
+ self.image_path = image_path
43
+ # If an external QMainWindow is provided (e.g. from the GUI), use it
44
+ # instead of spawning a new independent window.
45
+ self.main_window = main_window # type: ignore[assignment]
46
+ if os.path.isfile(self.image_path[:-8] + 'wht.fits') :
47
+ print("Weight file found: " + self.image_path[:-8] + 'wht.fits')
48
+ self.weight_path = self.image_path[:-8] + 'wht.fits'
49
+ else :
50
+ self.weight_path = None
51
+ self.image_data, self.pix_deg_scale, self.orientation, self.wcs, self.header = open_image(self.image_path)
52
+ self.sources = None
53
+ self.fig = None
54
+ self.ax = None
55
+ self.multiple_images = None
56
+ self.galaxy_selection = None
57
+ self.imported_cat = None
58
+ self.imported_cat_list = []
59
+ self.qt_image = None
60
+ self.qtItems_dict = {'sources': None,
61
+ 'potfile_cat': None,
62
+ 'imported_cat': None,
63
+ 'multiple_images': None}
64
+ self.ax = None
65
+ self.redshift = None
66
+ self.boosted_image_path = self.image_path[:-5] + '_boosted.fits'
67
+ if os.path.isfile(self.boosted_image_path) :
68
+ print("Boosted image found: " + self.boosted_image_path)
69
+ self.boosted_image, _, _, _, _ = open_image(self.boosted_image_path)
70
+ else :
71
+ self.boosted_image = None
72
+ self.boosted = False
73
+ self.extra_qt_images = []
74
+ self.extra_windows = []
75
+ if self.orientation==None :
76
+ self.orientation = 0.
77
+ self.cosmo = get_cosmo()
78
+
79
+ if plot_image :
80
+ self.plot_image()
81
+ self.filters = None
82
+
83
+
84
+ def set_cosmo(self, cosmo_name) :
85
+ self.cosmo = get_cosmo(cosmo_name)
86
+
87
+ def create_qt_image(self) :
88
+ to_plot = np.flip(self.image_data, axis=0) if not self.boosted else np.flip(self.boosted_image, axis=0)
89
+
90
+ #qt_image = pg.image(to_plot)
91
+ qt_image = pg.ImageView()
92
+ qt_image.setImage(to_plot)
93
+ #qt_image.autoLevels()
94
+
95
+ image_widget = DragWidget(qt_image)
96
+
97
+ qt_layout = QSplitter(Qt.Horizontal)
98
+ qt_layout.addWidget(image_widget)
99
+
100
+ if self.main_window is None:
101
+ window = QMainWindow()
102
+ window.setWindowTitle(os.path.basename(self.image_path))
103
+ window.setCentralWidget(qt_layout)
104
+ window.show()
105
+ else:
106
+ # Reuse the provided main window.
107
+ window = self.main_window
108
+ # Replace any existing central widget.
109
+ window.setCentralWidget(qt_layout)
110
+ window.setWindowTitle(os.path.basename(self.image_path))
111
+ # Ensure the window is visible (may already be shown).
112
+ window.show()
113
+
114
+ return qt_image, qt_layout, image_widget, window
115
+
116
+ def plot_image(self) :
117
+ #to_plot = self.image_data
118
+ #to_plot = np.transpose(self.image_data, axes=[1,0,2])
119
+ if self.qt_image is None or not self.qt_image.isVisible() :
120
+ print('Creating main window...')
121
+ self.qt_image, self.qt_layout, self.image_widget, self.window = self.create_qt_image()
122
+ print('Done')
123
+ to_return = self.qt_image
124
+ else :
125
+ print('Creating secondary window...')
126
+ extra_qt_image, _, _, extra_window = self.create_qt_image()
127
+ extra_window.setWindowTitle(os.path.basename(self.image_path) + ' (' + str(len(self.extra_qt_images)+2) + ')')
128
+ extra_window.show()
129
+ self.extra_qt_images.append(extra_qt_image)
130
+ self.extra_windows.append(extra_window)
131
+ print('Done')
132
+ to_return = extra_qt_image
133
+ return to_return
134
+
135
+ def boost(self, boost=[2,1.5,1]) :
136
+ if self.boosted_image is None :
137
+ print('Adjusting contrast...')
138
+ adjusted_image = adjust_contrast(self.image_data, boost[0], pivot=boost[1])
139
+ print('Adjusting luminosity...')
140
+ self.boosted_image = adjust_luminosity(adjusted_image, boost[2])
141
+ print('Writing to memory...')
142
+ hdul = fits.HDUList([fits.PrimaryHDU()] + [ fits.ImageHDU(data=self.boosted_image[:,:,i], header=self.wcs.to_header(), name=name) for i, name in enumerate(['RED','GREEN','BLUE']) ])
143
+ hdul.writeto(self.boosted_image_path, overwrite=True)
144
+ if not self.boosted :
145
+ print('Plotting...')
146
+ self.qt_image.setImage(np.flip(self.boosted_image, axis=0))
147
+ #self.qt_image.autoLevels()
148
+ self.boosted = True
149
+ for extra_qt_image in self.extra_qt_images :
150
+ extra_qt_image.setImage(np.flip(self.boosted_image, axis=0))
151
+ print('Done')
152
+
153
+ def unboost(self) :
154
+ if self.boosted :
155
+ print('Plotting...')
156
+ self.qt_image.setImage(np.flip(self.image_data, axis=0))
157
+ #self.qt_image.autoLevels()
158
+ self.boosted = False
159
+ for extra_qt_image in self.extra_qt_images :
160
+ extra_qt_image.setImage(np.flip(self.image_data, axis=0))
161
+ print('Done')
162
+
163
+ def set_weight(self, weight_path) :
164
+ self.weight_path = weight_path
165
+
166
+ def extract_sources(self, image_path=None, weight_path=None, DIM_ref_path=None, rerun=False, reproject=True) :
167
+ print("Source extraction not yet available. Work in progress.")
168
+ if False :
169
+ if image_path is None :
170
+ image_path = self.image_path
171
+ weight_path = self.weight_path
172
+
173
+ out_dir = os.path.join( os.path.dirname(image_path), 'source_extraction/' )
174
+ if not os.path.exists(out_dir) :
175
+ os.mkdir(out_dir)
176
+
177
+ outfile_name = 'SExtractor_cat.fits' if DIM_ref_path is None else 'SExtractor_cat_DIM.fits'
178
+ out_path = os.path.join( out_dir, outfile_name )
179
+ if os.path.isfile(out_path) and not rerun :
180
+ print('Previous SExtractor catalog found.')
181
+ with fits.open(out_path) as hdu :
182
+ self.sources_all = Table(hdu[1].data)
183
+ elif DIM_ref_path is None :
184
+ self.sources_all = source_extract(image_path, weight_path=weight_path, pixel_scale=self.pix_deg_scale*3600, zero_point=None, out_dir=out_dir,
185
+ outfile_name=outfile_name, return_sources=True)
186
+ else :
187
+ if type(DIM_ref_path) is list :
188
+ if reproject :
189
+ reprojected_image_path = self.reproject(DIM_ref_path[0], image_path)
190
+ reprojected_weight_path = self.reproject(DIM_ref_path[0], weight_path)
191
+ reprojected_image_path = [reprojected_image_path, reprojected_weight_path]
192
+ else :
193
+ reprojected_image_path = [image_path, weight_path]
194
+ else :
195
+ if reproject :
196
+ reprojected_image_path = self.reproject(DIM_ref_path, image_path)
197
+ else :
198
+ reprojected_image_path = image_path
199
+
200
+ self.sources_all = source_extract_DIM(DIM_ref_path, reprojected_image_path, pixel_scale=self.pix_deg_scale*3600, zero_point=None, out_dir=out_dir,
201
+ outfile_name=outfile_name, return_sources=True)
202
+
203
+ # This next part doesn't make sense here as the purpose of extract_sources() is to extract sources from the imported image,
204
+ # but the code should be added to import_cat()/make_catalog()
205
+ # THE WAY TO CONVERT ANGLES IS MORE COMPLICATED!!!
206
+ x, y = self.world_to_image(self.sources_all['RA'], self.sources_all['DEC'], unit='deg')
207
+ if x[0] != self.sources_all['X_IMAGE'][0] :
208
+ #print('Catalog sextracted from different image: replacing X_IMAGE, Y_IMAGE and THETA_IMAGE columns with current image coordinates.')
209
+ #self.sources_all['X_IMAGE'], self.sources_all['Y_IMAGE'] = x, y
210
+ print('Catalog SExtracted from different image: keeping X_IMAGE, Y_IMAGE and THETA_IMAGE and adding x, y, theta columns from current image coordinates.')
211
+ self.sources_all.add_column(x, name='x')
212
+ self.sources_all.add_column(y, name='y')
213
+ ref_image_angle = ( np.arctan2(self.wcs.wcs.get_pc()[1, 0], self.wcs.wcs.get_pc()[0, 0]) %np.pi ) * 360/np.pi
214
+ print('Overall angle of imported image: ' + str(ref_image_angle))
215
+ #self.sources_all.add_column(self.sources_all['THETA_WORLD'] + ref_image_angle, name='prout')
216
+
217
+ #mask_mag = self.sources_all['MAG_AUTO']<-10.
218
+ #mask = self.sources['KRON_RADIUS']
219
+ #mask_galstar = self.sources_all['CLASS_STAR']<0.4
220
+ #mask_size = self.sources_all['A_IMAGE']*self.sources_all['B_IMAGE']*np.pi>1000.
221
+ #mask = mask_mag & mask_galstar & mask_size
222
+
223
+ #self.sources = self.sources_all #[mask]
224
+ self.make_photometry(self.sources_all)
225
+ self.sources = self.make_catalog(self.sources_all)
226
+ return str(len(self.sources.cat)) + ' sources found.'
227
+
228
+ def reproject(self, ref_image_path, image_path) :
229
+ reprojected_image_path = image_path[:-len('.fits')] + '_reprojected.fits'
230
+ if os.path.isfile(reprojected_image_path) :
231
+ print('Previous reprojected image found.')
232
+ else :
233
+ with fits.open(ref_image_path) as hdu :
234
+ reference_header = hdu[0].header
235
+ with fits.open(image_path) as hdu :
236
+ print('Reprojecting image ' + image_path + ' onto reference ' + ref_image_path)
237
+ reprojected_data, footprint = reproject_interp(hdu[0], reference_header)
238
+ fits.writeto(reprojected_image_path, reprojected_data, reference_header)
239
+ return reprojected_image_path
240
+
241
+ def make_photometry(self, cat) :
242
+ print("################")
243
+ print("Figuring out the photometry:")
244
+ print("Assuming instrument HST/ACS")
245
+ print("PHOTFLAM = " + str(self.header['PHOTFLAM']))
246
+ print("Pivot wavelength = " + str(self.header['PHOTPLAM']))
247
+ print("################")
248
+ flux_lambda = self.header['PHOTFLAM'] * cat['FLUX_ISO'] * u.erg/u.cm**2/u.s/u.AA #FLUX_AUTO, FLUX_ISO, FLUX_APER
249
+ magST = -2.5*np.log10(flux_lambda.value) - 21.1
250
+ pivot_wavelength = self.header['PHOTPLAM'] * u.AA
251
+ flux_nu = flux_lambda.to(u.erg/u.cm**2/u.s/u.Hz, u.spectral_density(pivot_wavelength))
252
+ magAB = -2.5*np.log10(flux_nu.value) - 48.6
253
+ if 'FILTER2' in self.header.keys() :
254
+ print("filter " + self.header['FILTER2'] + " found")
255
+ cat.add_column(magAB, name='magAB_' + self.header['FILTER2'])
256
+ cat.add_column(magST, name='magST_' + self.header['FILTER2'])
257
+ else :
258
+ cat.add_column(magAB, name='magAB')
259
+ cat.add_column(magST, name='magST')
260
+ return 'Magnitudes calculated'
261
+
262
+
263
+ def make_catalog(self, cat, color=[1., 1., 0], mag_colnames=['magAB_F814W', 'magAB_F435W'], units=None) :
264
+ if self.qt_image is None :
265
+ self.plot_image()
266
+ #to_return = catalog(cat, self.image_data, self.wcs, self.qt_image, window=self.window, image_path=self.image_path,
267
+ # image_widget = self.image_widget, qt_layout=self.qt_layout, color=color,
268
+ # mag_colnames=mag_colnames, mpl_fig=self.fig, mpl_ax=self.ax,
269
+ # pix_deg_scale=self.pix_deg_scale, units=units)
270
+ to_return = catalog(cat, self, color=color, mag_colnames=mag_colnames, units=units)
271
+ return to_return
272
+
273
+ def import_catalog(self, cat, color=None, mag_colnames=['magAB_F814W', 'magAB_F435W'], units='pixel') :
274
+ self.imported_cat = self.make_catalog(cat, color=color, mag_colnames=mag_colnames, units=units)
275
+ self.imported_cat_list.append(self.imported_cat)
276
+
277
+ ###########################################################################
278
+
279
+
280
+ def world_to_image(self, ra, dec, unit='deg') :
281
+ coord = SkyCoord(ra, dec, unit=unit)
282
+ image_coord = WCS.world_to_pixel(self.wcs, coord)
283
+ if len(image_coord[0].shape)==0 :
284
+ image_coord = (image_coord[0]*1., image_coord[1]*1.)
285
+ return image_coord
286
+
287
+ def image_to_world(self, x, y, unit='deg') :
288
+ world_coord = WCS.pixel_to_world(self.wcs, x, y)
289
+ return world_coord.ra.deg, world_coord.dec.deg
290
+
291
+ def clear_Items(self) :
292
+ for key in self.qtItems_dict.keys() :
293
+ if self.qtItems_dict[key] is not None :
294
+ for i in tqdm( range(len(self.qtItems_dict[key])) ) :
295
+ self.qt_image.removeItem(self.qtItems_dict[key][i])
296
+
297
+
298
+
299
+ def import_lenstool(self, model_dir, use_wrapper=None) :
300
+ #self.lt_dir = model_dir
301
+ #if hasattr(self, 'lt'):
302
+ # del self.lt
303
+ self.lt = lenstool_model(model_dir, self, use_wrapper=use_wrapper)
304
+
305
+
306
+
307
+ def start_hand_select(self) :
308
+ cat_dict = {'id': [], 'ra': [], 'dec': [], 'x': [], 'y': [], 'a': [], 'b': [], 'theta': []}
309
+ cat = Table(cat_dict)
310
+ self.hand_made_cat = catalog(cat, self, units='pixel')
311
+
312
+ def mouse_clicked(evt):
313
+ if evt.double():
314
+ pos = evt.scenePos()
315
+ if self.qt_image.getView().sceneBoundingRect().contains(pos):
316
+ mouse_point = self.qt_image.getView().mapSceneToView(pos)
317
+ x, y_flipped = mouse_point.x(), mouse_point.y()
318
+ x, y = x, self.image_data.shape[0] - y_flipped
319
+ ra, dec = self.image_to_world(x, y)
320
+ self.hand_made_cat.cat.add_row( {'id': [len(self.hand_made_cat.cat) + 1], 'ra': [ra], 'dec': [dec], 'x': [x], 'y': [y], 'a': [10], 'b': [10], 'theta': [0]} )
321
+ self.hand_made_cat.qtItems.append(PyQt5.QtWidgets.QGraphicsEllipseItem())
322
+ self.hand_made_cat.plot(color=[1,1,1,0])
323
+
324
+ self._doubleclick_connection = self.qt_image.scene.sigMouseClicked.connect(mouse_clicked)
325
+
326
+ def keyPressEvent(event):
327
+ #print('Hand selection stopped.')
328
+ if event.key() == Qt.Key_Escape or event.key() == Qt.Key_Space :
329
+ if hasattr(self, '_doubleclick_connection'):
330
+ self.qt_image.scene.sigMouseClicked.disconnect(self._doubleclick_connection)
331
+ del self._doubleclick_connection
332
+ self.hand_made_cat.clear()
333
+ self.window.keyPressEvent = self._original_keyPressEvent
334
+ print('Hand selection stopped.')
335
+
336
+ self._original_keyPressEvent = self.window.keyPressEvent
337
+ self.window.keyPressEvent = keyPressEvent
338
+
339
+
340
+
341
+ def plot_image_mpl(self, wcs_projection=True, units='pixel', pos=111, make_axes_labels=True, make_grid=True, crop=None, replace_image=True, extra_pad=None) :
342
+ fig, ax = plot_image_mpl(self.image_data, wcs=self.wcs, wcs_projection=wcs_projection, units=units, pos=pos, \
343
+ make_axes_labels=make_axes_labels, make_grid=make_grid, crop=crop, extra_pad=extra_pad)
344
+ plot_NE_arrows(ax, self.wcs)
345
+ if replace_image :
346
+ self.fig, self.ax = fig, ax
347
+ return fig, ax
348
+
349
+ def plot_sub_region(self, ra, dec, size=3):
350
+ """
351
+ Plots a square region around given RA and Dec coordinates.
352
+
353
+ Parameters:
354
+ ra (float): Right Ascension of the center in degrees.
355
+ dec (float): Declination of the center in degrees.
356
+ size (float): Size of the square region in arcseconds (default is 10).
357
+ """
358
+
359
+ x_center, y_center = self.world_to_image(ra, dec, unit='deg')
360
+
361
+ size_pix = int( size / (self.pix_deg_scale*3600) / 2 )
362
+
363
+ fig, axs = plt.subplots(1,3)
364
+ for i in range(len(x_center)) :
365
+ x_min = int(x_center[i]) - size_pix
366
+ x_max = int(x_center[i]) + size_pix
367
+ y_min = int(y_center[i]) - size_pix
368
+ y_max = int(y_center[i]) + size_pix
369
+ region = self.image_data[y_min:y_max, x_min:x_max, :]
370
+ axs[i].imshow(region, origin='lower')
371
+ axs[i].axis('off')
372
+
373
+ return fig, axs
374
+
375
+ def load_filters(self, filter_dir=None):
376
+ if filter_dir==None :
377
+ listdir = os.listdir( os.path.dirname(self.image_path) )
378
+ listdir_lower = [ name.lower() for name in os.listdir( os.path.dirname(self.image_path) ) ]
379
+ indices = np.where([ 'filter' in name for name in listdir_lower ])[0]
380
+ filters = []
381
+ if len(indices)>0 :
382
+ for i in indices :
383
+ filter_dir = os.path.join(os.path.dirname(self.image_path), listdir[i])
384
+ print('Looking for filters in directory: ' + filter_dir)
385
+ f = load_filters(filter_dir)
386
+ filters.append(f)
387
+ l = np.array( [ len(f) for f in filters ] )
388
+ idx = np.argmax(l)
389
+ self.filters = filters[idx]
390
+ print( str(len(self.filters)) + ' filters loaded from directory: ' + listdir[indices[idx]] )
391
+ else :
392
+ raise ValueError('No filter directory found in image directory.')
393
+ else :
394
+ self.filters = load_filters(filter_dir)
395
+
396
+
397
+
398
+
399
+
400
+ class FilterImage(fits_image) :
401
+ def __init__(self, image_path, main_window=None, plot_image=False) :
402
+ super().__init__(image_path, main_window=main_window, plot_image=plot_image)
403
+ self.filter = self._get_filter_name()
404
+ self.wavelength = self._get_pivot_wavelength()
405
+ psf_path = self._get_psf_path()
406
+ if psf_path is not None :
407
+ self.psf = PSF(psf_path)
408
+ else :
409
+ self.psf = None
410
+
411
+ # ------------------ internal methods ------------------ #
412
+
413
+ def _get_filter_name(self):
414
+ """Extract filter name from header or filename."""
415
+ # Try header first
416
+ for key in ['FILTER', 'FILTER1', 'FILTER2']:
417
+ if key in self.header and isinstance(self.header[key], str):
418
+ val = self.header[key].strip().upper()
419
+ if re.match(r'F\d{3,4}[WMN]*', val):
420
+ return val
421
+
422
+ # Try filename
423
+ m = re.search(r'F\d{3,4}[WMN]*', os.path.basename(self.path).upper())
424
+ if m:
425
+ return m.group(0)
426
+
427
+ print(f"Could not determine filter name for {os.path.basename(self.path)}.")
428
+ return "UNKNOWN"
429
+
430
+ def _get_pivot_wavelength(self):
431
+ """Find pivot/effective wavelength using speclite or FITS header."""
432
+ lam = None
433
+ f_lower = self.filter.lower()
434
+
435
+ for prefix in ['jwst_nircam_', 'jwst_miri_', 'jwst_niriss_',
436
+ 'hst_acs_', 'hst_wfc3_', 'hst_wfpc2_']:
437
+ try:
438
+ fobj = specfilters.load_filter(prefix + f_lower)
439
+ lam = fobj.effective_wavelength.value
440
+ break
441
+ except Exception:
442
+ continue
443
+
444
+ # Fallback: header info
445
+ if lam is None:
446
+ for key in ['PHOTPLAM', 'PIVOTWL', 'WAVELENGTH']:
447
+ if key in self.header:
448
+ lam = self.header[key]
449
+ break
450
+
451
+ if lam is None:
452
+ lam = np.nan
453
+ print(f"Could not determine wavelength for {self.filter} ({self.path}).")
454
+
455
+ return lam
456
+
457
+ def _get_psf_path(self):
458
+ # Search first in parent of image dir, then in image dir
459
+ search_dirs = [
460
+ os.path.dirname(os.path.dirname(self.image_path)),
461
+ os.path.dirname(self.image_path)
462
+ ]
463
+
464
+ for base in search_dirs:
465
+ if not os.path.isdir(base):
466
+ continue
467
+ try:
468
+ listdir = os.listdir(base)
469
+ except Exception:
470
+ continue
471
+
472
+ listdir_lower = [name.lower() for name in listdir]
473
+ indices = [i for i, name in enumerate(listdir_lower) if 'psf' in name]
474
+ if len(indices) == 0:
475
+ continue
476
+
477
+ i = indices[0]
478
+ psf_dir = os.path.join(base, listdir[i])
479
+ print('Looking for psf in directory: ' + psf_dir)
480
+ possible_psf_files = []
481
+ for f in glob.glob(os.path.join(psf_dir, '*.fits')):
482
+ if self.filter.lower() in os.path.basename(f).lower():
483
+ possible_psf_files.append(f)
484
+ if len(possible_psf_files) > 0:
485
+ print(f"PSF file found for filter {self.filter}: {os.path.basename(possible_psf_files[0])}")
486
+ return possible_psf_files[0]
487
+ else:
488
+ print(f"No PSF file found for filter {self.filter} in {psf_dir}.")
489
+
490
+ return None
491
+
492
+ # ------------------ user-facing methods ------------------ #
493
+
494
+ def plot_throughput(self):
495
+ """Plot the filter's transmission curve using speclite."""
496
+ f_lower = self.filter.lower()
497
+ for prefix in ['jwst_nircam_', 'jwst_miri_', 'jwst_niriss_',
498
+ 'hst_acs_', 'hst_wfc3_', 'hst_wfpc2_']:
499
+ try:
500
+ fobj = specfilters.load_filter(prefix + f_lower)
501
+ lam = fobj.wavelength.value
502
+ trans = fobj.throughput
503
+ plt.figure(figsize=(6, 4))
504
+ plt.plot(lam, trans, label=f"{self.filter}")
505
+ plt.xlabel("Wavelength [Å]")
506
+ plt.ylabel("Throughput")
507
+ plt.title(f"Filter Throughput: {self.filter}")
508
+ plt.grid(True, alpha=0.3)
509
+ plt.legend()
510
+ plt.tight_layout()
511
+ plt.show()
512
+ return
513
+ except Exception:
514
+ continue
515
+
516
+ print(f"Could not plot throughput for {self.filter} (not found in speclite).")
517
+
518
+
519
+
520
+
521
+ def load_filters(path):
522
+ """
523
+ Load all FITS files in `path` and return a dict {filter_name: FilterImage},
524
+ ordered by increasing pivot wavelength.
525
+ If multiple files correspond to the same filter, selects the one most likely
526
+ to be the science image (e.g., containing 'SCI' in header or filename).
527
+ """
528
+ fits_files = [f for f in os.listdir(path) if f.lower().endswith('.fits')]
529
+ filter_groups = {}
530
+
531
+ # --- Group files by detected filter name ---
532
+ for fname in fits_files:
533
+ full_path = os.path.join(path, fname)
534
+ try:
535
+ # Just read header to find filter (faster)
536
+ with fits.open(full_path) as hdul:
537
+ hdr = hdul[0].header
538
+ filt = None
539
+ for key in ['FILTER', 'FILTER1', 'FILTER2']:
540
+ if key in hdr and isinstance(hdr[key], str):
541
+ val = hdr[key].strip().upper()
542
+ if re.match(r'F\d{3,4}[WMN]*', val):
543
+ filt = val
544
+ break
545
+ if filt is None:
546
+ m = re.search(r'F\d{3,4}[WMN]*', fname.upper())
547
+ if m:
548
+ filt = m.group(0)
549
+ except Exception:
550
+ print(f"Could not read header for {fname}. Skipping.")
551
+ continue
552
+
553
+ if filt is None:
554
+ continue
555
+
556
+ filter_groups.setdefault(filt, []).append(full_path)
557
+
558
+ # --- For each filter, pick best file if multiple ---
559
+ selected_files = {}
560
+ for filt, files in filter_groups.items():
561
+ if len(files) == 1:
562
+ selected_files[filt] = files[0]
563
+ else:
564
+ sci_candidates = []
565
+ for f in files:
566
+ try:
567
+ with fits.open(f) as hdul:
568
+ hdr = hdul[0].header
569
+ for key in ['EXTNAME', 'FILETYPE', 'IMAGETYP']:
570
+ if key in hdr and 'SCI' in str(hdr[key]).upper():
571
+ sci_candidates.append(f)
572
+ break
573
+ if 'SCI' in os.path.basename(f).upper():
574
+ sci_candidates.append(f)
575
+ except Exception:
576
+ continue
577
+
578
+ if sci_candidates:
579
+ selected_files[filt] = sci_candidates[0]
580
+ print(f"Selected {os.path.basename(sci_candidates[0])} for {filt}")
581
+ else:
582
+ print(f"Multiple files for {filt}, picking first arbitrarily.")
583
+ selected_files[filt] = files[0]
584
+
585
+ # --- Create FilterImage instances ---
586
+ filters_list = [FilterImage(fpath, plot_image=False) for fpath in selected_files.values()]
587
+ filters_list.sort(key=lambda f: np.inf if np.isnan(f.wavelength) else f.wavelength)
588
+
589
+ return {f.filter: f for f in filters_list}
590
+
591
+
592
+
593
+
594
+
595
+
596
+ class PSF :
597
+ def __init__(self, psf_path) :
598
+ with fits.open(psf_path) as hdu :
599
+ self.data = hdu[0].data
600
+ self.wcs = WCS(hdu[0].header)
601
+
602
+
603
+
604
+