def _ellip_smooth(R, E, deg): model = make_pipeline(PolynomialFeatures(deg), HuberRegressor(epsilon=2.)) model.fit(np.log10(R).reshape(-1, 1), _inv_x_to_eps(E)) return _x_to_eps(model.predict(np.log10(R).reshape(-1, 1)))
def Isophote_Initialize(IMG, results, options): """ Determine the global pa and ellipticity for a galaxy. First grow circular isophotes until reaching near the noise floor, then evaluate the phase of the second FFT coefficients and determine the average direction. Then fit an ellipticity for one of the outer isophotes. """ ###################################################################### # Initial attempt to find size of galaxy in image # based on when isophotes SB values start to get # close to the background noise level circ_ellipse_radii = [results['psf fwhm']] allphase = [] dat = IMG - results['background'] while circ_ellipse_radii[-1] < (len(IMG) / 2): circ_ellipse_radii.append(circ_ellipse_radii[-1] * (1 + 0.2)) isovals = _iso_extract(dat, circ_ellipse_radii[-1], 0., 0., results['center'], more=True, sigmaclip=True, sclip_nsigma=3, interp_mask=True) coefs = fft(isovals[0]) allphase.append(coefs[2]) # Stop when at 3 time background noise if np.quantile(isovals[0], 0.8) < (3 * results['background noise'] ) and len(circ_ellipse_radii) > 4: break logging.info('%s: init scale: %f pix' % (options['ap_name'], circ_ellipse_radii[-1])) # Find global position angle. phase = (-Angle_Median(np.angle(allphase[-5:])) / 2) % np.pi # Find global ellipticity test_ellip = np.linspace(0.05, 0.95, 15) test_f2 = [] for e in test_ellip: test_f2.append( sum( list( _fitEllip_loss(e, dat, circ_ellipse_radii[-2] * m, phase, results['center'], results['background noise']) for m in np.linspace(0.8, 1.2, 5)))) ellip = test_ellip[np.argmin(test_f2)] res = minimize(lambda e, d, r, p, c, n: sum( list( _fitEllip_loss(_x_to_eps(e[0]), d, r * m, p, c, n) for m in np.linspace(0.8, 1.2, 5))), x0=_inv_x_to_eps(ellip), args=(dat, circ_ellipse_radii[-2], phase, results['center'], results['background noise']), method='Nelder-Mead', options={ 'initial_simplex': [[_inv_x_to_eps(ellip) - 1 / 15], [_inv_x_to_eps(ellip) + 1 / 15]] }) if res.success: logging.debug( '%s: using optimal ellipticity %.3f over grid ellipticity %.3f' % (options['ap_name'], _x_to_eps(res.x[0]), ellip)) ellip = _x_to_eps(res.x[0]) # Compute the error on the parameters ###################################################################### RR = np.linspace(circ_ellipse_radii[-2] - results['psf fwhm'], circ_ellipse_radii[-2] + results['psf fwhm'], 10) errallphase = [] for rr in RR: isovals = _iso_extract(dat, rr, 0., 0., results['center'], more=True, sigmaclip=True, sclip_nsigma=3, interp_mask=True) coefs = fft(isovals[0]) errallphase.append(coefs[2]) sample_pas = (-np.angle(1j * np.array(errallphase) / np.mean(errallphase)) / 2) % np.pi pa_err = iqr(sample_pas, rng=[16, 84]) / 2 res_multi = map( lambda rrp: minimize(lambda e, d, r, p, c, n: _fitEllip_loss( _x_to_eps(e[0]), d, r, p, c, n), x0=_inv_x_to_eps(ellip), args=(dat, rrp[0], rrp[1], results['center'], results['background noise']), method='Nelder-Mead', options={ 'initial_simplex': [[ _inv_x_to_eps(ellip) - 1 / 15 ], [_inv_x_to_eps(ellip) + 1 / 15]] }), zip(RR, sample_pas)) ellip_err = iqr(list(_x_to_eps(rm.x[0]) for rm in res_multi), rng=[16, 84]) / 2 circ_ellipse_radii = np.array(circ_ellipse_radii) if 'ap_doplot' in options and options['ap_doplot']: ranges = [ [ max(0, int(results['center']['x'] - circ_ellipse_radii[-1] * 1.5)), min(dat.shape[1], int(results['center']['x'] + circ_ellipse_radii[-1] * 1.5)) ], [ max(0, int(results['center']['y'] - circ_ellipse_radii[-1] * 1.5)), min(dat.shape[0], int(results['center']['y'] + circ_ellipse_radii[-1] * 1.5)) ] ] LSBImage(dat[ranges[1][0]:ranges[1][1], ranges[0][0]:ranges[0][1]], results['background noise']) # plt.imshow(np.clip(dat[ranges[1][0]: ranges[1][1], ranges[0][0]: ranges[0][1]],a_min = 0, a_max = None), # origin = 'lower', cmap = 'Greys_r', norm = ImageNormalize(stretch=LogStretch())) plt.gca().add_patch( Ellipse((results['center']['x'] - ranges[0][0], results['center']['y'] - ranges[1][0]), 2 * circ_ellipse_radii[-1], 2 * circ_ellipse_radii[-1] * (1. - ellip), phase * 180 / np.pi, fill=False, linewidth=1, color='y')) plt.plot([results['center']['x'] - ranges[0][0]], [results['center']['y'] - ranges[1][0]], marker='x', markersize=3, color='r') if not ('ap_nologo' in options and options['ap_nologo']): AddLogo(plt.gcf()) plt.savefig( '%sinitialize_ellipse_%s.jpg' % (options['ap_plotpath'] if 'ap_plotpath' in options else '', options['ap_name']), dpi=options['ap_plotdpi'] if 'ap_plotdpi' in options else 300) plt.close() fig, ax = plt.subplots(2, 1, figsize=(6, 6)) plt.subplots_adjust(hspace=0.01, wspace=0.01) ax[0].plot(circ_ellipse_radii[:-1], ((-np.angle(allphase) / 2) % np.pi) * 180 / np.pi, color='k') ax[0].axhline(phase * 180 / np.pi, color='r') ax[0].axhline((phase + pa_err) * 180 / np.pi, color='r', linestyle='--') ax[0].axhline((phase - pa_err) * 180 / np.pi, color='r', linestyle='--') #ax[0].axvline(circ_ellipse_radii[-2], color = 'orange', linestyle = '--') ax[0].set_xlabel('Radius [pix]', fontsize=16) ax[0].set_ylabel('FFT$_{1}$ phase [deg]', fontsize=16) ax[0].tick_params(labelsize=12) ax[1].plot(test_ellip, test_f2, color='k') ax[1].axvline(ellip, color='r') ax[1].axvline(ellip + ellip_err, color='r', linestyle='--') ax[1].axvline(ellip - ellip_err, color='r', linestyle='--') ax[1].set_xlabel('Ellipticity [1 - b/a]', fontsize=16) ax[1].set_ylabel('Loss [FFT$_{2}$/med(flux)]', fontsize=16) ax[1].tick_params(labelsize=14) plt.tight_layout() if not ('ap_nologo' in options and options['ap_nologo']): AddLogo(plt.gcf()) plt.savefig( '%sinitialize_ellipse_optimize_%s.jpg' % (options['ap_plotpath'] if 'ap_plotpath' in options else '', options['ap_name']), dpi=options['ap_plotdpi'] if 'ap_plotdpi' in options else 300) plt.close() auxmessage = 'global ellipticity: %.3f +- %.3f, pa: %.3f +- %.3f deg, size: %f pix' % ( ellip, ellip_err, PA_shift_convention(phase) * 180 / np.pi, pa_err * 180 / np.pi, circ_ellipse_radii[-2]) return IMG, { 'init ellip': ellip, 'init ellip_err': ellip_err, 'init pa': phase, 'init pa_err': pa_err, 'init R': circ_ellipse_radii[-2], 'auxfile initialize': auxmessage }
def Isophote_Extract(IMG, results, options): """ Extract isophotes given output profile from Isophotes_Simultaneous_Fit which parametrizes pa and ellipticity via functions which map all the reals to the appropriate parameter range. This function also extrapolates the profile to large and small radii (simply by taking the parameters at the edge, no functional extrapolation). By default uses a linear radius growth, however for large images, it uses a geometric radius growth of 10% per isophote. """ use_center = results['center'] # Radius values to evaluate isophotes R = [ options['ap_sampleinitR'] if 'ap_sampleinitR' in options else min( 1., results['psf fwhm'] / 2) ] while (((R[-1] < options['ap_sampleendR'] if 'ap_sampleendR' in options else True) and R[-1] < 3 * results['fit R'][-1]) or (options['ap_extractfull'] if 'ap_extractfull' in options else False)) and R[-1] < max(IMG.shape) / np.sqrt(2): if 'ap_samplestyle' in options and options[ 'ap_samplestyle'] == 'geometric-linear': if len(R) > 1 and abs(R[-1] - R[-2]) >= ( options['ap_samplelinearscale'] if 'ap_samplelinearscale' in options else 3 * results['psf fwhm']): R.append(R[-1] + ( options['ap_samplelinearscale'] if 'ap_samplelinearscale' in options else results['psf fwhm'])) else: R.append(R[-1] * (1. + (options['ap_samplegeometricscale'] if 'ap_samplegeometricscale' in options else 0.1))) elif 'ap_samplestyle' in options and options[ 'ap_samplestyle'] == 'linear': R.append(R[-1] + (options['ap_samplelinearscale'] if 'ap_samplelinearscale' in options else 0.5 * results['psf fwhm'])) else: R.append(R[-1] * (1. + (options['ap_samplegeometricscale'] if 'ap_samplegeometricscale' in options else 0.1))) R = np.array(R) logging.info('%s: R complete in range [%.1f,%.1f]' % (options['ap_name'], R[0], R[-1])) # Interpolate profile values, when extrapolating just take last point E = _x_to_eps( np.interp(R, results['fit R'], _inv_x_to_eps(results['fit ellip']))) E[R < results['fit R'][0]] = results['fit ellip'][ 0] #R[R < results['fit R'][0]] * results['fit ellip'][0] / results['fit R'][0] E[R < results['psf fwhm']] = R[R < results['psf fwhm']] * results[ 'fit ellip'][0] / results['psf fwhm'] E[R > results['fit R'][-1]] = results['fit ellip'][-1] tmp_pa_s = np.interp(R, results['fit R'], np.sin(2 * results['fit pa'])) tmp_pa_c = np.interp(R, results['fit R'], np.cos(2 * results['fit pa'])) PA = _x_to_pa( ((np.arctan(tmp_pa_s / tmp_pa_c) + (np.pi * (tmp_pa_c < 0))) % (2 * np.pi)) / 2) #_x_to_pa(np.interp(R, results['fit R'], results['fit pa'])) PA[R < results['fit R'][0]] = _x_to_pa(results['fit pa'][0]) PA[R > results['fit R'][-1]] = _x_to_pa(results['fit pa'][-1]) # Get errors for pa and ellip if 'fit ellip_err' in results and (not results['fit ellip_err'] is None ) and 'fit pa_err' in results and ( not results['fit pa_err'] is None): Ee = np.clip(np.interp(R, results['fit R'], results['fit ellip_err']), a_min=1e-3, a_max=None) Ee[R < results['fit R'][0]] = results['fit ellip_err'][0] Ee[R > results['fit R'][-1]] = results['fit ellip_err'][-1] PAe = np.clip(np.interp(R, results['fit R'], results['fit pa_err']), a_min=1e-3, a_max=None) PAe[R < results['fit R'][0]] = results['fit pa_err'][0] PAe[R > results['fit R'][-1]] = results['fit pa_err'][-1] else: Ee = np.zeros(len(R)) PAe = np.zeros(len(R)) return IMG, _Generate_Profile(IMG, results, R, E, Ee, PA, PAe, options)
def Isophote_Fit_FFT_mean(IMG, results, options): """ Fit isophotes by minimizing the amplitude of the second FFT coefficient, relative to the local median flux. Included is a regularization term which penalizes isophotes for having large differences between parameters of adjacent isophotes. """ if 'ap_scale' in options: scale = options['ap_scale'] else: scale = 0.2 # subtract background from image during processing dat = IMG - results['background'] mask = results['mask'] if 'mask' in results else None if not np.any(mask): mask = None # Determine sampling radii ###################################################################### shrink = 0 while shrink < 5: sample_radii = [3 * results['psf fwhm'] / 2] while sample_radii[-1] < (max(IMG.shape) / 2): isovals = _iso_extract(dat, sample_radii[-1], results['init ellip'], results['init pa'], results['center'], more=False, mask=mask) if np.mean(isovals) < (options['ap_fit_limit'] if 'ap_fit_limit' in options else 1) * results['background noise']: break sample_radii.append(sample_radii[-1] * (1. + scale / (1. + shrink))) if len(sample_radii) < 15: shrink += 1 else: break if shrink >= 5: raise Exception( 'Unable to initialize ellipse fit, check diagnostic plots. Possible missed center.' ) ellip = np.ones(len(sample_radii)) * results['init ellip'] pa = np.ones(len(sample_radii)) * results['init pa'] logging.debug('%s: sample radii: %s' % (options['ap_name'], str(sample_radii))) # Fit isophotes ###################################################################### perturb_scale = np.array([0.03, 0.06]) regularize_scale = options[ 'ap_regularize_scale'] if 'ap_regularize_scale' in options else 1. N_perturb = 5 count = 0 count_nochange = 0 use_center = copy(results['center']) I = np.array(range(len(sample_radii))) while count < 300 and count_nochange < (3 * len(sample_radii)): # Periodically include logging message if count % 10 == 0: logging.debug('%s: count: %i' % (options['ap_name'], count)) count += 1 np.random.shuffle(I) for i in I: perturbations = [] perturbations.append({'ellip': copy(ellip), 'pa': copy(pa)}) perturbations[-1]['loss'] = _FFT_mean_loss( dat, sample_radii, perturbations[-1]['ellip'], perturbations[-1]['pa'], i, use_center, results['background noise'], mask=mask, reg_scale=regularize_scale if count > 4 else 0, name=options['ap_name']) for n in range(N_perturb): perturbations.append({'ellip': copy(ellip), 'pa': copy(pa)}) if count % 3 in [0, 1]: perturbations[-1]['ellip'][i] = _x_to_eps( _inv_x_to_eps(perturbations[-1]['ellip'][i]) + np.random.normal(loc=0, scale=perturb_scale[0])) if count % 3 in [1, 2]: perturbations[-1]['pa'][i] = ( perturbations[-1]['pa'][i] + np.random.normal( loc=0, scale=perturb_scale[1])) % np.pi perturbations[-1]['loss'] = _FFT_mean_loss( dat, sample_radii, perturbations[-1]['ellip'], perturbations[-1]['pa'], i, use_center, results['background noise'], mask=mask, reg_scale=regularize_scale if count > 4 else 0, name=options['ap_name']) best = np.argmin(list(p['loss'] for p in perturbations)) if best > 0: ellip = copy(perturbations[best]['ellip']) pa = copy(perturbations[best]['pa']) count_nochange = 0 else: count_nochange += 1 logging.info('%s: Completed isohpote fit in %i itterations' % (options['ap_name'], count)) # detect collapsed center ###################################################################### for i in range(5): if (_inv_x_to_eps(ellip[i]) - _inv_x_to_eps(ellip[i + 1])) > 0.5: ellip[:i + 1] = ellip[i + 1] pa[:i + 1] = pa[i + 1] # Smooth ellip and pa profile ###################################################################### smooth_ellip = copy(ellip) smooth_pa = copy(pa) ellip[:3] = min(ellip[:3]) smooth_ellip = _ellip_smooth(sample_radii, smooth_ellip, 5) smooth_pa = _pa_smooth(sample_radii, smooth_pa, 5) if 'ap_doplot' in options and options['ap_doplot']: ranges = [[ max(0, int(use_center['x'] - sample_radii[-1] * 1.2)), min(dat.shape[1], int(use_center['x'] + sample_radii[-1] * 1.2)) ], [ max(0, int(use_center['y'] - sample_radii[-1] * 1.2)), min(dat.shape[0], int(use_center['y'] + sample_radii[-1] * 1.2)) ]] LSBImage(dat[ranges[1][0]:ranges[1][1], ranges[0][0]:ranges[0][1]], results['background noise']) # plt.imshow(np.clip(dat[ranges[1][0]: ranges[1][1], ranges[0][0]: ranges[0][1]], # a_min = 0,a_max = None), origin = 'lower', cmap = 'Greys', norm = ImageNormalize(stretch=LogStretch())) for i in range(len(sample_radii)): plt.gca().add_patch( Ellipse((use_center['x'] - ranges[0][0], use_center['y'] - ranges[1][0]), 2 * sample_radii[i], 2 * sample_radii[i] * (1. - ellip[i]), pa[i] * 180 / np.pi, fill=False, linewidth=((i + 1) / len(sample_radii))**2, color='r')) if not ('ap_nologo' in options and options['ap_nologo']): AddLogo(plt.gcf()) plt.savefig( '%sfit_ellipse_%s.jpg' % (options['ap_plotpath'] if 'ap_plotpath' in options else '', options['ap_name']), dpi=options['ap_plotdpi'] if 'ap_plotdpi' in options else 300) plt.close() plt.scatter(sample_radii, ellip, color='r', label='ellip') plt.scatter(sample_radii, pa / np.pi, color='b', label='pa/$np.pi$') show_ellip = _ellip_smooth(sample_radii, ellip, deg=5) show_pa = _pa_smooth(sample_radii, pa, deg=5) plt.plot(sample_radii, show_ellip, color='orange', linewidth=2, linestyle='--', label='smooth ellip') plt.plot(sample_radii, show_pa / np.pi, color='purple', linewidth=2, linestyle='--', label='smooth pa/$np.pi$') #plt.xscale('log') plt.legend() if not ('ap_nologo' in options and options['ap_nologo']): AddLogo(plt.gcf()) plt.savefig( '%sphaseprofile_%s.jpg' % (options['ap_plotpath'] if 'ap_plotpath' in options else '', options['ap_name']), dpi=options['ap_plotdpi'] if 'ap_plotdpi' in options else 300) plt.close() # Compute errors ###################################################################### ellip_err = np.zeros(len(ellip)) ellip_err[:2] = np.sqrt(np.sum((ellip[:4] - smooth_ellip[:4])**2) / 4) ellip_err[-1] = np.sqrt(np.sum((ellip[-4:] - smooth_ellip[-4:])**2) / 4) pa_err = np.zeros(len(pa)) pa_err[:2] = np.sqrt(np.sum((pa[:4] - smooth_pa[:4])**2) / 4) pa_err[-1] = np.sqrt(np.sum((pa[-4:] - smooth_pa[-4:])**2) / 4) for i in range(2, len(pa) - 1): ellip_err[i] = np.sqrt( np.sum((ellip[i - 2:i + 2] - smooth_ellip[i - 2:i + 2])**2) / 4) pa_err[i] = np.sqrt( np.sum((pa[i - 2:i + 2] - smooth_pa[i - 2:i + 2])**2) / 4) res = { 'fit ellip': ellip, 'fit pa': pa, 'fit R': sample_radii, 'fit ellip_err': ellip_err, 'fit pa_err': pa_err, 'auxfile fitlimit': 'fit limit semi-major axis: %.2f pix' % sample_radii[-1] } return IMG, res
def Isophote_Initialize_mean(IMG, results, options): """Fit global elliptical isophote to a galaxy image using FFT coefficients. Same as the default isophote initialization routine, except uses mean/std measures for low S/N applications. Parameters ----------------- ap_fit_limit : float, default 2 noise level out to which to extend the fit in units of pixel background noise level. Default is 2, smaller values will end fitting further out in the galaxy image. Notes ---------- :References: - 'background' - 'background noise' - 'psf fwhm' - 'center' Returns ------- IMG : ndarray Unaltered galaxy image results : dict .. code-block:: python {'init ellip': , # Ellipticity of the global fit (float) 'init pa': ,# Position angle of the global fit (float) 'init R': ,# Semi-major axis length of global fit (float) 'auxfile initialize': # optional, message for aux file to record the global ellipticity and postition angle (string) } """ ###################################################################### # Initial attempt to find size of galaxy in image # based on when isophotes SB values start to get # close to the background noise level circ_ellipse_radii = [results["psf fwhm"]] allphase = [] dat = IMG - results["background"] while circ_ellipse_radii[-1] < (len(IMG) / 2): circ_ellipse_radii.append(circ_ellipse_radii[-1] * (1 + 0.2)) isovals = _iso_extract( dat, circ_ellipse_radii[-1], { "ellip": 0.0, "pa": 0.0 }, results["center"], more=True, ) coefs = fft(isovals[0]) allphase.append(coefs[2]) # Stop when at 3 times background noise if (np.mean(isovals[0]) < (3 * results["background noise"]) and len(circ_ellipse_radii) > 4): break logging.info("%s: init scale: %f pix" % (options["ap_name"], circ_ellipse_radii[-1])) # Find global position angle. phase = (-Angle_Median(np.angle(allphase[-5:])) / 2) % np.pi # (-np.angle(np.mean(allphase[-5:]))/2) % np.pi # Find global ellipticity test_ellip = np.linspace(0.05, 0.95, 15) test_f2 = [] for e in test_ellip: test_f2.append( sum( list( _fitEllip_mean_loss( e, dat, circ_ellipse_radii[-2] * m, phase, results["center"], results["background noise"], ) for m in np.linspace(0.8, 1.2, 5)))) ellip = test_ellip[np.argmin(test_f2)] res = minimize( lambda e, d, r, p, c, n: sum( list( _fitEllip_mean_loss(_x_to_eps(e[0]), d, r * m, p, c, n) for m in np.linspace(0.8, 1.2, 5))), x0=_inv_x_to_eps(ellip), args=( dat, circ_ellipse_radii[-2], phase, results["center"], results["background noise"], ), method="Nelder-Mead", options={ "initial_simplex": [ [_inv_x_to_eps(ellip) - 1 / 15], [_inv_x_to_eps(ellip) + 1 / 15], ] }, ) if res.success: logging.debug( "%s: using optimal ellipticity %.3f over grid ellipticity %.3f" % (options["ap_name"], _x_to_eps(res.x[0]), ellip)) ellip = _x_to_eps(res.x[0]) # Compute the error on the parameters ###################################################################### RR = np.linspace( circ_ellipse_radii[-2] - results["psf fwhm"], circ_ellipse_radii[-2] + results["psf fwhm"], 10, ) errallphase = [] for rr in RR: isovals = _iso_extract(dat, rr, { "ellip": 0.0, "pa": 0.0 }, results["center"], more=True) coefs = fft(isovals[0]) errallphase.append(coefs[2]) sample_pas = (-np.angle(1j * np.array(errallphase) / np.mean(errallphase)) / 2) % np.pi pa_err = np.std(sample_pas) res_multi = map( lambda rrp: minimize( lambda e, d, r, p, c, n: _fitEllip_mean_loss( _x_to_eps(e[0]), d, r, p, c, n), x0=_inv_x_to_eps(ellip), args=(dat, rrp[0], rrp[1], results["center"], results[ "background noise"]), method="Nelder-Mead", options={ "initial_simplex": [ [_inv_x_to_eps(ellip) - 1 / 15], [_inv_x_to_eps(ellip) + 1 / 15], ] }, ), zip(RR, sample_pas), ) ellip_err = np.std(list(_x_to_eps(rm.x[0]) for rm in res_multi)) circ_ellipse_radii = np.array(circ_ellipse_radii) if "ap_doplot" in options and options["ap_doplot"]: ranges = [ [ max(0, int(results["center"]["x"] - circ_ellipse_radii[-1] * 1.5)), min( dat.shape[1], int(results["center"]["x"] + circ_ellipse_radii[-1] * 1.5), ), ], [ max(0, int(results["center"]["y"] - circ_ellipse_radii[-1] * 1.5)), min( dat.shape[0], int(results["center"]["y"] + circ_ellipse_radii[-1] * 1.5), ), ], ] LSBImage( dat[ranges[1][0]:ranges[1][1], ranges[0][0]:ranges[0][1]], results["background noise"], ) # plt.imshow(np.clip(dat[ranges[1][0]: ranges[1][1], ranges[0][0]: ranges[0][1]],a_min = 0, a_max = None), # origin = 'lower', cmap = 'Greys_r', norm = ImageNormalize(stretch=LogStretch())) plt.gca().add_patch( Ellipse( ( results["center"]["x"] - ranges[0][0], results["center"]["y"] - ranges[1][0], ), 2 * circ_ellipse_radii[-1], 2 * circ_ellipse_radii[-1] * (1.0 - ellip), phase * 180 / np.pi, fill=False, linewidth=1, color="y", )) plt.plot( [results["center"]["x"] - ranges[0][0]], [results["center"]["y"] - ranges[1][0]], marker="x", markersize=3, color="r", ) plt.tight_layout() if not ("ap_nologo" in options and options["ap_nologo"]): AddLogo(plt.gcf()) plt.savefig( "%sinitialize_ellipse_%s.jpg" % ( options["ap_plotpath"] if "ap_plotpath" in options else "", options["ap_name"], ), dpi=options["ap_plotdpi"] if "ap_plotdpi" in options else 300, ) plt.close() fig, ax = plt.subplots(2, 1, figsize=(6, 6)) ax[0].plot( circ_ellipse_radii[:-1], ((-np.angle(allphase) / 2) % np.pi) * 180 / np.pi, color="k", ) ax[0].axhline(phase * 180 / np.pi, color="r") ax[0].axhline((phase + pa_err) * 180 / np.pi, color="r", linestyle="--") ax[0].axhline((phase - pa_err) * 180 / np.pi, color="r", linestyle="--") # ax[0].axvline(circ_ellipse_radii[-2], color = 'orange', linestyle = '--') ax[0].set_xlabel("Radius [pix]") ax[0].set_ylabel("FFT$_{1}$ phase [deg]") ax[1].plot(test_ellip, test_f2, color="k") ax[1].axvline(ellip, color="r") ax[1].axvline(ellip + ellip_err, color="r", linestyle="--") ax[1].axvline(ellip - ellip_err, color="r", linestyle="--") ax[1].set_xlabel("Ellipticity [1 - b/a]") ax[1].set_ylabel("Loss [FFT$_{2}$/med(flux)]") plt.tight_layout() if not ("ap_nologo" in options and options["ap_nologo"]): AddLogo(plt.gcf()) plt.savefig( "%sinitialize_ellipse_optimize_%s.jpg" % ( options["ap_plotpath"] if "ap_plotpath" in options else "", options["ap_name"], ), dpi=options["ap_plotdpi"] if "ap_plotdpi" in options else 300, ) plt.close() auxmessage = ( "global ellipticity: %.3f +- %.3f, pa: %.3f +- %.3f deg, size: %f pix" % ( ellip, ellip_err, PA_shift_convention(phase) * 180 / np.pi, pa_err * 180 / np.pi, circ_ellipse_radii[-2], )) return IMG, { "init ellip": ellip, "init ellip_err": ellip_err, "init pa": phase, "init pa_err": pa_err, "init R": circ_ellipse_radii[-2], "auxfile initialize": auxmessage, }
def Isophote_Initialize(IMG, results, options): """Fit global elliptical isophote to a galaxy image using FFT coefficients. A global position angle and ellipticity are fit in a two step process. First, a series of circular isophotes are geometrically sampled until they approach the background level of the image. An FFT is taken for the flux values around each isophote and the phase of the second coefficient is used to determine a direction. The average direction for the outer isophotes is taken as the position angle of the galaxy. Second, with fixed position angle the ellipticity is optimized to minimize the amplitude of the second FFT coefficient relative to the median flux in an isophote. To compute the error on position angle we use the standard deviation of the outer values from step one. For ellipticity the error is computed by optimizing the ellipticity for multiple isophotes within 1 PSF length of each other. Parameters ----------------- ap_fit_limit : float, default 2 noise level out to which to extend the fit in units of pixel background noise level. Default is 2, smaller values will end fitting further out in the galaxy image. ap_isoinit_pa_set : float, default None User set initial position angle in degrees, will override the calculation. ap_isoinit_ellip_set : float, default None User set initial ellipticity (1 - b/a), will override the calculation. Notes ---------- :References: - 'background' - 'background noise' - 'psf fwhm' - 'center' Returns ------- IMG : ndarray Unaltered galaxy image results : dict .. code-block:: python {'init ellip': , # Ellipticity of the global fit (float) 'init pa': ,# Position angle of the global fit (float) 'init R': ,# Semi-major axis length of global fit (float) 'auxfile initialize': # optional, message for aux file to record the global ellipticity and postition angle (string) } """ ###################################################################### # Initial attempt to find size of galaxy in image # based on when isophotes SB values start to get # close to the background noise level circ_ellipse_radii = [1.0] allphase = [] dat = IMG - results["background"] mask = results["mask"] if "mask" in results else None if not np.any(mask): mask = None while circ_ellipse_radii[-1] < (len(IMG) / 2): circ_ellipse_radii.append(circ_ellipse_radii[-1] * (1 + 0.2)) isovals = _iso_extract( dat, circ_ellipse_radii[-1], { "ellip": 0.0, "pa": 0.0 }, results["center"], more=True, mask=mask, sigmaclip=True, sclip_nsigma=3, interp_mask=True, ) coefs = fft(isovals[0]) allphase.append(coefs[2]) # Stop when at 3 time background noise if (np.quantile(isovals[0], 0.8) < ( (options["ap_fit_limit"] + 1 if "ap_fit_limit" in options else 3) * results["background noise"]) and len(circ_ellipse_radii) > 4): break logging.info("%s: init scale: %f pix" % (options["ap_name"], circ_ellipse_radii[-1])) # Find global position angle. phase = (-Angle_Median(np.angle(allphase[-5:])) / 2) % np.pi if "ap_isoinit_pa_set" in options: phase = PA_shift_convention(options["ap_isoinit_pa_set"] * np.pi / 180) # Find global ellipticity test_ellip = np.linspace(0.05, 0.95, 15) test_f2 = [] for e in test_ellip: test_f2.append( sum( list( _fitEllip_loss( e, dat, circ_ellipse_radii[-2] * m, phase, results["center"], results["background noise"], mask, ) for m in np.linspace(0.8, 1.2, 5)))) ellip = test_ellip[np.argmin(test_f2)] res = minimize( lambda e, d, r, p, c, n, msk: sum( list( _fitEllip_loss(_x_to_eps(e[0]), d, r * m, p, c, n, msk) for m in np.linspace(0.8, 1.2, 5))), x0=_inv_x_to_eps(ellip), args=( dat, circ_ellipse_radii[-2], phase, results["center"], results["background noise"], mask, ), method="Nelder-Mead", options={ "initial_simplex": [ [_inv_x_to_eps(ellip) - 1 / 15], [_inv_x_to_eps(ellip) + 1 / 15], ] }, ) if res.success: logging.debug( "%s: using optimal ellipticity %.3f over grid ellipticity %.3f" % (options["ap_name"], _x_to_eps(res.x[0]), ellip)) ellip = _x_to_eps(res.x[0]) if "ap_isoinit_ellip_set" in options: ellip = options["ap_isoinit_ellip_set"] # Compute the error on the parameters ###################################################################### RR = np.linspace( circ_ellipse_radii[-2] - results["psf fwhm"], circ_ellipse_radii[-2] + results["psf fwhm"], 10, ) errallphase = [] for rr in RR: isovals = _iso_extract( dat, rr, { "ellip": 0.0, "pa": 0.0 }, results["center"], more=True, sigmaclip=True, sclip_nsigma=3, interp_mask=True, ) coefs = fft(isovals[0]) errallphase.append(coefs[2]) sample_pas = (-np.angle(1j * np.array(errallphase) / np.mean(errallphase)) / 2) % np.pi pa_err = iqr(sample_pas, rng=[16, 84]) / 2 res_multi = map( lambda rrp: minimize( lambda e, d, r, p, c, n, m: _fitEllip_loss(_x_to_eps(e[0]), d, r, p, c, n, m), x0=_inv_x_to_eps(ellip), args=( dat, rrp[0], rrp[1], results["center"], results["background noise"], mask, ), method="Nelder-Mead", options={ "initial_simplex": [ [_inv_x_to_eps(ellip) - 1 / 15], [_inv_x_to_eps(ellip) + 1 / 15], ] }, ), zip(RR, sample_pas), ) ellip_err = iqr(list(_x_to_eps(rm.x[0]) for rm in res_multi), rng=[16, 84]) / 2 circ_ellipse_radii = np.array(circ_ellipse_radii) if "ap_doplot" in options and options["ap_doplot"]: Plot_Isophote_Init_Ellipse(dat, circ_ellipse_radii, ellip, phase, results, options) Plot_Isophote_Init_Optimize( circ_ellipse_radii, allphase, phase, pa_err, test_ellip, test_f2, ellip, ellip_err, results, options, ) auxmessage = ( "global ellipticity: %.3f +- %.3f, pa: %.3f +- %.3f deg, size: %f pix" % ( ellip, ellip_err, PA_shift_convention(phase) * 180 / np.pi, pa_err * 180 / np.pi, circ_ellipse_radii[-2], )) return IMG, { "init ellip": ellip, "init ellip_err": ellip_err, "init pa": phase, "init pa_err": pa_err, "init R": circ_ellipse_radii[-2], "auxfile initialize": auxmessage, }
def Isophote_Extract(IMG, results, options): """General method for extracting SB profiles. The default SB profile extraction method is highly flexible, allowing users to test a variety of techniques on their data to determine the most robust. The user may specify a variety of sampling arguments for the photometry extraction. For example, a start or end radius in pixels, or whether to sample geometrically or linearly in radius. Geometric sampling is the default as it is faster. Once the sampling profile of semi-major axis values has been chosen, the function interpolates (spline) the position angle and ellipticity profiles at the requested values. For any sampling beyond the outer radius from the *Isophotal Fitting* step, a constant value is used. Within 1 PSF, a circular isophote is used. Parameters ----------------- ap_zeropoint : float, default 22.5 Photometric zero point. For converting flux to mag units. ap_fluxunits : str, default "mag" units for outputted photometry. Can either be "mag" for log units, or "intensity" for linear units. ap_samplegeometricscale : float, default 0.1 growth scale for isophotes when sampling for the final output profile. Used when sampling geometrically. By default, each isophote is 10\% further than the last. ap_samplelinearscale : float, default None growth scale (in pixels) for isophotes when sampling for the final output profile. Used when sampling linearly. Default is 1 PSF length. ap_samplestyle : string, default 'geometric' indicate if isophote sampling radii should grow linearly or geometrically. Can also do geometric sampling at the center and linear sampling once geometric step size equals linear. Options are: 'linear', 'geometric', 'geometric-linear' ap_sampleinitR : float, default None Starting radius (in pixels) for isophote sampling from the image. Note that a starting radius of zero is not advised. Default is 1 pixel or 1PSF, whichever is smaller. ap_sampleendR : float, default None End radius (in pixels) for isophote sampling from the image. Default is 3 times the fit radius, also see *ap_extractfull*. ap_isoband_start : float, default 2 The noise level at which to begin sampling a band of pixels to compute SB instead of sampling a line of pixels near the isophote in units of pixel flux noise. Will never initiate band averaging if the band width is less than half a pixel ap_isoband_width : float, default 0.025 The relative size of the isophote bands to sample. flux values will be sampled at +- *ap_isoband_width* \*R for each radius. ap_isoband_fixed : bool, default False Use a fixed width for the size of the isobands, the width is set by *ap_isoband_width* which now has units of pixels, the default is 0.5 such that the full band has a width of 1 pixel. ap_truncate_evaluation : bool, default False Stop evaluating new isophotes once two negative flux isophotes have been recorded, presumed to have reached the end of the profile. ap_extractfull : bool, default False Tells AutoProf to extend the isophotal solution to the edge of the image. Will be overridden by *ap_truncate_evaluation*. ap_iso_interpolate_start : float, default 5 Use a Lanczos interpolation for isophotes with semi-major axis less than this number times the PSF. ap_iso_interpolate_method : string, default 'lanczos' Select method for flux interpolation on image, options are 'lanczos' and 'bicubic'. Default is 'lanczos' with a window size of 3. ap_iso_interpolate_window : int, default 3 Window size for Lanczos interpolation, default is 3, meaning 3 pixels on either side of the sample point are used for interpolation. ap_isoaverage_method : string, default 'median' Select the method used to compute the averafge flux along an isophote. Choose from 'mean', 'median', and 'mode'. In general, median is fast and robust to a few outliers. Mode is slow but robust to more outliers. Mean is fast and accurate in low S/N regimes where fluxes take on near integer values, but not robust to outliers. The mean should be used along with a mask to remove spurious objects such as foreground stars or galaxies, and should always be used with caution. ap_isoclip : bool, default False Perform sigma clipping along extracted isophotes. Removes flux samples from an isophote that deviate significantly from the median. Several iterations of sigma clipping are performed until convergence or *ap_isoclip_iterations* iterations are reached. Sigma clipping is a useful substitute for masking objects, though careful masking is better. Also an aggressive sigma clip may bias results. ap_isoclip_iterations : int, default None Maximum number of sigma clipping iterations to perform. The default is infinity, so the sigma clipping procedure repeats until convergence ap_isoclip_nsigma : float, default 5 Number of sigma above median to apply clipping. All values above (median + *ap_isoclip_nsigma* x sigma) are removed from the isophote. ap_iso_measurecoefs : tuple, default None tuple indicating which fourier modes to extract along fitted isophotes. Most common is (4,), which identifies boxy/disky isophotes. Also common is (1,3), which identifies lopsided galaxies. The outputted values are computed as a_i = imag(F_i)/abs(F_0) and b_i = real(F_i)/abs(F_0) where F_i is a fourier coefficient. Not activated by default as it adds to computation time. ap_plot_sbprof_ylim : tuple, default None Tuple with axes limits for the y-axis in the SB profile diagnostic plot. Be careful when using intensity units since this will change the ideal axis limits. ap_plot_sbprof_xlim : tuple, default None Tuple with axes limits for the x-axis in the SB profile diagnostic plot. ap_plot_sbprof_set_errscale : float, default None Float value by which to scale errorbars on the SB profile this makes them more visible in cases where the statistical errors are very small. Notes ---------- :References: - 'background' - 'background noise' - 'psf fwhm' - 'center' - 'init ellip' - 'init pa' - 'fit R' - 'fit ellip' - 'fit pa' - 'fit ellip_err' (optional) - 'fit pa_err' (optional) Returns ------- IMG : ndarray Unaltered galaxy image results : dict .. code-block:: python {'prof header': , # List object with strings giving the items in the header of the final SB profile (list) 'prof units': , # dict object that links header strings to units (given as strings) for each variable (dict) 'prof data': # dict object linking header strings to list objects containing the rows for a given variable (dict) } """ use_center = results["center"] # Radius values to evaluate isophotes R = [ options["ap_sampleinitR"] if "ap_sampleinitR" in options else min(1.0, results["psf fwhm"] / 2) ] while ( ( (R[-1] < options["ap_sampleendR"] if "ap_sampleendR" in options else True) and R[-1] < 3 * results["fit R"][-1] ) or (options["ap_extractfull"] if "ap_extractfull" in options else False) ) and R[-1] < max(IMG.shape) / np.sqrt(2): if ( "ap_samplestyle" in options and options["ap_samplestyle"] == "geometric-linear" ): if len(R) > 1 and abs(R[-1] - R[-2]) >= ( options["ap_samplelinearscale"] if "ap_samplelinearscale" in options else 3 * results["psf fwhm"] ): R.append( R[-1] + ( options["ap_samplelinearscale"] if "ap_samplelinearscale" in options else results["psf fwhm"] / 2 ) ) else: R.append( R[-1] * ( 1.0 + ( options["ap_samplegeometricscale"] if "ap_samplegeometricscale" in options else 0.1 ) ) ) elif "ap_samplestyle" in options and options["ap_samplestyle"] == "linear": R.append( R[-1] + ( options["ap_samplelinearscale"] if "ap_samplelinearscale" in options else 0.5 * results["psf fwhm"] ) ) else: R.append( R[-1] * ( 1.0 + ( options["ap_samplegeometricscale"] if "ap_samplegeometricscale" in options else 0.1 ) ) ) R = np.array(R) logging.info( "%s: R complete in range [%.1f,%.1f]" % (options["ap_name"], R[0], R[-1]) ) # Interpolate profile values, when extrapolating just take last point tmp_pa_s = UnivariateSpline( results["fit R"], np.sin(2 * results["fit pa"]), ext=3, s=0 )(R) tmp_pa_c = UnivariateSpline( results["fit R"], np.cos(2 * results["fit pa"]), ext=3, s=0 )(R) E = _x_to_eps( UnivariateSpline( results["fit R"], _inv_x_to_eps(results["fit ellip"]), ext=3, s=0 )(R) ) # np.arctan(tmp_pa_s / tmp_pa_c) + (np.pi * (tmp_pa_c < 0)) PA = _x_to_pa(((np.arctan2(tmp_pa_s, tmp_pa_c)) % (2 * np.pi)) / 2) parameters = list({"ellip": E[i], "pa": PA[i]} for i in range(len(R))) if "fit Fmodes" in results: for i in range(len(R)): parameters[i]["m"] = results["fit Fmodes"] parameters[i]["Am"] = np.array( list( UnivariateSpline( results["fit R"], results["fit Fmode A%i" % results["fit Fmodes"][m]], ext=3, s=0, )(R[i]) for m in range(len(results["fit Fmodes"])) ) ) parameters[i]["Phim"] = np.array( list( UnivariateSpline( results["fit R"], results["fit Fmode Phi%i" % results["fit Fmodes"][m]], ext=3, s=0, )(R[i]) for m in range(len(results["fit Fmodes"])) ) ) if "fit C" in results: CC = UnivariateSpline(results["fit R"], results["fit C"], ext=3, s=0)(R) for i in range(len(R)): parameters[i]["C"] = CC[i] # Get errors for pa and ellip for i in range(len(R)): if ( "fit ellip_err" in results and (not results["fit ellip_err"] is None) and "fit pa_err" in results and (not results["fit pa_err"] is None) ): parameters[i]["ellip err"] = np.clip( UnivariateSpline( results["fit R"], results["fit ellip_err"], ext=3, s=0 )(R[i]), a_min=1e-3, a_max=None, ) parameters[i]["pa err"] = np.clip( UnivariateSpline(results["fit R"], results["fit pa_err"], ext=3, s=0)( R[i] ), a_min=1e-3, a_max=None, ) else: parameters[i]["ellip err"] = 0.0 parameters[i]["pa err"] = 0.0 return IMG, _Generate_Profile(IMG, results, R, parameters, options)
def Isophote_Fit_FFT_mean(IMG, results, options): """Fit elliptical isophotes to a galaxy image using FFT coefficients and regularization. Same as the standard isophote fitting routine, except uses less robust mean/std measures. This is only intended for low S/N data where pixels have low integer counts. Parameters ----------------- ap_scale : float, default 0.2 growth scale when fitting isophotes, not the same as *ap_sample---scale*. ap_fit_limit : float, default 2 noise level out to which to extend the fit in units of pixel background noise level. Default is 2, smaller values will end fitting further out in the galaxy image. ap_regularize_scale : float, default 1 scale factor to apply to regularization coupling factor between isophotes. Default of 1, larger values make smoother fits, smaller values give more chaotic fits. Notes ---------- :References: - 'background' - 'background noise' - 'center' - 'psf fwhm' - 'init ellip' - 'init pa' Returns ------- IMG : ndarray Unaltered galaxy image results : dict .. code-block:: python {'fit ellip': , # array of ellipticity values (ndarray) 'fit pa': , # array of PA values (ndarray) 'fit R': , # array of semi-major axis values (ndarray) 'fit ellip_err': , # optional, array of ellipticity error values (ndarray) 'fit pa_err': , # optional, array of PA error values (ndarray) 'auxfile fitlimit': # optional, auxfile message (string) } """ if "ap_scale" in options: scale = options["ap_scale"] else: scale = 0.2 # subtract background from image during processing dat = IMG - results["background"] mask = results["mask"] if "mask" in results else None if not np.any(mask): mask = None # Determine sampling radii ###################################################################### shrink = 0 while shrink < 5: sample_radii = [3 * results["psf fwhm"] / 2] while sample_radii[-1] < (max(IMG.shape) / 2): isovals = _iso_extract( dat, sample_radii[-1], { "ellip": results["init ellip"], "pa": results["init pa"] }, results["center"], more=False, mask=mask, ) if (np.mean(isovals) < (options["ap_fit_limit"] if "ap_fit_limit" in options else 1) * results["background noise"]): break sample_radii.append(sample_radii[-1] * (1.0 + scale / (1.0 + shrink))) if len(sample_radii) < 15: shrink += 1 else: break if shrink >= 5: raise Exception( "Unable to initialize ellipse fit, check diagnostic plots. Possible missed center." ) ellip = np.ones(len(sample_radii)) * results["init ellip"] pa = np.ones(len(sample_radii)) * results["init pa"] logging.debug("%s: sample radii: %s" % (options["ap_name"], str(sample_radii))) # Fit isophotes ###################################################################### perturb_scale = np.array([0.03, 0.06]) regularize_scale = (options["ap_regularize_scale"] if "ap_regularize_scale" in options else 1.0) N_perturb = 5 count = 0 count_nochange = 0 use_center = copy(results["center"]) I = np.array(range(len(sample_radii))) while count < 300 and count_nochange < (3 * len(sample_radii)): # Periodically include logging message if count % 10 == 0: logging.debug("%s: count: %i" % (options["ap_name"], count)) count += 1 np.random.shuffle(I) for i in I: perturbations = [] perturbations.append({"ellip": copy(ellip), "pa": copy(pa)}) perturbations[-1]["loss"] = _FFT_mean_loss( dat, sample_radii, perturbations[-1]["ellip"], perturbations[-1]["pa"], i, use_center, results["background noise"], mask=mask, reg_scale=regularize_scale if count > 4 else 0, name=options["ap_name"], ) for n in range(N_perturb): perturbations.append({"ellip": copy(ellip), "pa": copy(pa)}) if count % 3 in [0, 1]: perturbations[-1]["ellip"][i] = _x_to_eps( _inv_x_to_eps(perturbations[-1]["ellip"][i]) + np.random.normal(loc=0, scale=perturb_scale[0])) if count % 3 in [1, 2]: perturbations[-1]["pa"][i] = ( perturbations[-1]["pa"][i] + np.random.normal( loc=0, scale=perturb_scale[1])) % np.pi perturbations[-1]["loss"] = _FFT_mean_loss( dat, sample_radii, perturbations[-1]["ellip"], perturbations[-1]["pa"], i, use_center, results["background noise"], mask=mask, reg_scale=regularize_scale if count > 4 else 0, name=options["ap_name"], ) best = np.argmin(list(p["loss"] for p in perturbations)) if best > 0: ellip = copy(perturbations[best]["ellip"]) pa = copy(perturbations[best]["pa"]) count_nochange = 0 else: count_nochange += 1 logging.info("%s: Completed isohpote fit in %i itterations" % (options["ap_name"], count)) # detect collapsed center ###################################################################### for i in range(5): if (_inv_x_to_eps(ellip[i]) - _inv_x_to_eps(ellip[i + 1])) > 0.5: ellip[:i + 1] = ellip[i + 1] pa[:i + 1] = pa[i + 1] # Smooth ellip and pa profile ###################################################################### smooth_ellip = copy(ellip) smooth_pa = copy(pa) ellip[:3] = min(ellip[:3]) smooth_ellip = _ellip_smooth(sample_radii, smooth_ellip, 5) smooth_pa = _pa_smooth(sample_radii, smooth_pa, 5) if "ap_doplot" in options and options["ap_doplot"]: ranges = [ [ max(0, int(use_center["x"] - sample_radii[-1] * 1.2)), min(dat.shape[1], int(use_center["x"] + sample_radii[-1] * 1.2)), ], [ max(0, int(use_center["y"] - sample_radii[-1] * 1.2)), min(dat.shape[0], int(use_center["y"] + sample_radii[-1] * 1.2)), ], ] LSBImage( dat[ranges[1][0]:ranges[1][1], ranges[0][0]:ranges[0][1]], results["background noise"], ) # plt.imshow(np.clip(dat[ranges[1][0]: ranges[1][1], ranges[0][0]: ranges[0][1]], # a_min = 0,a_max = None), origin = 'lower', cmap = 'Greys', norm = ImageNormalize(stretch=LogStretch())) for i in range(len(sample_radii)): plt.gca().add_patch( Ellipse( (use_center["x"] - ranges[0][0], use_center["y"] - ranges[1][0]), 2 * sample_radii[i], 2 * sample_radii[i] * (1.0 - ellip[i]), pa[i] * 180 / np.pi, fill=False, linewidth=((i + 1) / len(sample_radii))**2, color="r", )) if not ("ap_nologo" in options and options["ap_nologo"]): AddLogo(plt.gcf()) plt.savefig( "%sfit_ellipse_%s.jpg" % ( options["ap_plotpath"] if "ap_plotpath" in options else "", options["ap_name"], ), dpi=options["ap_plotdpi"] if "ap_plotdpi" in options else 300, ) plt.close() plt.scatter(sample_radii, ellip, color="r", label="ellip") plt.scatter(sample_radii, pa / np.pi, color="b", label="pa/$np.pi$") show_ellip = _ellip_smooth(sample_radii, ellip, deg=5) show_pa = _pa_smooth(sample_radii, pa, deg=5) plt.plot( sample_radii, show_ellip, color="orange", linewidth=2, linestyle="--", label="smooth ellip", ) plt.plot( sample_radii, show_pa / np.pi, color="purple", linewidth=2, linestyle="--", label="smooth pa/$np.pi$", ) # plt.xscale('log') plt.legend() if not ("ap_nologo" in options and options["ap_nologo"]): AddLogo(plt.gcf()) plt.savefig( "%sphaseprofile_%s.jpg" % ( options["ap_plotpath"] if "ap_plotpath" in options else "", options["ap_name"], ), dpi=options["ap_plotdpi"] if "ap_plotdpi" in options else 300, ) plt.close() # Compute errors ###################################################################### ellip_err = np.zeros(len(ellip)) ellip_err[:2] = np.sqrt(np.sum((ellip[:4] - smooth_ellip[:4])**2) / 4) ellip_err[-1] = np.sqrt(np.sum((ellip[-4:] - smooth_ellip[-4:])**2) / 4) pa_err = np.zeros(len(pa)) pa_err[:2] = np.sqrt(np.sum((pa[:4] - smooth_pa[:4])**2) / 4) pa_err[-1] = np.sqrt(np.sum((pa[-4:] - smooth_pa[-4:])**2) / 4) for i in range(2, len(pa) - 1): ellip_err[i] = np.sqrt( np.sum((ellip[i - 2:i + 2] - smooth_ellip[i - 2:i + 2])**2) / 4) pa_err[i] = np.sqrt( np.sum((pa[i - 2:i + 2] - smooth_pa[i - 2:i + 2])**2) / 4) res = { "fit ellip": ellip, "fit pa": pa, "fit R": sample_radii, "fit ellip_err": ellip_err, "fit pa_err": pa_err, "auxfile fitlimit": "fit limit semi-major axis: %.2f pix" % sample_radii[-1], } return IMG, res
def Isophote_Fit_FFT_Robust(IMG, results, options): """Fit elliptical isophotes to a galaxy image using FFT coefficients and regularization. The isophotal fitting routine simultaneously optimizes a collection of elliptical isophotes by minimizing the 2nd FFT coefficient power, regularized for robustness. A series of isophotes are constructed which grow geometrically until they begin to reach the background level. Then the algorithm iteratively updates the position angle and ellipticity of each isophote individually for many rounds. Each round updates every isophote in a random order. Each round cycles between three options: optimizing position angle, ellipticity, or both. To optimize the parameters, 5 values (pa, ellip, or both) are randomly sampled and the "loss" is computed. The loss is a combination of the relative amplitude of the second FFT coefficient (compared to the median flux), and a regularization term. The regularization term penalizes adjacent isophotes for having different position angle or ellipticity (using the l1 norm). Thus, all the isophotes are coupled and tend to fit smoothly varying isophotes. When the optimization has completed three rounds without any isophotes updating, the profile is assumed to have converged. An uncertainty for each ellipticity and position angle value is determined by repeatedly re-optimizing each ellipse with slight adjustments to it's semi-major axis length (+- 5%). The standard deviation of the PA/ellipticity after repeated fitting gives the uncertainty. Parameters ----------------- ap_scale : float, default 0.2 growth scale when fitting isophotes, not the same as *ap_sample---scale*. ap_fit_limit : float, default 2 noise level out to which to extend the fit in units of pixel background noise level. Default is 2, smaller values will end fitting further out in the galaxy image. ap_regularize_scale : float, default 1 scale factor to apply to regularization coupling factor between isophotes. Default of 1, larger values make smoother fits, smaller values give more chaotic fits. ap_isofit_robustclip : float, default 0.15 quantile of flux values at which to clip when extracting values along an isophote. Clipping outlier values (such as very bright stars) while fitting isophotes allows for robust computation of FFT coefficients along an isophote. ap_isofit_losscoefs : tuple, default (2,) Tuple of FFT coefficients to use in optimization procedure. AutoProf will attemp to minimize the power in all listed FFT coefficients. Must be a tuple, not a list. ap_isofit_superellipse : bool, default False If True, AutoProf will fit superellipses instead of regular ellipses. A superellipse is typically used to represent boxy/disky isophotes. The variable controlling the transition from a rectangle to an ellipse to a four-armed-star like shape is C. A value of C = 2 represents an ellipse and is the starting point of the optimization. ap_isofit_fitcoefs : tuple, default None Tuple of FFT coefficients to use in fitting procedure. AutoProf will attemp to fit ellipses with these Fourier mode perturbations. Such perturbations allow for lopsided, boxy, disky, and other types of isophotes beyond straightforward ellipses. Must be a tuple, not a list. Note that AutoProf will first fit ellipses, then turn on the Fourier mode perturbations, thus the fitting time will always be longer. ap_isofit_fitcoefs_FFTinit : bool, default False If True, the coefficients for the Fourier modes fitted from ap_isofit_fitcoefs will be initialized using an FFT decomposition along fitted elliptical isophotes. This can improve the fit result, though it is less stable and so users should examine the results after fitting. ap_isofit_perturbscale_ellip : float, default 0.03 Sampling scale for random adjustments to ellipticity made while optimizing isophotes. Smaller values will converge faster, but get stuck in local minima; larger values will escape local minima, but takes longer to converge. ap_isofit_perturbscale_pa : float, default 0.06 Sampling scale for random adjustments to position angle made while optimizing isophotes. Smaller values will converge faster, but get stuck in local minima; larger values will escape local minima, but takes longer to converge. ap_isofit_iterlimitmax : int, default 300 Maximum number of iterations (each iteration adjusts every isophote once) before automatically stopping optimization. For galaxies with lots of structure (ie detailed spiral arms) more iterations may be needed to fully fit the light distribution, but runtime will be longer. ap_isofit_iterlimitmin : int, default 0 Minimum number of iterations before optimization is allowed to stop. ap_isofit_iterstopnochange : float, default 3 Number of iterations with no updates to parameters before optimization procedure stops. Lower values will process galaxies faster, but may still be stuck in local minima, higher values are more likely to converge on the global minimum but can take a long time to run. Fractional values are allowed though not recomended. Notes ---------- :References: - 'background' - 'background noise' - 'psf fwhm' - 'center' - 'mask' (optional) - 'init ellip' - 'init pa' Returns ------- IMG : ndarray Unaltered galaxy image results : dict .. code-block:: python {'fit ellip': , # array of ellipticity values (ndarray) 'fit pa': , # array of PA values (ndarray) 'fit R': , # array of semi-major axis values (ndarray) 'fit ellip_err': , # optional, array of ellipticity error values (ndarray) 'fit pa_err': , # optional, array of PA error values (ndarray) 'fit C': , # optional, superellipse scale parameter (ndarray) 'fit Fmodes': , # optional, fitted Fourier mode indices (tuple) 'fit Fmode A*': , # optional, fitted Fourier mode amplitudes, * for each index (ndarray) 'fit Fmode Phi*': , # optional, fitted Fourier mode phases, * for each index (ndarray) 'auxfile fitlimit': # optional, auxfile message (string) } """ if "ap_scale" in options: scale = options["ap_scale"] else: scale = 0.2 # subtract background from image during processing dat = IMG - results["background"] mask = results["mask"] if "mask" in results else None if not np.any(mask): mask = None # Determine sampling radii ###################################################################### shrink = 0 while shrink < 5: sample_radii = [max(1.0, results["psf fwhm"] / 2)] while sample_radii[-1] < (max(IMG.shape) / 2): isovals = _iso_extract( dat, sample_radii[-1], { "ellip": results["init ellip"], "pa": results["init pa"] }, results["center"], more=False, mask=mask, ) if (np.median(isovals) < (options["ap_fit_limit"] if "ap_fit_limit" in options else 2) * results["background noise"]): break sample_radii.append(sample_radii[-1] * (1.0 + scale / (1.0 + shrink))) if len(sample_radii) < 15: shrink += 1 else: break if shrink >= 5: raise Exception( "Unable to initialize ellipse fit, check diagnostic plots. Possible missed center." ) ellip = np.ones(len(sample_radii)) * results["init ellip"] pa = np.ones(len(sample_radii)) * results["init pa"] logging.debug("%s: sample radii: %s" % (options["ap_name"], str(sample_radii))) # Fit isophotes ###################################################################### perturb_scale = 0.03 regularize_scale = (options["ap_regularize_scale"] if "ap_regularize_scale" in options else 1.0) robust_clip = (options["ap_isofit_robustclip"] if "ap_isofit_robustclip" in options else 0.15) N_perturb = 5 fit_coefs = (options["ap_isofit_losscoefs"] if "ap_isofit_losscoefs" in options else None) fit_params = (options["ap_isofit_fitcoefs"] if "ap_isofit_fitcoefs" in options else None) fit_superellipse = (options["ap_isofit_superellipse"] if "ap_isofit_superellipse" in options else False) parameters = list( { "ellip": ellip[i], "pa": pa[i], "m": fit_params, "C": 2 if fit_superellipse else None, "Am": None if fit_params is None else np.zeros(len(fit_params)), "Phim": None if fit_params is None else np.zeros(len(fit_params)), } for i in range(len(ellip))) count = 0 iterlimitmax = (options["ap_isofit_iterlimitmax"] if "ap_isofit_iterlimitmax" in options else 1000) iterlimitmin = (options["ap_isofit_iterlimitmin"] if "ap_isofit_iterlimitmin" in options else 0) iterstopnochange = (options["ap_isofit_iterstopnochange"] if "ap_isofit_iterstopnochange" in options else 3) count_nochange = 0 use_center = copy(results["center"]) I = np.array(range(len(sample_radii))) param_cycle = 2 base_params = 2 + int(fit_superellipse) while count < iterlimitmax: # Periodically include logging message if count % 10 == 0: logging.debug("%s: count: %i" % (options["ap_name"], count)) count += 1 np.random.shuffle(I) N_perturb = int(1 + (10 / np.sqrt(count))) for i in I: perturbations = [] perturbations.append(deepcopy(parameters)) perturbations[-1][i]["loss"] = _FFT_Robust_loss( dat, sample_radii, perturbations[-1], i, use_center, results["background noise"], mask=mask, reg_scale=regularize_scale if count > 4 else 0, robust_clip=robust_clip, fit_coefs=fit_coefs, name=options["ap_name"], ) for n in range(N_perturb): perturbations.append(deepcopy(parameters)) if count % param_cycle == 0: perturbations[-1][i]["ellip"] = _x_to_eps( _inv_x_to_eps(perturbations[-1][i]["ellip"]) + np.random.normal(loc=0, scale=perturb_scale)) elif count % param_cycle == 1: perturbations[-1][i]["pa"] = ( perturbations[-1][i]["pa"] + np.random.normal( loc=0, scale=np.pi * perturb_scale)) % np.pi elif (count % param_cycle) == 2 and not parameters[i]["C"] is None: perturbations[-1][i]["C"] = 10**( np.log10(perturbations[-1][i]["C"]) + np.random.normal( loc=0, scale=np.log10(1.0 + perturb_scale))) elif count % param_cycle < (base_params + len(parameters[i]["m"])): perturbations[-1][i]["Am"][ (count % param_cycle) - base_params] += np.random.normal(loc=0, scale=perturb_scale) elif count % param_cycle < (base_params + 2 * len(parameters[i]["m"])): phim_index = ((count % param_cycle) - base_params - len(parameters[i]["m"])) perturbations[-1][i]["Phim"][phim_index] = ( perturbations[-1][i]["Phim"][phim_index] + np.random.normal( loc=0, scale=2 * np.pi * perturb_scale / parameters[i]["m"][phim_index], )) % (2 * np.pi / parameters[i]["m"][phim_index]) else: raise Exception( "Unrecognized optimization parameter id: %i" % (count % param_cycle)) perturbations[-1][i]["loss"] = _FFT_Robust_loss( dat, sample_radii, perturbations[-1], i, use_center, results["background noise"], mask=mask, reg_scale=regularize_scale if count > 4 else 0, robust_clip=robust_clip, fit_coefs=fit_coefs, name=options["ap_name"], ) best = np.argmin(list(p[i]["loss"] for p in perturbations)) if best > 0: parameters = deepcopy(perturbations[best]) del parameters[i]["loss"] count_nochange = 0 else: count_nochange += 1 if not (count_nochange < (iterstopnochange * (len(sample_radii) - 1)) or count < iterlimitmin): if param_cycle > 2 or (parameters[i]["m"] is None and not fit_superellipse): break elif parameters[i]["m"] is None and fit_superellipse: logging.info("%s: Started C fitting at iteration %i" % (options["ap_name"], count)) param_cycle = 3 iterstopnochange = max(iterstopnochange, param_cycle) count_nochange = 0 count = 0 if fit_coefs is None: fit_coefs = (2, 4) else: logging.info("%s: Started Fmode fitting at iteration %i" % (options["ap_name"], count)) if fit_superellipse: logging.info("%s: Started C fitting at iteration %i" % (options["ap_name"], count)) param_cycle = base_params + 2 * len(parameters[i]["m"]) iterstopnochange = max(iterstopnochange, param_cycle) count_nochange = 0 count = 0 if fit_coefs is None and not fit_params is None: fit_coefs = fit_params if not 2 in fit_coefs: fit_coefs = tuple( sorted(set([2] + list(fit_coefs)))) if not parameters[i]["C"] is None and ( not "ap_isofit_losscoefs" in options or options["ap_isofit_losscoefs"] is None): fit_coefs = tuple(sorted(set([4] + list(fit_coefs)))) if ("ap_isofit_fitcoefs_FFTinit" in options and options["ap_isofit_fitcoefs_FFTinit"]): for ii in I: isovals = _iso_extract( dat, sample_radii[ii], parameters[ii], use_center, mask=mask, interp_mask=False if mask is None else True, interp_method="bicubic", ) if mask is None: coefs = fft( np.clip( isovals, a_max=np.quantile(isovals, 0.85), a_min=None, )) else: coefs = fft( np.clip( isovals, a_max=np.quantile(isovals, 0.9), a_min=None, )) for m in range(len(parameters[ii]["m"])): parameters[ii]["Am"][m] = np.abs( coefs[parameters[ii]["m"][m]] / coefs[0]) * np.sign( np.angle( coefs[parameters[ii]["m"][m]])) parameters[ii]["Phim"][m] = np.angle( coefs[parameters[ii]["m"][m]]) % (2 * np.pi) if not (count_nochange < (iterstopnochange * (len(sample_radii) - 1)) or count < iterlimitmin): break logging.info("%s: Completed isohpote fit in %i itterations" % (options["ap_name"], count)) # Compute errors ###################################################################### ellip_err, pa_err = _FFT_Robust_Errors( dat, sample_radii, parameters, use_center, results["background noise"], mask=mask, reg_scale=regularize_scale, robust_clip=robust_clip, fit_coefs=fit_coefs, name=options["ap_name"], ) for i in range(len(ellip)): parameters[i]["ellip err"] = ellip_err[i] parameters[i]["pa err"] = pa_err[i] # Plot fitting results ###################################################################### if "ap_doplot" in options and options["ap_doplot"]: Plot_Isophote_Fit(dat, sample_radii, parameters, results, options) res = { "fit ellip": np.array(list(parameters[i]["ellip"] for i in range(len(parameters)))), "fit pa": np.array(list(parameters[i]["pa"] for i in range(len(parameters)))), "fit R": sample_radii, "fit ellip_err": ellip_err, "fit pa_err": pa_err, "auxfile fitlimit": "fit limit semi-major axis: %.2f pix" % sample_radii[-1], } if not fit_params is None: res.update({"fit Fmodes": fit_params}) for m in range(len(fit_params)): res.update({ "fit Fmode A%i" % fit_params[m]: np.array( list(parameters[i]["Am"][m] for i in range(len(parameters)))), "fit Fmode Phi%i" % fit_params[m]: np.array( list(parameters[i]["Phim"][m] for i in range(len(parameters)))), }) if fit_superellipse: res.update({ "fit C": np.array(list(parameters[i]["C"] for i in range(len(parameters)))) }) return IMG, res