"""
This file contains useful classes and functions for profiling and integration
"""
import logging
import numpy as np
from scipy.spatial import KDTree
logger = logging.getLogger("laue-dials.algorithms.integration")
[docs]
def stack_panels(pixels):
"""
Normalize panel pixel data into a single (n_panels, rows, cols) array.
A 2D array is treated as a single panel, so the single-panel call
``Integrator(pixels, centroids)`` is unchanged. A sequence of 2D arrays --
one per panel, as returned by ``imageset.get_raw_data()`` -- is padded to
the largest panel shape; the padding is marked invalid by
:func:`panel_validity` and never enters the fit.
Args:
pixels (np.ndarray or sequence of np.ndarray): Panel pixel data.
Returns:
tuple: ``(stack, shapes)``, the padded (n_panels, rows, cols) array and
the list of true (rows, cols) shapes.
"""
if isinstance(pixels, np.ndarray) and pixels.ndim == 2:
return pixels[None, ...], [pixels.shape]
if isinstance(pixels, np.ndarray) and pixels.ndim == 3:
return pixels, [pixels.shape[1:]] * len(pixels)
arrays = [np.asarray(p) for p in pixels]
if len(arrays) == 0:
raise ValueError("no panel pixel data supplied")
if any(a.ndim != 2 for a in arrays):
raise ValueError("each panel must be a two-dimensional array of pixels")
shapes = [a.shape for a in arrays]
rows = max(s[0] for s in shapes)
cols = max(s[1] for s in shapes)
stack = np.zeros((len(arrays), rows, cols), dtype=arrays[0].dtype)
for i, a in enumerate(arrays):
stack[i, : a.shape[0], : a.shape[1]] = a
return stack, shapes
[docs]
def panel_validity(stack_shape, shapes, panel_masks=None):
"""
Build the per-pixel validity array for a padded panel stack.
A pixel is valid if it lies inside its panel's true extent and is not
masked off by the detector mask.
Args:
stack_shape (tuple): Shape of the padded stack, (n_panels, rows, cols).
shapes (list): True (rows, cols) shape of each panel.
panel_masks (sequence, optional): One boolean mask per panel, True for
good pixels. Entries may be None. If omitted, every real pixel is
valid.
Returns:
np.ndarray: Boolean array with the shape of the padded stack.
"""
n_panels, rows, cols = stack_shape
valid = np.zeros((n_panels, rows, cols), dtype=bool)
for i, (pr, pc) in enumerate(shapes):
valid[i, :pr, :pc] = True
if panel_masks is not None:
for i, mask in enumerate(panel_masks):
if mask is None:
continue
pr, pc = shapes[i]
valid[i, :pr, :pc] &= np.asarray(mask, dtype=bool).reshape(pr, pc)
return valid
[docs]
class IntegratorBase:
def __init__(
self,
pixels,
centroids,
panel_ids=None,
panel_masks=None,
neighbor_coords=None,
radius=None,
k=5,
isigi_cutoff=3.0,
epsilon=1e-6,
):
"""
Args:
pixels: Either a 2D pixel array (single panel) or a sequence of 2D
arrays, one per detector panel.
centroids (np.ndarray): (n, 2) predicted centroids as (x, y) in the
pixel coordinates of the panel each reflection lies on.
panel_ids (np.ndarray, optional): (n,) panel index per reflection.
Defaults to panel 0 for every reflection.
panel_masks (sequence, optional): One boolean detector mask per
panel, True for good pixels. Masked pixels are excluded from
the fit rather than dropping the reflection.
neighbor_coords (np.ndarray, optional): (n, 2) or (n, 3) centroid
coordinates in a frame shared by every panel, in pixel units.
Used only to estimate the integration radius and to find each
reflection's nearest strong neighbours. Panel-local pixel
coordinates cannot serve for either on a multi-panel detector:
every panel starts again at (0, 0), so the panels pile up on
top of one another, the nearest-neighbour distances collapse
and with them the estimated radius. Defaults to ``centroids``,
which is correct for a single panel.
"""
self.pixels, self.panel_shapes = stack_panels(pixels)
self.n_panels = len(self.panel_shapes)
self.panel_valid = panel_validity(
self.pixels.shape, self.panel_shapes, panel_masks
)
centroids = np.asarray(centroids)
if panel_ids is None:
panel_ids = np.zeros(len(centroids), dtype=int)
panel_ids = np.asarray(panel_ids).astype(int)
if panel_ids.shape != (len(centroids),):
raise ValueError("panel_ids must have one entry per centroid")
if panel_ids.size and (panel_ids.min() < 0 or panel_ids.max() >= self.n_panels):
raise ValueError("panel_ids refer to a panel with no pixel data")
self.panel_ids = panel_ids
if neighbor_coords is None:
neighbor_coords = centroids
self.neighbor_coords = np.asarray(neighbor_coords, dtype=float)
self.epsilon = epsilon
if radius is None:
radius = estimate_integration_radius(self.neighbor_coords)
window = np.mgrid[-radius : radius + 1, -radius : radius + 1].reshape((2, -1)).T
r = np.sqrt(np.square(window[:, 0]) + np.square(window[:, 1]))
self.radius = radius
self.window_mask = window[r <= radius]
self.centroids = centroids[..., ::-1]
self.n = len(self.centroids)
self.intensity = None
self.profile_scale = (
np.ones(self.n)[:, None, None] * np.eye(2) * self.radius / 2.0
)
self.profile_loc = self.centroids.copy()
self.background = np.ones((self.n, 1))
# Centre each window on the pixel that contains the centroid. Pixel
# index i spans [i, i+1) -- which is why `xy` adds 0.5 to recover the
# pixel centre -- so that pixel is floor(centroid), not
# round(centroid). Rounding put the window one pixel off whenever the
# centroid's fractional part was >= 0.5, i.e. for about half of all
# reflections, and disagreed with the mask-dilation filter in
# laue.integrate, which floors. That disagreement let a window reach a
# masked pixel the filter believed it had cleared.
window_idx = (
np.floor(self.centroids).astype("int")[:, None, :]
+ self.window_mask[None, :, :]
)
# Clamp each axis independently to panel bounds, and remember which
# window pixels were actually inside. The clamped duplicates keep the
# indexing legal but must not enter the fit: on a segmented detector a
# large fraction of windows overhang a panel edge (on 50 px wedges with
# a 9 px radius, about 38% of them), and folding the edge pixel in
# several times biases both the background and the profile.
h, w = self.pixels.shape[1:]
in_bounds = (
(window_idx[..., 0] >= 0)
& (window_idx[..., 0] < h)
& (window_idx[..., 1] >= 0)
& (window_idx[..., 1] < w)
)
window_idx[..., 0] = np.clip(window_idx[..., 0], 0, h - 1)
window_idx[..., 1] = np.clip(window_idx[..., 1], 0, w - 1)
# order is [xy, refl, pixel]
# you can index like self.pixels[tuple(self.window_idx)] -> array[refl, pixel]
self.window_idx = window_idx.transpose(2, 0, 1)
self.window_panel = np.broadcast_to(
self.panel_ids[:, None], self.window_idx.shape[1:]
)
self.window_valid = (
in_bounds
& self.panel_valid[
self.window_panel, self.window_idx[0], self.window_idx[1]
]
)
self.m = self.window_idx.shape[-1]
n_valid = self.window_valid.sum(-1)
if (n_valid == 0).any():
raise ValueError(
f"{int((n_valid == 0).sum())} reflection(s) have no usable pixels "
"in their integration window; drop them before integrating."
)
self.n_valid = n_valid
self.intensity = (self.windows * self.window_valid).sum(-1) / n_valid
self.uncertainty = np.sqrt(np.maximum(self.intensity, 0.0))
self.k = k
self.isigi_cutoff = isigi_cutoff
self.strong = np.ones(len(self.centroids), dtype=bool) # Start with all strong
@property
def windows(self):
return self.pixels[self.window_panel, self.window_idx[0], self.window_idx[1]]
[docs]
def fit(self, maxiter=2, tol=1e-3):
"""
Iteratively refine the background, profiles, and intensities.
The objective is evaluated after an iteration's updates have been
applied, so the value compared always describes the current state.
Fitting stops once an iteration fails to improve the objective by at
least ``tol`` in relative terms, which covers both a plateau and an
outright increase.
Args:
maxiter (int): Maximum number of iterations to run.
tol (float): Minimum relative improvement in the objective needed
to keep iterating. Defaults to 1e-3, i.e. 0.1 percent.
"""
previous = None
for _ in range(maxiter):
self.assign_knn()
self.estimate_background()
self.estimate_profiles()
self.integrate()
self.set_strong()
if not self.strong.any():
raise RuntimeError("No strong spots remaining after integration.")
score = self.score
if previous is not None:
improvement = (previous - score) / max(abs(previous), self.epsilon)
if improvement < tol:
break
previous = score
[docs]
def predict(self):
p = self.profile_values
v = np.maximum(0.0, self.intensity[:, None]) * p + self.background
return v
[docs]
def get_log_p_mdist(self):
return mvn_log_pdf(
self.xy, self.profile_loc, self.profile_scale, return_zscore=True
)
@property
def profile_dist(self):
log_p, mdist = self.get_log_p_mdist()
return mdist
@property
def log_profile_values(self):
log_p, _ = self.get_log_p_mdist()
return log_p
@property
def profile_values(self):
from scipy.special import softmax
# Normalizing over the valid pixels only keeps sum(p) == 1 over the
# pixels that are actually used, which is what makes the profile-fitted
# intensity and its variance consistent, and gives p == 0 on every
# invalid pixel so they drop out of the weighted sums downstream.
log_p = np.where(self.window_valid, self.log_profile_values, -np.inf)
p = softmax(log_p, axis=-1)
return p
@property
def xy(self):
retval = self.window_idx.transpose(1, 2, 0) + 0.5
return retval
[docs]
def plot_profiles(
self,
ax=None,
n_std=2.0,
weak_color="w",
strong_color="y",
facecolor="none",
**kwargs,
):
import matplotlib.transforms as transforms
from matplotlib import pyplot as plt
from matplotlib.patches import Ellipse
if ax is None:
ax = plt.gca()
retval = []
for loc, cov, strong in zip(self.profile_loc, self.profile_scale, self.strong):
ecolor = strong_color if strong else weak_color
loc = loc[..., ::-1]
cov = cov.swapaxes(-1, -2)
pearson = cov[0, 1] / np.sqrt(cov[0, 0] * cov[1, 1])
# Using a special case to obtain the eigenvalues of this
# two-dimensional dataset.
ell_radius_x = np.sqrt(1 + pearson)
ell_radius_y = np.sqrt(1 - pearson)
ellipse = Ellipse(
(0, 0),
width=ell_radius_x * 2,
height=ell_radius_y * 2,
facecolor=facecolor,
edgecolor=ecolor,
**kwargs,
)
# Calculating the standard deviation of x from
# the squareroot of the variance and multiplying
# with the given number of standard deviations.
scale_x = np.sqrt(cov[0, 0]) * n_std
scale_y = np.sqrt(cov[1, 1]) * n_std
mean_x, mean_y = loc
transf = (
transforms.Affine2D()
.rotate_deg(45)
.scale(scale_x, scale_y)
.translate(mean_x, mean_y)
)
ellipse.set_transform(transf + ax.transData)
retval.append(ax.add_patch(ellipse))
return retval
[docs]
def single_profile_image(self, fg_values, fill_value=0.0):
im = (
np.ones((2 * self.radius + 1, 2 * self.radius + 1), dtype=fg_values.dtype)
* fill_value
)
np.add.at(im, tuple(self.window_mask.T), fg_values)
im = np.fft.fftshift(im)
return im
[docs]
def fg_to_image(self, window_values, fill_value=0.0):
"""
convert an array of the same shape as self.pixels[*self.window_idx] -> array(refls x pixels)
to something the same shape as refls.pixels
"""
im = np.ones_like(self.pixels) * fill_value
np.add.at(
im,
(self.window_panel, self.window_idx[0], self.window_idx[1]),
window_values,
)
return im
[docs]
def plot_image(self, pixels=None, autoscale=True, **kwargs):
"""
autoscale uses skimage.exposure.adjust_log
"""
if pixels is None:
if self.n_panels > 1:
raise NotImplementedError(
"plot_image draws a single panel; pass pixels=... to choose one"
)
pixels = self.pixels[0]
from matplotlib import pyplot as plt
if autoscale:
from skimage import exposure
pixels = exposure.adjust_log(pixels)
plt.matshow(pixels, **kwargs)
[docs]
def plot_image_with_profiles(self):
self.plot_image()
for n_std in (1.0, 2.0, 3.0):
self.plot_profiles(n_std=n_std)
[docs]
class Integrator(IntegratorBase):
@property
def pixel_weights(self):
v = self.predict()
from scipy.stats import poisson
w = -poisson.logpmf(self.windows, v)
return np.where(self.window_valid, w, 0.0)
@property
def score(self):
"""Loss function value"""
return self.pixel_weights.sum()
[docs]
def set_strong(self):
"""set self.strong"""
self.strong = self.intensity >= self.isigi_cutoff * self.uncertainty
[docs]
def assign_knn(self):
k = self.k
n_strong = self.strong.sum()
if n_strong <= 1:
raise RuntimeError(
f"Only {n_strong} strong spot(s) found; cannot perform KNN profile estimation."
)
k_actual = min(k, n_strong - 1)
if k_actual < k:
logger.warning(
"Only %d strong spots available; using %d neighbors instead of %d.",
n_strong,
k_actual,
k,
)
# Neighbours are found in the shared frame, so a reflection near a panel
# edge pools with the spots physically next to it on the adjacent panel
# rather than with whatever happens to sit at the same panel-local
# coordinates several panels away.
knn_idx = KDTree(self.neighbor_coords[self.strong]).query(
self.neighbor_coords, k=k_actual + 1
)[1]
# Strong spots have themselves as first result (distance 0); non-strong
# spots are not in the tree so their first result is already a neighbor.
# Select the right k_actual columns for each case.
self.knn = np.where(self.strong)[0][
np.where(self.strong[:, None], knn_idx[:, 1:], knn_idx[:, :k_actual])
]
[docs]
def estimate_background(self):
c = self.windows
w = np.where(self.window_valid, self.profile_dist + self.epsilon, 0.0)
I = self.intensity
p = self.profile_values
bg = np.average(c - I[:, None] * p, axis=-1, weights=w, keepdims=True)
self.background = np.maximum(self.epsilon, bg)
[docs]
def estimate_profiles(self):
c = self.windows
xy = self.xy - self.centroids[:, None, :]
bg = self.background
p = np.exp(
self.log_profile_values
) # normalized over all space, not the profile
p = np.where(self.window_valid, p, 0.0)
num = np.maximum(0.0, (c - bg))
den = np.maximum(self.epsilon, self.intensity)
w = num / den[:, None] * p
w = w[self.knn].reshape((self.n, -1))
xy = xy[self.knn].reshape((self.n, -1, 2))
# Only update profiles for reflections with nonzero weights. All-zero
# weights (e.g. from dead pixels or negative intensity) would cause
# cov() to divide by zero. Keeping the previous profile estimate is
# better than overwriting it with nan.
has_signal = w.sum(-1) > 0
if has_signal.any():
pscale, ploc = cov(
xy[has_signal], w[has_signal][..., None], return_mean=True
)
ploc = ploc.squeeze(-2)
self.profile_loc[has_signal] = ploc + self.centroids[has_signal]
# Tikhonov regularization: add a small multiple of the identity to
# guarantee positive definiteness and prevent LinAlgError in
# mvn_log_pdf when neighboring pixels are collinear.
self.profile_scale[has_signal] = pscale + self.epsilon * np.eye(2)
[docs]
def integrate(self):
c = self.windows
b = self.background
v = self.predict()
p = self.profile_values
# p is zero on invalid pixels, so they contribute nothing to either sum
# and the weights stay normalized over the pixels that are used.
w = p / v / np.sum(np.square(p) / v, axis=-1, keepdims=True)
I = (c - b) * w
self.intensity = I.sum(-1)
SigI = v * w
SigI = np.sqrt(np.sum(SigI, axis=-1))
self.uncertainty = SigI
[docs]
def estimate_integration_radius(centroids):
"""
Estimate the default integration radius from the spacing of spot centroids.
The radius is half the 20th percentile of nearest-neighbor centroid
distances, rounded to the nearest integer. The same radius is used both for
the integration window and for dilating the detector mask when discarding
predictions that fall in masked regions, so the two stay consistent.
The centroids must be in a frame shared by every panel. Panel-local pixel
coordinates superimpose the panels, which drives the nearest-neighbor
distances -- and the radius with them -- towards zero.
Args:
centroids (np.ndarray): (n, 2) array of centroid pixel coordinates.
Returns:
int: Estimated radius in pixels.
"""
# Query k=2 because the nearest result is the point itself, at distance
# zero. A KDTree prunes to the nearest neighbor directly, where a full
# pairwise distance matrix would be O(n^2) in both time and memory --
# hundreds of MB for a densely predicted image.
nn_dist = KDTree(centroids).query(centroids, k=2)[0][:, 1]
radius = 0.5 * np.percentile(nn_dist, 20)
return int(np.round(radius))
[docs]
def detector_global_pixels(detector, panel_ids, spots):
"""
Map panel-local centroids onto a single detector-wide pixel grid.
``xyzcal.px`` is panel-local: every panel starts again at (0, 0), so on a
multi-panel detector the panels are superimposed and the coordinates are
useless as scaling metadata -- on the LADI drum they pile 48 wedges on top
of one another. This lays the panels out on a common grid instead.
The panels are assumed to share a slow direction, which is what makes a
two-dimensional layout meaningful at all. Writing ``s`` for the mean slow
axis, each lab-frame point is split into its component along ``s`` (the
slow coordinate) and its azimuth about ``s`` (the fast coordinate,
converted to a distance with the mean panel radius). For a curved detector
that is the unrolled arc length; for a flat one it is a smooth monotonic
function of the fast coordinate. The branch cut is placed in the largest
angular gap between panels, so a detector wrapping past 180 degrees -- the
LADI drum covers about 304 -- does not wrap around on itself. The origin is
the corner of the panel at the low end of both coordinates, so the result
is non-negative and depends only on the detector model.
Args:
detector: dxtbx detector model, or any sequence of panels supporting
get_slow_axis, get_origin, get_pixel_size, get_image_size and
get_pixel_lab_coord.
panel_ids (np.ndarray): (n,) panel index per centroid.
spots (np.ndarray): (n, 2) panel-local centroids in pixels.
Returns:
np.ndarray or None: (n, 2) centroids on the detector-wide grid, in
pixels. A single-panel detector is returned unchanged. None if the
panels have no common slow direction, or if they close a full circle
and so leave no gap to cut at.
"""
spots = np.asarray(spots, dtype=float)[:, :2]
panel_ids = np.asarray(panel_ids).astype(int)
if len(detector) < 2:
return spots.copy()
slow = np.array([p.get_slow_axis() for p in detector], dtype=float)
s = slow.mean(axis=0)
norm = np.linalg.norm(s)
if norm < 1e-9:
return None
s = s / norm
px = np.array([p.get_pixel_size() for p in detector], dtype=float)
qx, qy = float(px[:, 0].mean()), float(px[:, 1].mean())
size = np.array([p.get_image_size() for p in detector], dtype=float)
centres = np.array(
[
p.get_pixel_lab_coord((w / 2.0, h / 2.0))
for p, (w, h) in zip(detector, size)
],
dtype=float,
)
cq = centres - np.outer(centres @ s, s)
radii = np.linalg.norm(cq, axis=1)
radius = float(radii.mean())
if radius < 1e-9:
return None
e1 = cq[0] / radii[0]
e2 = np.cross(s, e1)
e2 = e2 / np.linalg.norm(e2)
# Orient the azimuth so that it increases along the panels' fast axis, i.e.
# in the same direction as the panel-local x of xyzcal.px. Without this the
# sign is whichever way round np.cross happens to come out, and the global
# coordinate can run backwards against the local one.
fast = np.array(detector[0].get_fast_axis(), dtype=float)
if np.dot(fast - np.dot(fast, s) * s, e2) < 0:
e2 = -e2
theta_panel = np.arctan2(cq @ e2, cq @ e1)
# Put the branch cut in the widest angular gap between panels, so the
# occupied arc is contiguous however far round it goes.
order = np.argsort(theta_panel)
ordered = theta_panel[order]
gaps = np.diff(np.append(ordered, ordered[0] + 2 * np.pi))
widest = int(np.argmax(gaps))
if gaps[widest] <= 0:
return None
cut = ordered[widest] + 0.5 * gaps[widest]
lab = np.array(
[
detector[int(p)].get_pixel_lab_coord((float(x), float(y)))
for p, (x, y) in zip(panel_ids, spots)
],
dtype=float,
)
along = lab @ s
q = lab - np.outer(along, s)
theta = np.mod(np.arctan2(q @ e2, q @ e1) - cut, 2 * np.pi)
# Anchor on the detector, not on the reflections, so repeated runs and
# different images of the same detector share one coordinate system.
theta_ref = np.mod(theta_panel - cut, 2 * np.pi) - 0.5 * size[:, 0] * qx / radius
along_ref = np.array([np.dot(p.get_origin(), s) for p in detector], dtype=float)
x = radius * (theta - theta_ref.min()) / qx
y = (along - along_ref.min()) / qy
return np.column_stack([x, y])
[docs]
def unmasked_prediction_selection(np_mask, x, y, radius, img_row_size):
"""
Determine which predicted centroids fall outside the detector mask,
dilated by the given radius.
If np_mask has no bad (False) pixels at all -- e.g. no external mask
file was supplied -- a genuine 1px border is marked as invalid around
the detector edge, and the dilation radius is reduced by 1 to
compensate for that added border. This is needed because
skimage.morphology.isotropic_dilation relies on
scipy.ndimage.distance_transform_edt, which has no real background
reference point when there are no bad pixels at all, and would
otherwise spuriously mask a small region near pixel (0, 0).
Args:
np_mask (np.ndarray): Boolean detector mask with shape (n_rows,
n_cols); True for valid pixels.
x (np.ndarray): Integer pixel x-coordinates of predicted centroids.
y (np.ndarray): Integer pixel y-coordinates of predicted centroids.
radius (int): Radius in pixels to dilate the detector mask by.
img_row_size (int): Number of pixels per detector row, used to
flatten (x, y) coordinates into the flattened mask.
Returns:
np.ndarray: Boolean array, True for centroids to keep.
"""
from skimage.morphology import isotropic_dilation
bad_pixels = ~np_mask
dilation_radius = radius
if not bad_pixels.any():
bad_pixels[0, :] = True
bad_pixels[-1, :] = True
bad_pixels[:, 0] = True
bad_pixels[:, -1] = True
dilation_radius = max(radius - 1, 0)
expanded_mask = ~isotropic_dilation(bad_pixels, dilation_radius)
expanded_mask_flat = expanded_mask.flatten()
return expanded_mask_flat[x + img_row_size * y]
[docs]
def cov(m, aweights=None, return_mean=False, ddof=0):
"""
A batched version of np.cov to estimate the sample covariance matrix where m has leading batch dims.
"""
if ddof not in (0, 1):
raise ValueError(f"ddof can only be 0 or 1, but received {ddof}")
if aweights is None:
loc = np.mean(m, axis=-2, keepdims=True)
if ddof == 0:
denom = m.shape[-2]
elif ddof == 1:
denom = m.shape[-2] - 1
else:
loc, w_sum = np.average(
m, axis=-2, weights=aweights * np.ones_like(m), keepdims=True, returned=True
)
if ddof == 0:
denom = w_sum
elif ddof == 1:
denom = (
w_sum - np.square(aweights).sum(-2, keepdims=True) / w_sum
) # This is for ddof=1 version
X = m - loc
if aweights is not None:
X_T = (X * aweights).swapaxes(-1, -2)
else:
X_T = X.swapaxes(-1, -2)
S = X_T @ X / denom
if return_mean:
return S, loc
return S
[docs]
def mvn_log_pdf(x, loc, scale, return_zscore=False):
"""a batched version of scipy.stats.multivariate_normal.log_pdf"""
d = loc.shape[-1]
diff = x - loc[..., None, :]
Sinv = np.linalg.inv(scale)
log_Z = -0.5 * d * np.log(2 * np.pi) - 0.5 * np.linalg.slogdet(scale)[1]
zscore = (diff[..., None, :] @ Sinv[..., None, :, :] @ diff[..., :, None]).squeeze(
(-1, -2)
)
log_p = log_Z[..., None] - 0.5 * zscore
if return_zscore:
return log_p, zscore
return log_p