Tutorial about drift analysis and correction#

Lateral drift correction is useful in most SMLM experiments. To determine the amount of drift a method based on image cross-correlation or an iterative closest point algorithm can be applied.

We demonstrate drift analysis and correction on simulated data.

from pathlib import Path

%matplotlib inline

import numpy as np
import matplotlib.pyplot as plt
import scipy.stats as stats

import locan as lc
lc.show_versions(system=False, dependencies=False, verbose=False)
Locan:
   version: 0.20.0.dev41+g755b969

Python:
   version: 3.11.6

Synthetic data#

We use synthetic data that follows a Neyman-Scott spatial distribution (blobs). The intensity values are exponentially distributed and the number of localizations per frame follows a Poisson distribution:

rng = np.random.default_rng(seed=1)
intensity_mean = 1000
localizations_per_frame_mean = 3
dat_blob = lc.simulate_Thomas(parent_intensity=1e-4, region=((0, 1000), (0, 1000)), cluster_mu=1000, cluster_std=10, seed=rng)
dat_blob.dataframe['intensity'] = stats.expon.rvs(scale=intensity_mean, size=len(dat_blob), loc=500)
dat_blob.dataframe['frame'] = lc.simulate_frame_numbers(n_samples=len(dat_blob), lam=localizations_per_frame_mean, seed=rng)

dat_blob = lc.LocData.from_dataframe(dataframe=dat_blob.data)

print('Data head:')
print(dat_blob.data.head(), '\n')
print('Summary:')
dat_blob.print_summary()
print('Properties:')
print(dat_blob.properties)
Jupyter environment detected. Enabling Open3D WebVisualizer.
[Open3D INFO] WebRTC GUI backend enabled.
[Open3D INFO] WebRTCWindowSystem: HTTP handshake server disabled.
Data head:
   position_x  position_y  cluster_label    intensity  frame
0  915.763326  465.421770             28  3371.713793      0
1  729.786200   81.672520             56   814.920825      1
2  869.708004    9.266125             41  3385.119344      1
3  521.743493   54.508814             14  1397.009488      2
4  430.972011  375.461311             61  1879.050494      3 

Summary:
identifier: "2"
comment: ""
source: DESIGN
state: RAW
element_count: 98201
frame_count: 31016
creation_time {
  2024-03-14T11:04:58.105764Z
}

Properties:
{'localization_count': 98201, 'position_x': 495.13588743804684, 'uncertainty_x': 0.8971814298938875, 'position_y': 507.65355920347866, 'uncertainty_y': 0.8964573205917536, 'intensity': 146845730.2327197, 'frame': 0, 'region_measure_bb': 999959.2361869529, 'localization_density_bb': 0.09820500321039116, 'subregion_measure_bb': 3999.918472062067}
lc.render_2d(dat_blob, bin_size=10, rescale='equal');
../../_images/fff29a0b00945fff0c2d2b050f874f44fe6828cda1b4966226690d0a55166008.png

Add linear drift#

We add linear drift with a velocity given in length units per frame.

dat_blob_with_drift = lc.add_drift(dat_blob, velocity=(0.002, 0.001), seed=rng)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
lc.render_2d(dat_blob_with_drift, ax=axes[0], bin_size=10);
lc.render_2d(dat_blob_with_drift, ax=axes[1], bin_size=2, rescale='equal', bin_range=((0, 500),(0, 500)));
lc.render_2d_mpl(dat_blob_with_drift, ax=axes[2], other_property='frame', bin_size=2, bin_range=((0, 500),(0, 500)), cmap='viridis');
../../_images/38f3a45ae5e930d028b53b8f2fcf86f442699e30fcbde70ed5b50de0678cdace.png

Estimate RMS errors#

Knowing the ground truth, you can define a root mean squared error between the original localization coordinates and those after drift and later after correction.

def rmse(locdata, other_locdata):
    return np.sqrt(np.mean(np.square(np.subtract(locdata.coordinates, other_locdata.coordinates)), axis=0))
rmse(dat_blob, dat_blob_with_drift).round(2)
array([37.76, 18.88])

Estimate drift#

Drift can be estimated by comparing different chunks of successive localizations using either an “iterative closest point” algorithm or a “cross-correlation” algorithm. Per default, the icp algorithm is applied.

%%time
drift = lc.Drift(chunk_size=10_000, target='first', method='icp').compute(dat_blob_with_drift)
CPU times: user 7.81 s, sys: 31.2 ms, total: 7.84 s
Wall time: 4.35 s

