"""
.. include:: ../include/links.rst
"""
import numpy
from scipy import stats, optimize, signal, interpolate
from skimage import measure
from astropy.stats import sigma_clip
from astropy.modeling import models, fitting
from IPython import embed
[docs]
def sigma_clip_stdfunc_mad(data, **kwargs):
"""
A simple wrapper for `scipy.stats.median_abs_deviation`_ that omits NaN
values and rescales the output to match a normal distribution for use in
`astropy.stats.sigma_clip`_.
Args:
data (`numpy.ndarray`_):
Data to clip.
**kwargs:
Passed directly to `scipy.stats.median_abs_deviation`_.
Returns:
scalar-like, `numpy.ndarray`_: See `scipy.stats.median_abs_deviation`_.
"""
return stats.median_abs_deviation(data, **kwargs, nan_policy='omit', scale='normal')
[docs]
class Circle:
"""
Fit the center and radius of a circle fit to a set of data.
Args:
x (array):
The Cartesian x coordinates of the circle
y (array):
The Cartesian y coordinates of the circle
"""
def __init__(self, x, y):
self.x = numpy.atleast_1d(x)
self.y = numpy.atleast_1d(y)
[docs]
def _fom(self, p):
"""
Compute the figure-of-merit that the optimization method
should minimize.
"""
return numpy.sqrt((self.x-p[0])**2 + (self.y-p[1])**2) - p[2]
[docs]
def fit(self):
"""
Determine the best-fitting center for the circle.
Returns:
array: Two-element array with the best-fitting center (x,y).
"""
cx = numpy.median(self.x)
cy = numpy.median(self.y)
cr = numpy.median(numpy.sqrt((self.x-cx)**2 + (self.y - cy)**2))
p0 = numpy.array([cx, cy, cr])
lb = numpy.array([numpy.amin(self.x), numpy.amin(self.y), 0.])
ub = numpy.array([numpy.amax(self.x), numpy.amax(self.y),
numpy.amax(numpy.sqrt(self.x**2+self.y**2))])
r = optimize.least_squares(self._fom, p0, method='trf', bounds=(lb, ub),
diff_step=numpy.full(p0.size, 1e-4, dtype=float)) #, verbose=2)
return r.x
[docs]
@staticmethod
def sample(p, n=1000):
theta = numpy.linspace(-numpy.pi, numpy.pi, n)
return p[2]*numpy.cos(theta) + p[0], p[2]*numpy.sin(theta) + p[1]
[docs]
@staticmethod
def polar(x, y, p):
_x = x - p[0]
_y = y - p[1]
return numpy.sqrt(_x**2 + _y**2), numpy.arctan2(_y, _x)
[docs]
@staticmethod
def area(p):
return numpy.pi * p[2]**2
[docs]
class Ellipse:
"""
Fit an ellipse to a set of coordinates.
Equations are:
Args:
x (array):
The Cartesian x coordinates of the polygon to model.
y (array):
The Cartesian y coordinates of the polygon to model.
"""
def __init__(self, x, y):
self.x = numpy.atleast_1d(x)
self.y = numpy.atleast_1d(y)
[docs]
def _fom(self, p):
"""
Compute the figure-of-merit that the optimization method
should minimize.
"""
cosa = numpy.cos(numpy.radians(p[2]))
sina = numpy.sin(numpy.radians(p[2]))
x = (self.x - p[0]) * cosa - (self.y - p[1]) * sina
y = (self.x - p[0]) * sina + (self.y - p[1]) * cosa
return numpy.absolute(numpy.sqrt(x**2 + (y/p[3])**2) - p[4])
[docs]
def fit(self):
"""
Determine the best-fitting ellipse parameters
"""
cx = numpy.median(self.x)
cy = numpy.median(self.y)
rot = 45.
q = 0.5
a = numpy.median(numpy.sqrt((self.x-cx)**2 + (self.y - cy)**2))
p0 = numpy.array([cx, cy, rot, q, a])
lb = numpy.array([numpy.amin(self.x), numpy.amin(self.y), 0., 0., 0.])
ub = numpy.array([numpy.amax(self.x), numpy.amax(self.y), 180., 1.,
numpy.amax(numpy.sqrt(self.x**2+self.y**2))])
r = optimize.least_squares(self._fom, p0, method='trf', bounds=(lb, ub),
diff_step=numpy.full(p0.size, 1e-4, dtype=float), verbose=2)
return r.x
[docs]
@staticmethod
def sample(p, n=1000):
theta = numpy.linspace(-numpy.pi, numpy.pi, n)
_x = p[4] * numpy.cos(theta)
_y = p[3] * p[4] * numpy.sin(theta)
cosa = numpy.cos(numpy.radians(p[2]))
sina = numpy.sin(numpy.radians(p[2]))
return _x * cosa + _y * sina + p[0], - _x * sina + _y * cosa + p[1]
[docs]
@staticmethod
def area(p):
return numpy.pi * p[3] * p[4]**2
[docs]
@staticmethod
def polar(x, y, p):
cosa = numpy.cos(numpy.radians(p[2]))
sina = numpy.sin(numpy.radians(p[2]))
_x = (x - p[0]) * cosa - (y - p[1]) * sina
_y = ((x - p[0]) * sina + (y - p[1]) * cosa)/p[3]
return numpy.sqrt(_x**2 + _y**2), numpy.arctan2(_y, _x)
[docs]
def get_bg(img, clip_iter=None, sigma_lower=100., sigma_upper=5.):
"""
Measure the background in an image.
Args:
img (`numpy.ndarray`_, `numpy.ma.MaskedArray`_):
2D array with image data.
clip_iter (:obj:`int`, optional):
Number of clipping iterations. If None, no clipping is
performed.
sigma_lower (:obj:`float`, optional):
Sigma level for clipping. Clipping only removes negative outliers.
Ignored if clip_iter is None.
sigma_upper (:obj:`float`, optional):
Sigma level for clipping. Clipping only removes positive
outliers. Ignored if clip_iter is None.
Returns:
:obj:`tuple`: Returns the background level, the standard
deviation in the background, and the number of rejected values
excluded from the computation.
"""
if clip_iter is None:
# Assume the image has sufficient background pixels relative to
# pixels with the fiber output image to find the background
# using a simple median
_img = img.compressed() if isinstance(img, numpy.ma.MaskedArray) else img
bkg = numpy.median(_img)
sig = stats.median_abs_deviation(_img, axis=None, nan_policy='omit', scale='normal')
return bkg, sig, 0
# Clip the high values of the image to get the background and
# background error
clipped_img = sigma_clip(img, sigma_lower=sigma_lower, sigma_upper=sigma_upper,
stdfunc=sigma_clip_stdfunc_mad, maxiters=clip_iter)
bkg = numpy.ma.median(clipped_img)
sig = stats.median_abs_deviation(clipped_img.compressed(), scale='normal')
nrej = numpy.sum(numpy.ma.getmaskarray(clipped_img))
return bkg, sig, nrej
[docs]
def fit_bg(img, degree, sigma_lower=30., sigma_upper=3., maxclipiters=10, cenfunc='median',
fititer=1):
"""
"""
# Check the input
if isinstance(degree, (int, numpy.integer)):
_deg = (degree, degree)
elif isinstance(degree, tuple):
if len(degree) != 2:
raise ValueError('Degree tuple must have two and only two elements.')
_deg = degree
else:
raise TypeError('Degree must be an integer or a tuple of two integers')
# Parse the image
if isinstance(img, numpy.ma.MaskedArray):
input_img = img.data
input_bpm = numpy.ma.getmaskarray(img)
else:
input_img = numpy.atleast_2d(img)
input_bpm = numpy.zeros(input_img.shape, dtype=bool)
# Get the 2D coordinate arrays
n = input_img.shape
y, x = numpy.mgrid[:n[0],:n[1]]
# Iteratively fit
p_init = models.Legendre2D(_deg[0], _deg[1])
fit_p = fitting.LinearLSQFitter()
bg_model = numpy.zeros(n, dtype=float)
for i in range(fititer):
_img = numpy.ma.MaskedArray(input_img - bg_model, mask=input_bpm)
_img = sigma_clip(_img, sigma_lower=sigma_lower, sigma_upper=sigma_upper,
maxiters=maxclipiters, cenfunc=cenfunc)
gpm = numpy.logical_not(numpy.ma.getmaskarray(_img))
print('fitting')
p = fit_p(p_init, x[gpm], y[gpm], input_img[gpm])
print('done')
if i < fititer - 1:
bg_model = p(x, y)
return bg_model, x, y, gpm
[docs]
def iterative_filter(data, window_length, polyorder, clip_iter=None, sigma=3., **kwargs):
"""
Iteratively filter and reject 1D data.
kwargs are passed directly to scipy.signal.savgol_filter.
"""
if data.ndim > 1:
raise ValueError('Data must be 1D.')
if clip_iter is None:
return signal.savgol_filter(data, window_length, polyorder, **kwargs)
_sigma = numpy.squeeze(numpy.asarray([sigma]))
if _sigma.size == 1:
_sigma = numpy.array([sigma, sigma])
elif _sigma.size > 2:
raise ValueError('Either provide a single sigma for both lower and upper rejection, or '
'provide them separately. Cannot provide more than two values.')
nrej = 1
i = 0
_data = numpy.ma.MaskedArray(data)
while i < clip_iter:
bpm = numpy.ma.getmaskarray(_data)
gpm = numpy.logical_not(bpm)
_filt = signal.savgol_filter(_data[gpm], window_length, polyorder, **kwargs)
tmp = sigma_clip(_data[gpm] - _filt, sigma_lower=_sigma[0], sigma_upper=_sigma[1],
stdfunc=sigma_clip_stdfunc_mad, maxiters=1)
_bpm = numpy.ma.getmaskarray(tmp)
if not numpy.any(_bpm):
break
bpm[gpm] |= _bpm
_data[bpm] = numpy.ma.masked
gpm = numpy.logical_not(bpm)
_filt = signal.savgol_filter(data[gpm], window_length, polyorder, **kwargs)
x = numpy.arange(data.size)
return interpolate.interp1d(x[gpm], _filt)(x)
[docs]
class ContourError(Exception):
pass
# TODO:
# - Allow to smooth the image before finding the contour
# - Provide the contour level directly
[docs]
def get_contour(img, level=None, threshold=None, bg=None, sig=None, clip_iter=10, sigma_lower=100.,
sigma_upper=3.):
"""
Return a single coherent contour of an image that contains the most number
of contour points (as a proxy for the one that covers the most area).
If ``sig`` and ``bg`` are None, ``img`` is used with :func:`get_bg` to get
both (using the clipping arguments provided). If ``sig`` is None, but
``bg`` is provided, it *must* be a `numpy.ndarray`_ and :func:`get_bg` is
used to determine ``sig`` and a constant background. If both ``sig`` and
``bg`` are provided, ``bg`` is subtracted directly from ``img``.
Args:
img (`numpy.ndarray`_):
Image to contour
level (float, optional):
The exact level to contour. If provided, all other keyword values
are ignored.
threshold (float, optional):
The threshold in units of background sigma used to set the contour
level.
bg (float, `numpy.ndarray`_, optional):
A background level to subtract. It can be anything that broadcasts
to the shape of ``img``.
sig (float, optional):
The (assumed) constant noise level in the background of the image.
clip_iter (int, optional):
Number of clipping iterations to use when measuring the background
and noise level. This is only used if sig or bg is None. See
:func:`get_bg`.
sigma_lower (float, optional):
Lower sigma rejection limit. This is only used if sig or bg is
None. See :func:`get_bg`.
sigma_upper (float, optional):
Upper sigma rejection limit. This is only used if sig or bg is
None. See :func:`get_bg`.
Returns:
tuple: The contour level, the contour coordinates, the noise level, and
the background level. The noise level will be None if the level is
provided directly.
"""
if level is None:
# Compute the background flux, the standard deviation in the
# background, and the number of rejected pixels
if sig is None:
if bg is None:
bkg, sig, nrej = get_bg(img, clip_iter=clip_iter, sigma_lower=sigma_lower,
sigma_upper=sigma_upper)
else:
bkg, sig, nrej = get_bg(bg, clip_iter=clip_iter, sigma_lower=sigma_lower,
sigma_upper=sigma_upper)
else:
bkg = 0. if bg is None else bg
# Find the contour matching the defined sigma threshold
img_bksub = img - bkg
if threshold is None:
threshold = 2 * numpy.ma.std(img_bksub / sig)
_level = threshold*sig
else:
bkg = 0. if bg is None else bg
img_bksub = img - bkg
_level = level
sig = None
# Transpose to match the numpy/matplotlib convention
_img_bksub = img_bksub.filled(0.0) if isinstance(img_bksub, numpy.ma.MaskedArray) \
else img_bksub
contour = measure.find_contours(_img_bksub.T, level=_level)
if len(contour) == 0:
raise ContourError('no contours found')
# Only keep one contour with the most number of points.
ci = -1
nc = 0
for i,p in enumerate(contour):
if p.shape[0] > nc:
nc = p.shape[0]
ci = i
return _level, contour[ci], sig, bkg
[docs]
def growth_lim(a, lim, fac=1.0, midpoint=None, default=[0., 1.]):
"""
Set the plots limits of an array based on two growth limits.
Args:
a (array-like):
Array for which to determine limits.
lim (:obj:`float`):
Fraction of the total range of the array values to cover. Should
be in the range [0, 1].
fac (:obj:`float`, optional):
Factor to contract/expand the range based on the growth limits.
Default is no change.
midpoint (:obj:`float`, optional):
Force the midpoint of the range to be centered on this value. If
None, set to the median of the data.
default (:obj:`list`, optional):
Default range to return if `a` has no data.
Returns:
:obj:`list`: Lower and upper limits for the range of a plot of the
data in `a`.
"""
# Get the values to plot
_a = a.compressed() if isinstance(a, numpy.ma.MaskedArray) else numpy.asarray(a).ravel()
if len(_a) == 0:
# No data so return the default range
return default
# Sort the values
srt = numpy.ma.argsort(_a)
# Set the starting and ending values based on a fraction of the
# growth
_lim = 1.0 if lim > 1.0 else lim
start = int(len(_a)*(1.0-_lim)/2)
end = int(len(_a)*(_lim + (1.0-_lim)/2))
if end == len(_a):
end -= 1
# Set the full range and increase it by the provided factor
Da = (_a[srt[end]] - _a[srt[start]])*fac
# Set the midpoint if not provided
mid = (_a[srt[start]] + _a[srt[end]])/2 if midpoint is None else midpoint
# Return the range for the plotted data
return [ mid - Da/2, mid + Da/2 ]
[docs]
def atleast_one_decade(lim):
"""
Increase a provided set of limits so that they span at least one decade.
Args:
lim (array-like):
A two-element object with, respectively, the lower and upper limits
on a range.
Returns:
:obj:`list`: The adjusted lower and upper limits on the range.
"""
lglim = numpy.log10(lim)
if int(lglim[1]) - int(numpy.ceil(lglim[0])) > 0:
return (10**lglim).tolist()
m = numpy.sum(lglim)/2
ld = lglim[0] - numpy.floor(lglim[0])
fd = numpy.ceil(lglim[1]) - lglim[1]
w = lglim[1] - m
dw = ld*1.01 if ld < fd else fd*1.01
_lglim = numpy.array([m - w - dw, m + w + dw])
# TODO: The next few lines are a hack to avoid making the upper limit to
# large. E.g., when lim = [ 74 146], the output is [11 1020]. This pushes
# the middle of the range to lower values.
dl = numpy.diff(_lglim)[0]
if dl > 1 and dl > 3*numpy.diff(lglim)[0]:
return atleast_one_decade([lim[0]/3,lim[1]])
return atleast_one_decade((10**_lglim).tolist())
[docs]
def rotate_y_ticks(ax, rotation, va):
"""
Rotate all the existing y tick labels by the provided rotation angle
(deg) and reset the vertical alignment.
Args:
ax (`matplotlib.axes.Axes`_):
Rotate the tick labels for this Axes object. **The object is
edited in place.**
rotation (:obj:`float`):
Rotation angle in degrees
va (:obj:`str`):
Vertical alignment for the tick labels.
"""
for tick in ax.get_yticklabels():
tick.set_rotation(rotation)
tick.set_verticalalignment(va)
[docs]
def convert_radius(r, pixelsize=None, distance=None, inverse=False):
"""
Convert radius coordinates from pixels to mm or degrees.
Args:
r (`numpy.ndarray`_):
Radius **in pixels** for the forward operation. For the reverse
operation (see ``inverse``), the radius should be in degrees if both
``pixelsize`` and ``distance`` are provided, or in mm if only
``pixelsize`` is provided.
pixelsize (:obj:`float`, optional):
Size of the image pixels in mm.
distance (:obj:`float`, optional):
Distance from the fiber output to the detector in mm.
inverse (:obj:`bool`, optional):
Perform the inverse operation; i.e., convert radius coordinates from
mm or degrees to pixels.
Returns:
:obj:`tuple`: A string with the radius units and the updated radius
values converted to either mm in the detector plane or output angle in
degrees with respect to the fiber face normal vector. For the reverse
(see ``inverse``) operation, the returned units should be pixels.
"""
_r = r.copy()
if inverse:
if pixelsize is not None and distance is not None :
_r = distance * numpy.tan(numpy.radians(_r))
r_units = 'mm'
if pixelsize is not None :
_r /= pixelsize
r_units = 'pix'
return r_units, _r
# Convert the radius from pixels to mm
r_units = 'pix'
if pixelsize is not None :
_r *= pixelsize
r_units = 'mm'
# Convert the radius from mm to angle
if pixelsize is not None and distance is not None :
_r = numpy.degrees(numpy.arctan(_r/distance))
r_units = 'deg'
return r_units, _r