# -*- coding: utf-8 -*-
"""Functions for manipulating Kerr (or any other) images.
All of these functions are accessible through the :class:`ImageArray` attributes e.g.:
k=ImageArray('myfile'); k.level_image().
The first 'im' function argument is automatically added in this case.
If you want to add new functions that's great. There's a few important points:
* Please make sure they take an image as the first argument
* Don't give them the same name as functions from the numpy library or
skimage library if you don't want to override them.
* The function should not change the shape of the array. Please use crop_image
before doing the function if you want to do that.
* After that you're free to treat im as a ImageArray
or numpy array, it should all behave the same.
"""
__all__ = [
"adjust_contrast",
"align",
"convert",
"correct_drift",
"subtract_image",
"fft",
"filter_image",
"gridimage",
"hist",
"imshow",
"level_image",
"normalise",
"profile_line",
"quantize",
"radial_coordinates",
"radial_profile",
"remove_outliers",
"rotate",
"translate",
"translate_limits",
"plot_histogram",
"threshold_minmax",
"do_nothing",
"denoise",
]
import warnings
import os
import io
import numpy as np
from scipy.interpolate import griddata
from scipy.ndimage import gaussian_filter
from scipy import signal
from matplotlib.colors import to_rgba
import matplotlib.cm as cm
from matplotlib import pyplot as plt
from skimage import measure, transform, filters
from PIL import Image, PngImagePlugin
from Stoner.tools import isTuple, isiterable, make_Data
# from .core import ImageArray
from ..core.base import metadataObject
from .util import sign_loss, _dtype2, _supported_types, prec_loss, dtype_range, _dtype, _scale as im_scale
from ..tools.decorators import changes_size, keep_return_type
from .widgets import LineSelect
from ..compat import string_types, get_filedialog # Some things to help with Python2 and Python3 compatibility
from ..plot.utils import auto_fit_fontsize
try:
from PyQt5.QtGui import QImage
from PyQt5.QtWidgets import QApplication
except ImportError:
QImage = None
QApplication = None
try: # Make OpenCV an optional import
import cv2
except ImportError:
cv2 = None
try: # Make imreg_dfft2 optional
import imreg_dft
except ImportError:
imreg_dft = None
try: # image_registration module
from image_registration import chi2_shift
except ImportError:
chi2_shift = None
IMAGE_FILES = [("Tiff File", "*.tif;*.tiff"), ("PNG files", "*.png", "Numpy Files", "*.npy")]
def _scale(coord, scale=1.0, to_pixel=True):
"""Convert pixel coordinates to scaled coordinates or visa versa.
Args:
coord(int,float or iterable): Coordinates to be scaled
Keyword Arguments:
scale(float): Microns per Pixel scale of image
to_pixel(bool): Force the conversion to be to pixels
Returns:
scaled coordinates.
"""
if isinstance(coord, int):
if not to_pixel:
coord = float(coord) * scale
elif isinstance(coord, float):
if to_pixel:
coord = int(round(coord / scale))
elif isiterable(coord):
coord = tuple([_scale(c, scale, to_pixel) for c in coord])
else:
raise ValueError("coord should be an integer or a float or an iterable of integers and floats")
return coord
[docs]def adjust_contrast(im, lims=(0.1, 0.9), percent=True):
"""Rescale the intensity of the image.
Mostly a call through to skimage.exposure.rescale_intensity. The absolute limits of contrast are
added to the metadata as 'adjust_contrast'
Parameters
----------
lims: 2-tuple
limits of rescaling the intensity
percent: bool
if True then lims are the give the percentile of the image intensity
histogram, otherwise lims are absolute
Returns
-------
image: ImageArray
rescaled image
"""
if percent:
vmin, vmax = np.percentile(im, np.array(lims) * 100)
else:
vmin, vmax = lims[0], lims[1]
im.metadata["adjust_contrast"] = (vmin, vmax)
im = im.rescale_intensity(in_range=(vmin, vmax)) # clip the intensity
return im
def _align_scharr(im, ref, **kargs):
"""Return the translation vector to shirt im to align to ref using the Scharr method."""
scale = np.ceil(np.max(im.shape) / 500.0)
ref1 = filters.edges.scharr(gaussian_filter(ref, sigma=scale, mode="wrap"))
im1 = filters.edges.scharr(gaussian_filter(im, sigma=scale, mode="wrap"))
return _align_imreg_dft(im1, ref1, **kargs)
def _align_chi2_shift(im, ref, **kargs):
"""Return the translation vector to shirt im to align to ref using the chi^2 shift method."""
results = np.array(chi2_shift(ref, im, **kargs))
return -results[1::-1], {}
def _align_imreg_dft(im, ref, **kargs):
"""Return the translation vector to shirt im to align to ref using the imreg_dft method."""
constraints = kargs.pop("constraints", {"angle": [0.0, 0.0], "scale": [1.0, 0.0]})
with warnings.catch_warnings(): # This causes a warning due to the masking
warnings.simplefilter("ignore")
result = imreg_dft.similarity(ref, im, constraints=constraints)
tvec = result["tvec"]
return np.array(tvec), result
def _align_cv2(im, ref, **kargs): # pylint: disable=unused-argument
"""Return the translation vector to shirt im to align to ref using the cv2 method."""
im1_gray = ref.convert("uint8", force_copy=True)
im2_gray = im.convert("uint8", force_copy=True)
# Define the motion model
warp_mode = cv2.MOTION_TRANSLATION
warp_matrix = np.eye(2, 3, dtype=np.float32)
# Specify the number of iterations.
number_of_iterations = 5000
# Specify the threshold of the increment
# in the correlation coefficient between two iterations
termination_eps = 1e-10
# Define termination criteria
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, number_of_iterations, termination_eps)
# Run the ECC algorithm. The results are stored in warp_matrix.
(_, warp_matrix) = cv2.findTransformECC(
im1_gray, im2_gray, warp_matrix, warp_mode, criteria, inputMask=None, gaussFiltSize=1
)
return -warp_matrix[1::-1, 2], {}
[docs]def align(im, ref, method="scharr", **kargs):
"""Use one of a variety of algroithms to align two images.
Args:
im (ndarray) image to align
ref (ndarray) reference array
Keyword Args:
method (str or None):
If given specifies which module to try and use.
Options: 'scharr', 'chi2_shift', 'imreg_dft', 'cv2'
_box (integer, float, tuple of images or floats):
Used with ImageArray.crop to select a subset of the image to use for the aligning process.
scale (int):
Rescale the image and reference image by constant factor before finding the translation vector.
prefilter (callable):
A method to apply to the image before carrying out the translation to the align to the reference.
**kargs (various): All other keyword arguments are passed to the specific algorithm.
Returns
(ImageArray or ndarray) aligned image
Notes:
Currently three algorithms are supported:
- image_registration module's chi^2 shift: This uses a dft with an automatic
up-sampling of the fourier transform for sub-pixel alignment. The metadata
key *chi2_shift* contains the translation vector and errors.
- imreg_dft module's similarity function. This implements a full scale, rotation, translation
algorithm (by default constrained for just translation). It's unclear how much sub-pixel translation
is accommodated.
- cv2 module based affine transform on a gray scale image.
from: http://www.learnopencv.com/image-alignment-ecc-in-opencv-c-python/
"""
# To be consistent with x-y coordinate systems
align_methods = {
"scharr": (_align_scharr, imreg_dft),
"chi2_shift": (_align_chi2_shift, chi2_shift),
"imreg_dft": (_align_imreg_dft, imreg_dft),
"cv2": (_align_cv2, cv2),
}
cls = type(im)
for meth in list(align_methods.keys()):
mod = align_methods[meth][1]
if mod is None:
del align_methods[meth]
method = method.lower()
if not len(align_methods):
raise ImportError("align requires one of imreg_dft, chi2_shift or cv2 modules to be available.")
if method not in align_methods:
raise ValueError(f"{method} is not available either because it is not recognised or there is a missing module")
if "box" in kargs or "_box" in kargs:
box = kargs.pop("box", kargs.pop("_box"))
if not isiterable(box):
box = [box]
working = im.crop(*box, copy=True)
if ref.shape != working.shape:
ref = ref.view(cls).crop(*box, copy=True)
else:
working = im
scale = kargs.pop("scale", None)
mode = kargs.pop("mode", "mirror")
cval = kargs.pop("cval", im.mean())
if mode == "mean":
mode = "constant"
cval = im.mean()
if scale:
working = working.rescale(scale, order=3)
ref = transform.rescale(ref, scale, order=3)
prefilter = kargs.pop("prefilter", True)
try:
tvec, data = align_methods[method][0](working, ref, **kargs)
except (ValueError, TypeError):
tvec = (0, 0)
data = im
data.pop("timg", None)
if scale:
tvec /= scale
new_im = im.shift((tvec[1], tvec[0]), prefilter=prefilter, mode=mode, cval=cval)
new_im.metadata.update(data)
new_im["tvec"] = tuple(tvec)
new_im["translation_limits"] = new_im.translate_limits("tvec")
return new_im
[docs]def convert(image, dtype, force_copy=False, uniform=False, normalise=True):
"""Convert an image to the requested data-type.
Warnings are issued in case of precision loss, or when negative values
are clipped during conversion to unsigned integer types (sign loss).
Floating point values are expected to be normalized and will be clipped
to the range [0.0, 1.0] or [-1.0, 1.0] when converting to unsigned or
signed integers respectively.
Numbers are not shifted to the negative side when converting from
unsigned to signed integer types. Negative values will be clipped when
converting to unsigned integers.
Parameters
----------
image : ndarray
Input image.
dtype : dtype
Target data-type.
force_copy : bool
Force a copy of the data, irrespective of its current dtype.
uniform : bool
Uniformly quantize the floating point range to the integer range.
By default (uniform=False) floating point values are scaled and
rounded to the nearest integers, which minimizes back and forth
conversion errors.
normalise : bool
When converting from int types to float normalise the resulting array
by the maximum allowed value of the int type.
References
----------
(1) DirectX data conversion rules.
http://msdn.microsoft.com/en-us/library/windows/desktop/dd607323%28v=vs.85%29.aspx
(2) Data Conversions.
In "OpenGL ES 2.0 Specification v2.0.25", pp 7-8. Khronos Group, 2010.
(3) Proper treatment of pixels as integers. A.W. Path.
In "Graphics Gems I", pp 249-256. Morgan Kaufmann, 1990.
(4) Dirty Pixels. J. Blinn.
In "Jim Blinn's corner: Dirty Pixels", pp 47-57. Morgan Kaufmann, 1998.
"""
image = np.asarray(image)
dtypeobj = np.dtype(dtype)
dtypeobj_in = image.dtype
dtype = dtypeobj.type
dtype_in = dtypeobj_in.type
if dtype_in == dtype:
if force_copy:
image = image.clone
return image
if not (dtype_in in _supported_types and dtype in _supported_types):
raise ValueError(f"can not convert {dtype_in} to {dtype}.")
kind = dtypeobj.kind
kind_in = dtypeobj_in.kind
itemsize = dtypeobj.itemsize
itemsize_in = dtypeobj_in.itemsize
if kind == "b":
# to binary image
if kind_in in "fi":
sign_loss(dtype_in, dtypeobj)
prec_loss(dtypeobj_in, dtypeobj)
return image > dtype_in(dtype_range[dtype_in][1] / 2)
if kind_in == "b":
# from binary image, to float and to integer
result = np.where(~image, *dtype_range[dtype])
return result
if kind in "ui":
imin = np.iinfo(dtype).min
imax = np.iinfo(dtype).max
if kind_in in "ui":
imin_in = np.iinfo(dtype_in).min
imax_in = np.iinfo(dtype_in).max
if kind_in == "f":
if np.min(image) < -1.0 or np.max(image) > 1.0:
raise ValueError("Images of type float must be between -1 and 1.")
if kind == "f":
# floating point -> floating point
if itemsize_in > itemsize:
prec_loss(dtypeobj_in, dtypeobj)
return image.astype(dtype)
# floating point -> integer
prec_loss(dtypeobj_in, dtypeobj)
# use float type that can represent output integer type
image = np.array(image, _dtype(itemsize, dtype_in, np.float32, np.float64))
if not uniform:
if kind == "u":
image *= imax
elif itemsize_in <= itemsize and itemsize == 8: # f64->int64 needs care to avoid overruns!
image *= 2**54 # float64 has 52bits of mantissa, so this will avoid precission loss for a +/-1 range
np.rint(image, out=image)
image = image.astype(dtype)
np.clip(image, -(2**54), 2**54 - 1, out=image)
image *= 512
else:
image *= imax - imin
image -= 1.0
image /= 2.0
np.rint(image, out=image)
np.clip(image, imin, imax, out=image)
elif kind == "u":
image *= imax + 1
np.clip(image, 0, imax, out=image)
else:
image *= (imax - imin + 1.0) / 2.0
np.floor(image, out=image)
np.clip(image, imin, imax, out=image)
return image.astype(dtype)
if kind == "f":
# integer -> floating point
if itemsize_in >= itemsize:
prec_loss(dtypeobj_in, dtypeobj)
# use float type that can exactly represent input integers
image = np.array(image, _dtype(itemsize_in, dtype, np.float32, np.float64))
if normalise: # normalise floats by maximum value of int type
if kind_in == "u":
image /= imax_in
# DirectX uses this conversion also for signed ints
# if imin_in:
# np.maximum(image, -1.0, out=image)
else:
image *= 2.0
image += 1.0
image /= imax_in - imin_in
return image.astype(dtype)
if kind_in == "u":
if kind == "i":
# unsigned integer -> signed integer
image = im_scale(image, 8 * itemsize_in, 8 * itemsize - 1, dtypeobj_in, dtypeobj)
return image.view(dtype)
# unsigned integer -> unsigned integer
return im_scale(image, 8 * itemsize_in, 8 * itemsize, dtypeobj_in, dtypeobj)
if kind == "u":
# signed integer -> unsigned integer
sign_loss(dtype_in, dtypeobj)
image = im_scale(image, 8 * itemsize_in - 1, 8 * itemsize, dtypeobj_in, dtypeobj)
result = np.empty(image.shape, dtype)
np.maximum(image, 0, out=result, dtype=image.dtype, casting="unsafe")
return result
# signed integer -> signed integer
if itemsize_in > itemsize:
return im_scale(image, 8 * itemsize_in - 1, 8 * itemsize - 1, dtypeobj_in, dtypeobj)
image = image.astype(_dtype2("i", itemsize * 8))
image -= imin_in
image = im_scale(image, 8 * itemsize_in, 8 * itemsize, dtypeobj_in, dtypeobj, copy=False)
image += imin
return image.astype(dtype)
[docs]def correct_drift(im, ref, **kargs):
"""Align images to correct for image drift.
Args:
ref (ImageArray): Reference image with assumed zero drift
Keyword Arguments:
threshold (float): threshold for detecting imperfections in images
(see skimage.feature.corner_fast for details)
upsample_factor (float): the resolution for the shift 1/upsample_factor pixels registered.
see skimage.feature.register_translation for more details
box (sequence of 4 ints): defines a region of the image to use for identifyign the drift
defaults to the whol image. Use this to avoid drift calculations being confused by
the scale bar/annotation region.
do_shift (bool): Shift the image, or just calculate the drift and store in metadata (default True, shit)
Returns:
A shifted image with the image shift added to the metadata as 'correct drift'.
Detects common features on the images and tracks them moving.
Adds 'drift_shift' to the metadata as the (x,y) vector that translated the
image back to it's origin.
"""
do_shift = kargs.pop("do_shift", True)
kargs["scale"] = kargs.pop("upscale", kargs.get("scale", 5.0))
kargs.setdefault("meothd", "scharr")
ret = align(im, ref, **kargs)
if do_shift:
im = ret
im["correct_drift"] = -np.array(ret["tvec"])[::-1]
return im
[docs]def subtract_image(im, background, contrast=16, clip=True, offset=0.5):
"""Subtract a background image from the ImageArray.
Multiply the contrast by the contrast parameter.
If clip is on then clip the intensity after for the maximum allowed data range.
"""
im = im.asfloat(normalise=False, clip_negative=False)
im = contrast * (im - background) + offset
if clip:
im = im.clip_intensity()
return im
[docs]def fft(im, shift=True, phase=False, remove_dc=False, gaussian=None, window=None):
"""Perform a 2d fft of the image and shift the result to get zero frequency in the centre.
Keyword Args:
shift (bool):
Shift the fft so that zero order is in the centre of the image. Default True
phase (bool, None):
If true, return the phase angle rather than the magnitude if False. If None, return the raw fft.
Default False - return magnitude of fft.
remove_dc(bool):
Replace the points around the dc offset with the mean of the fft to avoid dc offset artefacts.
Default False
gaussian (None or float):
Apply a gaussian blur to the fft where this parameter is the width of the blue in px. Default None for off.
window (None or str):
If not None (default) the image is multiplied by the given window function before the fft is calculated.
This avpoids leaking some signal into the higher frequency bands due to discontinuities at the image edges.
Return:
fft of the image, preserving metadata.
"""
if window:
window = filters.window(window, im.shape)
im = im.clone * window
r = np.fft.fft2(im)
if remove_dc:
fill = r.mean()
r[0, 0] = fill
r[-1, 0] = fill
r[-1, -1] = fill
r[0, -1] = fill
if shift:
r = np.fft.fftshift(r)
if phase is None:
pass
if not phase:
r = np.abs(r)
else:
r = np.angle(r)
r = r.view(type(im))
if isinstance(gaussian, (float, int)):
r.gaussian(gaussian)
r.metadata.update(im.metadata)
return r
[docs]def filter_image(im, sigma=2):
"""Alias for skimage.filters.gaussian."""
return im.gaussian(sigma=sigma)
[docs]def gridimage(im, points=None, xi=None, method="linear", fill_value=None, rescale=False):
"""Use :py:func:`scipy.interpolate.griddata` to shift the image to a regular grid of coordinates.
Args:
points (tuple of (x-co-ords,yco-ordsa)):
The actual sampled data coordinates
xi (tupe of (2D array,2D array)):
The regular grid coordinates (as generated by e.g. :py:func:`np.meshgrid`)
Keyword Arguments:
method ("linear","cubic","nearest"):
how to interpolate, default is linear
fill_value (folat, Callable, None):
What to put when the coordinates go out of range (default is None). May be a callable
in which case the initial image is presented as the only argument. If None, use the mean value.
rescale (bool):
If the x and y coordinates are very different in scale, set this to True.
Returns:
A copy of the modified image. The image data is interpolated and metadata kets "actual_x","actual_y","sample_
x","samp[le_y" are set to give coordinates of new grid.
Notes:
If points and or xi are missed out then we try to construct them from the metadata. For points, the metadata
keys "actual-x" and "actual_y" are looked for and for xi, the metadata keys "sample_x" and "sample_y" are
used. These are set, for example, by the :py:class:`Stoner.HDF5.SXTMImage` loader if the interformeter stage
data was found in the file.
The metadata used in this case is then adjusted as well to ensure that repeated application of this method
doesn't change the image after it has been corrected once.
"""
if points is None:
points = np.column_stack((im["actual_x"].ravel(), im["actual_y"].ravel()))
if xi is None:
xi = xi = (im["sample_x"], im["sample_y"])
if fill_value is None:
fill_value = np.mean
if callable(fill_value):
fill_value = fill_value(im)
im2 = griddata(points, im.ravel(), xi, method, fill_value, rescale)
im2 = type(im)(im2)
im2.metadata = im.metadata
im2.metadata["actual_x"] = xi[0]
im2.metadata["actual_y"] = xi[1]
return im2
[docs]def hist(im, *args, **kargs):
"""Pass through to :py:func:`matplotlib.pyplot.hist` function."""
if isinstance(im, np.ma.MaskedArray):
im_data = im[~im.mask]
else:
im_data = im.ravel()
counts, edges = np.histogram(im_data, *args, **kargs)
centres = (edges[1:] + edges[:-1]) / 2
new = make_Data(np.column_stack((centres, counts)))
new.column_headers = ["Intensity", "Frequency"]
new.setas = "xy"
return new
[docs]def imshow(im, **kwargs):
"""Quickly plot of image.
Keyword Arguments:
figure (int, str or matplotlib.figure):
if int then use figure number given, if figure is 'new' then create a new figure, if None then use
whatever default figure is available
show_axis (bool):
If True, show the axis otherwise don't (default)'
title (str,None,False):
Title for plot - defaults to False (no title). None will take the title from the filename if present
title_args (dict):
Arguments to pass to the title function to control formatting.
cmap (str,matplotlib.cmap):
Colour scheme for plot, defaults to gray
Any masked areas are set to NaN which stops them being plotted at all.
"""
figure = kwargs.pop("figure", "new")
ax = kwargs.pop("ax", None)
# Get a title - from keyword argument, from title attr or filename attr
title = os.path.split(getattr(im, "title", getattr(im, "filename", "")))[-1]
title = kwargs.pop("title", title)
title_args = kwargs.pop("title_args", {})
cmap = kwargs.pop("cmap", "gray")
mask_alpha = kwargs.pop("mask_alpha", getattr(im, "_mask_alpha", 0.5))
mask_col = kwargs.pop("mask_color", getattr(im, "_mask_color", "red"))
if isinstance(cmap, string_types):
cmap = getattr(cm, cmap)
im_data = im
if figure is not None and isinstance(figure, int):
fig = plt.figure(figure)
elif figure is not None and figure == "new":
fig = plt.figure()
elif figure is not None: # matplotlib.figure instance
fig = plt.figure(figure.number)
else:
fig = plt.figure()
if ax is not None:
plt.sca(ax)
else:
ax = plt.gca()
ax.imshow(im_data.view(np.ndarray), cmap=cmap, **kwargs)
if np.ma.is_masked(im):
mask_col = list(to_rgba(mask_col))
mask_col[-1] = mask_alpha
mask_data = np.zeros(im.shape + (4,))
mask_data[im.mask] = mask_col
ax.imshow(mask_data)
if title is None:
if "filename" in im.metadata.keys():
title = os.path.split(im["filename"])[1]
elif hasattr(im, "filename"):
title = os.path.split(im.filename)[1]
else:
title = " "
elif isinstance(title, bool) and not title:
title = ""
txt = ax.set_title(title, **title_args)
if not title_args.get("fontdict", {}).get("fontsize", False):
gs = ax.get_gridspec()
width = 0.9 / gs.ncols
height = 0.09 / gs.nrows
auto_fit_fontsize(txt, width, height)
ax.axis("on" if kwargs.get("show_axis", False) else "off")
if QImage is None: # No Qt5
return fig
def add_figure_to_clipboard(event):
if event.key == "ctrl+c":
with io.BytesIO() as buffer:
fig.savefig(buffer)
QApplication.clipboard().setImage(QImage.fromData(buffer.getvalue()))
fig.canvas.mpl_connect("key_press_event", add_figure_to_clipboard)
return fig
[docs]def level_image(im, poly_vert=1, poly_horiz=1, box=None, poly=None, mode="clip"):
"""Subtract a polynomial background from image.
Keyword Arguments:
poly_vert (int): fit a polynomial in the vertical direction for the image of order
given. If 0 do not fit or subtract in the vertical direction
poly_horiz (int): fit a polynomial of order poly_horiz to the image. If 0 given
do not subtract
box (array, list or tuple of int): [xmin,xmax,ymin,ymax] define region for fitting. IF None use entire
image
poly (list or None): [pvert, phoriz] pvert and phoriz are arrays of polynomial coefficients
(highest power first) to subtract in the horizontal and vertical
directions. If None function defaults to fitting its own polynomial.
mode (str): Either 'clip' or 'norm' - specifies how to handle intensitry values that end up being outside
of the accepted range for the image.
Returns:
A new copy of the processed images.
Fit and subtract a background to the image. Fits a polynomial of order
given in the horizontal and vertical directions and subtracts. If box
is defined then level the *entire* image according to the
gradient within the box. The polynomial subtracted is added to the
metadata as 'poly_vert_subtract' and 'poly_horiz_subtract'
"""
if box is None:
box = im.max_box
cim = im.crop(box=box)
(vertl, horizl) = cim.shape
p_horiz = 0
p_vert = 0
if poly_horiz > 0:
comp_vert = np.average(cim, axis=0) # average (compress) the vertical values
if poly is not None:
p_horiz = poly[0]
else:
p_horiz = np.polyfit(np.arange(horizl), comp_vert, poly_horiz) # fit to the horizontal
av = np.average(comp_vert) # get the average pixel height
p_horiz[-1] = p_horiz[-1] - av # maintain the average image height
horizcoord = np.indices(im.shape)[1] # now apply level to whole image
for i, c in enumerate(p_horiz):
im = im - c * horizcoord ** (len(p_horiz) - i - 1)
if poly_vert > 0:
comp_horiz = np.average(cim, axis=1) # average the horizontal values
if poly is not None:
p_vert = poly[1]
else:
p_vert = np.polyfit(np.arange(vertl), comp_horiz, poly_vert)
av = np.average(comp_horiz)
p_vert[-1] = p_vert[-1] - av # maintain the average image height
vertcoord = np.indices(im.shape)[0]
for i, c in enumerate(p_vert):
im = im - c * vertcoord ** (len(p_vert) - i - 1)
im.metadata["poly_sub"] = (p_horiz, p_vert)
if mode == "clip":
im = im.clip_intensity() # saturate any pixels outside allowed range
elif mode == "norm":
im = im.normalise()
return im
[docs]def normalise(im, scale=None, sample=False, limits=(0.0, 1.0), scale_masked=False):
"""Norm alise the data to a fixed scale.
Keyword Arguments:
scale (2-tuple):
The range to scale the image to, defaults to -1 to 1.
saple (box):
Only use a section of the input image to calculate the new scale over. Default is False - whole image
limits (low,high):
Take the input range from the *high* and *low* fraction of the input when sorted.
scale_masked (bool):
If True then the masked region is also scaled, otherwise any masked region is ignored. Default, False.
Returns:
A scaled version of the data. The ndarray min and max methods are used to allow masked images
to be operated on only on the unmasked areas.
Notes:
The *sample* keyword controls the area in which the range of input values is calculated, the actual scaling is
done on the whole image.
The *limits* parameter is used to set the input scale being normalised from - if an image has a few outliers
then this setting can be used to clip the input range before normalising. The parameters in the limit are the
values at the *low* and *high* fractions of the cumulative distribution functions.
"""
mask = im.mask
cls = type(im)
im = im.astype(float)
if scale is None:
scale = (-1.0, 1.0)
section = im[im._box(sample)]
section = section[~section.mask]
if limits != (0.0, 1.0):
low, high = limits
low = np.sort(section.ravel())[int(low * section.size)]
high = np.sort(section.ravel())[int(high * section.size)]
im.clip_intensity(limits=(low, high))
else:
high = section.max()
low = section.min()
if not isTuple(scale, float, float, strict=False):
raise ValueError("scale should be a 2-tuple of floats.")
scaled = (im.data - low) / (high - low)
delta = scale[1] - scale[0]
offset = scale[0]
scaled = scaled * delta + offset
if not scale_masked:
im = np.where(im.mask, im, scaled).view(cls)
else:
im = scaled.view(cls)
im.mask = mask
return im
def clip_neg(im):
"""Clip negative pixels to 0.
Most useful for float where pixels above 1 are reduced to 1.0 and -ve pixels
are changed to 0.
"""
im[im < 0] = 0
return im
[docs]def profile_line(img, src=None, dst=None, linewidth=1, order=1, mode="constant", cval=0.0, constrain=True, **kargs):
"""Wrap sckit-image method of the same name to get a line_profile.
Parameters:
img(ImageArray):
Image data to take line section of
Keyword Parameters:
src, dst (2-tuple of int or float):
start and end of line profile. If the coordinates
are given as integers then they are assumed to be pxiel coordinates, floats are
assumed to be real-space coordinates using the embedded metadata.
linewidth (int):
the wideth of the profile to be taken.
order (int 1-3):
Order of interpolation used to find image data when not aligned to a point
mode (str):
How to handle data outside of the image.
cval (float):
The constant value to assume for data outside of the image is mode is "constant"
constrain (bool):
Ensure the src and dst are within the image (default True).
no_scale (bool):
Do not attempt to scale values by the image scale and work in pixels throughout. (default False)
Returns:
A :py:class:`Stoner.Data` object containing the line profile data and the metadata from the image.
"""
scale = 1.0 if kargs.get("no_scale", False) else img.get("MicronsPerPixel", 1.0)
r, c = img.shape
fast_mode = False
if src is None and dst is None:
if "x" in kargs:
src = (0, kargs["x"])
dst = (r, kargs["x"])
fast_mode = kargs.get("no_scale", False)
if "y" in kargs:
src = (kargs["y"], 0)
dst = (kargs["y"], c)
fast_mode = kargs.get("no_scale", False)
if src is None and dst is None:
src, dst = LineSelect()(img)
if isinstance(src, float):
src = (src, src)
if isinstance(dst, float):
dst = (dst, dst)
dst = _scale(dst, scale)
src = _scale(src, scale)
if not isTuple(src, int, int):
raise ValueError("src coordinates are not a 2-tuple of ints.")
if not isTuple(dst, int, int):
raise ValueError("dst coordinates are not a 2-tuple of ints.")
if constrain:
fix = lambda x, mx: int(round(sorted([0, x, mx])[1]))
r, c = img.shape
src = list(src)
src = (fix(src[0], r), fix(src[1], c))
dst = (fix(dst[0], r), fix(dst[1], c))
if fast_mode:
if "x" in kargs:
result = img[:, src[1]]
points = np.row_stack((np.ones(img.shape[0]) * src[1], np.arange(img.shape[0])))
else:
result = img[src[0], :]
points = np.row_stack((np.arange(img.shape[1]), np.ones(img.shape[1]) * src[0]))
else:
result = measure.profile_line(img, src, dst, linewidth, order, mode, cval)
points = measure.profile._line_profile_coordinates(src, dst, linewidth)[:, :, 0]
ret = make_Data()
ret.data = points.T
ret.setas = "xy"
x, y = points
x -= x[0]
y -= y[0]
ret &= np.sqrt(x**2 + y**2) * scale
ret &= result
ret.column_headers = ["X", "Y", "Distance", "Intensity"]
ret.setas = "..xy"
ret.metadata = img.metadata.copy()
return ret
[docs]def radial_coordinates(im, centre=(None, None), pixel_size=(1, 1), angle=False):
"""Rerurn a map of the radial coordinates of an image from a given centre, with adjustments for pixel size.
Keyword Arguments:
centre (2-tuple):
Coordinates of centre point in terms of the original pixels. Defaults to(None,None) for the middle of the
image.
pixel_size (2-tuple):
The size of one pixel in (dx by dy) - defaults to 1,1
angle (bool, None):
Whether to return the angles (in radians, True), distances (False) o a complex number (None).
Returns:
An array of the same class as the input, but with values corresponding to the radial coordinates.
"""
cx, cy = centre
r, c = im.shape
dx, dy = pixel_size
cx = c / 2 if cx is None else cx
cy = r / 2 if cy is None else cy
x_r = dx * (np.linspace(0, c - 1, c) - cx)
y_r = dy * (np.linspace(0, r - 1, r) - cy)
Y, X = np.meshgrid(x_r, y_r)
Z = -Y + (0 + 1j) * X
if angle is None:
pass
elif not angle:
Z = np.abs(Z)
else:
Z = np.angle(Z)
Z = Z.view(type(im))
Z.metadata = im.metadata
return Z
[docs]def radial_profile(im, angle=None, r=None, centre=(None, None), pixel_size=(1, 1)):
"""Extract a radial profile line from an image.
Keyword Parameters:
angle (float, tuple, None):
Select the radial angle to include:
- float selects a single angle
- tuple selects a range of angles
- None integrates over all angles
r (array, None):
Edges of the bins in the radual direction - will return r.size-1 points. Default is None which uses the
minimum r value found on the edges of the image.
centre (2-tuple):
Coordinates of centre point in terms of the original pixels. Defaults to(None,None) for the middle of the
image.
pixel_size (2-tuple):
The size of one pixel in (dx by dy) - defaults to 1,1
Returns:
(Data):
A py:class:`Stoner.Data` object with a column for r and columns for mean, std, and number of pixels.
"""
coords = im.radial_coordinates(centre=centre, pixel_size=pixel_size, angle=None)
if r is None: # Identify the minimum edge value
edges = np.append(coords[:, 0], coords[-1, :])
edges = np.append(edges, coords[:, -1])
edges = np.append(edges, coords[0, :])
r_limit = np.abs(edges).min()
r = np.linspace(0, np.ceil(r_limit), int(np.ceil(r_limit) + 1))
r_l = r[:-1]
r_h = r[1:]
r_m = (r_l + r_h) / 2
if angle is None:
angle_select = np.ones_like(coords).astype(bool)
elif isinstance(angle, tuple):
angle_low = np.angle(coords) >= angle[0]
angle_high = np.angle(coords) <= angle[1]
angle_select = np.logical_and(angle_low, angle_high)
elif isinstance(angle, (int, float)):
angle_select = np.isclose(np.angle(coords), angle)
else:
raise TypeError(f"angle should be a float, tuple of two floats or None not a {type(angle)}")
ret = make_Data()
for low, high, mid in zip(r_l, r_h, r_m):
r_select = np.logical_and(np.abs(coords) >= low, np.abs(coords) < high)
data = im[np.logical_and(r_select, angle_select)]
if data.size == 0:
continue
if data.size == 1:
avg = data[0]
std = np.nan
else:
avg = data.mean()
std = data.std()
num = data.size
ret += np.array([low, mid, high, avg, std, num])
ret.column_headers = ["Low_r", "r", "high_r", "mean", "std", "number"]
ret.setas = ".x.ye."
ret.metadata = im.metadata
ret.filename = im.filename[:-4] + "_profile.txt"
return ret
[docs]def quantize(im, output, levels=None):
"""Quantise the image data into fixed levels given by a mapping.
Args:
output (list,array,tuple): Output levels to return.
Keyword Arguments:
levels (list, array or None): The input band markers. If None is constructed from the data.
The number of levels should be one less than the number of output levels given.
Notes:
The routine will ignore all masked pixels and will preserve the mask.
"""
section = im[~im.mask]
mask = im.mask
lmin, lmax = section.min(), section.max() # Dudge to ensure that the bottom and top elements are included.
delta = (lmax - lmin) / 100
if levels is None:
levels = np.linspace(lmin - delta, lmax + delta, len(output) + 1)
elif len(levels) == len(output) + 1:
pass
elif len(levels) == len(output) - 1:
lvl = np.zeros(len(output) + 1)
lvl[1:-1] = levels
lvl[0] = section.min() - delta
lvl[-1] = section.max() + delta
levels = lvl
else:
raise RuntimeError(f"{len(output)} output levels and {len(levels)} input levels")
ret = im.clone
ret.mask = False
for lvl, lvh, val in zip(levels[:-1], levels[1:], output):
select = np.logical_and(np.less_equal(im, lvh), np.greater(im, lvl))
ret[select] = val
ret[mask] = im.view(np.ndarray)[mask] # Put back the original image values where they are masked.
ret.mask = mask # restore mask
return ret
[docs]def remove_outliers(im, percentiles=(0.01, 0.99), replace=None):
"""Find values of the data that are beyond a percentile of the overall distribution and replace them.
Keyword Parameters:
percentile (2 tuple):
Fraction percentiles to consider to be outliers (default is (0.01,0.99) for 1% limits)
replace (2 tuple or None):
Values to set outliers to. If None, then the pixel values at the percentile limits are used.
Returns:
(ndarray):
The modified array.
Use this method if you have an image with a small number of pixels with extreme values that are
out of range.
"""
from scipy.interpolate import interp1d
cdf, bins = im.cumulative_distribution(nbins=min(1000, im.count()))
cdf = interp1d(cdf, bins, kind="linear")
limits = [cdf(x) for x in percentiles]
if replace is None:
replace = limits
im[im < limits[0]] = replace[0]
im[im > limits[1]] = replace[1]
return im
[docs]def rotate(im, angle, resize=False, center=None, order=1, mode="constant", cval=0, clip=True, preserve_range=False):
"""Rotate image by a certain angle around its center.
Parameters:
angle (float):
Rotation angle in **radians** in clockwise direction.
Keyword Parameters:
resize (bool):
Determine whether the shape of the output image will be automatically
calculated, so the complete rotated image exactly fits. Default is
False.
center (iterable of length 2):
The rotation center. If ``center=None``, the image is rotated around
its center, i.e. ``center=(cols / 2 - 0.5, rows / 2 - 0.5)``. Please
note that this parameter is (cols, rows), contrary to normal skimage
ordering.
order (int):
The order of the spline interpolation, default is 1. The order has to
be in the range 0-5. See `skimage.transform.warp` for detail.
mode ({'constant', 'edge', 'symmetric', 'reflect', 'wrap'}):
Points outside the boundaries of the input are filled according
to the given mode. Modes match the behaviour of `numpy.pad`.
cval (float):
Used in conjunction with mode 'constant', the value outside
the image boundaries.
clip (bool):
Whether to clip the output to the range of values of the input image.
This is enabled by default, since higher order interpolation may
produce values outside the given input range.
preserve_range (bool):
Whether to keep the original range of values. Otherwise, the input
image is converted according to the conventions of `Stpomer.Image.ImageArray.as_float`.
Returns:
(ImageFile/ImageArray):
Rotated image
Notes:
(pass through to the skimage.transform.warps.rotate function)
"""
ang = np.rad2deg(-angle)
data = transform.rotate(
im,
ang,
resize=resize,
center=center,
order=order,
mode=mode,
cval=cval,
clip=clip,
preserve_range=preserve_range,
)
print(type(im), type(data))
ret = data.view(type(im))
try:
ret.metadata = im.metadata
ret.metadata["transform:rotation"] = angle
except AttributeError:
pass
return ret
def sgolay2d(img, points=15, poly=1, derivative=None):
"""Implements a 2D Savitsky Golay Filter for a 2D array (e.g. image).
Arguments:
img (ImageArray or ImageFile):
image to be filtered
Keyword Arguments:
points (int):
The number of points in the window aperture. Must be an odd number. (default 15)
poly (int):
Degree of polynomial to use in the filter. (default 1)
derivative (str or None):
Type of defivative to calculate. Can be:
None - smooth only (default)
"x","y" - calculate dIntentity/dx or dIntensity/dy
"both" - calculate the full derivative and return magnitud and angle.
ReturnsL
(imageArray or ImageFile):
filtered image.
Raises:
ValueError if points, order or derivative are incorrect.
Notes:
Adapted from code on the scipy cookbook : https://scipy-cookbook.readthedocs.io/items/SavitzkyGolay.html
"""
# number of terms in the polynomial expression
n_terms = (poly + 1) * (poly + 2) / 2.0
if points % 2 == 0:
raise ValueError("window_size must be odd")
if points**2 < n_terms:
raise ValueError("order is too high for the window size")
half_size = points // 2
# exponents of the polynomial.
# p(x,y) = a0 + a1*x + a2*y + a3*x^2 + a4*y^2 + a5*x*y + ...
# this line gives a list of two item tuple. Each tuple contains
# the exponents of the k-th term. First element of tuple is for x
# second element for y.
# Ex. exps = [(0,0), (1,0), (0,1), (2,0), (1,1), (0,2), ...]
exps = [(k - n, n) for k in range(poly + 1) for n in range(k + 1)]
# coordinates of points
ind = np.arange(-half_size, half_size + 1, dtype=np.float64)
dx = np.repeat(ind, points)
dy = np.tile(ind, [points, 1]).reshape(points**2)
# build matrix of system of equation
A = np.empty((points**2, len(exps)))
for i, exp in enumerate(exps):
A[:, i] = (dx ** exp[0]) * (dy ** exp[1])
# pad input array with appropriate values at the four borders
new_shape = img.shape[0] + 2 * half_size, img.shape[1] + 2 * half_size
Z = np.zeros((new_shape))
# top band
band = img[0, :]
Z[:half_size, half_size:-half_size] = band - np.abs(np.flipud(img[1 : half_size + 1, :]) - band)
# bottom band
band = img[-1, :]
Z[-half_size:, half_size:-half_size] = band + np.abs(np.flipud(img[-half_size - 1 : -1, :]) - band)
# left band
band = np.tile(img[:, 0].reshape(-1, 1), [1, half_size])
Z[half_size:-half_size, :half_size] = band - np.abs(np.fliplr(img[:, 1 : half_size + 1]) - band)
# right band
band = np.tile(img[:, -1].reshape(-1, 1), [1, half_size])
Z[half_size:-half_size, -half_size:] = band + np.abs(np.fliplr(img[:, -half_size - 1 : -1]) - band)
# central band
Z[half_size:-half_size, half_size:-half_size] = img
# top left corner
band = img[0, 0]
Z[:half_size, :half_size] = band - np.abs(np.flipud(np.fliplr(img[1 : half_size + 1, 1 : half_size + 1])) - band)
# bottom right corner
band = img[-1, -1]
Z[-half_size:, -half_size:] = band + np.abs(
np.flipud(np.fliplr(img[-half_size - 1 : -1, -half_size - 1 : -1])) - band
)
# top right corner
band = Z[half_size, -half_size:]
Z[:half_size, -half_size:] = band - np.abs(np.flipud(Z[half_size + 1 : 2 * half_size + 1, -half_size:]) - band)
# bottom left corner
band = Z[-half_size:, half_size].reshape(-1, 1)
Z[-half_size:, :half_size] = band - np.abs(np.fliplr(Z[-half_size:, half_size + 1 : 2 * half_size + 1]) - band)
# solve system and convolve
if derivative is None:
m = np.linalg.pinv(A)[0].reshape((points, -1))
ret = signal.fftconvolve(Z, m, mode="valid").view(type(img))
elif derivative == "x":
c = np.linalg.pinv(A)[1].reshape((points, -1))
ret = signal.fftconvolve(Z, -c, mode="valid").view(type(img))
elif derivative == "y":
r = np.linalg.pinv(A)[2].reshape((points, -1))
ret = signal.fftconvolve(Z, -r, mode="valid").view(type(img))
elif derivative == "both":
c = np.linalg.pinv(A)[1].reshape((points, -1))
r = np.linalg.pinv(A)[2].reshape((points, -1))
ret = signal.fftconvolve(Z, -r, mode="valid"), signal.fftconvolve(Z, -c, mode="valid").view(type(img))
else:
raise ValueError(f"Unknown derivative mode {derivative}")
ret.metadata.update(img.metadata)
return ret
def span(im):
"""Return the minimum and maximum values in the image."""
return np.min(im), np.max(im)
[docs]def translate(im, translation, add_metadata=False, order=3, mode="wrap", cval=None):
"""Translate the image.
Areas lost by move are cropped, and areas gained are made black (0)
The area not lost or cropped is added as a metadata parameter
'translation_limits'
Args:
translate (2-tuple):
translation (x,y)
Keyword Arguments:
add_metadata (bool):
Record the shift in the image metadata order (int): Interpolation order (default, 3, bi-cubic)
mode (str):
How to handle points outside the original image. See :py:func:`skimage.transform.warp`. Defaults to "wrap"
cval (float):
The value to fill with if *mode* is constant. If not specified or None, defaults to the mean pixcel value.
Returns:
im (ImageArray): translated image
"""
translation = [-x for x in translation]
trans = transform.SimilarityTransform(translation=translation)
if cval is None:
cval = im.mean()
im = im.warp(trans, order=order, mode=mode, cval=cval)
if add_metadata:
im.metadata["translation"] = translation
im.metadata["translation_limits"] = translate_limits(im, translation)
return im
[docs]def translate_limits(im, translation, reverse=False):
"""Find the limits of an image after a translation.
After using ImageArray.translate some areas will be black,
this finds the max area that still has original pixels in
Args:
translation: 2-tuple
the (x,y) translation applied to the image
Keyword Args:
reverse (bool):
whether to reverse the translation vector (default False, no)
Returns:
limits: 4-tuple
(xmin,xmax,ymin,ymax) the maximum coordinates of the image with original
information
"""
if isinstance(translation, string_types):
translation = im[translation]
translation = np.array(translation)
if reverse:
translation *= -1
shape = im.shape
xmin = max(0, translation[0])
xmax = min(shape[0], shape[0] + translation[0])
ymin = max(0, translation[1])
ymax = min(shape[1], shape[1] + translation[1])
return (xmin, xmax, ymin, ymax)
[docs]def plot_histogram(im, bins=256):
"""Plot the histogram and cumulative distribution for the image."""
hist, bins = np.histogram(im, bins)
cum, bins = im.cumulative_distribution(nbins=bins)
cum = cum * np.max(hist) / np.max(cum)
plt.figure()
plt.plot(bins, hist, "k-")
plt.plot(bins, cum, "r-")
[docs]def threshold_minmax(im, threshmin=0.1, threshmax=0.9):
"""Return a boolean array which is thresholded between threshmin and threshmax.
(ie True if value is between threshmin and threshmax)
"""
im = im.convert(float)
return np.logical_and(im > threshmin, im < threshmax)
[docs]def denoise(im, weight=0.1):
"""Rename the skimage restore function."""
return im.denoise_tv_chambolle(weight=weight)
[docs]def do_nothing(self):
"""Nulop function for testing the integration into ImageArray."""
return self
@changes_size
def crop(self, *args, **kargs):
"""Crop the image according to a box.
Args:
box(tuple) or 4 separate args or None:
(xmin,xmax,ymin,ymax)
If None image will be shown and user will be asked to select
a box (bit experimental)
Keyword Arguments:
copy(bool):
If True return a copy of ImageFile with the cropped image
Returns:
(ImageArray):
view or copy of array asked for
Notes:
This is essentially like taking a view onto the array
but uses image x,y coords (x,y --> col,row)
Returns a view according to the coords given. If box is None it will
allow the user to select a rectangle. If a tuple is given with None
included then max extent is used for that coord (analogous to slice).
If copy then return a copy of self with the cropped image.
The box can be specified in a number of ways:
- (int):
A border around all sides of the given number pixels is ignored.
- (float 0.0-1.0):
A border of the given fraction of the images height and width is ignored
- (string):
A corresponding item of metadata is located and used to specify the box
- (tuple of 4 ints or floats):
For each item in the tuple it is interpreted as follows:
- (int):
A pixel coordinate in either the x or y direction
- (float 0.0-1.0):
A fraction of the width or height in from the left, right, top, bottom sides
- (float > 1.0):
Is rounded to the nearest integer and used a pixel coordinate.
- None:
The extent of the image is used.
Example:
a=ImageFile(np.arange(12).reshape(3,4))
a.crop(1,3,None,None)
"""
box = self._box(*args, **kargs)
ret = self[box]
if "copy" in kargs.keys() and kargs["copy"]:
ret = ret.clone
return ret
def dtype_limits(self, clip_negative=True):
"""Return intensity limits, i.e. (min, max) tuple, of the image's dtype.
Args:
image(ndarray):
Input image.
clip_negative(bool):
If True, clip the negative range (i.e. return 0 for min intensity)
even if the image dtype allows negative values.
Returns:
(imin, imax : tuple): Lower and upper intensity limits.
"""
if clip_negative is None:
clip_negative = True
imin, imax = dtype_range[self.dtype.type]
if clip_negative:
imin = 0
return imin, imax
@keep_return_type
def asarray(self):
"""Provide a consistent way to get at the underlying array data in both ImageArray and ImageFile objects."""
return self
def asfloat(self, normalise=True, clip=False, clip_negative=False):
"""Return the image converted to floating point type.
If currently an int type and normalise then floats will be normalised
to the maximum allowed value of the int type.
If currently a float type then no change occurs.
If clip then clip values outside the range -1,1
If clip_negative then further clip values to range 0,1
Keyword Arguments:
normalise(bool):
normalise the image to the max value of current int type
clip_negative(bool):
clip negative intensity to 0
"""
if self.dtype.kind == "f":
ret = self
else:
ret = self.convert(dtype=np.float64, normalise=normalise).view(type(self)) # preserve metadata
tmp = metadataObject.__new__(metadataObject)
for k, v in tmp.__dict__.items():
if k not in ret.__dict__:
ret.__dict__[k] = v
c = self.clone # copy formatting and apply to new array
for k, v in c._optinfo.items():
setattr(ret, k, v)
if clip or clip_negative:
ret = ret.clip_intensity(clip_negative=clip_negative)
return ret
def clip_intensity(self, clip_negative=False, limits=None):
"""Clip intensity outside the range -1,1 or 0,1.
Keyword Arguments:
clip_negative(bool):
if True clip to range 0,1 else range -1,1
limits (low,high):
Clip the intensity between low and high rather than zero and 1.
Ensure data range is -1 to 1 or 0 to 1 if clip_negative is True.
"""
if limits is None:
dl = self.dtype_limits(clip_negative=clip_negative)
else:
dl = list(limits)
np.clip(self, dl[0], dl[1], out=self)
return self
def asint(self, dtype=np.uint16):
"""Convert the image to unsigned integer format.
May raise warnings about loss of precision.
"""
if self.dtype.kind == "f" and (np.max(self) > 1 or np.min(self) < -1):
self = self.normalise()
ret = self.convert(dtype)
ret = ret.view(type(self))
tmp = metadataObject.__new__(metadataObject)
for k, v in tmp.__dict__.items():
if k not in ret.__dict__:
ret.__dict__[k] = v
for k, v in self._optinfo.items():
setattr(ret, k, v)
return ret
def save(self, filename=None, **kargs):
"""Save the image into the file 'filename'.
Args:
filename (string, bool or None):
Filename to save data as, if this is None then the current filename for the object is used
If this is not set, then then a file dialog is used. If filename is False then a file dialog is forced.
Keyword Args:
fmt (string or list):
format to save data as. 'tif', 'png' or 'npy' or a list of them. If not included will guess from
filename.
forcetype (bool):
integer data will be converted to np.float32 type for saving. if forcetype then preserve and save as
int type (will be unsigned).
Notes:
Metadata will be preserved in .png and .tif format.
fmt can be 'png', 'npy', 'tif', 'tiff' or a list of more than one of those.
tif is recommended since metadata is lost in .npy format but data is
converted to integer format for png so that definition cannot be
saved.
Since Stoner.Image is meant to be a general 2d array often with negative
and floating point data this poses a problem for saving images. Images
are naturally saved as 8 or more bit unsigned integer values representing colour.
The only obvious way to save an image and preserve negative data
is to save as a float32 tif. This has the advantage over the npy
data type which cannot be opened by external programs and will not
save metadata.
"""
# Standard filename block
if filename is None:
filename = getattr(self, "filename", None)
if filename is None or (isinstance(filename, bool) and not filename):
# now go and ask for one
filename = get_filedialog(what="file", filetypes=IMAGE_FILES)
def_fmt = os.path.splitext(filename)[1][1:] # Get a default format from the filename
if def_fmt not in self.fmts: # Default to png if nothing else
def_fmt = "png"
fmt = kargs.pop("fmt", [def_fmt])
if not isinstance(fmt, list):
fmt = [fmt]
if set(fmt) & set(self.fmts) == set([]):
raise ValueError(f"fmt must be {','.join(self.fmts)}")
fmt = ["tiff" if f == "tif" else f for f in fmt]
self.filename = filename
for fm in fmt:
saver = getattr(self, f"save_{fm}", "save_tif")
if fm == "tiff":
forcetype = kargs.pop("forcetype", False)
saver(filename, forcetype)
else:
saver(filename)
def save_png(self, filename):
"""Save the ImageArray with metadata in a png file.
This can only save as 8bit unsigned integer so there is likely
to be a loss of precision on floating point data"""
pngname = os.path.splitext(filename)[0] + ".png"
meta = PngImagePlugin.PngInfo()
info = self.metadata.export_all()
info = [(i.split("=")[0], "=".join(i.split("=")[1:])) for i in info]
for k, v in info:
meta.add_text(k, v)
s = (self - self.min()) * 256 / (self.max() - self.min())
im = Image.fromarray(s.astype("uint8"), mode="L")
im.save(pngname, pnginfo=meta)
def save_npy(self, filename):
"""Save the ImageArray as a numpy array."""
npyname = os.path.splitext(filename)[0] + ".npy"
np.save(npyname, np.array(self))
def save_tiff(self, filename, forcetype=False):
"""Save the ImageArray as a tiff image with metadata.
Args:
filename (str):
Filename to save file as.
Keyword Args:
forcetype(bool):
(deprecated) if forcetype then preserve data type as best as possible on save.
Otherwise we let the underlying pillow library choose the best data type.
Note:
PIL can save in modes "L" (8bit unsigned int), "I" (32bit signed int),
or "F" (32bit signed float). In general max info is preserved for "F"
type so if forcetype is not specified then this is the default. For
boolean type data mode "L" will suffice and this is chosen in all cases.
The type name is added as a string to the metadata before saving.
"""
from PIL.TiffImagePlugin import ImageFileDirectory_v2
import json
dtype = np.dtype(self.dtype).name # string representation of dtype we can save
self["ImageArray.dtype"] = dtype # add the dtype to the metadata for saving.
if forcetype: # PIL supports uint8, int32 and float32, try to find the best match
if self.dtype == np.uint8 or self.dtype.kind == "b": # uint8 or boolean
im = Image.fromarray(self, mode="L")
elif self.dtype.kind in ["i", "u"]:
im = Image.fromarray(self.astype("int32"), mode="I")
else: # default to float32
im = Image.fromarray(self.astype(np.float32), mode="F")
else:
if (
self.dtype.kind == "b"
): # boolean we're not going to lose data by saving as unsigned int im = Image.fromarray(self)
im = Image.fromarray(self, mode="L")
else: # try to convert everything else to float32 which can has maximum preservation of info
try:
im = Image.fromarray(self)
except TypeError:
im = Image.fromarray(self.astype("float32"))
ifd = ImageFileDirectory_v2()
ifd[270] = json.dumps(
{"type": type(self).__name__, "module": type(self).__module__, "metadata": self.metadata.export_all()}
)
ext = os.path.splitext(filename)[1]
if ext in [".tif", ".tiff"]: # ensure extension is preserved in save
pass
else: # default to tiff
ext = ".tiff"
tiffname = os.path.splitext(filename)[0] + ext
im.save(tiffname, tiffinfo=ifd)