Skip to content

Commit 050b62d

Browse files
authored
Merge pull request #1409 from pllim/fit-line-to-histogram
Simple Aperture Photometry: Gaussian1D fitting for radial profile
2 parents 0ef0a1f + 03f0e5f commit 050b62d

6 files changed

Lines changed: 162 additions & 36 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ Cubeviz
2727
Imviz
2828
^^^^^
2929

30+
- Added the ability to fit Gaussian1D model to radial profile in
31+
Simple Aperture Photometry plugin. Radial profile and curve of growth now center
32+
on source centroid, not Subset center. [#1409]
33+
3034
Mosviz
3135
^^^^^^
3236

docs/imviz/plugins.rst

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ an interactively selected region. A typical workflow is as follows:
160160
Caution: having too many data points may cause performance issues with this feature.
161161
The exact limitations depend on your hardware.
162162

163-
10. Once all inputs are populated correctly, click on the :guilabel:`CALCULATE`
163+
10. Toggle :guilabel:`Fit Gaussian` on to fit a `~astropy.modeling.functional_models.Gaussian1D`
164+
model to the radial profile data. This is disabled for curve-of-growth.
165+
11. Once all inputs are populated correctly, click on the :guilabel:`CALCULATE`
164166
button to perform simple aperture photometry.
165167

166168
.. note::
@@ -169,8 +171,8 @@ an interactively selected region. A typical workflow is as follows:
169171
However, if NaN exists in data, it will be treated as 0.
170172

171173
When calculation is complete, a plot would show the radial profile
172-
of the background subtracted data and the photometry results are displayed under the
173-
:guilabel:`CALCULATE` button.
174+
of the background subtracted data and the photometry and model fitting (if requested)
175+
results are displayed under the :guilabel:`CALCULATE` button.
174176

175177
.. figure:: img/imviz_radial_profile.png
176178
:alt: Imviz radial profile plot.
@@ -182,7 +184,14 @@ of the background subtracted data and the photometry results are displayed under
182184

183185
Radial profile (raw).
184186

185-
You can also retrieve the results as `~astropy.table.QTable` as follows,
187+
If you opted to fit a `~astropy.modeling.functional_models.Gaussian1D`
188+
to the radial profile, the last fitted model parameters will be displayed
189+
under the radial profile plot. The model itself can be obtained by as follows.
190+
See :ref:`astropy:astropy-modeling` on how to manipulate the model::
191+
192+
my_gaussian1d = imviz.app.fitted_models['phot_radial_profile']
193+
194+
You can also retrieve the photometry results as `~astropy.table.QTable` as follows,
186195
assuming ``imviz`` is the instance of your Imviz application::
187196

188197
results = imviz.get_aperture_photometry_results()

jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import os
2+
import warnings
13
from datetime import datetime
24

35
import bqplot
46
import numpy as np
57
from astropy import units as u
8+
from astropy.modeling.fitting import LevMarLSQFitter
9+
from astropy.modeling import Parameter
10+
from astropy.modeling.models import Gaussian1D
611
from astropy.table import QTable
712
from astropy.time import Time
813
from ipywidgets import widget_serialization
@@ -41,6 +46,8 @@ class SimpleAperturePhotometry(TemplateMixin, DatasetSelectMixin):
4146
current_plot_type = Unicode().tag(sync=True)
4247
plot_available = Bool(False).tag(sync=True)
4348
radial_plot = Any('').tag(sync=True, **widget_serialization)
49+
fit_radial_profile = Bool(False).tag(sync=True)
50+
fit_results = List().tag(sync=True)
4451

4552
def __init__(self, *args, **kwargs):
4653
super().__init__(*args, **kwargs)
@@ -63,6 +70,7 @@ def __init__(self, *args, **kwargs):
6370
self._fig = bqplot.Figure()
6471
self.plot_types = ["Curve of Growth", "Radial Profile", "Radial Profile (Raw)"]
6572
self.current_plot_type = self.plot_types[0]
73+
self._fitted_model_name = 'phot_radial_profile'
6674

6775
def reset_results(self):
6876
self.result_available = False
@@ -218,6 +226,11 @@ def vue_do_aper_phot(self, *args, **kwargs):
218226
data = self._selected_data
219227
reg = self._selected_subset
220228

229+
# Reset last fitted model
230+
fit_model = None
231+
if self._fitted_model_name in self.app.fitted_models:
232+
del self.app.fitted_models[self._fitted_model_name]
233+
221234
try:
222235
comp = data.get_component(data.main_components[0])
223236
try:
@@ -319,38 +332,68 @@ def vue_do_aper_phot(self, *args, **kwargs):
319332
line_y_sc = bqplot.LinearScale()
320333

321334
if self.current_plot_type == "Curve of Growth":
322-
self._fig.title = 'Curve of growth from Subset center'
335+
self._fig.title = 'Curve of growth from source centroid'
323336
x_arr, sum_arr, x_label, y_label = _curve_of_growth(
324-
comp_data, aperture, phot_table['sum'][0], wcs=data.coords,
325-
background=bg, pixarea_fac=pixarea_fac)
337+
comp_data, phot_aperstats.centroid, aperture, phot_table['sum'][0],
338+
wcs=data.coords, background=bg, pixarea_fac=pixarea_fac)
326339
self._fig.axes = [bqplot.Axis(scale=line_x_sc, label=x_label),
327340
bqplot.Axis(scale=line_y_sc, orientation='vertical',
328341
label=y_label)]
329342
bqplot_line = bqplot.Lines(x=x_arr, y=sum_arr, marker='circle',
330343
scales={'x': line_x_sc, 'y': line_y_sc},
331344
marker_size=32, colors='gray')
345+
bqplot_marks = [bqplot_line]
332346

