hyper-py-photometry 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.
hyper_py/single_map.py ADDED
@@ -0,0 +1,716 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ from astropy.coordinates import SkyCoord
6
+ from astropy.io import ascii, fits
7
+ from astropy.stats import SigmaClip, sigma_clipped_stats
8
+ from astropy.wcs import WCS
9
+
10
+ from collections.abc import Iterable
11
+
12
+ from hyper_py.paths_io import get_hyper_single_map_paths
13
+ from hyper_py.survey import get_beam_info
14
+ from hyper_py.map_io import read_and_prepare_map
15
+ from hyper_py.detection import detect_sources
16
+ from hyper_py.data_output import write_tables
17
+ from hyper_py.groups import group_sources
18
+ from hyper_py.photometry import aperture_photometry_on_sources
19
+ from hyper_py.gaussfit import fit_isolated_gaussian
20
+ from hyper_py.fitting import fit_group_with_background
21
+ from hyper_py.visualization import plot_fit_summary
22
+ from hyper_py.logger import setup_logger
23
+
24
+
25
+ def main(map_name=None, cfg=None, dir_root=None, logger=None, logger_file_only=None):
26
+
27
+ paths_dict = get_hyper_single_map_paths(cfg, map_name)
28
+
29
+ # - input/output paths - #
30
+ datacube = cfg.get("control", "datacube", False)
31
+
32
+ dir_root = cfg.get("paths", "output")["dir_root"]
33
+
34
+ if datacube:
35
+ input_map_path = Path(dir_root, cfg.get("control")["dir_datacube_slices"], map_name)
36
+ else:
37
+ input_map_path = paths_dict["input_map_path"]
38
+
39
+ output_dir_path = paths_dict["output_dir_path"]
40
+
41
+ base_name_with_suffix = paths_dict["base_name_with_suffix"]
42
+ centroids_file = paths_dict["centroids_file"]
43
+ ellipses_file = paths_dict["ellipses_file"]
44
+ suffix = paths_dict["suffix"]
45
+
46
+ # - control - #
47
+ detection_only = cfg.get("control", "detection_only", False)
48
+ fixed_radius = cfg.get("photometry", "fixed_radius", False)
49
+
50
+ # - params - #
51
+ survey_code = cfg.get("survey", "survey_code")
52
+
53
+ # - visualization params - #
54
+ try:
55
+ visualize_deblended = cfg.get("visualization", "visualize_deblended", False)
56
+ visualize_output_dir_deblended = os.path.join(dir_root, cfg.get("visualization", "output_dir_deblended", "images/deblended"))
57
+ except:
58
+ visualize_deblended = False
59
+
60
+ # - Fits save params - #
61
+ try:
62
+ fits_deblended = cfg.get("fits_output", "fits_deblended", False)
63
+ fits_output_dir_deblended = os.path.join(dir_root, cfg.get("fits_output", "fits_output_dir_deblended", "fits/deblended"))
64
+ except:
65
+ fits_deblended = False
66
+
67
+
68
+ # - Setup log file specifically for this map - #
69
+ log_new_map_file = f"Hyper_log_{suffix}.log"
70
+ log_new_map_dir = cfg.get("paths")["output"]["dir_log_out"]
71
+ log_path_each_map = os.path.join(dir_root, log_new_map_dir, log_new_map_file)
72
+
73
+ # Give this map a unique logger name
74
+ if logger is None:
75
+ logger, logger_file_only = setup_logger(
76
+ log_path=log_path_each_map,
77
+ logger_name=f"HyperLogger_{suffix}", # unique name per map
78
+ overwrite=True,
79
+ process_name=os.path.basename(input_map_path) # Use the map name as the process name
80
+ )
81
+
82
+ # initialize vectors and Table
83
+ radius_val_1 = []
84
+ radius_val_2 = []
85
+ PA_val = []
86
+ sky_val = []
87
+ poly_order_val = []
88
+ nmse_val = []
89
+ redchi_val = []
90
+ bic_val = []
91
+ updated_xcen = []
92
+ updated_ycen = []
93
+ flux_peak = []
94
+ flux = []
95
+ flux_err = []
96
+
97
+
98
+ fit_statuts_val = []
99
+ deblend_val = []
100
+ cluster_val = []
101
+
102
+ source_id_save = []
103
+
104
+ bg_model = None
105
+
106
+
107
+ # --- see if peaks position and aperture radius are fixed or not --- #
108
+ def ensure_list(x, n):
109
+ return x if isinstance(x, Iterable) and not isinstance(x, str) else [x] * n
110
+
111
+ # --- Load fixed table if specified --- #
112
+ use_fixed_table = cfg.get("detection", "use_fixed_source_table", False)
113
+ fixed_radius = cfg.get("photometry", "fixed_radius", False)
114
+ fixed_peaks = cfg.get("detection", "fixed_peaks", False)
115
+
116
+ if use_fixed_table:
117
+ table_path = os.path.join(dir_root, cfg.get("detection", "fixed_source_table_path"))
118
+ fixed_sources = ascii.read(table_path, format="ipac")
119
+
120
+
121
+ # - read header and map - #
122
+ beam_arcsec, beam_area_arcsec2, beam_area_sr = get_beam_info(survey_code, input_map_path)
123
+
124
+ map_struct = read_and_prepare_map(
125
+ filepath=input_map_path,
126
+ beam=beam_arcsec,
127
+ beam_area_arcsec2=beam_area_arcsec2,
128
+ beam_area_sr = beam_area_sr,
129
+ convert_mjy=cfg.get("units", "convert_mJy")
130
+ )
131
+
132
+ real_map = map_struct["map"]
133
+
134
+ # # --- zero-mean for the input map --- #
135
+ # map_zero_mean = real_map - np.nanmean(real_map)
136
+ # real_map = map_zero_mean
137
+
138
+ header = map_struct["header"]
139
+ wcs = WCS(header)
140
+ pix_dim = map_struct["pix_dim"]
141
+ beam_dim = map_struct["beam_dim"]
142
+ beam_area = map_struct["beam_area_arcsec2"]
143
+
144
+
145
+ # --- map rms used to define real sources in the map - accounting for non-zero background --- #
146
+ use_maual_rms = cfg.get("detection", "use_manual_rms", False)
147
+ if use_maual_rms == True:
148
+ real_rms = cfg.get("detection", "rms_value", False)
149
+ else:
150
+ sigma_clip = SigmaClip(sigma=3.0, maxiters=10)
151
+ map_zero_mean_detect = real_map - np.nanmean(real_map)
152
+ clipped = sigma_clip(map_zero_mean_detect)
153
+ real_rms = np.sqrt(np.nanmean(clipped**2))
154
+
155
+
156
+ # --- run sources identification --- #
157
+ if fixed_peaks:
158
+ if use_fixed_table:
159
+ logger.info("[INFO] Using manually provided peak coordinates from IPAC table.")
160
+ xcen = np.array(fixed_sources["RA"]) # these are assumed to be in world coordinates
161
+ ycen = np.array(fixed_sources["DEC"])
162
+
163
+ # Convert WCS coordinates to pixel positions
164
+ xpix, ypix = wcs.wcs_world2pix(xcen, ycen, 0)
165
+ xcen = xpix
166
+ ycen = ypix
167
+
168
+ else:
169
+ logger.info("[INFO] Using manually provided peak coordinates from config file.")
170
+ xcen_fix = cfg.get("detection", "xcen_fix")
171
+ ycen_fix = cfg.get("detection", "ycen_fix")
172
+
173
+ # Convert WCS coordinates to pixel positions
174
+ xpix, ypix = wcs.wcs_world2pix(xcen_fix, ycen_fix, 0)
175
+ xcen = xpix
176
+ ycen = ypix
177
+
178
+ sources = xcen
179
+ all_sources_xcen = xcen
180
+ all_sources_ycen = ycen
181
+
182
+ else:
183
+ sources = detect_sources(
184
+ map_struct_list=map_struct,
185
+ dist_limit_arcsec=cfg.get("detection", "dist_limit_arcsec", 0),
186
+ real_map=map_zero_mean_detect,
187
+ rms_real=real_rms,
188
+ snr_threshold=cfg.get("detection", "sigma_thres"),
189
+ roundlim=cfg.get("detection", "roundlim", [-1.0, 1.0]),
190
+ sharplim=cfg.get("detection", "sharplim", [-1.0, 2.0]),
191
+ config=cfg
192
+ )
193
+ xcen = sources["xcentroid"]
194
+ ycen = sources["ycentroid"]
195
+
196
+ all_sources_xcen = xcen
197
+ all_sources_ycen = ycen
198
+
199
+
200
+ # -- if fixed_radius = True generate a xcen vector of aperture radii -- #
201
+ if fixed_radius:
202
+ if use_fixed_table:
203
+ fwhm_1_list = np.array(fixed_sources["FWHM_1"])
204
+ fwhm_2_list = np.array(fixed_sources["FWHM_2"])
205
+ PA_list = np.array(fixed_sources["PA"])
206
+ else:
207
+ N = len(xcen)
208
+ fwhm_1_list = ensure_list(cfg.get("photometry", "fwhm_1", 3.0), N)
209
+ fwhm_2_list = ensure_list(cfg.get("photometry", "fwhm_2", 2.0), N)
210
+ PA_list = ensure_list(cfg.get("photometry", "PA_val", 0.0), N)
211
+
212
+
213
+ # --- organize sources in isolated or groups ---#
214
+ start_group, common_group, deblend = group_sources(
215
+ xcen=xcen,
216
+ ycen=ycen,
217
+ pix_dim=pix_dim,
218
+ beam_dim=beam_dim,
219
+ aper_sup=cfg.get("photometry", "aper_sup"),
220
+ )
221
+
222
+ tot_sources = len(sources)
223
+ isolated = np.where(start_group == 0)[0]
224
+ blended = np.where(start_group == 1)[0]
225
+
226
+ logger.info(f"{tot_sources} sources above threshold.")
227
+ logger.info(f"{len(isolated)} sources are isolated")
228
+ logger.info(f"{len(blended)} sources are blended")
229
+
230
+
231
+ if detection_only:
232
+ logger.info("[INFO] Detection-only mode enabled. Skipping photometry and fitting.")
233
+
234
+ # Convert to sky coordinates
235
+ x_pixels = np.array(xcen, dtype=np.float64)
236
+ y_pixels = np.array(ycen, dtype=np.float64)
237
+ ra, dec = wcs.wcs_pix2world(x_pixels, y_pixels, 0)
238
+ ra_save = np.where(ra < 0., ra + 360., ra)
239
+
240
+ skycoords = SkyCoord(ra=ra, dec=dec, unit='deg', frame='icrs')
241
+ glon = skycoords.galactic.l.deg
242
+ glat = skycoords.galactic.b.deg
243
+
244
+ # Prepare zeroed output table
245
+ N = len(xcen)
246
+ data_dict = {
247
+ "MAP_ID": [str(suffix)],
248
+ "HYPER_ID": [0],
249
+ "FLUX_PEAK": [0.0],
250
+ "FLUX": [0.0],
251
+ "FLUX_ERR": [0.0],
252
+ "RESIDUALS": [0.0],
253
+ "POLYN": [0],
254
+ "NMSE": [0.0],
255
+ "CHI2_RED": [0.0],
256
+ "FWHM_1": [0.0],
257
+ "FWHM_2": [0.0],
258
+ "PA": [0.0],
259
+ "STATUS": [0],
260
+ "GLON": [0.0],
261
+ "GLAT": [0.0],
262
+ "RA": [0.0],
263
+ "DEC": [0.0],
264
+ "DEBLEND": [0],
265
+ "CLUSTER": [0],
266
+ }
267
+
268
+ sigma_thres = cfg.get("detection", "sigma_thres")
269
+ write_tables(data_dict, output_dir_path, cfg, sigma_thres, real_rms, base_filename=base_name_with_suffix)
270
+
271
+ ######################## Write only the centroid region file ########################
272
+ radecsys = (header.get("RADESYS") or header.get("RADECSYS") or wcs.wcs.radesys or "FK5").strip().upper()
273
+ if "ICRS" in radecsys:
274
+ ds9_coordsys = "icrs"
275
+ elif "FK5" in radecsys:
276
+ ds9_coordsys = "fk5"
277
+ elif "GAL" in radecsys:
278
+ ds9_coordsys = "galactic"
279
+ else:
280
+ ds9_coordsys = "fk5"
281
+
282
+ with open(centroids_file, "w") as f:
283
+ f.write("# Region file format: DS9 version 4.1\n")
284
+ f.write("global color=cyan dashlist=8 3 width=1 font='helvetica 10 normal' select=1 "
285
+ "highlite=1 edit=1 move=1 delete=1 include=1 fixed=0\n")
286
+ f.write(f"{ds9_coordsys}\n")
287
+ for xw, yw in zip(ra_save, dec):
288
+ f.write(f"point({xw:.8f},{yw:.8f}) # point=cross\n")
289
+
290
+ logger.info(f"Detection-only mode complete. Saved table and centroid region file for {N} sources.\n")
291
+ return map_name, bg_model, header, header # ✅ Done!
292
+
293
+
294
+
295
+
296
+
297
+ ######################## ISOLATED sources photometry ########################
298
+ for idx_iso, i in enumerate(isolated):
299
+
300
+ logger_file_only.info(f"Photometry on isolated source {idx_iso + 1} of {len(isolated)}")
301
+
302
+ fit_status, fit_result, model_fn, bg_order, cutout, (yslice, xslice), bg_mean, bg_model, cutout_header, final_nmse, final_redchi, final_bic = fit_isolated_gaussian(
303
+ image=real_map,
304
+ xcen=xcen[i],
305
+ ycen=ycen[i],
306
+ all_sources_xcen = all_sources_xcen,
307
+ all_sources_ycen = all_sources_ycen,
308
+ source_id=idx_iso,
309
+ map_struct=map_struct,
310
+ suffix=suffix,
311
+ config=cfg,
312
+ logger=logger,
313
+ logger_file_only=logger_file_only
314
+ )
315
+
316
+ if fit_result is None:
317
+ logger_file_only.error(f"Fit failed for isolated source {i}")
318
+ continue
319
+
320
+ # --- Extract fitted Gaussian parameters ---
321
+ sig_x = fit_result.params["g_sigmax"].value
322
+ sig_y = fit_result.params["g_sigmay"].value
323
+ fwhm_x = 2.3548 * sig_x
324
+ fwhm_y = 2.3548 * sig_y
325
+ theta = np.rad2deg(fit_result.params["g_theta"].value) #+90. # rotated for photometry #
326
+
327
+
328
+ # --- radius fixed if decided in the config file ---
329
+ if fixed_radius:
330
+ if len(fwhm_1_list) == 1:
331
+ fwhm_x = fwhm_1_list[0] / pix_dim
332
+ fwhm_y = fwhm_2_list[0] / pix_dim
333
+ theta = PA_list[0]
334
+ else:
335
+ fwhm_x = fwhm_1_list[i] / pix_dim
336
+ fwhm_y = fwhm_2_list[i] / pix_dim
337
+ theta = PA_list[i]
338
+
339
+ # --- Evaluate full model on the cutout grid ---
340
+ yy, xx = np.indices(cutout.shape)
341
+
342
+ # --- Zero out the Gaussian component ---
343
+ params_bg_only = fit_result.params.copy()
344
+ for name in params_bg_only:
345
+ if name.startswith("g_"):
346
+ params_bg_only[name].set(value=0.0)
347
+
348
+ model_bg_only = model_fn(params_bg_only, xx, yy)
349
+
350
+ # --- Final cleaned map for aperture photometry ---
351
+ source_only_map = cutout - model_bg_only
352
+
353
+ # --- Photometry: use centroid within cutout ---
354
+ phot_single = aperture_photometry_on_sources(
355
+ image=source_only_map,
356
+ xcen=[fit_result.params["g_centerx"].value], # relative coordinates inside cutout
357
+ ycen=[fit_result.params["g_centery"].value],
358
+ config=cfg,
359
+ radius_val_1=[fwhm_x],
360
+ radius_val_2=[fwhm_y],
361
+ PA_val=[theta]
362
+ )
363
+
364
+ # --- populate the Table --- #
365
+
366
+ # - flux peak in mJy/beam -#
367
+ xc_rel = int(round(fit_result.params["g_centerx"].value))
368
+ yc_rel = int(round(fit_result.params["g_centery"].value))
369
+ flux_peak_mjy_pix = source_only_map[yc_rel, xc_rel] # in mJy/pixel
370
+ beam_area_pix = beam_area / (pix_dim**2) # beam area in pixel²
371
+ flux_peak_mjy_beam = flux_peak_mjy_pix / beam_area_pix # → mJy/beam
372
+ flux_peak.append(flux_peak_mjy_beam)
373
+
374
+
375
+ flux.append(phot_single["flux"][0])
376
+ flux_err.append(phot_single["error"][0])
377
+
378
+ radius_val_1.append(fwhm_x * pix_dim) # save value in arcsec
379
+ radius_val_2.append(fwhm_y * pix_dim) # save value in arcsec
380
+ PA_val.append(theta)
381
+ updated_xcen.append(fit_result.params["g_centerx"].value + xslice.start)
382
+ updated_ycen.append(fit_result.params["g_centery"].value + yslice.start)
383
+ sky_val.append(bg_mean)
384
+ poly_order_val.append(bg_order)
385
+ nmse_val.append(final_nmse)
386
+ redchi_val.append(final_redchi)
387
+ bic_val.append(final_bic)
388
+
389
+ fit_statuts_val.append(fit_status)
390
+ deblend_val.append(0) # not deblended
391
+ cluster_val.append(1) # only one source
392
+
393
+ source_id_save.append(i+1) #source_id to save in params files
394
+
395
+ tot_fitted_isolated = len(updated_xcen)
396
+ logger.info(f"✓ Fitted {tot_fitted_isolated} isolated sources with Gaussian + background")
397
+
398
+
399
+
400
+
401
+ ######################## BLENDED sources photometry ########################
402
+ seen_groups = set()
403
+ group_counter = 0
404
+ total_groups = len(set([tuple(sorted(common_group[i][common_group[i] >= 0])) for i in blended]))
405
+
406
+ count_blended_sources = 0
407
+ for i in blended:
408
+ group_indices = common_group[i]
409
+ group_indices = group_indices[group_indices >= 0]
410
+ group_key = tuple(sorted(group_indices))
411
+
412
+ if group_key in seen_groups:
413
+ continue # already processed this group
414
+
415
+ seen_groups.add(group_key)
416
+ group_counter += 1
417
+
418
+ #- counts indexes for plots -#
419
+ count_source_blended_indexes = (tot_fitted_isolated + count_blended_sources +1, tot_fitted_isolated + count_blended_sources +len(group_indices))
420
+
421
+ logger_file_only.info(f"Photometry on source group {group_counter} of {total_groups} ({len(group_indices)} sources)")
422
+
423
+ group_x = xcen[group_indices]
424
+ group_y = ycen[group_indices]
425
+
426
+ fit_status, fit_result, model_fn, bg_order, cutout, cutout_masked_full, cutout_slice, cutout_header, bg_mean, bg_model, box_size, final_nmse, final_redchi, final_bic = fit_group_with_background(
427
+ image=real_map,
428
+ xcen=group_x,
429
+ ycen=group_y,
430
+ all_sources_xcen = all_sources_xcen,
431
+ all_sources_ycen = all_sources_ycen,
432
+ group_indices = group_indices,
433
+ map_struct=map_struct,
434
+ config=cfg,
435
+ suffix=suffix,
436
+ logger=logger,
437
+ logger_file_only=logger_file_only,
438
+ group_id=group_indices,
439
+ count_source_blended_indexes = count_source_blended_indexes
440
+ )
441
+
442
+
443
+ # --- radius fixed if decided in the config file ---
444
+ if fixed_radius == True:
445
+ if len(fwhm_1_list) == 1:
446
+ fwhm_x_group = np.full(len(group_indices), fwhm_1_list[0] / pix_dim)
447
+ fwhm_y_group = np.full(len(group_indices), fwhm_2_list[0] / pix_dim)
448
+ theta_group = np.full(len(group_indices), PA_list[0])
449
+ else:
450
+ fwhm_x_group = fwhm_1_list[group_indices] / pix_dim
451
+ fwhm_y_group = fwhm_2_list[group_indices] / pix_dim
452
+ theta_group = PA_list[group_indices]
453
+
454
+
455
+ if fit_result is None:
456
+ logger.error(f"Group fit failed for sources {group_key}")
457
+ continue
458
+
459
+ # Unpack slices
460
+ yslice, xslice = cutout_slice
461
+ x0_global = xslice.start
462
+ y0_global = yslice.start
463
+
464
+ # Pre-evaluate full model on the same grid
465
+ xx, yy = np.meshgrid(np.arange(cutout.shape[1]), np.arange(cutout.shape[0]))
466
+
467
+ for j, idx in enumerate(group_indices):
468
+ # --- Create residual map where only source j is preserved ---
469
+ params_sub = fit_result.params.copy()
470
+
471
+ # Zero the current source j only
472
+ for name in params_sub:
473
+ if name.startswith(f"g{j}_"):
474
+ params_sub[name].set(value=0.0)
475
+
476
+ model_without_j = model_fn(params_sub, xx, yy)
477
+ source_only_map = cutout - model_without_j # subtract background + companions
478
+
479
+ # --- Extract Gaussian parameters for aperture ---
480
+ sig_x = fit_result.params[f"g{j}_sx"].value
481
+ sig_y = fit_result.params[f"g{j}_sy"].value
482
+ fwhm_x = 2.3548 * sig_x # FWHM in pixels
483
+ fwhm_y = 2.3548 * sig_y
484
+ theta = np.rad2deg(fit_result.params[f"g{j}_theta"].value)
485
+
486
+ # --- radius fixed if decided in the config file ---
487
+ if fixed_radius == True:
488
+ fwhm_x = fwhm_x_group[j]
489
+ fwhm_y = fwhm_y_group[j]
490
+ theta = theta_group[j]
491
+
492
+
493
+
494
+ # --- Perform aperture photometry on residual image relative to cutout --- #
495
+ phot_res = aperture_photometry_on_sources(
496
+ image=source_only_map,
497
+ xcen=[fit_result.params[f"g{j}_x0"].value],
498
+ ycen=[fit_result.params[f"g{j}_y0"].value],
499
+ config=cfg,
500
+ radius_val_1=[fwhm_x],
501
+ radius_val_2=[fwhm_y],
502
+ PA_val=[theta]
503
+ )
504
+
505
+
506
+ # -- Save the cutout, model, and residual maps for deblended sources as fits files -- #
507
+ if fits_deblended:
508
+ def save_fits(array, output_dir, label_name, extension_name, header=None):
509
+
510
+ # Ensure the output directory exists
511
+ os.makedirs(output_dir, exist_ok=True)
512
+
513
+ # Create the FITS filename based on the label and extension type
514
+ filename = f"{output_dir}/{label_name}_{extension_name}.fits"
515
+
516
+ # Create a PrimaryHDU object and write the array into the FITS file
517
+ hdu = fits.PrimaryHDU(data=array, header=header)
518
+
519
+ convert_mjy=cfg.get("units", "convert_mJy")
520
+ if convert_mjy:
521
+ hdu.header['BUNIT'] = 'mJy/pixel'
522
+ else: hdu.header['BUNIT'] = 'Jy/pixel'
523
+
524
+ hdul = fits.HDUList([hdu])
525
+ # Write the FITS file
526
+ hdul.writeto(filename, overwrite=True)
527
+
528
+ save_fits(cutout, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "cutout", header=cutout_header)
529
+ save_fits(cutout_masked_full, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "cutout_masked_full", header=cutout_header)
530
+ save_fits(model_without_j, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "model", header=cutout_header)
531
+ save_fits(source_only_map, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "residual", header=cutout_header)
532
+
533
+
534
+ # --- visualize plots of the cutout, model, and residual maps for deblended sources an png files --- #
535
+ if visualize_deblended:
536
+ plot_fit_summary(
537
+ cutout=cutout, # original cutout background subtracted
538
+ cutout_masked_full=cutout_masked_full, # original cutout masked
539
+ model=model_without_j, # model of only source j
540
+ residual=source_only_map, # what you're analyzing
541
+ output_dir=visualize_output_dir_deblended, # or configurable
542
+ label_name=f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", # unique label per source
543
+ box_size=box_size,
544
+ poly_order=bg_order,
545
+ nmse=final_nmse
546
+ )
547
+
548
+ # Save results in Table
549
+ xc_rel = int(round(fit_result.params[f"g{j}_x0"].value))
550
+ yc_rel = int(round(fit_result.params[f"g{j}_y0"].value))
551
+ flux_peak_mjy_pix = source_only_map[yc_rel, xc_rel] # in mJy/pixel
552
+ beam_area_pix = beam_area / (pix_dim**2) # beam area in pixel²
553
+ flux_peak_mjy_beam = flux_peak_mjy_pix / beam_area_pix # → mJy/beam
554
+ flux_peak.append(flux_peak_mjy_beam)
555
+
556
+ flux.append(phot_res["flux"][0])
557
+ flux_err.append(phot_res["error"][0])
558
+
559
+ radius_val_1.append(fwhm_x * pix_dim) # save value in arcsec
560
+ radius_val_2.append(fwhm_y * pix_dim) # save value in arcsec
561
+ PA_val.append(theta)
562
+ updated_xcen.append(fit_result.params[f"g{j}_x0"].value + x0_global)
563
+ updated_ycen.append(fit_result.params[f"g{j}_y0"].value + y0_global)
564
+
565
+ sky_val.append(bg_mean)
566
+ poly_order_val.append(bg_order)
567
+ nmse_val.append(final_nmse)
568
+ redchi_val.append(final_redchi)
569
+ bic_val.append(final_bic)
570
+
571
+ fit_statuts_val.append(fit_status)
572
+ deblend_val.append(1) # multi-Gaussian fit
573
+ cluster_val.append(len(group_indices)) # number of sources in the group
574
+
575
+
576
+ count_blended_sources = count_blended_sources + len(group_indices)
577
+
578
+
579
+ cluster_array = np.array(cluster_val)
580
+ tot_blended_sources = np.sum(cluster_array >= 2)
581
+ logger.info(f"✓ Fitted {tot_blended_sources} blended sources with Multiple Gaussians + background")
582
+
583
+
584
+
585
+ ######################## Parameters for Table and region file ########################
586
+
587
+ # Assuming you have your WCS object (usually from your FITS header)
588
+ header = map_struct["header"]
589
+ # Convert pixel coordinates to sky coordinates (RA, Dec)
590
+ x_pixels = np.array(updated_xcen, dtype=np.float64)
591
+ y_pixels = np.array(updated_ycen, dtype=np.float64)
592
+
593
+ # Initialize WCS from header
594
+ wcs = WCS(header)
595
+ ra, dec = wcs.wcs_pix2world(x_pixels, y_pixels, 0)
596
+ ra_save = np.where(ra < 0., ra + 360., ra)
597
+
598
+ skycoords = SkyCoord(ra=ra, dec=dec, unit='deg', frame='icrs')
599
+ glon = skycoords.galactic.l.deg
600
+ glat = skycoords.galactic.b.deg
601
+
602
+
603
+ ######################## Write Table after photometry ########################
604
+
605
+ if len(updated_xcen) == 0:
606
+ data_dict = {
607
+ "MAP_ID": [str(suffix)],
608
+ "HYPER_ID": [0],
609
+ "FLUX_PEAK": [0.0],
610
+ "FLUX": [0.0],
611
+ "FLUX_ERR": [0.0],
612
+ "RESIDUALS": [0.0],
613
+ "POLYN": [0],
614
+ "NMSE": [0.0],
615
+ "CHI2_RED": [0.0],
616
+ "FWHM_1": [0.0],
617
+ "FWHM_2": [0.0],
618
+ "PA": [0.0],
619
+ "STATUS": [0],
620
+ "GLON": [0.0],
621
+ "GLAT": [0.0],
622
+ "RA": [0.0],
623
+ "DEC": [0.0],
624
+ "DEBLEND": [0],
625
+ "CLUSTER": [0],
626
+ }
627
+ else:
628
+ data_dict = {
629
+ "MAP_ID": [str(suffix)] * len(updated_xcen),
630
+ "HYPER_ID": list(range(1, len(updated_xcen) + 1)),
631
+ "FLUX_PEAK": list(flux_peak),
632
+ "FLUX": list(flux),
633
+ "FLUX_ERR": list(flux_err),
634
+ "RESIDUALS": list(sky_val),
635
+ "POLYN": list(poly_order_val),
636
+ "NMSE": list(nmse_val),
637
+ "CHI2_RED": list(redchi_val),
638
+ "BIC": list(bic_val),
639
+ "FWHM_1": list(radius_val_1),
640
+ "FWHM_2": list(radius_val_2),
641
+ "PA": list(PA_val),
642
+ "STATUS": list(fit_statuts_val),
643
+ "GLON": list(glon),
644
+ "GLAT": list(glat),
645
+ "RA": list(ra_save),
646
+ "DEC": list(dec),
647
+ "DEBLEND": list(deblend_val),
648
+ "CLUSTER": list(cluster_val),
649
+ }
650
+
651
+
652
+
653
+ # -- Print the output directory and file path --#
654
+ sigma_thres=cfg.get("detection", "sigma_thres")
655
+ write_tables(data_dict, output_dir_path, cfg, sigma_thres, real_rms, base_filename=base_name_with_suffix)
656
+ logger_file_only.info(f"✅ eCSV and IPAC tables written to: {output_dir_path}")
657
+
658
+
659
+
660
+
661
+ ######################## Write Region file ########################
662
+ # Convert PA to DS9 convention
663
+ # Initialize WCS from header
664
+ theta_DS9 = [(pa + 90) % 180 for pa in PA_val]
665
+
666
+
667
+ # --- Extract coordinate system ---
668
+ # Extract WCS and coordinate system
669
+ wcs = WCS(header)
670
+ ra = ra_save
671
+
672
+ # Map RADESYS to DS9 coordinate system name
673
+ radecsys = (header.get("RADESYS") or header.get("RADECSYS") or wcs.wcs.radesys or "FK5").strip().upper()
674
+ if "ICRS" in radecsys:
675
+ ds9_coordsys = "icrs"
676
+ elif "FK5" in radecsys:
677
+ ds9_coordsys = "fk5"
678
+ elif "GAL" in radecsys:
679
+ ds9_coordsys = "galactic"
680
+ else:
681
+ ds9_coordsys = "fk5" # Fallback
682
+
683
+
684
+ # --- Write centroids only file ---
685
+ with open(centroids_file, "w") as f:
686
+ f.write("# Region file format: DS9 version 4.1\n")
687
+ f.write("global color=cyan dashlist=8 3 width=1 font='helvetica 10 normal' select=1 "
688
+ "highlite=1 edit=1 move=1 delete=1 include=1 fixed=0\n")
689
+ f.write(f"{ds9_coordsys}\n")
690
+ for xw, yw in zip(ra, dec):
691
+ f.write(f"point({xw:.8f},{yw:.8f}) # point=cross\n")
692
+
693
+ # --- Write ellipses file, with ellipses and centroids ---
694
+ with open(ellipses_file, "w") as f:
695
+ f.write("# Region file format: DS9 version 4.1\n")
696
+ f.write("global color=magenta dashlist=8 3 width=1 font='helvetica 10 normal' select=1 "
697
+ "highlite=1 edit=1 move=1 delete=1 include=1 fixed=0\n")
698
+ f.write(f"{ds9_coordsys}\n")
699
+ for i, (xw, yw, a, b, angle) in enumerate(zip(ra, dec, radius_val_1, radius_val_2, theta_DS9), 1):
700
+ f.write(f"ellipse({xw:.8f},{yw:.8f},{a:.4f}\",{b:.4f}\",{angle:.3f})\n")
701
+ f.write(f"point({xw:.8f},{yw:.8f}) # point=cross text={{ID {i}}}\n")
702
+
703
+
704
+ if bg_model is not None:
705
+ return map_name, bg_model, cutout_header, header
706
+ else:
707
+ valid_real_map_nobg = ~np.isnan(real_map)
708
+ mean_valid_real_map_nobg, median_valid_real_map_nobg, std_valid_real_map_nobg = sigma_clipped_stats(real_map[valid_real_map_nobg], sigma=3.0, maxiters=5)
709
+ real_map_nobg = np.full_like(real_map, median_valid_real_map_nobg)
710
+ real_map_nobg[np.isnan(real_map)] = np.nan
711
+ bg_model = real_map_nobg
712
+ return map_name, bg_model, header, header
713
+
714
+ #################################### MAIN CALL ####################################
715
+ if __name__ == "__main__":
716
+ main()