Transformations to register the different data chunks are represented by a transformation matrix and a transformation offset that together specifiy an affine transformation. The tansformation parameters are kept under the transformations attribute.

drift.transformations
[Transformation(matrix=array([[1., 0.],
        [0., 1.]]), offset=array([0., 0.])),
 Transformation(matrix=array([[ 9.98792443e-01,  4.43801557e-04],
        [-4.43801557e-04,  9.98792443e-01]]), offset=array([-6.56553239, -2.7171172 ])),
 Transformation(matrix=array([[ 0.99716405,  0.00268322],
        [-0.00268322,  0.99716405]]), offset=array([-13.15345296,  -4.05470652])),
 Transformation(matrix=array([[ 9.97764214e-01,  5.81845172e-04],
        [-5.81845172e-04,  9.97764214e-01]]), offset=array([-19.46087507,  -8.60939708])),
 Transformation(matrix=array([[ 9.98398817e-01,  6.25680278e-04],
        [-6.25680278e-04,  9.98398817e-01]]), offset=array([-25.94756073, -12.08151622])),
 Transformation(matrix=array([[ 9.98495460e-01, -4.65982241e-04],
        [ 4.65982241e-04,  9.98495460e-01]]), offset=array([-32.71859007, -15.76861207])),
 Transformation(matrix=array([[ 0.99857889,  0.00120287],
        [-0.00120287,  0.99857889]]), offset=array([-39.95058187, -18.65026698])),
 Transformation(matrix=array([[ 9.96629116e-01, -5.37378964e-04],
        [ 5.37378964e-04,  9.96629116e-01]]), offset=array([-44.18251022, -22.15203275])),
 Transformation(matrix=array([[ 9.98851114e-01, -4.33292180e-04],
        [ 4.33292180e-04,  9.98851114e-01]]), offset=array([-52.9063275 , -26.00989829])),
 Transformation(matrix=array([[ 9.97771654e-01, -6.47144209e-04],
        [ 6.47144209e-04,  9.97771654e-01]]), offset=array([-58.14458854, -28.33975215]))]

The parameters can be visualized using the plot function. The matrix in this case is close to the unit matrix.

drift.plot(transformation_component='matrix', element=None);
plt.legend();
../../_images/ff8c5a3cb729eb91460f163a7be45411ded11f5dce8d2fccb07f2b34b379810c.png
drift.plot(transformation_component='offset', element=None)
plt.legend();
../../_images/85c21f547dd81fc5cbecffdc8f1326b9de0f46deea5dbe6613b9d5a4775999f0.png

Model drift#

A continuous transformation model as function of frame number is estimated by fitting the individual transformation components with the specified fit models. Fit models can be provided as DriftComponent or by a string representing standard model functions.

from lmfit.models import ConstantModel, LinearModel, PolynomialModel

drift.fit_transformations(slice_data=slice(None), offset_models=(lc.DriftComponent('spline', s=100), 'linear'), verbose=True);
../../_images/982e8442570bdf5f6e671da5c0f2fe25e8cfeae4e0a3f05efd5862efb04146c4.png ../../_images/b2678cb52af0eb7d7c3cd33cc44f791e21425d1dbc6ad2ea4079bdcef890d690.png

The fit models are represented as DriftComponent and can be accessed through the transformation_models attribute.

drift.transformation_models
{'matrix': None,
 'offset': [<locan.analysis.drift.DriftComponent at 0x7f88c21cd3d0>,
  <locan.analysis.drift.DriftComponent at 0x7f88bca74690>]}
drift.transformation_models['offset'][0].type
'spline'
drift.transformation_models['offset'][0].eval(0)
array(3.33056629)

Each DriftModel carries detailed information about the fit under the model_result attribute. In most cases, except splines, this will be a lmfit.ModelResult object.

drift.transformation_models['offset'][0].model_result
(array([ 1639.0867    ,  1639.0867    ,  1639.0867    ,  1639.0867    ,
        31356.39897573, 31356.39897573, 31356.39897573, 31356.39897573]),
 array([ 3.39564863e-02, -1.98083279e+01, -3.82432449e+01, -5.82808309e+01,
         0.00000000e+00,  0.00000000e+00,  0.00000000e+00,  0.00000000e+00]),
 3)
drift.transformation_models['offset'][1].type
'linear'
drift.transformation_models['offset'][1].model_result

Fit Result