333347
else: # Radial profile
334348
self._fig.axes = [bqplot.Axis(scale=line_x_sc, label='pix'),
335349
bqplot.Axis(scale=line_y_sc, orientation='vertical',
336350
label=comp.units or 'Value')]
337351

338352
if self.current_plot_type == "Radial Profile":
339-
self._fig.title = 'Radial profile from Subset center'
353+
self._fig.title = 'Radial profile from source centroid'
340354
x_data, y_data = _radial_profile(
341-
phot_aperstats.data_cutout, phot_aperstats.bbox, aperture, raw=False)
355+
phot_aperstats.data_cutout, phot_aperstats.bbox, phot_aperstats.centroid,
356+
raw=False)
342357
bqplot_line = bqplot.Lines(x=x_data, y=y_data, marker='circle',
343358
scales={'x': line_x_sc, 'y': line_y_sc},
344359
marker_size=32, colors='gray')
345360
else: # Radial Profile (Raw)
346-
self._fig.title = 'Raw radial profile from Subset center'
347-
radial_r, radial_img = _radial_profile(
348-
phot_aperstats.data_cutout, phot_aperstats.bbox, aperture, raw=True)
349-
bqplot_line = bqplot.Scatter(x=radial_r, y=radial_img, marker='circle',
361+
self._fig.title = 'Raw radial profile from source centroid'
362+
x_data, y_data = _radial_profile(
363+
phot_aperstats.data_cutout, phot_aperstats.bbox, phot_aperstats.centroid,
364+
raw=True)
365+
bqplot_line = bqplot.Scatter(x=x_data, y=y_data, marker='circle',
350366
scales={'x': line_x_sc, 'y': line_y_sc},
351367
default_size=1, colors='gray')
352368

353-
self._fig.marks = [bqplot_line]
369+
# Fit Gaussian1D to radial profile data.
370+
# mean is fixed at 0 because we recentered to centroid.
371+
if self.fit_radial_profile:
372+
fitter = LevMarLSQFitter()
373+
y_max = y_data.max()
374+
std = 0.5 * (phot_table['semimajor_sigma'][0] +
375+
phot_table['semiminor_sigma'][0])
376+
if isinstance(std, u.Quantity):
377+
std = std.value
378+
gs = Gaussian1D(amplitude=y_max, mean=0, stddev=std,
379+
fixed={'mean': True, 'amplitude': True},
380+
bounds={'amplitude': (y_max * 0.5, y_max)})
381+
with warnings.catch_warnings(record=True) as warns:
382+
fit_model = fitter(gs, x_data, y_data)
383+
if len(warns) > 0:
384+
msg = os.linesep.join([str(w.message) for w in warns])
385+
self.hub.broadcast(SnackbarMessage(
386+
f"Radial profile fitting: {msg}", color='warning', sender=self))
387+
y_fit = fit_model(x_data)
388+
self.app.fitted_models[self._fitted_model_name] = fit_model
389+
bqplot_fit = bqplot.Lines(x=x_data, y=y_fit, marker=None,
390+
scales={'x': line_x_sc, 'y': line_y_sc},
391+
colors='magenta', line_style='dashed')
392+
bqplot_marks = [bqplot_line, bqplot_fit]
393+
else:
394+
bqplot_marks = [bqplot_line]
395+
396+
self._fig.marks = bqplot_marks
354397

355398
except Exception as e: # pragma: no cover
356399
self.reset_results()
@@ -379,7 +422,18 @@ def vue_do_aper_phot(self, *args, **kwargs):
379422
f'{x:.4e} ({phot_table["aperture_sum_counts_err"][0]:.4e})'})
380423
else:
381424
tmp.append({'function': key, 'result': str(x)})
425+
426+
# Also display fit results
427+
fit_tmp = []
428+
if fit_model is not None and isinstance(fit_model, Gaussian1D):
429+
for param in ('fwhm', 'amplitude'): # mean is fixed at 0
430+
p_val = getattr(fit_model, param)
431+
if isinstance(p_val, Parameter):
432+
p_val = p_val.value
433+
fit_tmp.append({'function': param, 'result': f'{p_val:.4e}'})
434+
382435
self.results = tmp
436+
self.fit_results = fit_tmp
383437
self.result_available = True
384438
self.radial_plot = self._fig
385439
self.bqplot_figs_resize = [self._fig]
@@ -389,7 +443,7 @@ def vue_do_aper_phot(self, *args, **kwargs):
389443
# NOTE: These are hidden because the APIs are for internal use only
390444
# but we need them as a separate functions for unit testing.
391445

