# -*- coding: utf-8 -*-
# written by Ralf Biehl at the Forschungszentrum Jülich ,
# Jülich Center for Neutron Science 1 and Institute of Complex Systems 1
# Jscatter is a program to read, analyse and plot data
# Copyright (C) 2015-2019 Ralf Biehl
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Read 2D image files (TIFF) from SAXS cameras and extract the corresponding data.
The sasImage is a 2D array that allows direct subtraction and multiplication (e.g. transmission)
respecting given masks in operations. E.g. ::
sample=js.sas.sasImage('sample.tiff')
solvent=js.sas.sasImage('solvent.tiff')
corrected = sample/sampletransmission - solvent/solventtransmission
Image manipulation like Gaussian filter from scipy.ndimage can be used.
Calibration of detector distance including offset detector positions, radial average, size reduction and more.
.pickCenter allows sensitive detection of the beamcenter in SAS geometry.
.calibrateOffsetDetector allows sensitive calibration of the detector position parameters.
An example is shown in :py:class:`~.sasimagelib.sasImage` .
------
"""
import os
import glob
import copy
import functools
import pickle
import numbers
import warnings
from collections import deque
from xml.etree import ElementTree
import numpy as np
import numpy.ma as ma
import scipy
import scipy.linalg as la
from scipy import ndimage
from scipy.interpolate import griddata, LinearNDInterpolator, NearestNDInterpolator
from scipy.spatial.transform import Rotation
import PIL
import PIL.ImageOps
import PIL.ExifTags
import PIL.ImageSequence
import matplotlib.cm as cm
from matplotlib import colors
from matplotlib.patches import Circle
from matplotlib import pyplot
from matplotlib.widgets import Button
from . import formel
from .dataarray import dataArray as dA
from . import mpl
from . import structurefactor as sf
#: normalized gaussian function
def _gauss(x, A, mean, sigma, bgr):
return A * np.exp(-0.5 * (x - mean) ** 2 / sigma ** 2) / sigma / np.sqrt(2 * np.pi) + bgr
[docs]def shortprint(values, threshold=6, edgeitems=2):
"""
Creates a short handy representation string for array values.
Parameters
----------
values : object
Values to print.
threshold: int default 6
Number of elements to switch to reduced form.
edgeitems : int default 2
Items at the edge.
"""
opt = np.get_printoptions()
np.set_printoptions(threshold=threshold, edgeitems=edgeitems)
valuestr = np.array_str(values)
np.set_printoptions(**opt)
return valuestr
def _w2f(word):
"""
Converts strings if possible to float.
"""
try:
return float(word)
except ValueError:
return word
# noinspection PyMissingOrEmptyDocstring
[docs]def parseXML(text):
root = ElementTree.fromstring(text)
r = etree_to_dict(root)
return r
# noinspection PyMissingOrEmptyDocstring
[docs]def etree_to_dict(root):
# d = {root.tag : map(etree_to_dict, root.getchildren())}
d = {child.attrib['name']: child.text for child in root.iter() if child.text is not None}
return d
[docs]def phase(phases):
"""Transform to [-pi,pi] range."""
return (phases + np.pi) % (2 * np.pi) - np.pi
# noinspection PyIncorrectDocstring
[docs]def sImemoize(cachesize=4, attrnames=[]):
"""
A least-recently-used cache decorator to cache attributes from sasImages dependent on the position attributes.
Cache is according to attribute names of the used class given as list in attrnames.
default is empty list
We use this to avoid multiple calculation of some sasImage parameters for same detector settings
Therefore a small cachesize is ok.
"""
def _memoize(function):
function.hitsmisses = [0, 0]
cache = function.cache = {}
deck = function.deck = deque([], maxlen=cachesize)
function.last = lambda i=-1: function.cache[function.deck[i]]
clsid = function.clsid = None
def clear():
while len(function.deck) > 0:
del function.cache[function.deck.pop()]
function.hitsmisses = [0, 0]
function.clear = clear
@functools.wraps(function)
def _memoizer(*args, **kwargs):
# This is the class of the memoized method
cls = args[0]
# make relevant attribute list and corresponding key
attributes = [getattr(cls, an, None) for an in attrnames]
key = pickle.dumps(attributes, protocol=1)
if function.clsid != id(cls) and len(cache) > 0:
# if wrong id we clear the cache not returning wrong stuff
# because of a copy includes the cache
function.clear()
if len(cache) == 0:
# update clsid at first call
function.clsid = id(cls)
if key in cache:
function.hitsmisses[0] += 1
deck.remove(key)
deck.append(key)
return cache[key]
else:
function.hitsmisses[1] += 1
cache[key] = function(*args, **kwargs)
if len(deck) >= cachesize:
del cache[deck.popleft()]
deck.append(key)
return cache[key]
return _memoizer
return _memoize
# calc peak positions of AgBe
# q=np.r_[0.5:10:0.0001]
# iq=js.sas.AgBeReference(q,data.wavelength[0]/10,n=np.r_[1:15])
# iq.iX[scipy.signal.argrelmax(iq.iY,order=3)[0]]
#: AgBe peak positions
AgBepeaks = [1.0753, 2.1521, 3.2286, 4.3049, 5.3813, 6.4576, 7.5339, 8.6102, 9.6865, 10.7628]
#: Create AgBe peak positions profile
def _agbpeak(q, center=0, fwhm=1, lg=1, asym=0, amplitude=1, bgr=0):
peak = formel.voigt(x=q, center=center, fwhm=fwhm, lg=lg, asym=asym, amplitude=amplitude)
peak.Y += bgr
return peak
# While reading the image file, data are extracted from XML string or text in the EXIF data of the image.
# The following describe what to extract in an line/entry and how to replace:
# 1 name to look for
# 2 the new attribute name (to have later unique names from different detectors)
# 3 a dictionary of char to replace in the line before looking for the keyword/content
# 4 factor to convert to specific units
# 5 return value 'list' or 'string', default list with possible conversion to float
# Not extracted information is in .artist or .imageDescription
exchangekeywords = [['Wavelength', 'wavelength', None, 1, None],
['Flux', 'flux', None, 1, None],
['det_exposure_time', 'exposure_time', None, 1, None],
['det_pixel_size', 'pixel_size', None, 1, None],
['beamcenter_nominal', 'center', None, 1, None],
['detector_dist', 'detector_distance', None, 0.001, None],
['Meas.Description', 'description', None, 1, 'string'],
['wavelength', 'wavelength', None, 1, None],
['Exposure_time', 'exposure_time', None, 1, None],
['Pixel_size', 'pixel_size', {'m': '', 'x ': ''}, 1, None],
['Detector_distance', 'detector_distance', None, 1, None],
['saxsconf_Izero', 'Izero', None, 1, None],
['sample_transfact', 'transmission_factor', None, 1, None],
['sample_thickness', 'sample_thickness', None, 1, None],
['ygon', 'position_y', None, 1, None],
['zgon', 'position_z', None, 1, None]]
[docs]class SubArray(np.ndarray):
"""see __new__"""
def __new__(cls, arr):
"""
Subclass used in sasImage.
Dont use this directly as intended use is through sasImage.
Defines a generic np.ndarray subclass, that stores some metadata in attributes
It seems to be the default way for subclassing maskedArrays to have the array_finalize from this subclass.
"""
x = np.asanyarray(arr).view(cls)
x.comment = []
return x
def __array_finalize__(self, obj):
if callable(getattr(super(SubArray, self), '__array_finalize__', None)):
super(SubArray, self).__array_finalize__(obj)
if hasattr(obj, 'attr'):
for attribut in obj.attr:
self.__dict__[attribut] = getattr(obj, attribut)
try:
# copy tags from reading
self._tags = getattr(obj, '_tags')
except AttributeError:
pass
return
@property
def array(self):
"""As bare array"""
return self.view(np.ndarray)
[docs] def setattr(self, objekt, prepend='', keyadd='_'):
"""
Set (copy) attributes from objekt.
Parameters
----------
objekt : objekt with attr or dictionary
Can be a dictionary of names:value pairs like {'name':[1,2,3,7,9]}
If object has property attr the returned attributenames are copied.
prepend : string, default ''
Prepend this string to all attribute names.
keyadd : char, default='_'
If reserved attributes (T, mean, ..) are found the name is 'T'+keyadd
"""
if hasattr(objekt, 'attr'):
for attribut in objekt.attr:
try:
setattr(self, prepend + attribut, getattr(objekt, attribut))
except AttributeError:
self.comment.append('mapped ' + attribut + ' to ' + attribut + keyadd)
setattr(self, prepend + attribut + keyadd, getattr(objekt, attribut))
elif isinstance(objekt, dict):
for key in objekt:
try:
setattr(self, prepend + key, objekt[key])
except AttributeError:
self.comment.append('mapped ' + key + ' to ' + key + keyadd)
setattr(self, prepend + key + keyadd, objekt[key])
@property
def attr(self):
"""
Show specific attribute names as sorted list of attribute names.
"""
if hasattr(self, '__dict__'):
return sorted([key for key in self.__dict__ if key[0] != '_'])
else:
return []
[docs] def showattr(self, maxlength=None, exclude=None):
"""
Show specific attributes with values as overview.
Parameters
----------
maxlength : int
Truncate string representation after maxlength char.
exclude : list of str,default=['comment']
List of attribute names to exclude from result.
"""
if exclude is None:
exclude = ['comment']
for attr in self.attr:
if attr not in exclude:
values = getattr(self, attr)
# noinspection PyBroadException
try:
valstr = shortprint(values.split('\n'))
print('{:>24} = {:}'.format(attr, valstr[0]))
for vstr in valstr[1:]:
print('{:>25} {:}'.format('', vstr))
except:
print('%24s = %s' % (attr, str(values)[:maxlength]))
def __repr__(self):
# hide that we have a ndarray subclass, just not to confuse people
return self.view(np.ndarray).__repr__()
subarray = SubArray
# noinspection PyProtectedMember,PyMissingOrEmptyDocstring
[docs]class PickerBeamCenter:
""" see init """
def __init__(self, circle, image, destination, symmetry=6):
"""
Class to pick the center of calibration sasImage
circle :
Circle around center to move
image :
image to use for calculation of profiles
destination :
axes where to plot profiles
symmetry :
number of sectors to use for averaging
"""
self.circle = circle
self.fig = circle.figure
self.ax = circle.figure.axes[0]
self.image = image
self.imagegauss = ndimage.filters.gaussian_filter(image.data, 0.8)
self.iX = image.iX
self.iY = image.iY
self.symmetry = symmetry
self.destination = destination
self.cidpress = circle.figure.canvas.mpl_connect('button_press_event', self.on_button_press)
self.cidscroll = circle.figure.canvas.mpl_connect('scroll_event', self.on_scroll)
self.keypress = circle.figure.canvas.mpl_connect('key_press_event', self.on_keypress)
self.destination.text(circle.radius, 0.95,
'center \n[{0:.1f}, {1:.1f}]'.format(self.circle.center[1], self.circle.center[0]),
fontsize=8)
self.radialwidth = circle.radius * 0.3
self.update()
[docs] def on_keypress(self, event):
pressedkey = event.key
# print('-',str(pressedkey), '-')
if pressedkey == 'up':
self.circle.center = self.circle.center[0], self.circle.center[1] - 1
elif pressedkey == 'down':
self.circle.center = self.circle.center[0], self.circle.center[1] + 1
elif pressedkey == 'left':
self.circle.center = self.circle.center[0] - 1, self.circle.center[1]
elif pressedkey == 'right':
self.circle.center = self.circle.center[0] + 1, self.circle.center[1]
elif pressedkey == 'ctrl+up':
self.circle.center = self.circle.center[0], self.circle.center[1] - 0.1
elif pressedkey == 'ctrl+down':
self.circle.center = self.circle.center[0], self.circle.center[1] + 0.1
elif pressedkey == 'ctrl+left':
self.circle.center = self.circle.center[0] - 0.1, self.circle.center[1]
elif pressedkey == 'ctrl+right':
self.circle.center = self.circle.center[0] + 0.1, self.circle.center[1]
elif pressedkey == 'u':
pass
elif pressedkey == '+':
self.circle.set_radius(self.circle.radius + 1)
elif pressedkey == '-':
self.circle.set_radius(self.circle.radius - 1)
elif pressedkey == 'ctrl++':
self.radialwidth += 1
elif pressedkey == 'ctrl+-':
self.radialwidth -= 1
self.update()
[docs] def update(self):
dphi = 2 * np.pi / self.symmetry
# calc azimuth and radial with new center
self.image.setPlaneCenter([self.circle.center[1], self.circle.center[0]])
azimuth = self.image._polarazimuth
radial = self.image._polarradial
awidth = dphi / 2
image = self.imagegauss
for i, angle in enumerate(np.r_[-np.pi:np.pi:dphi]):
mask = ((azimuth > (angle - awidth)) & (azimuth < (angle + awidth)) &
(radial > self.circle.radius - self.radialwidth) & (radial < self.circle.radius + self.radialwidth))
# noinspection PyBroadException
try:
rad = dA(np.stack([radial[mask], image[mask]]))
rad.isort() # sorts along X by default
# return lower number of points from prune
result = rad[:, rad.Y > 0].prune(number=50, type='sum', kind='lin')
result.Y = result.Y / result.Y.max()
if len(self.destination.lines) > i:
# update data
self.destination.lines[i].set_xdata(result.X)
self.destination.lines[i].set_ydata(result.Y)
else:
# line not yet plotted
self.destination.plot(result.X, result.Y)
except:
pass
self.destination.set_xlim(self.circle.radius * 0.7, self.circle.radius * 1.3)
self.destination.texts[0].set_text(
'center \n[{0:.1f}, {1:.1f}]'.format(self.circle.center[1], self.circle.center[0]))
self.destination.texts[0].set_position([self.circle.radius, 0.95])
self.fig.canvas.draw_idle()
# noinspection PyProtectedMember,PyMissingOrEmptyDocstring
[docs]class PickerDetPosition:
""" see init """
def __init__(self, image, lattice, axes, buttons, latticeparameters):
"""
Class to pick the detector position of calibration sasImage
We have 2 axes with the image and the lines to align.
We use buttons to update the image parameters directly and use a single update method for all.
"""
self.lattice = lattice
self.domainsize = latticeparameters[0]
self.asym = latticeparameters[1]
self.lg = latticeparameters[2]
self.rmsd = latticeparameters[3]
self.hklmax = latticeparameters[4]
self.image = image
self.iX = image.iX
self.iY = image.iY
self.axdif = axes[0]
self.axline = axes[1]
self.figure = axes[0].figure
self.dsteps = [0.0001, 0.001, 0.01, 0.1]
self.dstepi = 2
self.asteps = [0.1, 3, 30]
self.astepi = 1
self.csteps = [0.1, 1, 10, 100]
self.cstepi = 1
self.buttons = buttons
self.buttons[0].on_clicked(self.bstep)
self.buttons[1].on_clicked(self.bcenterx_p)
self.buttons[2].on_clicked(self.bcenterx_m)
self.buttons[3].on_clicked(self.bcentery_p)
self.buttons[4].on_clicked(self.bcentery_m)
self.buttons[5].on_clicked(self.bdistance_p)
self.buttons[6].on_clicked(self.bdistance_m)
self.buttons[7].on_clicked(self.balpha_p)
self.buttons[8].on_clicked(self.balpha_m)
self.buttons[9].on_clicked(self.bbeta_p)
self.buttons[10].on_clicked(self.bbeta_m)
self.buttons[11].on_clicked(self.bgamma_p)
self.buttons[12].on_clicked(self.bgamma_m)
self.buttons[13].on_clicked(self.bastep)
self.buttons[14].on_clicked(self.bcstep)
self.axline.text(0.7, 0.6, 'center \n[{0:.1f}, {1:.1f}]'.format(self.image.center[0], self.image.center[1]),
fontsize=8)
self.vmax = self.axline.lines[1].get_ydata().max()
self.vmin = self.vmax * 0.1
self.cidpress = axes[1].figure.canvas.mpl_connect('button_press_event', self.on_button_press)
self.update()
[docs] def bstep(self, event):
# distance steps
self.dstepi = (self.dstepi + 1)
if self.dstepi >= len(self.dsteps):
self.dstepi = 0
self.buttons[0].label.set_text(r'step $\pm${0:}'.format(self.dsteps[self.dstepi] * 1000))
self.figure.canvas.draw_idle()
[docs] def bastep(self, event):
# angular step
self.astepi = (self.astepi + 1)
if self.astepi >= len(self.asteps):
self.astepi = 0
self.buttons[13].label.set_text(r'step $\pm${0:}'.format(self.asteps[self.astepi]))
self.figure.canvas.draw_idle()
[docs] def bcstep(self, event):
# center step
self.cstepi = (self.cstepi + 1)
if self.cstepi >= len(self.csteps):
self.cstepi = 0
self.buttons[14].label.set_text(r'step $\pm${0:}'.format(self.csteps[self.cstepi]))
self.figure.canvas.draw_idle()
[docs] def bcenterx_p(self, event):
self.image.setPlaneCenter([self.image.center[0] + self.csteps[self.cstepi], self.image.center[1]])
self.update()
[docs] def bcenterx_m(self, event):
self.image.setPlaneCenter([self.image.center[0] - self.csteps[self.cstepi], self.image.center[1]])
self.update()
[docs] def bcentery_p(self, event):
self.image.setPlaneCenter([self.image.center[0], self.image.center[1] + self.csteps[self.cstepi]])
self.update()
[docs] def bcentery_m(self, event):
self.image.setPlaneCenter([self.image.center[0], self.image.center[1] - self.csteps[self.cstepi]])
self.update()
[docs] def bdistance_p(self, event):
self.image.setDetectorDistance(self.image.detector_distance[0] + self.dsteps[self.dstepi])
self.update()
[docs] def bdistance_m(self, event):
self.image.setDetectorDistance(self.image.detector_distance[0] - self.dsteps[self.dstepi])
self.update()
[docs] def balpha_p(self, event):
self.image.setPlaneOrientation(alpha=self.image.alpha + self.asteps[self.astepi])
self.update()
[docs] def balpha_m(self, event):
self.image.setPlaneOrientation(alpha=self.image.alpha - self.asteps[self.astepi])
self.update()
[docs] def bbeta_p(self, event):
self.image.setPlaneOrientation(beta=self.image.beta + self.asteps[self.astepi])
self.update()
[docs] def bbeta_m(self, event):
self.image.setPlaneOrientation(beta=self.image.beta - self.asteps[self.astepi])
self.update()
[docs] def bgamma_p(self, event):
self.image.setPlaneOrientation(gamma=self.image.gamma + self.asteps[self.astepi])
self.update()
[docs] def bgamma_m(self, event):
self.image.setPlaneOrientation(gamma=self.image.gamma - self.asteps[self.astepi])
self.update()
[docs] def update(self):
domainsize = self.domainsize
hklmax = self.hklmax
rmsd = self.rmsd
# looking at self
selfradial = self.image.radialAverage()
self.axline.lines[0].set_xdata(selfradial.X)
self.axline.lines[0].set_ydata(selfradial.Y)
bgr = selfradial.Y.min()
srYmax = selfradial.Y.max()
# looking at the lattice
Iradial = sf.radial3DLSF(self.image.pQ.reshape(-1, 3), lattice=self.lattice, domainsize=domainsize,
rmsd=rmsd, hklmax=hklmax, wavelength=self.image.wavelength[0] / 10)
I2d = Iradial.Y.reshape(self.image.shape)
# mask values like center extrema not to be included in min/max
I2d[self.image.mask] = Iradial.Y.min()
# scale to selfradial
scaling = (srYmax - bgr) / (Iradial.Y.max() - Iradial.Y.min())
I2d = (I2d - Iradial.Y.min()) * scaling + bgr
# it is better to recalc for q values and scale this
latticeradial = sf.latticeStructureFactor(selfradial.X, lattice=self.lattice, domainsize=domainsize,
rmsd=rmsd, hklmax=hklmax, wavelength=self.image.wavelength[0] / 10)
scaling = (srYmax - bgr) / (latticeradial.Y.max() - latticeradial.Y.min())
self.axline.lines[1].set_xdata(latticeradial.X)
self.axline.lines[1].set_ydata(latticeradial.Y * scaling + bgr)
visible = ((min(selfradial.X) < np.r_[Iradial.q_hkl]) & (np.r_[Iradial.q_hkl] < max(selfradial.X)))
print('[hkl], 2theta :')
for a, b in zip(Iradial.hkl[visible][:len(Iradial.Braggtheta)],
Iradial.Braggtheta[visible[:len(Iradial.Braggtheta)]]):
print('{0}, {1:.2f}'.format(a, b))
# set new data and use limits to show signal border lines (clip was set to transparency for outliers)
self.axdif.images[1].set_data(I2d)
vmin, vmax = sorted([self.vmin, self.vmax, bgr * 1.1, srYmax * 0.9])[1:3]
self.axdif.images[1].set_clim(vmin=vmin, vmax=vmax)
self.axline.lines[2].set_xdata(latticeradial.X)
self.axline.lines[2].set_ydata(np.ones_like(latticeradial.X) * vmin)
self.axline.lines[3].set_xdata(latticeradial.X)
self.axline.lines[3].set_ydata(np.ones_like(latticeradial.X) * vmax)
self.axline.relim()
self.axline.autoscale_view()
self.figure.texts[0].set_text('center [{0:.1f}, {1:.1f}] pixel\ndistance {2:.1f} mm\n '
r'$\alpha,\beta,\gamma$ '
'{3:.1f}, {4:.1f}, {5:.1f} deg.'.format(
self.image.center[0], self.image.center[1], self.image.detector_distance[0] * 1000,
self.image.alpha, self.image.beta, self.image.gamma))
self.figure.canvas.draw_idle()
# noinspection PyAbstractClass
[docs]class sasImage(SubArray, np.ma.MaskedArray):
""" see __new__ """
def __new__(cls, file, detector_distance=None, center=None,
alpha=None, beta=None, gamma=None, pixel_size=None, wavelength=None,
copy=None, maskbelow=0):
r"""
Creates/reads sasImage as maskedArray from a detector image or array for evaluation.
- All methods of maskedArrays including masking of invalid areas work.
- Masked areas are automatically masked for all math operations.
- Arithmetic operations for sasImages work as for numpy arrays
e.g. to subtract background image or multiplying with transmission or corrections [1]_.
Use the numpy.ma methods.
- Pixel coordinates in images are [height,width] with origin located at upper-left corner.
Parameters
----------
file : string, PIL.Image, ndarray
Filename to read as image or created PIL.Image to process.
- Images are read and information in the EXIF tag is used if present
(if not present add manually detector_distance, center,) using the sasImage methods.
- An PIL.Image can be read with .open or created directly from an numpy array
(maybe read with np.loadtxt) ::
import PIL
import numpy as np
image0 = PIL.Image.open('filename') # read image
imagearray = np.random.rand(256,256)*100 # example as a random array
image1 = PIL.Image.fromarray(imagearray) # create image from array (try image1.show())
# create sasImage with needed parameters
si=js.sas.sasImage(image1,1,[100,100],pixel_size=0.001,wavelength=2)
detector_distance : float, sasImage, optional
Detector distance from calibration measurement or calibrated image in unit m.
Overwrites value in the file EXIF tag.
center : None, list 2xfloat, sasImage, optional
Center is [height, width] pixel position of closest point to sample or primary beam in standard SAS geometry
with origin located at upper-left corner.
Overwrites value given in the file EXIF tag.
alpha : float, optional
Rotation angle between incident beam and detector plane normal in degree.
beta : float, optional
Rotation angle of Detector pixel X dimension around detector plane normal in degree.
gamma : float, optional
Rotation of detector normal around incident beam in degree.
pixel_size : [float,float], optional
Pixel size in [x,y] direction in units m.
wavelength : float, optional
Wavelength in units angstrom.
copy : sasImage, optional
Copy center, detector_distance, alpha, beta, gamma, wavelength, pixel_size from sasImage.
Overwrites corresponding data from file.
maskbelow : float, default =0, optional
Mask values below this value.
Returns
-------
image : sasImage with attributes
- .center : center
- .iX : Height pixel positions
- .iY : Width pixel positions
- .filename
- .artist : Additional attributes from EXIF Tag Artist
- .imageDescription : Additional attributes from EXIF Tag ImageDescription
- .detector_distance
- .alpha, .beta, .gamma as detector plane orientation
- .wavelength wavelength in units Å.
- .pixel_size in units m.
Notes
-----
- Unmasked data can be accessed as .data
- The mask is .mask and initial set to all negative values.
- Masking of a pixel is done as ``image[i,j]=np.ma.masked``.
Use mask methods as implemented.
- Geometry mask methods can be used and additional masking methods from numpy masked Arrays.
::
import jscatter as js
from numpy import ma
cal = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
cal.mask = ma.nomask # reset mask
cal[cal<0]= ma.masked # mask negative values
cal[(cal>30) & (cal<100)] = ma.masked # mask region of values
- TIFF tags with index above 700 are ignored.
- Tested for reading tiff image files from Pilatus detectors as given from our
metal jet SAXS machines Ganesha and Galaxi at JCNS, Jülich.
- Additional SAXSpace TIFF files are supported which show frames per pixel on the Y axis.
This allows to examine the time evolution of the measurement on these line collimation cameras
(Kratky camera).
Instead of the old PIL the newer fork Pillow is needed for the multi page TIFFs.
Additional the pixel_size is set to 0.024 (µm) as for the JCNS CCD camera.
- Center & orientation:
The x,y orientation for images are not well defined and dependent
on the implementation on the specific camera setup.
Typically coordinates are used in [height,width] with the origin in the upper left corner.
This is opposed to the expectation of [x,y] coordinates with the X horizontal
and the origin at the lower-left.
To depict 2D images in the way we expect it from the experimental setup
(location of the center, orientation) it is not useful to change orientation.
Correspondingly the first coordinate (usually expected X) is the height coordinate in vertical direction.
- For convenient reading of several images:
1 Read calibration measurement as ::
cal=js.sas.sasImage('mycalibration.tif')
2 Determine detector distance and center which are stored in calibration sasImage.
3 Read following sasImages copying the information stored in sasImage ``cal`` by ::
sample=js.sas.sasImage('nextsample.tif', copy=cal)
Examples
--------
::
import jscatter as js
#
# Look at calibration measurement
calibration = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
# Check center
# For correct center it should show straight lines (change center to see change)
calibration.showPolar(center=calibration.center,scaleR=3)
# or use pickBeamcenter which seems to be more accurate
calibration.pickBeamcenter()
# Recalibrate with previous found center (calibration sets it already)
calibration.recalibrateDetDistance(showfits=True)
iqcal=calibration.radialAverage()
# This might be used to calibrate detector distance for following measurements as
# empty.setDetectorDistance(calibration)
#
empty = js.sas.sasImage(js.examples.datapath+'/emptycell.tiff')
# Mask beamstop (not the same as calibration, unluckily)
empty.mask4Polygon([370,194],[380,194],[466,0],[456,0])
empty.maskCircle(empty.center, 17)
empty.show()
buffer = js.sas.sasImage(js.examples.datapath+'/buffer.tiff')
buffer.maskFromImage(empty)
buffer.show()
bsa = js.sas.sasImage(js.examples.datapath+'/BSA11mg.tiff')
bsa.maskFromImage(empty)
bsa.show() # by default a log scaled image
#
# subtract buffer (transmission factor is just a guess here, sorry)
new=bsa-buffer*0.2
new.show()
#
iqempty=empty.radialAverage()
iqbuffer=buffer.radialAverage()
iqbsa=bsa.radialAverage()
#
p=js.grace(1,1)
p.plot(iqempty,le='empty cell')
p.plot(iqbuffer,le='buffer')
p.plot(iqbsa,le='bsa 11 mg/ml')
p.title('raw data, no transmission correction')
p.yaxis(min=1,max=1e3,scale='l',label='I(q) / a.u.')
p.xaxis(scale='l',label='q / nm\S-1')
p.legend()
References
----------
.. [1] Everything SAXS: small-angle scattering pattern collection and correction
Brian Richard Pauw J. Phys.: Condens. Matter 25, 383201 (2013)
DOI https://doi.org/10.1088/0953-8984/25/38/383201
"""
# open file
if isinstance(file, str):
# read tiff image
image = PIL.Image.open(file)
else:
# try if this was an opened image
image = file
# get EXIF tags
if hasattr(image, 'tag_v2'):
tags = image.tag_v2
elif hasattr(image,'tag'):
tags = image.tag
else:
tags={}
# convert image to a writeable array (np.array creates by default a copy)
# this ensures that we can write to the array
try:
# try im we have multiple frames as for SAXSpace
# seek(1) returns error for single frame
image.seek(1)
image.seek(0)
# squeeze for single columns
im = np.array([np.array(image) for _ii in PIL.ImageSequence.Iterator(image)]).squeeze()
except (EOFError, AttributeError):
# tif to array conversion for single frame
# im = np.asarray(image.transpose(PIL.Image.FLIP_TOP_BOTTOM))
im = np.array(image)
# create the maskedArray from the base class as view
# create default mask from values smaller zero
# Pilatus detectors have negative values outside sensitive detector area.
sub_im = SubArray(im)
data = np.ma.MaskedArray.__new__(cls, data=sub_im, mask=sub_im < maskbelow)
# default values
data.imageDescription = []
data.artist = []
data.set_fill_value(0)
# the EXIF tags contain all meta information.
# Take them as dictionary and add to artist, imageDescription or respective name from PIL.ExifTags.TAGS.
data._getEXIF(tags)
# set attributes from exif and extract some of these data
data.filename = file
data.description = '---'
# keywords to replace
data._extractAttributes_(exchangekeywords)
if copy is not None:
data.setAttrFromImage(copy)
else:
if center is not None:
data.setPlaneCenter(center)
if detector_distance is not None:
data.setDetectorDistance(detector_distance)
if pixel_size is not None:
data.setPixelSize(pixel_size)
if wavelength is not None:
data.setWavelength(wavelength)
data.setPlaneOrientation(0 if alpha is None else alpha,
0 if beta is None else beta,
0 if gamma is None else gamma)
data._issasImage = True
return data
def _extractAttributes_(self, attriblist):
# extract attributes from EXIF entries
# first words in comments
firstwords = [line.split()[0] for line in self.imageDescription + self.artist if len(line.strip()) > 0]
for attribs in attriblist:
if attribs[0] in firstwords:
self.getfromcomment(attribs[0], replace=attribs[2], newname=attribs[1])
if attribs[4] == 'string':
setattr(self, attribs[1], ' '.join([str(v) for v in getattr(self, attribs[1])]))
else:
setattr(self, attribs[1],
[v * attribs[3] if isinstance(v, numbers.Number) else v for v in getattr(self, attribs[1])])
# noinspection PyBroadException
def _getEXIF(self, tags):
# Take them as dictionary and add to artist, imageDescription or respective name from PIL.ExifTags.TAGS.
self._tags = tags
# extract EXIF data and save them in artist and imageDescription
for k, v in dict(self._tags).items():
if k > 700:
continue
elif k == 270:
# TAGS[270] = 'ImageDescription'
# from Galaxy or Ganesha
self.setattr(
{'imageDescription': [vv[1:].strip() if vv[0] == '#' else vv.strip() for vv in v.splitlines()]})
elif k == 315:
# TAGS[315] = 'Artist'
# in XML tag from Ganesha. Throws error if not a XML tag as for Galaxy
try:
self.entriesXML = parseXML(self._tags[315])
self.setattr({'artist': [str(k) + ' ' + str(v) for k, v in self.entriesXML.items()]})
except ElementTree.ParseError:
if isinstance(self._tags[315], str):
# catch if it is a single string as for SAXSPACE
self.setattr({'artist': [self._tags[315]]})
else:
self.setattr({'artist': []})
else:
if k in PIL.ExifTags.TAGS:
self.setattr({PIL.ExifTags.TAGS[k]: v if isinstance(v, (list, set)) else [v]})
try:
if self.artist[0] == 'Anton Paar GmbH':
# catches SAXSPACE TIFF files
# iv are specific for SAXSPACE
for k, iv in dict({'wavelength': 65024, 'detector_distance': 65060}).items():
v = self._tags[iv]
self.setattr({k: v if isinstance(v, (list, set)) else [v]})
self.pixelSize = 0.024 # 24 µm
except:
pass
return
def _setEXIF(self):
# set Exif entries according to attributes if these were changed
# see PIL.TiffTags.TYPES for types
# we add anything new to TAGS[270]
for k, v in dict(self._tags).items():
if k > 700:
continue
elif k == 270:
# TAGS[270] = 'ImageDescription'
content = ['processed by Jscatter']
content += self.imageDescription
for ekw in exchangekeywords:
# noinspection PyBroadException
try:
content.append(ekw[0] + ' ' + ' '.join([str(a) for a in getattr(self, ekw[1])]))
except:
pass
self._tags[k] = '\n'.join(content)
elif k == 315:
# TAGS[315] = 'Artist'
self._tags[k] = '\n'.join(self.artist)
else:
if k in PIL.ExifTags.TAGS:
content = getattr(self, PIL.ExifTags.TAGS[k])[0]
typ = self._tags.tagtype[k]
if typ == 2:
self._tags[k] = ' '.join(content)
elif typ in [3, 4, 8, 9]:
self._tags[k] = content
else:
self._tags[k] = content
return
@property
@sImemoize(cachesize=1, attrnames=[])
def iY(self):
"""
Y pixel coordinates
"""
return np.repeat(np.r_[0:self.shape[1]][None, :], self.shape[0], axis=0)
@property
@sImemoize(cachesize=1, attrnames=[])
def iX(self):
"""
X pixel coordinates
"""
return np.repeat(np.r_[0:self.shape[0]][:, None], self.shape[1], axis=1)
@property
def array(self):
"""
Strip of all attributes and return a simple array without mask.
"""
return self.data.array
def __repr__(self):
center = self.center if hasattr(self, 'center') else None
detector_distance = self.detector_distance if hasattr(self, 'detector_distance') else None
alpha = self.alpha if hasattr(self, 'alpha') else None
beta = self.beta if hasattr(self, 'beta') else None
gamma = self.gamma if hasattr(self, 'gamma') else None
desc = "sasImage-> \n{0} \ncenter={1} \ndetector distance={2} \nalpha, beta, " \
"gamma ={3},{4},{5} \nshape={6} "
return desc.format(self, center, detector_distance, alpha, beta, gamma, self.shape)
[docs] def setDetectorDistance(self, detector_distance):
"""
Set detector distance as shortest distance to sample along plane normal vector.
Parameters
----------
detector_distance : float, sasImage
New value for detector distance.
If sasImage the detector_distance is copied.
Notes
-----
EXIF data show this as list so we stay to this.
"""
if isinstance(detector_distance, numbers.Number):
self.detector_distance = [detector_distance]
elif isinstance(detector_distance, (list, set)):
self.detector_distance = [v if isinstance(v, numbers.Number) else v for v in detector_distance]
elif isinstance(detector_distance, sasImage):
self.detector_distance = [v if isinstance(v, numbers.Number) else v
for v in detector_distance.detector_distance]
[docs] def setPlaneCenter(self, center):
"""
Set center of plane where plane normal has shortest distance to sample.
In standard SAS geometry this is equal to the beamcenter
Parameters
----------
center : 2x float, sasImage
New value for center as [height, width] coordinates.
If sasImage the center is copied.
"""
if isinstance(center, sasImage):
# copy from object
self.center = list(center.center)
else:
self.center = list(center)
[docs] def setPixelSize(self, pixel_size):
"""
Set pixel_size.
Parameters
----------
pixel_size : [float,float]
Pixel size in [x,y] direction in units m.
"""
if isinstance(pixel_size, sasImage):
# copy from object
self.pixel_size = list(pixel_size.pixel_size)
else:
if isinstance(pixel_size, numbers.Number):
self.pixel_size = [pixel_size]*2
else:
self.pixel_size = list(pixel_size)
[docs] def setWavelength(self, wavelength):
"""
Set wavelength.
Parameters
----------
wavelength : [float]
Wavelength in units angstrom.
"""
if isinstance(wavelength, sasImage):
# copy from object
self.wavelength = list(wavelength.wavelength)
else:
if isinstance(wavelength, numbers.Number):
self.wavelength = [wavelength]
else:
self.wavelength = list(wavelength[0])
[docs] def setPlaneOrientation(self, alpha=None, beta=None, gamma=None):
"""
Set orientation angles of detector plane .
In standard SAS geometry these are equal 0.
Parameters
----------
alpha : float
Rotation angle between incident beam and detector plane normal in degree.
beta : float
Rotation angle of Detector pixel X dimension around detector plane normal in degree.
gamma : float
Rotation of detector normal around incident beam in degree.
"""
if isinstance(alpha, sasImage):
# copy from object
self.alpha = alpha.alpha
self.beta = alpha.beta
self.gamma = alpha.gamma
else:
if alpha is not None:
self.alpha = alpha
if beta is not None:
self.beta = beta
if gamma is not None:
self.gamma = gamma
[docs] def setDetectorPosition(self, center, detector_distance, alpha=None, beta=None, gamma=None):
"""
Set parameters describing the position and orientation of the detector.
Parameters
----------
center : 2x float
Center of the detector where the plane normal is going through the sample origin in pixel units.
For conventional small angle scattering geometry (detector plane perpendicular to incoming beam)
this is the beam center.
detector_distance : float
Distance of the detector center to sample origin in units m.
alpha : float
Rotation angle between incident beam and detector plane normal in degree.
beta : float
Rotation angle of Detector pixel X dimension around detector plane normal in degree.
gamma : float
Rotation of detector normal around incident beam in degree.
"""
self.setPlaneCenter(center)
self.setDetectorDistance(detector_distance)
self.setPlaneOrientation(alpha, beta, gamma)
[docs] def setAttrFromImage(self, image):
"""
Copy center, detector_distance, alpha, beta, gamma wavelength, pixel_size from image.
Parameters
----------
image sasImage
sasImage to copy attributes to self.
"""
self.setPlaneCenter(image)
self.setDetectorDistance(image)
self.pixel_size = copy.copy(image.pixel_size)
self.wavelength = copy.copy(image.wavelength)
self.alpha = copy.copy(image.alpha)
self.beta = copy.copy(image.beta)
self.gamma = copy.copy(image.gamma)
[docs] def pickBeamcenter(self, levels=8, symmetry=6):
"""
Open image to pick the center from a calibration sample as AgBe in standard SAS geometry.
Radial averaged sectors allow to find the optimal center with best overlap of peaks.
Closing the image accepts the actual selected center.
Standard SAS geometry => alpha=beta=gamma=0
Parameters
----------
levels : int
Number of levels in contour image.
symmetry : int
Number of sectors around center for radial averages.
Returns
-------
After closing the selected center is saved in the sasImage.
Notes
-----
**How it works**
A figure with the AgBe picture (right) and a radial average over sectors is shown
(left, symmetry defines number of sectors) .
- Beamcenter: A circle is shown around the center.
Mouse left click changes the center to mouse pointer position.
- The center can be moved by arrow keys (+-1) or ctrl+arrow (+-0.1)
- The default radius corresponds to an AgBe reflex.
By middle or right click the radius can be set to mouse pointer position.
Additional the radius of the circle (center of left plot data) can be increased/decreased by +/-.
- Width around radius (for left plot) can be increased/decrease by ctrl++/ctrl+-.
- A radial average in sectors is calculated (after some smoothing) and shown in the left axes.
- The center is OK if the peaks show maximum overlap and symmetry.
Examples
--------
::
import jscatter as js
#
calibration = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
# use pickBeamcenter
calibration.pickBeamcenter()
"""
if mpl._headless:
warnings.warn('pickBeamcenter cannot be used in headless mode!')
return
colorMap = 'jet'
origin = 'lower'
fontsize = 10
extend = None
wl = self.wavelength[0] / 10. # conversion to nm
dd = self.detector_distance[0]
# pixel r from q
pfq = lambda q: dd * np.tan(2 * np.arcsin(np.asarray(q) * wl / 4. / np.pi))
pixelpeaks = pfq(AgBepeaks) / self.pixel_size[0]
# guess good AgBe peak
pixelradius = pixelpeaks[np.abs(pixelpeaks - np.min(self.shape) / 5).argmin()]
figsize = pyplot.figaspect(0.6)
fig = pyplot.figure(figsize=figsize)
ax1 = fig.add_axes([0.4, 0.05, 0.6, 0.85])
ax0 = fig.add_axes([0.1, 0.1, 0.3, 0.8])
cmap = pyplot.get_cmap(colorMap)
lmap = pyplot.get_cmap(None)
fig.suptitle(
'Move center: Pick with mouse; Close to accept \narrows(+-1 pixel) or ctrl+arrow (+-0.1 pixel) ',
fontsize=10)
ax1.yaxis.tick_right()
ax1.yaxis.set_label_position("right")
logself = np.ma.log(self)
im = ax1.imshow(logself, cmap=cmap, extent=extend, origin=origin)
im.cset = ax1.contour(logself, levels=levels, linewidths=1, cmap=lmap, extent=extend, origin=origin)
im.labels = ax1.clabel(im.cset, inline=True, fmt='%1.1f', fontsize=fontsize)
fig.colorbar(im, ax=ax1, orientation='horizontal', shrink=0.7, fraction=0.03,
pad=0.1) # note that colorbar is a method of the figure, not the axes
ax1.invert_yaxis()
ax1.set_xlabel('Y dimension / pixel')
ax1.set_ylabel('X dimension / pixel')
ax0.set_xlabel('radius / pixel')
ax0.set_ylabel('normalized mean count rate / pixel')
# create circle and add it to figure
if hasattr(self, 'center'):
ccenter = [self.center[1], self.center[0], ]
print('Old position of center: [{0:.2f},{1:.2f}]'.format(ccenter[1], ccenter[0]))
else:
ccenter = (self.shape[1] / 2, self.shape[0] / 2)
print('No center defined')
circle = Circle(ccenter, pixelradius, color='k', fill=False, linewidth=2, linestyle=(0, (6, 3)))
ax1.add_artist(circle)
# create picker and turn matplotlib to blocking mode to wait until window is closed
pick = PickerBeamCenter(circle=circle, image=self, destination=ax0, symmetry=symmetry)
mpl.pyplot.show(block=True)
# now set center
self.setPlaneCenter([pick.circle.center[1], pick.circle.center[0]])
print('Set center to [{0:.2f},{1:.2f}]'.format(self.center[0], self.center[1]))
[docs] def maskReset(self):
"""
Reset the mask.
By default values smaller 0 are automatically masked again as is also default for reading
"""
self.mask = ma.nomask
self[self < 0] = ma.masked
[docs] def maskFromImage(self, image):
"""
Use/copy mask from image.
Parameters
----------
image : sasImage
sasImage to use mask for resetting mask.
image needs to have same dimension.
"""
if image.shape == self.shape:
self.mask = image.mask
[docs] def maskRegion(self, xmin, xmax, ymin, ymax):
"""
Mask rectangular region.
Parameters
----------
xmin,xmax,ymin,ymax : int
Corners of the region to mask
"""
self[xmin:xmax, ymin:ymax] = ma.masked
[docs] def maskRegions(self, regions):
"""
Mask several regions.
Parameters
----------
regions : list
List of regions as in maskRegion.
"""
for region in regions:
self.maskRegion(*region)
[docs] def maskbelowLine(self, p1, p2):
"""
Mask points at one side of line.
The masked side is left looking from p1 to p2.
Parameters
----------
p1, p2 : list of 2x float
Points in pixel coordinates defining line.
"""
points = np.stack([self.iX, self.iY])
pp1 = np.array(p1)
pp2 = np.array(p2)
d = np.cross((pp1 - pp2)[:, None, None], pp2[:, None, None] - points, axis=0)
self[d > 0] = ma.masked
[docs] def maskTriangle(self, p1, p2, p3, invert=False):
"""
Mask inside triangle.
Parameters
----------
p1,p2,p3 : list of 2x float
Edge points of triangle.
invert : bool
Invert region. Mask outside circle.
"""
points = np.stack([self.iX, self.iY], axis=2)
pp1 = np.array(p1)
pp2 = np.array(p2)
pp3 = np.array(p3)
# cross to get sides of lines
d1 = np.sign(
np.cross((pp1 - pp2)[None, None, :], points - pp2[None, None, :], axis=2).reshape(points.shape[0], -1))
d2 = np.sign(
np.cross((pp2 - pp3)[None, None, :], points - pp3[None, None, :], axis=2).reshape(points.shape[0], -1))
d3 = np.sign(
np.cross((pp3 - pp1)[None, None, :], points - pp1[None, None, :], axis=2).reshape(points.shape[0], -1))
# equal side if sign equal sign of 3rd point
mask = ((d1 == d1[p3[0], p3[1]]) & (d2 == d2[p1[0], p1[1]]) & (d3 == d3[p2[0], p2[1]]))
if invert:
self[~mask] = ma.masked
else:
self[mask] = ma.masked
[docs] def mask4Polygon(self, p1, p2, p3, p4, invert=False):
"""
Mask inside polygon of 4 points.
Points need to be given in right hand order.
Parameters
----------
p1,p2,p3,p4 : list of 2x float
Edge points.
invert : bool
Invert region. Mask outside circle.
"""
points = np.stack([self.iX, self.iY], axis=2)
pp1 = np.array(p1, dtype=np.int32)
pp2 = np.array(p2, dtype=np.int32)
pp3 = np.array(p3, dtype=np.int32)
pp4 = np.array(p4, dtype=np.int32)
# cross to get sides of lines
d1 = np.sign(
np.cross((pp1 - pp2)[None, None, :], points - pp2[None, None, :], axis=2).reshape(points.shape[0], -1))
d2 = np.sign(
np.cross((pp2 - pp3)[None, None, :], points - pp3[None, None, :], axis=2).reshape(points.shape[0], -1))
d3 = np.sign(
np.cross((pp3 - pp4)[None, None, :], points - pp4[None, None, :], axis=2).reshape(points.shape[0], -1))
d4 = np.sign(
np.cross((pp4 - pp1)[None, None, :], points - pp1[None, None, :], axis=2).reshape(points.shape[0], -1))
# equal side if sign equal sign of 3rd point
mask = ((d1 == d1[pp3[0], pp3[1]]) & (d2 == d2[pp4[0], pp4[1]]) & (d3 == d3[pp1[0], pp1[1]]) & (
d4 == d3[pp2[0], pp2[1]]))
if invert:
self[~mask] = ma.masked
else:
self[mask] = ma.masked
[docs] def maskCircle(self, center, radius, invert=False):
"""
Mask points inside circle.
Parameters
----------
center : list of 2x float
Center point. This is not the plane center.
radius : float
Radius in pixel units
invert : bool
Invert region. Mask outside circle.
"""
points = np.stack([self.iX, self.iY])
distance = la.norm(points - np.array(center)[:, None, None], axis=0)
mask = distance < radius
if invert:
self[~mask] = ma.masked
else:
self[mask] = ma.masked
[docs] def maskSectors(self, angles, width, radialmax=None, invert=False):
"""
Mask sector around center.
Zero angle is
Parameters
----------
angles : list of float
Center angles of sectors in grad.
width : float or list of float
Width of the sectors in grad.
If single value all sectors are equal.
radialmax : float
Maximum radius in pixels.
invert : bool
Invert mask or not.
Examples
--------
::
import jscatter as js
cal = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
cal.maskSectors([-90,0,90,-180],20,radialmax=200,invert=True)
cal.show()
"""
angles = np.asarray(angles)
if isinstance(width, numbers.Number):
width = np.ones_like(angles) * width
mask = self.mask.copy()
mask[:] = False
for a, w in zip(np.deg2rad(angles), np.deg2rad(np.abs(width))):
limits = np.r_[a - w / 2, a + w / 2] % (2 * np.pi) - np.pi
if radialmax is None:
if limits[0] < limits[1]:
mask = np.logical_or(mask, (self._polarazimuth > limits[0]) & (self._polarazimuth < limits[1]))
else:
mask = np.logical_or(mask, ~((self._polarazimuth < limits[0]) & (self._polarazimuth > limits[1])))
else:
if limits[0] < limits[1]:
mask = np.logical_or(mask, (self._polarazimuth > limits[0]) & (self._polarazimuth < limits[1]) &
(self._polarradial < radialmax))
else:
mask = np.logical_or(mask, ~((self._polarazimuth < limits[0]) & (self._polarazimuth > limits[1])) &
(self._polarradial < radialmax))
if invert:
self[~mask] = ma.masked
else:
self[mask] = ma.masked
[docs] def pQaxes(self):
"""
Get scattering vector along detector pixel axes X, Y around center.
In standard small angle geometry the detector pixel X,Y directions are perpendicular
to the incident beam in Z direction. For an offset detector this not necessarily the case
as we have curvilinear coordinates.
Needs wavelength, detector_distance and center defined.
Returns
-------
qx,qy with image x and y dimension
"""
X = self.pQ[:, min(max(int(self.center[1]), 0), self.shape[1] - 1), 0]
Y = self.pQ[min(max(int(self.center[0]), 0), self.shape[0] - 1), :, 1]
return X, Y
@property
@sImemoize(4, ['center'])
def _polarazimuth(self):
"""
Polar pixel azimuth on detector plane.
Orientation chosen that azimuth is same as pQrpt.
"""
X = (self.iX - self.center[0])
Y = (self.iY - self.center[1])
return np.arctan2(Y, X)
@property
@sImemoize(4, ['center'])
def _polarradial(self):
"""
Polar pixel radial distance from center on detector plane.
"""
X = (self.iX - self.center[0])
Y = (self.iY - self.center[1])
return np.linalg.norm([X, Y], axis=0)
@property
@sImemoize(4, ['center', 'detector_distance', 'pixel_size', 'alpha', 'beta', 'gamma'])
def _pXYZ(self):
"""
Cartesian pixel Coordinates of displaced detector.
Use setDetectorPosition to place it.
"""
# pixel positions (units m) in detector plane
# with center as start point of plane normal vector pointing to the sample
# For standard geometry normal is parallel to primary beam
xyz = np.zeros(self.iX.shape + (3,))
xyz[:, :, 0] = (self.iX - self.center[0]) * self.pixel_size[0]
xyz[:, :, 1] = (self.iY - self.center[1]) * self.pixel_size[1]
xyz[:, :, 2] = self.detector_distance[0] # in units m
if self.alpha == 0 and self.beta == 0 and self.gamma == 0:
return xyz
else:
# rotation along AXES all in intrinsic coordinates:
# first rotate around Z (incoming beam) by gamma (will be rotation of detector normal around incoming beam )
# then rotate detector plane by alpha around Y (alpha is between detector normal and incoming beam)
# and by beta around Z (rotation of detector around detector plane normal)
# create rotation matrix
R = Rotation.from_euler('ZYZ', [self.gamma, self.alpha, self.beta], degrees=True).as_dcm()
# rotate pixel coordinates
Rxyz = np.einsum('ij,klj->kli', R, xyz)
return Rxyz
@property
@sImemoize(4, ['center', 'detector_distance', 'pixel_size'])
def _pXYZnorm(self):
"""
Norm of Cartesian pixel Coordinates is distance from center
Independent of rotation of detector if normal doesnt change
"""
return la.norm(self._pXYZ, axis=-1)
@property
@sImemoize(4, ['center', 'detector_distance', 'pixel_size', 'alpha', 'beta', 'gamma', 'wavelength'])
def pQ(self):
r"""
3D scattering vector :math:`q` for pixels with detector placed in standard SAS or offset geometry.
The detector is placed at an offset position with the detector normal rotated
against the incoming beam direction by angles alpha, beta, gamma.
If these are zero we have conventional SAS geometry.
Use .setDetectorPosition to place the detector.
Returns
-------
array
scattering vector with shape(image) x 3 in cartesian coordinates and units 1/nm.
Notes
-----
The incident beam is directed in Z direction :math:`k_{i}=[0, 0, k]`.
The scattered wavevector is
.. math:: k_f=k \begin{bmatrix} cos(\phi)sin(\theta) \\ sin(\phi)sin(\theta) \\ cos(\theta) \end{bmatrix}
= \begin{bmatrix} k_x \\ k_y \\ k_z \end{bmatrix}
with polar coordinates :math:`k, \phi, \theta` where :math:`\theta` is the conventional scattering vector
used in small angle scattering.
The scattering vector is
.. math:: q=k_f-k_i=
k \begin{bmatrix} cos(\phi)sin(\theta) \\ sin(\phi)sin(\theta) \\ cos(\theta) -1 \end{bmatrix}
with :math:`|q|=4\pi/\lambda` and :math:`|k|=2\pi/\lambda`.
**Pixel and pQ orientation**
The convention is as follows
- Pixel origin [0,0] is upper left corner with coordinates in [height, width] of the detector.
- pQ (wavevector coordinates) are that the pixel origin [0,0] is in the [positive, negative] quadrant
that the lower left corner is [negative,negative] as expected for a scattering pattern
with axes from left to right and bottom up and the origin (incident beam) somewhere
in the image and viewed from the sample location.
"""
# wavevector absolute value units 1/nm
k = 2 * np.pi / (self.wavelength[0] / 10.)
# scattered wavevector
kf = self._pXYZ / self._pXYZnorm[:, :, None] * k
# incident wavevector
ki = np.r_[0, 0, k]
kfki = kf - ki[None, None, :]
# invert kfki x component to have later --quadrant in lower left corner
kfki[:, :, 0] *= -1
return kfki
@property
@sImemoize(4, ['center', 'detector_distance', 'pixel_size', 'alpha', 'beta', 'gamma', 'wavelength'])
def pQnorm(self):
"""
3D scattering vector :math:`|q|` for detector pixel. See .pQ .
"""
return la.norm(self.pQ, axis=-1)
@property
@sImemoize(4, ['center', 'detector_distance', 'pixel_size', 'alpha', 'beta', 'gamma', 'wavelength'])
def _pQrpt(self):
r"""
3D scattering vector :math:`q` for detector pixel in spherical coordinates
:mat:`|q|`, phi, theta with laboratory z-axis along incident beam.
In standard SAS geometry :math:`\theta \approx \pi/2 `.
"""
return formel.xyz2rphitheta(self.pQ.reshape((-1, 3))).reshape(self.shape + (3,))
[docs] def findCenterOfIntensity(self, center=None, size=100):
"""
Find beam center as center of intensity around center.
Only values above the mean value are used to calc center of intensity.
Use an image with a clear symmetric and strong scattering sample as AgBe.
Use *.showPolar([600,699],scaleR=5)* to see if peak is symmetric.
Parameters
----------
center : list 2x int
First estimate of center as [height, width] position.
If not given preliminary center is estimated as center of intensity of full image.
size : int
Defines size of rectangular region of interest (ROI) around the center to look at.
Returns
-------
Adds (replaces) .center as attribute.
Notes
-----
If ROI is to large the result may be biased due to asymmetry of
the intensity distribution inside of ROI.
Additional strong masking in ROI leads to bias of the found center.
"""
if isinstance(size, numbers.Numbers):
size = np.rint(size).astype(np.int)
med = (self.max() + self.min()).array / 2.
if center is None:
# as first guess
center = ndimage.measurements.center_of_mass(ma.masked_less(self, med, copy=True).filled(0).array)
if size is not None:
# take smaller portion to reduce bias from image size
bc = np.rint(center).astype(np.int)
data = self[bc[0] - size:bc[0] + size, bc[1] - size:bc[1] + size]
# mask values smaller than mean and take centerofmass
med = (data.max() + data.min()).array / 2.
ccenter = ndimage.measurements.center_of_mass(ma.masked_less(data, med, copy=True).filled(0).array)
center = [ccenter[0] + bc[0] - size, ccenter[1] + bc[1] - size]
self.setPlaneCenter(center)
[docs] def radialAverage(self, center=None, number=300, kind='log', calcError=False, units=None):
r"""
Radial average of image and conversion to wavevector q.
Remember to set .detector_distance to calibrated value.
Setting units to 'sr' will scale the output to counts/micro_steradians
which also accounts for different detector distances.
Parameters
----------
center : list 2x float
Sets center in data and uses this.
If not given the attributecenter in the data is used.
number : int, default 500
Number of intervals on new X scale.
kind : 'lin', default 'log'
Determines how points are distributed.
calcError : 'poisson','std', default None
How to calculate error.
- 'poisson' according to Poisson statistics.
Use only for original images showing unprocessed photon counts.
- 'std' as standard deviation of the values in an interval.
- otherwise no error
units : None, 'sr'
Units of the returned radial average as :
- None default counts per pixel
- 'sr' counts per solid angle as counts/micro steradians = 1e-6 counts/steradians.
This accounts also different detector distances.
Returns
-------
dataArray with added attributes of the image. artist and entriesXML are ommited
Notes
-----
- Correction of pixel size for flat detector projected to Ewald sphere included.
The correction is relative to the pixel located at the center. The intensity remains in units counts/pixel.
- The value in a q binning is the average count rate :math:`c(q)=(\sum c_i)/N`
with counts in pixel *i* :math:`c_i` and number of pixels :math:`N`
- Setting units to 'sr' scales to counts per solid angle (micro steradians).
In these units the scattered intensity is independent of the detector distance.
Scaling is done after calculation of the error.
- **calcError** :
If the image is unprocessed (no background subtraction or transmission correction) containing original
photon count rates the standard error can be calculated from Poisson statistic.
- The error (standard deviation) is calculated in a q binning as
:math:`e=(\sum c_i)^{1/2}/N`
- The error is valid for single photon counting detectors showing Poisson statistics
as the today typical Pilatus detectors from DECTRIS.
- The error for :math:`\sum c_i) <= 0` is set to zero.
One may estimate the corresponding error from neighboring intervals.
- In later 1D processing as e.g. background correction
the error can be included according to error propagation.
'std' calcs the error as standard deviation in an interval.
Examples
--------
Mask and do radial average over sectors. ::
import jscatter as js
cal = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
p=js.grace()
calc=cal.copy()
calc.maskSectors([0,180],20,radialmax=100,invert=True)
calc.show()
icalc=calc.radialAverage()
p.plot(icalc,le='horizontal')
calc=cal.copy()
calc.maskSectors([90+0,90+180],20,radialmax=100,invert=True)
calc.show()
icalc=calc.radialAverage()
p.plot(icalc,le='vertical')
p.yaxis(scale='l')
p.legend()
p.title('The AgBe is not isotropically ordered')
"""
if center is not None:
self.setPlaneCenter(center)
# see Correcting for spherical angles
# in Pauw, Everything SAXS:.... J. Phys.: Condens. Matter 25 (2013) 383201
# correction for flat detector with pixel area as (distance pixel / distance to detector center)**3
lpl0 = self._pXYZnorm / self.detector_distance[0]
data = self.data * lpl0 ** 3
mask = self.mask
radial = dA(np.stack([self.pQnorm[~mask], data[~mask]]))
radial.isort() # sorts along X by default
# return lower number of points from prune
if calcError == 'poisson':
result = radial.prune(number=number, type='sum', kind=kind) # sum and number without error
err = np.copy(result[1])
result[1] = result[1] / result[2] # calc mean
err[err > 0] **= 0.5
err[err <= 0] = 0
result[2] = err / result[2] # mean error
result.setColumnIndex(iey=2)
elif calcError == 'std':
result = radial.prune(number=number, type='mean+std', kind=kind) # average without error
else:
# no error
result = radial.prune(number=number, type='mean', kind=kind) # average without error
result.filename = self.filename
# add some attributes from image
for attri in self.attr:
if attri not in ['artist', 'entriesXML']:
try:
setattr(result, attri, getattr(self, attri))
except AttributeError:
pass
if units == 'sr':
# normalize to solid angle as pixel_area/r**2 in units micro steradians
result[1:] = result[1:] / (self.pixel_size[0] * self.pixel_size[1] / self.detector_distance[0] ** 2 * 1e6)
return result
[docs] def azimuthAverage(self, center=None, qrange=[None, None], number=180, kind='lin', calcError=False):
r"""
Azimuthal average of image and conversion to wavevector q.
Remember to set .detector_distance to calibrated value.
Parameters
----------
center : list 2x float
Sets center in data and uses this.
If not given the attribute center in the data is used.
qrange : 2x float, default [0,max]
Range of q values to include as [min,max].
number : int, default 180
Number of intervals on new X scale.
kind : 'log', default 'lin'
Determines how points are distributed.
calcError : 'poisson','std', default None
How to calculate error.
- 'poisson' according to Poisson statistics.
Use only for original images showing unprocessed photon counts.
- 'std' as standard deviation of the values in an interval.
- otherwise no error
Returns
-------
dataArray with added attributes of the image. artist and entriesXML are ommited
Notes
-----
- Correction of pixel size for flat detector projected to Ewald sphere included.
- The value in a q binning is the average count rate :math:`c(q)=(\sum c_i)/N`
with counts in pixel *i* :math:`c_i` and number of pixels :math:`N`
- **calcError** :
If the image is unprocessed (no background subtraction or transmission correction) containing original
photon count rates the standard error can be calculated from Poisson statistic.
- The error (standard deviation) is calculated in a q binning as
:math:`e=(\sum c_i)^{1/2}/N`
- The error is valid for single photon counting detectors showing Poisson statistics
as the today typical Pilatus detectors from DECTRIS.
- The error for :math:`\sum c_i) <= 0` is set to zero.
One may estimate the corresponding error from neighboring intervals.
- In later 1D processing as e.g. background correction
the error can be included according to error propagation.
'std' calcs the error as standard deviation in an interval.
Examples
--------
::
import jscatter as js
cal = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
p=js.grace()
az=cal.azimuthAverage(qrange=[0.9,1.3])
p.plot(az,legend='q=0.9-1.3 nm\S-1')
az=cal.azimuthAverage(qrange=[2,2.4])
p.plot(az,legend='q=2-2.4 nm\S-1')
az=cal.azimuthAverage(qrange=[3,3.5])
p.plot(az,legend='q=3-3.5 nm\S-1')
az=cal.azimuthAverage(qrange=[4.1,4.4])
p.plot(az,legend='q=4.1-4.4 nm\S-1')
p.yaxis(label=r'I(\xj\f{})',scale='log')
p.xaxis(label=r'azimuth \xj\f{} / rad')
p.title('The AgBe is not isotropically ordered')
p.legend(x=0,y=1)
p.text('beamstop arm',x=-2,y=0.1)
p.text('mask',x=-3,y=4)
"""
if center is not None:
self.setPlaneCenter(center)
# see Correcting for spherical angles
# in Pauw, Everything SAXS:.... J. Phys.: Condens. Matter 25 (2013) 383201
# correction for flat detector with pixel area as (distance pixel / distance to detector center)**3
lpl0 = self._pXYZnorm / self.detector_distance[0]
data = self.data * lpl0 ** 3
mask = self.mask
pQrpt = self._pQrpt
if qrange[0] is None: qrange[0] = np.min(pQrpt[:, :, 0])
if qrange[1] is None: qrange[1] = np.max(pQrpt[:, :, 0])
mask = np.logical_or(mask, ~((pQrpt[:, :, 0] > qrange[0]) & (pQrpt[:, :, 0] < qrange[1])))
radial = dA(np.stack([pQrpt[~mask, 1], data[~mask]]))
radial.setColumnIndex(ix=0, iy=1, iey=None)
radial.isort() # sorts along X by default
# return lower number of points from prune
if calcError == 'poisson':
result = radial.prune(number=number, type='sum', kind=kind) # sum and number without error
err = np.copy(result.Y)
result.Y = result.Y / result[-1] # calc mean
err[err > 0] **= 0.5
err[err <= 0] = 0
result[-1] = err / result[-1] # mean error
result.setColumnIndex(iey=-1)
elif calcError == 'std':
result = radial.prune(number=number, type='mean+std', kind=kind) # average without error
else:
# no error
result = radial.prune(number=number, type='mean', kind=kind) # average without error
result.filename = self.filename
# add some attributes from image
for attri in self.attr:
if attri not in ['artist', 'entriesXML']:
try:
setattr(result, attri, getattr(self, attri))
except AttributeError:
pass
return result
[docs] def lineAverage(self, center=None, number=None, minmax='auto', show=False):
"""
Line average of image and conversion to wavevector q for line collimation cameras.
Remember to set .detector_distance to calibrated value.
Parameters
----------
center : float
Sets beam center in data and uses this.
If not given the beam center is determined from semitransparent beam.
number : int, default None
Number of intervals on new X scale. None means all pixels.
minmax : [int,int], 'auto'
Interval for determination of center.
show : bool
Show the fit of the primary beam.
Returns
-------
dataArray
- .filename
- .detector_distance
- .description
- .center
Notes
-----
- Detector distance in attributes is used.
- The primary beam is automatically detected.
- Correction for flat detector projected to Ewald sphere included.
"""
if center is None:
# take average
imageav = dA(np.c_[np.r_[0:self.shape[0]], self.mean(axis=1)].T)
# find minima from argmax if not given explicitly
if minmax[0] == 'a': # auto
# for normal empty cell or buffer measurement the primary beam is the maximum
imax = imin = imageav.Y.argmax()
while imageav.Y[imax + 1] < imageav.Y[imax]: imax += 1
while imageav.Y[imin - 1] < imageav.Y[imin]: imin -= 1
xmax = imageav.X[imax]
xmin = imageav.X[imin]
else:
xmin = minmax[0]
xmax = minmax[1]
# prune to smaller interval
primarybeam = imageav.prune(lower=xmin, upper=xmax)
# subtract min value , which is basically dark current
primarybeam.Y -= primarybeam.Y.min()
norm = scipy.integrate.simps(primarybeam.Y, primarybeam.X)
primarybeam.Y = primarybeam.Y / norm
# fit mean position and width
primarybeam.fit(_gauss, {'mean': imageav.Y.argmax(), 'sigma': 0.015, 'bgr': 0, 'A': 1}, {}, {'x': 'X'})
center = primarybeam.mean
self.primarybeam_hwhm = primarybeam.sigma * np.sqrt(np.log(2.0))
self.primarybeam_peakmax = primarybeam.modelValues(x=primarybeam.mean).Y[0] * norm
if show:
primarybeam.showlastErrPlot()
self.center = center
r = (self.iX[0] - self.center) * self.pixelSize # µm pixel size
# calc radial wavevectors
angle = np.arctan(r / self.detector_distance[0])
wl = self.wavelength[0]
self.q = 4 * np.pi / wl * np.sin(angle / 2)
# correction for flat detector with pixel area
lpl0 = 1. / np.cos(angle)
data = self.mean(axis=0) * lpl0 # because of line collimation only power 1
error = self.std(axis=0) * lpl0 # because of line collimation only power 1
result = dA(np.stack([self.q, data, error]))
if number is not None:
# return lower number of points from prune
result = result.prune(number=number, kind='mean+') # makes averages with errors
result.filename = self.filename
result.detector_distance = self.detector_distance
result.description = self.description
return result
[docs] def recalibrateDetDistance(self, center=None, number=500, fcenter=1., fwhm=0.1, showfits=False):
"""
Recalibration of detectorDistance by AgBe reference for point collimation.
Use only for AgBe reference measurements to determine the correction factor.
For non AgBe measurements set during reading or .detector_distance to the new value.
May not work if the detector distance is totally wrong.
Parameters
----------
center : list 2x float
Sets beam center or radial center in data and uses this.
If not given the attributecenter in the data is used.
number : int, default 1000
number of intervals on new X scale.
fcenter : float, default 1
Determines start value for peak fitting.
By default the position of the peak maximum is used if it is larger than
(mean(Y)+2std(Y)) of the signal Y. Otherwise fcenter*peakposition[i] is used.
Negative fcenter forces the start value to be |fcenter|*peakposition[i].
Reference peakpositions in 1/nm:
[1.0753, 2.1521, 3.2286, 4.3049, 5.3813, 6.4576, 7.5339, 8.6102, 9.6865, 10.7628]
fwhm : float, default 0.1
Start value for full width half maximum in peak fitting.
showfits : bool
Show the AgBe peak fits.
Notes
-----
- .distanceCorrection will contain factor for correction.
Repeating this results in a .distanceCorrection close to 1.
We fit a Voigt function to each of the detected peaks in the image
and use the average of the resulting correction factors for each peak as overall correction factor.
"""
# do radial average
iq = self.radialAverage(center=center, number=number)
# later distance corrections
self.distanceCorrection = []
dq = 0.3 # around peak positions
for agp in AgBepeaks:
# AgBepeaks contains a list of AgBe peak positions to test for
# we fit each with a voigt function and take later the average
if iq.X.max() > agp + dq and iq.X.min() < agp - dq:
# cut between lower and upper and fit Voigt function for peak
iqq = iq.prune(lower=agp - dq, upper=agp + dq, weight=None)
# iqq.setColumnIndex(iey=None)
if iqq.shape[1] < 5:
continue
iqq.setLimit(amplitude=[0], bgr=[0], fwhm=[0.001, agp])
if (iqq.Y.argmax() > iqq.Y.mean() + 2 * iqq.Y.std()) and (fcenter > 0):
centerstart = iqq.X[iqq.Y.argmax()]
else:
centerstart = agp * abs(fcenter)
ret = iqq.fit(_agbpeak, {'center': centerstart, 'amplitude': iqq.Y.max() / 4.,
'fwhm': abs(fwhm), 'asym': 1, 'bgr': iqq.Y.min() / 2.},
{}, {'q': 'X'}, output=False)
if ret == -1:
continue
if showfits:
iqq.showlastErrPlot()
self.distanceCorrection += [iqq.center / agp]
corfactor = np.mean(self.distanceCorrection)
corstd = np.std(self.distanceCorrection) / corfactor
# set new detectorDistance
self.detector_distance[0] *= corfactor
print('\nCorrection factor {0:.4f} to new distance {1:.4f} (rel error : {2:.4f} )'.
format(corfactor, self.detector_distance[0], corstd))
[docs] def calibrateOffsetDetector(self, lattice=None, center=None, distance=None, alpha=None, beta=None, gamma=None,
domainsize=1000, asym=0, lg=1, rmsd=0.02, hklmax=17):
"""
Compare sasImage to calibration standard in powder average to determine detector position.
Any detector orientation relative to the sample and incoming beam can be used to
calibrate parameters describing the detector position.
A standard sample as silicon (large angles) or AgBe (small angles) can be used.
Opens a window with changeable parameters to align sasImage and simulated lattice image.
This can also be used in standard SAS geometry with the detector normal being the incoming beam direction.
Parameters
----------
lattice : lattice object
A lattice object (see structurefactor lattices) representing the used
reference material to determine the expected scattering pattern.
E.g. :code:`silicon = js.sf.diamondLattice(0.543, 1)`
center,distance,alpha,beta,gamma : 2x float, float, float,float
Parameters determining the detector position.
See sasImage.setDetectorPosition for detailed description.
domainsize : float
Domainsize of the used powder changing the peak width. See sf.latticeStructureFactor.
asym : float
Asymmetry of the peaks. See sf.latticeStructureFactor.
lg : float
Lorenzian/gaussian fraction. See sf.latticeStructureFactor.
rmsd : float
Root mean square displacement in lattice . See sf.latticeStructureFactor.
hklmax : int, default = 17
Maximum order of the Bragg peaks to include.
If hklmax is to small bragg peaks of higher order might be missing.
Returns
-------
None
Sets corresponding values in setDetectorPosition(...) when window is closed.
Notes
-----
Usage:
- Opens a window with
1. Original gray scale image of calibration measurements with colored overlay for simulated peak positions.
2. A radial average over the image and simulated data.
The radial average depends on center location and angles and is updated if parameters change.
- The overlay in left image shows the range between selected doted lines in right image.
- The selection is done using the right/left mouse button for max/min values.
- Selection might highlight the maxima of peaks or the flanks dependent on the selected range.
Small ranges highlight thin lines in the 2D image.
- Lines have to be aligned to measured pattern and peak positions need to coincident.
- Use the calibrated sasImage as argument to copy while creating a new sasImage to use (copy)
calibrated values in new image.
- A detector geometry as 45deg with the beamcenter at the detector edge might be used to cover
a large region from lowest to highest wavevectors.
Examples
--------
Silicon powder for an offset detector located parallel to the incoming beam in reverse orientation (beta=90)
The detector was positioned about 70 mm away from the incoming beam a bit behind the sample.
Here the distance and center need to be adjusted.
::
import jscatter as js
sic = js.sas.sasImage(js.examples.datapath+'/Silicon.tiff')
silicon = js.sf.diamondLattice(0.543, 1)
cc=[130,-100]
sic.setPlaneOrientation(90,90,0)
sic.calibrateOffsetDetector(center=cc,distance=0.070,lattice=silicon,domainsize=30,rmsd=0.003)
Conventional AgBe reference with center located on the detector.
Beamcenter is center on detector.
::
import jscatter as js
#
cal = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
# mask some parts of the detector (because of shading and the beam stop) to get clearer radial average.
bc=cal.center
# beamstop arm
cal.mask4Polygon([bc[0]+5,bc[1]],[bc[0]-5,bc[1]],[bc[0]-5+43,0],[bc[0]+5+43,0])
# beamstop
cal.maskCircle(cal.center, 18)
# shade of beam entrance
cal.maskCircle([500,320], 280,invert=True)
# AgBe reference
agbe=js.sf.latticeFromCIF(js.examples.datapath+'/1507774.cif',size=[1,1,1])
cal.calibrateOffsetDetector(center=cal.center,distance=0.18,lattice=agbe,domainsize=50,rmsd=0.003)
"""
if mpl._headless:
warnings.warn('calibrateOffsetDetector cannot be used in headless mode!')
return
# change detector position
self.setDetectorPosition(center, distance, alpha=alpha, beta=beta, gamma=gamma)
origin = 'lower'
bfontsize = 8
extend = None
normdif = colors.LogNorm(clip=True)
figsize = pyplot.figaspect(0.35)
fig = pyplot.figure(figsize=figsize)
# axes
axdif = fig.add_axes([0.05, 0.1, 0.4, 0.75])
axline = fig.add_axes([0.46, 0.12, 0.5, 0.66])
# arrange buttons
xx = 0.46
yy = 0.79
dx = 0.06
dy = 0.05
dl = 0.05
dly = 0.045
# center
axcenterx_p = pyplot.axes([xx + dx / 2, yy + dy * 2, dl, dly])
bcenterx_p = Button(axcenterx_p, 'center X+', color='linen', hovercolor='green')
bcenterx_p.label.set_fontsize(bfontsize)
axcenterx_m = pyplot.axes([xx + dx / 2, yy, dl, dly])
bcenterx_m = Button(axcenterx_m, 'center X-', color='linen', hovercolor='green')
bcenterx_m.label.set_fontsize(bfontsize)
axcentery_p = pyplot.axes([xx + dx, yy + dy, dl, dly])
bcentery_p = Button(axcentery_p, 'center Y+', color='linen', hovercolor='green')
bcentery_p.label.set_fontsize(bfontsize)
axcentery_m = pyplot.axes([xx, yy + dy, dl, dly])
bcentery_m = Button(axcentery_m, 'center Y-', color='linen', hovercolor='green')
bcentery_m.label.set_fontsize(bfontsize)
# center step
acstep = pyplot.axes([xx + 0.5 * dx, yy + 3 * dy, dl, dly])
bcstep = Button(acstep, r'step $\pm$1', color='linen', hovercolor='green')
bcstep.label.set_fontsize(bfontsize)
# detector distance
axdistance_p = pyplot.axes([xx + 2 * dx, yy + dy, dl, dly])
bdistance_p = Button(axdistance_p, 'distance +', color='grey', hovercolor='green')
bdistance_p.label.set_fontsize(bfontsize)
axdistance_m = pyplot.axes([xx + 2 * dx, yy, dl, dly])
bdistance_m = Button(axdistance_m, 'distance -', color='grey', hovercolor='green')
bdistance_m.label.set_fontsize(bfontsize)
# alpha
axalpha_p = pyplot.axes([xx + 3. * dx, yy + dy, dl, dly])
balpha_p = Button(axalpha_p, 'alpha +', color='lightgrey', hovercolor='green')
balpha_p.label.set_fontsize(bfontsize)
axalpha_m = pyplot.axes([xx + 3. * dx, yy, dl, dly])
balpha_m = Button(axalpha_m, 'alpha -', color='lightgrey', hovercolor='green')
balpha_m.label.set_fontsize(bfontsize)
# beta
axbeta_p = pyplot.axes([xx + 4. * dx, yy + dy, dl, dly])
bbeta_p = Button(axbeta_p, 'beta +', color='lightgrey', hovercolor='green')
bbeta_p.label.set_fontsize(bfontsize)
axbeta_m = pyplot.axes([xx + 4. * dx, yy, dl, dly])
bbeta_m = Button(axbeta_m, 'beta -', color='lightgrey', hovercolor='green')
bbeta_m.label.set_fontsize(bfontsize)
# gamma
axgamma_p = pyplot.axes([xx + 5. * dx, yy + dy, dl, dly])
bgamma_p = Button(axgamma_p, 'gamma +', color='lightgrey', hovercolor='green')
bgamma_p.label.set_fontsize(bfontsize)
axgamma_m = pyplot.axes([xx + 5. * dx, yy, dl, dly])
bgamma_m = Button(axgamma_m, 'gamma -', color='lightgrey', hovercolor='green')
bgamma_m.label.set_fontsize(bfontsize)
# distance step
axstep = pyplot.axes([xx + 2 * dx, yy + 3 * dy, dl, dly])
bstep = Button(axstep, r'step $\pm$10', color='grey', hovercolor='green')
bstep.label.set_fontsize(bfontsize)
# angular step
aastep = pyplot.axes([xx + 4. * dx, yy + 3 * dy, dl, dly])
bastep = Button(aastep, r'step $\pm$3', color='lightgrey', hovercolor='green')
bastep.label.set_fontsize(bfontsize)
# buttonlist for picker
buttons = [bstep, bcenterx_p, bcenterx_m, bcentery_p, bcentery_m, bdistance_p, bdistance_m,
balpha_p, balpha_m, bbeta_p, bbeta_m, bgamma_p, bgamma_m, bastep, bcstep]
axdif.set_title(
'Move detector position and orientation according to buttons. '
'\n to align rings to measured pattern.\n'
' intervals (dots) are set by left/right mouse click in right plot.',
fontsize=9)
# preliminary show something in picker, it is immediately updated
# looking at self
selfradial = self.radialAverage()
axline.plot(selfradial.X, selfradial.Y)
bgr = selfradial.Y.min()
# looking at the lattice
Iradial = sf.radial3DLSF(self.pQ.reshape(-1, 3), lattice=lattice, domainsize=domainsize, asym=asym,
lg=lg, rmsd=rmsd, beta=None, hklmax=hklmax, wavelength=self.wavelength[0] / 10)
I2d = Iradial.Y.reshape(self.shape)
scaling = (selfradial.Y.max() - bgr) / (Iradial.Y.max() - Iradial.Y.min())
I2d = I2d * scaling + bgr
I2d[self.mask] = bgr
latticeradial = sf.latticeStructureFactor(selfradial.X, lattice=lattice, domainsize=domainsize, asym=asym,
lg=lg, rmsd=rmsd, beta=None, hklmax=hklmax,
wavelength=self.wavelength[0] / 10)
scaling = (selfradial.Y.max() - bgr) / (latticeradial.Y.max() - latticeradial.Y.min())
axline.plot(latticeradial.X, latticeradial.Y * scaling + bgr)
axline.plot(latticeradial.X, np.ones_like(latticeradial.Y) * latticeradial.Y.max() * 0.9 * scaling + bgr,
linestyle=':')
axline.plot(latticeradial.X, np.ones_like(latticeradial.Y) * latticeradial.Y.max() * 0.1 * scaling + bgr,
linestyle=':')
normdif.autoscale(self)
imdif1 = axdif.imshow(self, cmap='gray', extent=extend, origin=origin, norm=normdif)
imdif2 = axdif.imshow(I2d, cmap='Wistia', extent=extend, origin=origin)
axdif.images[1].cmap.set_bad(alpha=0)
axdif.images[1].cmap.set_under(alpha=0)
axdif.images[1].cmap.set_over(alpha=0.2)
fig.colorbar(imdif1, ax=axdif, pad=0.0)
fig.text(x=xx + 6. * dx, y=yy + 1 * dy, s='center \ndistance ')
axdif.invert_yaxis()
axdif.set_xlabel('Y dimension / pixel')
axdif.set_ylabel('X dimension / pixel')
axline.set_yscale('log')
axline.set_xlabel(r'Q / $nm^{-1}$')
# create picker and turn matplotlib to blocking mode to wait until window is closed
pick = PickerDetPosition(image=self, lattice=lattice, axes=[axdif, axline], buttons=buttons,
latticeparameters=[domainsize, asym, lg, rmsd, hklmax])
mpl.pyplot.show(block=True)
# If the picker is closed the detector position is already updated to the last values
return
# noinspection PyIncorrectDocstring
[docs] def show(self, **kwargs):
r"""
Show sasImage as matplotlib figure.
Parameters
----------
scale : 'log', 'symlog', default = 'norm'
Scale for intensities.
- 'norm' Linear scale.
- 'log' Logarithmic scale
- 'symlog' Symmetrical logarithmic scale is logarithmic in both the positive
and negative directions from the origin. This works also for only positive data.
Use linthresh, linscale to adjust.
levels : int, None
Number of contour levels.
colorMap : string
Get a colormap instance from name.
Standard mpl colormap name (see showColors).
badcolor : float, color
Set the color for bad values (like masked) values in an image.
Default is bad values be transparent.
Color can be matplotlib color as 'k','b' or
float value in interval [0,1] of the chosen colorMap.
0 sets to minimum value, 1 to maximum value.
linthresh : float, default = 1
Only used for scale 'symlog'.
The range within which the plot is linear (-linthresh to linthresh).
linscale : float, default = 1
Only used for scale 'symlog'.
Its value is the number of decades to use for each half of the linear range.
E.g. 10 uses 1 decade.
lineMap : string
Label color
Colormap name as in colorMap, otherwise as cs in in Axes.clabel
* if None, the color of each label matches the color of the corresponding contour
* if one string color, e.g., colors = ‘r’ or colors = ‘red’, all labels will be plotted in this color
* if a tuple of matplotlib color args (string, float, rgb, etc),
different labels will be plotted in different colors in the order specified
fontsize : int, default 10
Size of line labels in pixel
axis : None, 'pixel'
If coordinates should be forced to pixel, otherwise wavevectors if possible.
invert_yaxis, invert_xaxis : bool
Invert corresponding axis.
block : bool
Open in blocking or non-blocking mode
origin : 'lower','upper'
Origin of the plot. See matplotlib imshow.
Returns
-------
image handle
Notes
-----
To show data as Image in correct orientation the option ax.invert_yaxis() is used.
If you plot directly with matplotlib use the same.
Examples
--------
Use radial averaged data to interpolate ::
import jscatter as js
import numpy as np
calibration = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
calibration.show(colorMap='ocean')
calibration.show(scale='sym',linthresh=20, linscale=5)
Use ``scale='symlog'`` for mixed lin-log scaling to pronounce low scattering.
See mpl.contourImage for more options also available using ``.show``. ::
import jscatter as js
import numpy as np
# sets negative values to zero
bsa = js.sas.sasImage(js.examples.datapath+'/BSA11mg.tiff')
fig=js.mpl.contourImage(bsa,scale='sym',linthresh=30, linscale=10)
fig.axes[0].set_xlabel(r'$Q_{{ \mathrm{{X}} }}\;/\;\mathrm{{nm^{{-1}}}}$ ')
fig.axes[0].set_ylabel(r'$Q_{{ \mathrm{{Y}} }}\;/\;\mathrm{{nm^{{-1}}}}$ ')
"""
block = kwargs.pop('block', False)
kwargs.update({'block': False})
axis = kwargs.pop('axis', None)
if axis != 'pixel':
try:
# if wavevectors are working we name them
_ = self.pQaxes()
unit = r'$Q_{{ \mathrm{{ {0:s}}} }}\;/\;\mathrm{{nm^{{-1}}}}$ '
except AttributeError:
unit = '{0:s} pixel'
kwargs.update({'axis': 'pixel'})
else:
unit = '{0:s} pixel'
kwargs.update({'axis': 'pixel'})
fig = mpl.contourImage(x=self, **kwargs)
fig.axes[0].set_xlabel(unit.format('Y'))
fig.axes[0].set_ylabel(unit.format('X'))
mpl.show(block=block)
return fig
[docs] def gaussianFilter(self, sigma=2):
"""
Gaussian filter in place.
Uses ndimage.filters.gaussian_filter with default parameters except sigma.
Parameters
----------
sigma : float
Gaussian kernel sigma.
"""
self[self.mask] = ndimage.filters.gaussian_filter(self.data, sigma)[self.mask]
[docs] def reduceSize(self, bin=2, center=None, border=None):
"""
Reduce size of image using uniform average in box.
Center, pixel_size are scaled correspondingly.
Parameters
----------
bin : int
Size of box to average within.
Also factor for reduction in image size.
center : [int,int]
Center of crop region.
border : int
Size of crop region.
- If center is given a box with 2*size around center is used.
- If center is None the border is cut by size.
Returns
-------
sasImage
"""
i1 = i3 = 0
i2 = i4 = 100000
if border is not None:
# set box around center or from border
if center is not None:
center = np.asarray(center, int)
i1 = center[0] - border
i2 = center[0] + border
i3 = center[1] - border
i4 = center[1] + border
else:
i1 = border
i2 = self.shape[0] - border
i3 = border
i4 = self.shape[1] - border
data = self[max(i1, 0):min(i2, self.shape[0]), max(i3, 0):min(i4, self.shape[1])].copy()
data[data.mask] = ndimage.filters.uniform_filter(data.data, size=bin)[data.mask]
smalldata = data[::bin, ::bin]
# clear caches
# copy attributes
smalldata.setAttrFromImage(self)
# change some attributes according to bin
try:
# increase pixel size
smalldata.pixel_size = [pz * bin for pz in smalldata.pixel_size]
except AttributeError:
pass
try:
bc = self.center[:]
bc[0] = (bc[0] - max(i1, 0)) / bin
bc[1] = (bc[1] - max(i3, 0)) / bin
smalldata.setPlaneCenter(bc)
except AttributeError:
pass
# set pixel coordinates
smalldata.ImageWidth = [smalldata.shape[1]]
smalldata.ImageLength = [smalldata.shape[0]]
try:
# for some images the XYResolution if it exists
smalldata.XResolution[0] = data.XResolution[0] * float(bin)
smalldata.YResolution[0] = data.YResolution[0] * float(bin)
except AttributeError:
pass
smalldata._setEXIF()
return smalldata
[docs] def getPolar(self, center=None, scaleR=1, offset=0):
"""
Transform to polar coordinates around center with interpolation.
Azimuth corresponds:
center line upwards, upper quarter center to right
upper/lower edge = center downwards, lower quarter center to left
Parameters
----------
center : [int,float]
Beamcenter
scaleR : float
Scaling factor for radial component to zoom.
Works only for alpha,beta,gamma = 0 .
offset : float >0
Offset to remove center from polar image.
Works only for alpha,beta,gamma = 0 .
Returns
-------
ndarray
Examples
--------
See showPolar for examples.
Use radial averaged data to interpolate ::
import jscatter as js
calibration = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
pol = calibration.getPolar()
"""
if center is not None:
self.setPlaneCenter(center)
def transformsas(pr, bc, shape, scale, offset, rptmin, rptwidth):
"""
Transform polar coordinates to cartesian with bc shift and scaling of radial component to magnify
This is the simple case of standard SAS geometry.
Calculation is done in pixel coordinates.
"""
phi = rptmin[1] + (pr[0] / shape[0] * rptwidth[1])
r = (rptmin[0] + offset + pr[1]) / scale
return r * np.cos(phi) + bc[0], r * np.sin(phi) + bc[1]
rptmin = self._pQrpt.min(axis=(0, 1))
rptmax = self._pQrpt.max(axis=(0, 1))
rptwidth = rptmax - rptmin
if self.alpha == 0 and self.beta == 0 and self.gamma == 0:
newimage = np.zeros_like(self.data)
# use edges and width to transform pixel coordinates to new image
ndimage.geometric_transform(self, mapping=transformsas, output=newimage,
extra_keywords={'bc': self.center,
'scale': scaleR,
'offset': abs(offset),
'shape': self.shape,
'rptmin': rptmin,
'rptwidth': rptwidth})
return newimage
else:
points = self._pQrpt[~self.mask][:, :2]
values = self[~self.mask]
gridx, gridy = np.mgrid[rptmin[1]:rptmax[1]:self.shape[0] * 1j, rptmin[0]:rptmax[0]:self.shape[1] * 1j]
ndInterpol = LinearNDInterpolator(points=points, values=values, fill_value=0, rescale=True)
newimage = ndInterpol(gridy, gridx)
return newimage + 1
[docs] def showPolar(self, center=None, scaleR=1, offset=0, scale='log'):
"""
Show image transformed to polar coordinates around center.
Azimuth for standard SAS geometry corresponds to:
center line upwards, upper quarter center to right
upper/lower edge = center downwards, lower quarter center to left
Parameters
----------
center : [int,int]
Beamcenter
scaleR : float
Scaling factor for radial component to zoom the center.
Works only for alpha,beta,gamma = 0 .
offset : float
Offset to remove center from polar image.
Works only for alpha,beta,gamma = 0 .
scale : 'log', 'symlog', default = 'log'
Scale for intensities.
- 'norm' Linear scale.
- 'log' Logarithmic scale
- 'symlog' Symmetrical logarithmic scale is logarithmic in both the positive
and negative directions from the origin. This works also for only positive data.
Use linthresh, linscale to adjust.
Returns
-------
Handle to figure
Examples
--------
Use polar coordinates to see if center is in middle of Debye-Scherrer rings.
First standard SAS geometry::
import jscatter as js
calibration = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
calibration.showPolar()
For offset detector ::
import jscatter as js
import numpy as np
sic = js.sas.sasImage(js.examples.datapath+'/Silicon.tiff')
sic.setDetectorPosition([130,-100],0.070,90,90,0)
sic.showPolar()
"""
newimage = self.getPolar(center=center, scaleR=scaleR, offset=offset)
f = mpl.contourImage(newimage, axis='pixel', scale=scale)
f.axes[0].set_ylabel('azimuth / pixel')
f.axes[0].set_xlabel('radial / pixel')
mpl.show(block=False)
return f
[docs] def asdataArray(self, masked=0):
"""
Return representation of sasImage as dataArray representing wavevectors (qx,qy) against intensity.
Parameters
----------
masked : float, None, string, default=0
How to deal with masked values.
- float : Set masked pixels to this value
- None : Remove from dataArray.
To recover the image the masked pixels need to be interpolated on a regular grid.
- ‘linear’, ‘cubic’, ‘nearest’ : interpolate masked points by scipy.interpolate.griddata
using specified order of interpolation on 2D image.
- 'radial' Use the radial averaged data to interpolate.
Returns
-------
dataArray with [qx, qy, qz, I(qx,qy,qz)]
- .qx, .qy : original q pixel values corresponding to image pixels along axes through the center
to recover the image. (.qx for .qy=0 and .qy for .qx=0.)
Please keep in mind that the values are only exact for integer center values.
Values are also not equidistant as for larger values the curvature of the Ewald sphere is important.
To recover the image use .regrid(method='nearest') to avoid artefacts due to this inaccuracy
and mask values according to original mask.
- .sasImageshape as shape of original sasImage
- Image attributes except ['artist', 'imageDescription'] are copied.
- [qx,qy,qz] correspond to [.X, .Z, .W] in dataArray with .Y as I(qx,qy,qz).
- The third dimension .qz (.W in dataArray) results from the fact that the flat detector image represents
the scattering vectors on the Ewald sphere which has also a qz component.
For small angle scattering this component might be small compared to qx,qy.
Examples
--------
Use radial averaged data to interpolate the regions where the detector is dark ::
import jscatter as js
import numpy as np
bsa = js.sas.sasImage(js.examples.datapath+'/BSA11mg.tiff')
bsa.maskCircle(bsa.center,40)
bsar=bsa.asdataArray('radial')
js.mpl.surface(bsar.X, bsar.Z, bsar.Y,shape=bsar.sasImageshape)
This demo will show the interpolation in the masked regions of an artificial intensity distribution.
The examples might allow interpolation in a masked region like a unwanted Bragg reflex::
import jscatter as js
import numpy as np
calibration = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
# manipulate data (not the mask)
# this only creates here a flat plateau for later interpolation.
calibration.data[:300,60:1200]=100
calibration.data[:300,120:180]=300
calibration.data[:300,180:]=600
# mask a circle
calibration.maskCircle([200,200], 120)
cal=calibration.asdataArray('linear')
cal.Y[cal.Y<=0.1]=1.1
js.mpl.surface(cal.X, cal.Z, cal.Y,shape=cal.sasImageshape)
cal2=calibration.asdataArray(None) # this is reduced in size due to the mask
"""
qxzw = self.pQ.reshape((-1, 3)) # array of qx and qz, qw
# return flat array without masked data
mask = ~self.mask.flatten()
if isinstance(masked, numbers.Number):
out = dA(np.vstack([qxzw.T, self.data.flatten()]), XYeYeX=[0, 3, None, None, 1, None, 2, None])
out[3, ~mask] = masked
elif isinstance(masked, str) and self.mask.sum() > 0:
if masked not in ['linear', 'cubic', 'nearest', 'radial']:
masked = 'nearest'
ix = self.iX.flatten()
iy = self.iY.flatten()
dat = self.data.flatten()
if masked[0] != 'r':
# 2D interpolation for masked points along pixel numbers
f = griddata(np.stack([ix[mask], iy[mask]], axis=1), dat[mask],
(ix[~mask], iy[~mask]), method=masked)
dat[~mask] = f
out = dA(np.vstack([qxzw.T, dat]), XYeYeX=[0, 3, None, None, 1, None, 2, None])
else:
# interpolate from radial averaged image
qr = la.norm(qxzw, axis=-1)
# radial averaged data as dataarray with interp function
radial = self.radialAverage()
dat[~mask] = radial.interp(qr[~mask])
out = dA(np.vstack([qxzw.T, dat]), XYeYeX=[0, 3, None, None, 1, None, 2, None])
else:
out = dA(np.vstack([qxzw[mask, :].T, self.flatten()[mask]]), XYeYeX=[0, 3, None, None, 1, None, 2, None])
for attr in self.attr:
if attr not in ['artist', 'imageDescription']:
setattr(out, attr, getattr(self, attr))
out.qx, out.qy = self.pQaxes()
# out.qx = qxzw[:, 0]
# out.qy = qxzw[:, 1]
out.sasImageshape = self.shape
return out
[docs] def interpolateMaskedRadial(self, radial=None):
"""
Interpolate masked values from radial averaged image or function.
This can be used to "extrapolate" over masked regions if e.g a background was measured
at wrong distance.
Parameters
----------
radial : dataArray, function, default = None
Determines how to determine masked values based on radial *q* values from center.
- Function accepting array to calculate masked data.
- dataArray for linear interpolating masked points.
- None uses the radialAverage image.
Returns
-------
sasImage including original parameters.
Examples
--------
Use radial averaged data to interpolate ::
import jscatter as js
import numpy as np
calibration = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
cal=calibration.interpolateMaskedRadial()
# or
# cal=calibration.interpolateMaskedRadial(calibration.radialAverage())
cal.show()
Generate image for different detector distance ::
cal.setDetectorDistance(0.3)
# mask whole image
cal.mask=np.ma.masked
# recover image with radial average from original
cal2=cal.interpolateMaskedRadial(calibration.radialAverage())
cal2.show()
"""
qxzw = self.pQ.reshape((-1, 3)) # array of qx and qz, qw
# return flat array without masked data
mask = self.mask.flatten()
dat = self.data.flatten()
qr = la.norm(qxzw, axis=-1)
if formel._getFuncCode(radial):
# is a function to call
dat[mask] = radial(qr[mask])
elif radial is None:
# radial average in q units
radial = self.radialAverage()
dat[mask] = radial.interp(qr[mask])
elif hasattr(radial, '_isdataArray'):
dat[mask] = radial.interp(qr[mask])
else:
raise Exception('Unknown radial ')
image = self.copy()
image.data[self.mask] = dat.reshape(self.shape[0], self.shape[1])[self.mask]
image.maskReset()
return image
[docs] def saveAsTIF(self, filename, fill=None, **params):
"""
Save the sasImage as float32 tif without loosing information.
Conversion from float64 to float32 is necessary.
To save colored images use asImage.save() (see :py:func:`~sasImage.asImage`)
Parameters
----------
filename : string
Filename to save to.
fill : float, 'min' default None
Fill value for masked values. By default this is -1.
'min' uses the minimal value of the respective data type
- np.iinfo(np.int32).min = -2147483648 for int32
- np.finfo(np.float32).min = -3.4028235e+38 for float32
params : kwargs
Additional kwargs for PIL.Image.save if needed.
Examples
--------
::
import jscatter as js
cal = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
cal2=cal/2.
cal2.saveAsTIF('mycal',fill=-100)
mycal=js.sas.sasImage('mycal.tif',maskbelow=-200)
mycal.show()
"""
# create image
_, file_extension = os.path.splitext(filename)
if file_extension not in ['.tif', '.tiff']:
filename += '.tif'
if fill is None:
fill = -1
if self.dtype == 'int32':
if fill == 'min':
fill = np.iinfo(np.int32).min
image = PIL.Image.fromarray(self.filled(fill), mode='I')
else: # as float32
if fill == 'min':
fill = np.finfo(np.float32).min
image = PIL.Image.fromarray(self.filled(fill).astype(np.float32), mode='F')
# write image
params.update({'tiffinfo': self._tags})
image.save(fp=filename, **params)
[docs] def asImage(self, scale='log', colormap='jet', inverse=False, linthresh=1.0, linscale=1.0):
"""
Returns the sasImage as 8bit RGB image using PIL.
See `PIL(Pillow) <https://pillow.readthedocs.io/en/latest/>`_ for more info about PIL images
and image manipulation possibilities as e.g. in notes.
Conversion to 8bit RGB looses floating point information but is for presenting and publication.
Parameters
----------
scale : 'log', 'symlog', default = 'norm'
Scale for intensities.
- 'norm' Linear scale.
- 'log' Logarithmic scale
- 'symlog' Symmetrical logarithmic scale is logarithmic in both the positive
and negative directions from the origin.
Use linthresh, linscale to adjust.
colormap : string, None
Colormap from matplotlib or None for grayscale.
For standard colormap names look in js.mpl.showColors().
inverse : bool
Inverse colormap.
linthresh : float, default = 1
Only used for scale 'sym'.
The range within which the plot is linear (-linthresh to linthresh).
linscale : float, default = 1
Only used for scale 'sym'.
Its value is the number of decades to use for each half of the linear range.
Returns
-------
PIL image
Notes
-----
Pillow (fork of PIL) allows image manipulation.
As a prerequisite of Jscatter it is installed on your system and
can be imported as ``import PIL`` ::
image=mysasimage.asImage()
image.show() # show in system default viewer
image.save('test.pdf', format=None, **params) # save the image in different formats
image.save('test.jpg',subsampling=0, quality=100) # use these for best jpg quality
image.save('test.png',transparency=(0,0,0)) # png image with black as transparent
image.crop((10,10,200,200)) # cut border
import PIL.ImageOps as PIO
nimage=PIO.equalize(image, mask=None) # Equalize the image histogram.
nimage=PIO.autocontrast(image, cutoff=0, ignore=None) # Automatic contrast
nimage=PIO.expand(image, border=20, fill=(255,255,255)) # add border to image (here white)
Examples
--------
::
import jscatter as js
import PIL.ImageOps as PIO
cal = js.sas.sasImage(js.examples.datapath+'/calibration.tiff')
# create image for later usage
image=cal.asImage(colormap='inferno',scale='log',inverse=1)
# create image and just show it
cal.asImage(colormap='inferno',scale='log').show()
# expand image and show it or save it
PIO.expand(image, border=20, fill=(255,255,255)).show()
PIO.expand(image, border=20, fill=(255,255,255)).save('myimageas.pdf')
"""
if colormap is not None:
cmap = cm.get_cmap(colormap)
else:
cmap = cm.get_cmap('gray')
if scale[:3] == 'log':
norm = colors.LogNorm(clip=True)
elif scale[:3] == 'sym':
norm = colors.SymLogNorm(clip=True, linthresh=linthresh, linscale=linscale)
else: # default: scale == 'normalize':
norm = colors.Normalize(clip=True)
# initialize min,max values
norm.autoscale(self)
# do normalization
data = norm(self)
if inverse:
data = 1 - data
# conversion to colormap in range 0:255
cdata = cmap(data, bytes=True)
image = PIL.Image.fromarray(cdata[:, :, :-1], mode='RGB')
return image
[docs]def createImageFromArray(data, xgrid=None, zgrid=None, method='nearest', fill_value=0):
"""
Create sasImage from 2D dataArray with .X and .Z coordinates and .Y values.
If points are missing these are interpolated using .regrid.
Parameters
----------
data : dataArray
Data to create image.
xgrid : array, None, int
New grid in x dimension. If None the unique values in .X are used.
For integer the xgrid with these number of points between [min(X),max(X)] is generated.
zgrid :array, None
New grid in z dimension (second dimension). If None the unique values in .Z are used.
For integer the zgrid with these number of points between [min(X),max(X)] is generated.
method : float,'linear', 'nearest', 'cubic'
Filling value for new points as float or order of interpolation
between existing points.
See `griddata <https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.griddata.html>`_
fill_value
Value used to fill in for requested points outside of the convex
hull of the input points.
See `griddata <https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.griddata.html>`_
Returns
-------
sasImage
Examples
--------
::
import jscatter as js
import numpy as np
import matplotlib.pyplot as pyplot
import matplotlib.tri as tri
def func(x, y):
return x*(1-x)*np.cos(4*np.pi*x) * np.sin(4*np.pi*y**2)**2
# create random points in [0,1]
xz = np.random.rand(1000, 2)
v = func(xz[:,0], xz[:,1])
# create dataArray
data=js.dA(np.stack([xz[:,0], xz[:,1],v],axis=0),XYeYeX=[0, 2, None, None, 1, None])
newdata=data.regrid(np.r_[0:1:100j],np.r_[0:1:200j],method='cubic')
newdata.Y+=newdata.Y.max()
image=js.sas.createImageFromArray(newdata,100,100)
image.show()
"""
ndata = data.regrid(xgrid=xgrid, zgrid=zgrid, wgrid=None, method=method, fill_value=fill_value)
im = ndata.Y.reshape(np.unique(ndata.X).shape[0], np.unique(ndata.Z).shape[0])
im2 = PIL.Image.fromarray(im)
im2.tag = ''
image = sasImage(im2)
return image
[docs]def readImages(filenames):
"""
Read a list of images returning sasImage`s.
Parameters
----------
filenames : string
Glob pattern to read
Returns
-------
list of sasImage`s
Notes
-----
To get a list of image descriptions::
images=js.sas.readImages(path+'/latest*.tiff')
[i.description for i in images]
"""
try:
filelist = glob.glob(filenames)
except AttributeError:
raise AttributeError('No filename pattern in ', filenames)
else:
data = []
for ff in filelist:
data.append(sasImage(ff))
return data
[docs]def createImageDescriptions(images):
"""
Create text file with image descriptions as list of content.
Parameters
----------
images : list of sasImages or glob pattern
List of images
Returns
-------
"""
if not isinstance(images, (list, set)):
images = readImages(images)
commonprefix = os.path.commonprefix([i.filename for i in images])
description = [i.filename[len(os.path.dirname(commonprefix)):] + ' ' + i.description for i in images]
description.sort()
commonname = os.path.split(commonprefix)[-1]
if commonname == '':
commonname = '--'
with open('ContentOf_' + commonname + '.txt', 'w') as f:
f.writelines("%s\n" % l for l in ['Content of dir ' + os.path.dirname(commonprefix), ' '])
f.writelines("%s\n" % l for l in description)
[docs]def createLogPNG(filenames, center=None, size=None, colormap='jet', equalize=False, contrast=None):
"""
Create .png files from grayscale images with log scale conversion to values between [1,255].
This generates images viewable in simple image viewers as overview.
The new files are stored in the same folder as the original files.
Parameters
----------
filenames : string
Filename with glob pattern as 'file*.tif'
center : [int,int]
Center of crop region.
size : int
Size of crop region.
- If center is given a box with 2*size around center is used.
- If center is None the border is cut by size.
colormap : string, None
Colormap from matplotlib or None for grayscale.
For standard colormap names look in mpl.showColors().
equalize : bool
Equalize the images.
contrast : None, float
Autocontrast for the image.
The value (0.1=10%) determines how much percent are cut from the intensity histogram before linear
spread of intensities.
"""
if colormap is not None:
cmap = mpl.matplotlib.cm.get_cmap(colormap)
else:
cmap = None
i1 = i3 = 0
i2 = i4 = 100000
if size is not None:
# set box around center or from border
if center is not None:
i1 = center[1] - size
i2 = center[1] + size
i3 = center[0] - size
i4 = center[0] + size
else:
i1 = size
i2 = - size
i3 = size
i4 = - size
try:
filelist = glob.glob(filenames)
except AttributeError:
raise AttributeError('No filename pattern in ', filenames)
else:
for ff in filelist:
image = PIL.Image.open(ff)
# crop image array
image2 = np.array(image)[max(i1, 0):min(i2, image.height), max(i3, 0):min(i4, image.width)]
# log scale mapped to 0-255
image2[image2 < 1] = 1
image2 = np.log(image2)
image2 = image2 / np.max(image2) * 255
if cmap is None:
newimage = PIL.Image.fromarray(image2.astype(np.uint8)).convert('L')
else:
# cmap needs uint to work properly
image2 = cmap(image2.astype(np.uint8), bytes=True)
newimage = PIL.Image.fromarray(image2[:, :, :-1], mode='RGB')
if contrast is not None:
newimage = PIL.ImageOps.autocontrast(newimage, contrast)
if equalize:
newimage = PIL.ImageOps.equalize(newimage)
newimage.save(ff + '.png')
return