Model: Model(linear)

Drift correction#

The estimated drift is corrected by applying a transformation on the localization chunks (from_model=False).

%%time
drift.apply_correction(from_model=False);
CPU times: user 416 ms, sys: 4.03 ms, total: 420 ms
Wall time: 418 ms
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
lc.render_2d(drift.locdata_corrected, ax=axes[0], bin_size=2, rescale='equal', bin_range=((0, 200),(0, 200)));
lc.render_2d_mpl(drift.locdata_corrected, ax=axes[1], other_property='frame', bin_size=2, bin_range=((0, 200),(0, 200)), cmap='viridis');
../../_images/7326af329a723be5512d0e6ba7c27db6d5f5a8c3414b269a42024403f44c54f6.png
rmse(dat_blob, drift.locdata_corrected).round(2)
array([9.55, 4.82])

Or the estimated drift is corrected by applying a transformation on each individual localization using the drift models (from_model=True).

%%time
drift.apply_correction(from_model=True)
CPU times: user 64.1 ms, sys: 8 ms, total: 72.1 ms
Wall time: 72 ms
Drift(chunks=None, chunk_size=10000, n_chunks=None, target=first, method=icp, kwargs_chunk=None, kwargs_register=None)
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
lc.render_2d(drift.locdata_corrected, ax=axes[0], bin_size=2, rescale='equal', bin_range=((0, 200),(0, 200)));
lc.render_2d_mpl(drift.locdata_corrected, ax=axes[1], other_property='frame', bin_size=2, bin_range=((0, 200),(0, 200)), cmap='viridis');
../../_images/27cca43161afc22d61c8dfc53aca5e6ffa342ed07ba41aae0190a223ec6e1e8b.png
rmse(dat_blob, drift.locdata_corrected).round(2)
array([3.93, 2.77])
drift.locdata_corrected.meta
identifier: "26"
source: DESIGN
state: MODIFIED
history {
  name: "LocData.from_dataframe"
}
history {
  name: "add_drift"
  parameter: "{\'locdata\': <locan.data.locdata.LocData object at 0x7f88c7558a90>, \'diffusion_constant\': None, \'velocity\': (0.002, 0.001), \'seed\': Generator(PCG64) at 0x7F88DCCC90E0}"
}
history {
  name: "apply_correction"
  parameter: "{\'self\': Drift(chunks=None, chunk_size=10000, n_chunks=None, target=first, method=icp, kwargs_chunk=None, kwargs_register=None), \'locdata\': None, \'from_model\': True}"
}
ancestor_identifiers: "2"
ancestor_identifiers: "3"
element_count: 98201
frame_count: 31016
creation_time {
  seconds: 1710414298
  nanos: 105764000
}
modification_time {
  seconds: 1710414306
  nanos: 259044000
}

Drift analysis by a cross-correlation algorithm#

The same kind of drift estimation and correction can be applied using the image cross-correlation algorithm.

%%time
drift = lc.Drift(chunk_size=10_000, target='first', method='cc').\
        compute(dat_blob_with_drift).\
        fit_transformations(slice_data=slice(None), offset_models=(LinearModel(), LinearModel()), verbose=True).\
        apply_correction(from_model=True);
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
/home/docs/checkouts/readthedocs.org/user_builds/locan/envs/latest/lib/python3.11/site-packages/locan/analysis/drift.py:265: UserWarning: The function register_cc has been refactored. The kwargs max_offset and verbose are deprecated . It now calls _register_cc_skimage. Use _register_cc_picasso for legacy behavior.
  transformation = register_cc(
CPU times: user 1.03 s, sys: 128 ms, total: 1.16 s
Wall time: 950 ms
../../_images/759bb0869307f98e14869693f549f60fc568954bb627fe04f18de1676919a3b4.png ../../_images/e04c0f0bb31f2fed38987c79fc750ad7320dc199565d80f891dee7a7e3f9b3ec.png
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
lc.render_2d(drift.locdata_corrected, ax=axes[0], bin_size=2, rescale='equal', bin_range=((0, 200),(0, 200)));
lc.render_2d_mpl(drift.locdata_corrected, ax=axes[1], other_property='frame', bin_size=2, bin_range=((0, 200),(0, 200)), cmap='viridis');
../../_images/e8d9d14e00b8f4456cedb59f87e74e6f61421aae1e1598b15f25766740956980.png
rmse(dat_blob, drift.locdata_corrected)
array([3.22658567, 1.66731276])