392-
def _radial_profile(radial_cutout, reg_bb, aperture, raw=False):
446+
def _radial_profile(radial_cutout, reg_bb, centroid, raw=False):
393447
"""Calculate radial profile.
394448
395449
Parameters
@@ -400,23 +454,24 @@ def _radial_profile(radial_cutout, reg_bb, aperture, raw=False):
400454
reg_bb : obj
401455
Bounding box from ``ApertureStats``.
402456
403-
aperture : obj
404-
``photutils`` aperture object.
457+
centroid : tuple of int
458+
``ApertureStats`` centroid or desired center in ``(x, y)``.
405459
406460
raw : bool
407461
If `True`, returns raw data points for scatter plot.
408462
Otherwise, use ``imexam`` algorithm for a clean plot.
409463
410464
"""
411465
reg_ogrid = np.ogrid[reg_bb.iymin:reg_bb.iymax, reg_bb.ixmin:reg_bb.ixmax]
412-
radial_dx = reg_ogrid[1] - aperture.positions[0]
413-
radial_dy = reg_ogrid[0] - aperture.positions[1]
466+
radial_dx = reg_ogrid[1] - centroid[0]
467+
radial_dy = reg_ogrid[0] - centroid[1]
414468
radial_r = np.hypot(radial_dx, radial_dy)[~radial_cutout.mask].ravel() # pix
415469
radial_img = radial_cutout.compressed() # data unit
416470

417471
if raw:
418-
x_arr = radial_r
419-
y_arr = radial_img
472+
i_arr = np.argsort(radial_r)
473+
x_arr = radial_r[i_arr]
474+
y_arr = radial_img[i_arr]
420475
else:
421476
# This algorithm is from the imexam package,
422477
# see licenses/IMEXAM_LICENSE.txt for more details
@@ -427,7 +482,7 @@ def _radial_profile(radial_cutout, reg_bb, aperture, raw=False):
427482
return x_arr, y_arr
428483

429484

