diff --git a/CHANGES.rst b/CHANGES.rst
index e27820312e..ed8cff47fe 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -27,6 +27,10 @@ Cubeviz
Imviz
^^^^^
+- Added the ability to fit Gaussian1D model to radial profile in
+ Simple Aperture Photometry plugin. Radial profile and curve of growth now center
+ on source centroid, not Subset center. [#1409]
+
Mosviz
^^^^^^
diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst
index b683640810..0571d4a92d 100644
--- a/docs/imviz/plugins.rst
+++ b/docs/imviz/plugins.rst
@@ -160,7 +160,9 @@ an interactively selected region. A typical workflow is as follows:
Caution: having too many data points may cause performance issues with this feature.
The exact limitations depend on your hardware.
-10. Once all inputs are populated correctly, click on the :guilabel:`CALCULATE`
+10. Toggle :guilabel:`Fit Gaussian` on to fit a `~astropy.modeling.functional_models.Gaussian1D`
+ model to the radial profile data. This is disabled for curve-of-growth.
+11. Once all inputs are populated correctly, click on the :guilabel:`CALCULATE`
button to perform simple aperture photometry.
.. note::
@@ -169,8 +171,8 @@ an interactively selected region. A typical workflow is as follows:
However, if NaN exists in data, it will be treated as 0.
When calculation is complete, a plot would show the radial profile
-of the background subtracted data and the photometry results are displayed under the
-:guilabel:`CALCULATE` button.
+of the background subtracted data and the photometry and model fitting (if requested)
+results are displayed under the :guilabel:`CALCULATE` button.
.. figure:: img/imviz_radial_profile.png
:alt: Imviz radial profile plot.
@@ -182,7 +184,14 @@ of the background subtracted data and the photometry results are displayed under
Radial profile (raw).
-You can also retrieve the results as `~astropy.table.QTable` as follows,
+If you opted to fit a `~astropy.modeling.functional_models.Gaussian1D`
+to the radial profile, the last fitted model parameters will be displayed
+under the radial profile plot. The model itself can be obtained by as follows.
+See :ref:`astropy:astropy-modeling` on how to manipulate the model::
+
+ my_gaussian1d = imviz.app.fitted_models['phot_radial_profile']
+
+You can also retrieve the photometry results as `~astropy.table.QTable` as follows,
assuming ``imviz`` is the instance of your Imviz application::
results = imviz.get_aperture_photometry_results()
diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py
index 2aca9f8c69..a49eba7569 100644
--- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py
+++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py
@@ -1,8 +1,13 @@
+import os
+import warnings
from datetime import datetime
import bqplot
import numpy as np
from astropy import units as u
+from astropy.modeling.fitting import LevMarLSQFitter
+from astropy.modeling import Parameter
+from astropy.modeling.models import Gaussian1D
from astropy.table import QTable
from astropy.time import Time
from ipywidgets import widget_serialization
@@ -41,6 +46,8 @@ class SimpleAperturePhotometry(TemplateMixin, DatasetSelectMixin):
current_plot_type = Unicode().tag(sync=True)
plot_available = Bool(False).tag(sync=True)
radial_plot = Any('').tag(sync=True, **widget_serialization)
+ fit_radial_profile = Bool(False).tag(sync=True)
+ fit_results = List().tag(sync=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -63,6 +70,7 @@ def __init__(self, *args, **kwargs):
self._fig = bqplot.Figure()
self.plot_types = ["Curve of Growth", "Radial Profile", "Radial Profile (Raw)"]
self.current_plot_type = self.plot_types[0]
+ self._fitted_model_name = 'phot_radial_profile'
def reset_results(self):
self.result_available = False
@@ -218,6 +226,11 @@ def vue_do_aper_phot(self, *args, **kwargs):
data = self._selected_data
reg = self._selected_subset
+ # Reset last fitted model
+ fit_model = None
+ if self._fitted_model_name in self.app.fitted_models:
+ del self.app.fitted_models[self._fitted_model_name]
+
try:
comp = data.get_component(data.main_components[0])
try:
@@ -319,16 +332,17 @@ def vue_do_aper_phot(self, *args, **kwargs):
line_y_sc = bqplot.LinearScale()
if self.current_plot_type == "Curve of Growth":
- self._fig.title = 'Curve of growth from Subset center'
+ self._fig.title = 'Curve of growth from source centroid'
x_arr, sum_arr, x_label, y_label = _curve_of_growth(
- comp_data, aperture, phot_table['sum'][0], wcs=data.coords,
- background=bg, pixarea_fac=pixarea_fac)
+ comp_data, phot_aperstats.centroid, aperture, phot_table['sum'][0],
+ wcs=data.coords, background=bg, pixarea_fac=pixarea_fac)
self._fig.axes = [bqplot.Axis(scale=line_x_sc, label=x_label),
bqplot.Axis(scale=line_y_sc, orientation='vertical',
label=y_label)]
bqplot_line = bqplot.Lines(x=x_arr, y=sum_arr, marker='circle',
scales={'x': line_x_sc, 'y': line_y_sc},
marker_size=32, colors='gray')
+ bqplot_marks = [bqplot_line]
else: # Radial profile
self._fig.axes = [bqplot.Axis(scale=line_x_sc, label='pix'),
@@ -336,21 +350,50 @@ def vue_do_aper_phot(self, *args, **kwargs):
label=comp.units or 'Value')]
if self.current_plot_type == "Radial Profile":
- self._fig.title = 'Radial profile from Subset center'
+ self._fig.title = 'Radial profile from source centroid'
x_data, y_data = _radial_profile(
- phot_aperstats.data_cutout, phot_aperstats.bbox, aperture, raw=False)
+ phot_aperstats.data_cutout, phot_aperstats.bbox, phot_aperstats.centroid,
+ raw=False)
bqplot_line = bqplot.Lines(x=x_data, y=y_data, marker='circle',
scales={'x': line_x_sc, 'y': line_y_sc},
marker_size=32, colors='gray')
else: # Radial Profile (Raw)
- self._fig.title = 'Raw radial profile from Subset center'
- radial_r, radial_img = _radial_profile(
- phot_aperstats.data_cutout, phot_aperstats.bbox, aperture, raw=True)
- bqplot_line = bqplot.Scatter(x=radial_r, y=radial_img, marker='circle',
+ self._fig.title = 'Raw radial profile from source centroid'
+ x_data, y_data = _radial_profile(
+ phot_aperstats.data_cutout, phot_aperstats.bbox, phot_aperstats.centroid,
+ raw=True)
+ bqplot_line = bqplot.Scatter(x=x_data, y=y_data, marker='circle',
scales={'x': line_x_sc, 'y': line_y_sc},
default_size=1, colors='gray')
- self._fig.marks = [bqplot_line]
+ # Fit Gaussian1D to radial profile data.
+ # mean is fixed at 0 because we recentered to centroid.
+ if self.fit_radial_profile:
+ fitter = LevMarLSQFitter()
+ y_max = y_data.max()
+ std = 0.5 * (phot_table['semimajor_sigma'][0] +
+ phot_table['semiminor_sigma'][0])
+ if isinstance(std, u.Quantity):
+ std = std.value
+ gs = Gaussian1D(amplitude=y_max, mean=0, stddev=std,
+ fixed={'mean': True, 'amplitude': True},
+ bounds={'amplitude': (y_max * 0.5, y_max)})
+ with warnings.catch_warnings(record=True) as warns:
+ fit_model = fitter(gs, x_data, y_data)
+ if len(warns) > 0:
+ msg = os.linesep.join([str(w.message) for w in warns])
+ self.hub.broadcast(SnackbarMessage(
+ f"Radial profile fitting: {msg}", color='warning', sender=self))
+ y_fit = fit_model(x_data)
+ self.app.fitted_models[self._fitted_model_name] = fit_model
+ bqplot_fit = bqplot.Lines(x=x_data, y=y_fit, marker=None,
+ scales={'x': line_x_sc, 'y': line_y_sc},
+ colors='magenta', line_style='dashed')
+ bqplot_marks = [bqplot_line, bqplot_fit]
+ else:
+ bqplot_marks = [bqplot_line]
+
+ self._fig.marks = bqplot_marks
except Exception as e: # pragma: no cover
self.reset_results()
@@ -379,7 +422,18 @@ def vue_do_aper_phot(self, *args, **kwargs):
f'{x:.4e} ({phot_table["aperture_sum_counts_err"][0]:.4e})'})
else:
tmp.append({'function': key, 'result': str(x)})
+
+ # Also display fit results
+ fit_tmp = []
+ if fit_model is not None and isinstance(fit_model, Gaussian1D):
+ for param in ('fwhm', 'amplitude'): # mean is fixed at 0
+ p_val = getattr(fit_model, param)
+ if isinstance(p_val, Parameter):
+ p_val = p_val.value
+ fit_tmp.append({'function': param, 'result': f'{p_val:.4e}'})
+
self.results = tmp
+ self.fit_results = fit_tmp
self.result_available = True
self.radial_plot = self._fig
self.bqplot_figs_resize = [self._fig]
@@ -389,7 +443,7 @@ def vue_do_aper_phot(self, *args, **kwargs):
# NOTE: These are hidden because the APIs are for internal use only
# but we need them as a separate functions for unit testing.
-def _radial_profile(radial_cutout, reg_bb, aperture, raw=False):
+def _radial_profile(radial_cutout, reg_bb, centroid, raw=False):
"""Calculate radial profile.
Parameters
@@ -400,8 +454,8 @@ def _radial_profile(radial_cutout, reg_bb, aperture, raw=False):
reg_bb : obj
Bounding box from ``ApertureStats``.
- aperture : obj
- ``photutils`` aperture object.
+ centroid : tuple of int
+ ``ApertureStats`` centroid or desired center in ``(x, y)``.
raw : bool
If `True`, returns raw data points for scatter plot.
@@ -409,14 +463,15 @@ def _radial_profile(radial_cutout, reg_bb, aperture, raw=False):
"""
reg_ogrid = np.ogrid[reg_bb.iymin:reg_bb.iymax, reg_bb.ixmin:reg_bb.ixmax]
- radial_dx = reg_ogrid[1] - aperture.positions[0]
- radial_dy = reg_ogrid[0] - aperture.positions[1]
+ radial_dx = reg_ogrid[1] - centroid[0]
+ radial_dy = reg_ogrid[0] - centroid[1]
radial_r = np.hypot(radial_dx, radial_dy)[~radial_cutout.mask].ravel() # pix
radial_img = radial_cutout.compressed() # data unit
if raw:
- x_arr = radial_r
- y_arr = radial_img
+ i_arr = np.argsort(radial_r)
+ x_arr = radial_r[i_arr]
+ y_arr = radial_img[i_arr]
else:
# This algorithm is from the imexam package,
# see licenses/IMEXAM_LICENSE.txt for more details
@@ -427,7 +482,7 @@ def _radial_profile(radial_cutout, reg_bb, aperture, raw=False):
return x_arr, y_arr
-def _curve_of_growth(data, aperture, final_sum, wcs=None, background=0, n_datapoints=10,
+def _curve_of_growth(data, centroid, aperture, final_sum, wcs=None, background=0, n_datapoints=10,
pixarea_fac=None):
"""Calculate curve of growth for aperture photometry.
@@ -436,8 +491,14 @@ def _curve_of_growth(data, aperture, final_sum, wcs=None, background=0, n_datapo
data : ndarray or `~astropy.units.Quantity`
Data for the calculation.
+ centroid : tuple of int
+ ``ApertureStats`` centroid or desired center in ``(x, y)``.
+
aperture : obj
- ``photutils`` aperture object.
+ ``photutils`` aperture to use, except its center will be
+ changed to the given ``centroid``. This is because the aperture
+ might be hand-drawn and a more accurate centroid has been
+ recalculated separately.
final_sum : float or `~astropy.units.Quantity`
Aperture sum that is already calculated in the
@@ -477,20 +538,20 @@ def _curve_of_growth(data, aperture, final_sum, wcs=None, background=0, n_datapo
if isinstance(aperture, CircularAperture):
x_label = 'Radius (pix)'
x_arr = np.linspace(0, aperture.r, num=n_datapoints)[1:]
- aper_list = [CircularAperture(aperture.positions, cur_r) for cur_r in x_arr[:-1]]
+ aper_list = [CircularAperture(centroid, cur_r) for cur_r in x_arr[:-1]]
elif isinstance(aperture, EllipticalAperture):
x_label = 'Semimajor axis (pix)'
x_arr = np.linspace(0, aperture.a, num=n_datapoints)[1:]
a_arr = x_arr[:-1]
b_arr = aperture.b * a_arr / aperture.a
- aper_list = [EllipticalAperture(aperture.positions, cur_a, cur_b, theta=aperture.theta)
+ aper_list = [EllipticalAperture(centroid, cur_a, cur_b, theta=aperture.theta)
for (cur_a, cur_b) in zip(a_arr, b_arr)]
elif isinstance(aperture, RectangularAperture):
x_label = 'Width (pix)'
x_arr = np.linspace(0, aperture.w, num=n_datapoints)[1:]
w_arr = x_arr[:-1]
h_arr = aperture.h * w_arr / aperture.w
- aper_list = [RectangularAperture(aperture.positions, cur_w, cur_h, theta=aperture.theta)
+ aper_list = [RectangularAperture(centroid, cur_w, cur_h, theta=aperture.theta)
for (cur_w, cur_h) in zip(w_arr, h_arr)]
else:
raise TypeError(f'Unsupported aperture: {aperture}')
diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue
index f913d891b8..a4d285f24b 100644
--- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue
+++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue
@@ -110,6 +110,16 @@
>
+