430-
def _curve_of_growth(data, aperture, final_sum, wcs=None, background=0, n_datapoints=10,
485+
def _curve_of_growth(data, centroid, aperture, final_sum, wcs=None, background=0, n_datapoints=10,
431486
pixarea_fac=None):
432487
"""Calculate curve of growth for aperture photometry.
433488
@@ -436,8 +491,14 @@ def _curve_of_growth(data, aperture, final_sum, wcs=None, background=0, n_datapo
436491
data : ndarray or `~astropy.units.Quantity`
437492
Data for the calculation.
438493
494+
centroid : tuple of int
495+
``ApertureStats`` centroid or desired center in ``(x, y)``.
496+
439497
aperture : obj
440-
``photutils`` aperture object.
498+
``photutils`` aperture to use, except its center will be
499+
changed to the given ``centroid``. This is because the aperture
500+
might be hand-drawn and a more accurate centroid has been
501+
recalculated separately.
441502
442503
final_sum : float or `~astropy.units.Quantity`
443504
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
477538
if isinstance(aperture, CircularAperture):
478539
x_label = 'Radius (pix)'
479540
x_arr = np.linspace(0, aperture.r, num=n_datapoints)[1:]
480-
aper_list = [CircularAperture(aperture.positions, cur_r) for cur_r in x_arr[:-1]]
541+
aper_list = [CircularAperture(centroid, cur_r) for cur_r in x_arr[:-1]]
481542
elif isinstance(aperture, EllipticalAperture):
482543
x_label = 'Semimajor axis (pix)'
483544
x_arr = np.linspace(0, aperture.a, num=n_datapoints)[1:]
484545
a_arr = x_arr[:-1]
485546
b_arr = aperture.b * a_arr / aperture.a
486-
aper_list = [EllipticalAperture(aperture.positions, cur_a, cur_b, theta=aperture.theta)
547+
aper_list = [EllipticalAperture(centroid, cur_a, cur_b, theta=aperture.theta)
487548
for (cur_a, cur_b) in zip(a_arr, b_arr)]
488549
elif isinstance(aperture, RectangularAperture):
489550
x_label = 'Width (pix)'
490551
x_arr = np.linspace(0, aperture.w, num=n_datapoints)[1:]
491552
w_arr = x_arr[:-1]
492553
h_arr = aperture.h * w_arr / aperture.w
493-
aper_list = [RectangularAperture(aperture.positions, cur_w, cur_h, theta=aperture.theta)
554+
aper_list = [RectangularAperture(centroid, cur_w, cur_h, theta=aperture.theta)
494555
for (cur_w, cur_h) in zip(w_arr, h_arr)]
495556
else:
496557
raise TypeError(f'Unsupported aperture: {aperture}')

jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@
110110
></v-select>
111111
</v-row>
112112

113+
<v-row v-if="current_plot_type.indexOf('Radial Profile') != -1">
114+
115+
<v-switch
116+
label="Fit Gaussian"
117+
hint="Fit Gaussian1D to radial profile"
118+
v-model="fit_radial_profile"
119+
persistent-hint>
120+
</v-switch>
121+
</v-row>
122+
113123
<v-row justify="end">
114124
<v-btn color="primary" text @click="do_aper_phot">Calculate</v-btn>
115125
</v-row>
@@ -123,8 +133,25 @@
123133
<jupyter-widget :widget="radial_plot" style="width: 100%; height: 480px" />
124134
</v-row>
125135

136+
<div v-if="plot_available && fit_radial_profile">
137+
<j-plugin-section-header>Gaussian Fit Results</j-plugin-section-header>
138+
<v-row no-gutters>
139+
<v-col cols=6><U>Result</U></v-col>
140+
<v-col cols=6><U>Value</U></v-col>
141+
</v-row>
142+
<v-row
143+
v-for="item in fit_results"
144+
:key="item.function"
145+
no-gutters>
146+
<v-col cols=6>
147+
{{ item.function }}
148+
</v-col>
149+
<v-col cols=6>{{ item.result }}</v-col>
150+
</v-row>
151+
</div>
152+
126153
<div v-if="result_available">
127-
<j-plugin-section-header>Results</j-plugin-section-header>
154+
<j-plugin-section-header>Photometry Results</j-plugin-section-header>
128155
<v-row no-gutters>
129156
<v-col cols=6><U>Result</U></v-col>
130157
<v-col cols=6><U>Value</U></v-col>

jdaviz/configs/imviz/tests/test_simple_aper_phot.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ def test_plugin_wcs_dithered(self):
1818

1919
phot_plugin = self.imviz.app.get_tray_item_from_name('imviz-aper-phot-simple')
2020

21+
# Model fitting is already tested in astropy.
22+
# Here, we enable it just to make sure it does not crash.
23+
phot_plugin.fit_radial_profile = True
24+
2125
# Make sure invalid Data/Subset selection does not crash plugin.
2226
with pytest.raises(ValueError):
2327
phot_plugin.dataset_selected = 'no_such_data'
@@ -164,7 +168,7 @@ def test_plugin_wcs_dithered(self):
164168
# Curve of growth
165169
phot_plugin.current_plot_type = 'Curve of Growth'
166170
phot_plugin.vue_do_aper_phot()
167-
assert phot_plugin._fig.title == 'Curve of growth from Subset center'
171+
assert phot_plugin._fig.title == 'Curve of growth from source centroid'
168172

169173

170174
class TestSimpleAperPhot_NoWCS(BaseImviz_WCS_NoWCS):
@@ -255,21 +259,22 @@ def test_annulus_background(imviz_helper):
255259
class TestRadialProfile():
256260
def setup_class(self):
257261
data = np.ones((51, 51)) * u.nJy
258-
self.aperture = EllipticalAperture((25, 25), 20, 15)
259-
phot_aperstats = ApertureStats(data, self.aperture)
262+
aperture = EllipticalAperture((25, 25), 20, 15)
263+
phot_aperstats = ApertureStats(data, aperture)
260264
self.data_cutout = phot_aperstats.data_cutout
261265
self.bbox = phot_aperstats.bbox
266+
self.centroid = phot_aperstats.centroid
262267

263268
def test_profile_raw(self):
264-
x_arr, y_arr = _radial_profile(self.data_cutout, self.bbox, self.aperture, raw=True)
269+
x_arr, y_arr = _radial_profile(self.data_cutout, self.bbox, self.centroid, raw=True)
265270
# Too many data points to compare each one for X.
266271
assert x_arr.shape == y_arr.shape == (923, )
267272
assert_allclose(x_arr.min(), 0)
268273
assert_allclose(x_arr.max(), 19.4164878389476)
269274
assert_allclose(y_arr, 1)
270275

271276
def test_profile_imexam(self):
272-
x_arr, y_arr = _radial_profile(self.data_cutout, self.bbox, self.aperture, raw=False)
277+
x_arr, y_arr = _radial_profile(self.data_cutout, self.bbox, self.centroid, raw=False)
273278
assert_allclose(x_arr, range(20))
274279
assert_allclose(y_arr, 1)
275280

@@ -293,11 +298,12 @@ def test_curve_of_growth(with_unit):
293298
RectangularAperture(cen, 20, 15))
294299

295300
for aperture in apertures:
296-
final_sum = ApertureStats(data, aperture).sum
301+
astat = ApertureStats(data, aperture)
302+
final_sum = astat.sum
297303
if pixarea_fac is not None:
298304
final_sum = final_sum * pixarea_fac
299305
x_arr, sum_arr, x_label, y_label = _curve_of_growth(
300-
data, aperture, final_sum, background=bg, pixarea_fac=pixarea_fac)
306+
data, astat.centroid, aperture, final_sum, background=bg, pixarea_fac=pixarea_fac)
301307
assert_allclose(x_arr, [2, 4, 6, 8, 10, 12, 14, 16, 18, 20])
302308
assert y_label == expected_ylabel
303309

@@ -316,5 +322,5 @@ def test_curve_of_growth(with_unit):
316322
assert_allclose(sum_arr, [3, 12, 27, 48, 75, 108, 147, 192, 243, 300])
317323

318324
with pytest.raises(TypeError, match='Unsupported aperture'):
319-
_curve_of_growth(data, EllipticalAnnulus(cen, 3, 8, 5), 100,
325+
_curve_of_growth(data, cen, EllipticalAnnulus(cen, 3, 8, 5), 100,
320326
pixarea_fac=pixarea_fac)

0 commit comments

Comments
 (0)