Mass calibration#

This tutorial demonstrates how to estimate a contrast-to-mass calibration from a calibration movie:

  1. Load a calibration movie.

  2. Run detection + PSF fitting to obtain a table of event contrasts.

  3. Fit peaks in the contrast histogram.

  4. Fit a (linear) contrast-to-mass calibration and plot the result.

Imports#

We first import necessary packages and modules.

import matplotlib.pyplot as plt
import numpy as np

Download example data#

This tutorial expects a calibration movie. For now, the example downloads a small calibration file from Zenodo. You can replace this block with a direct file path once you have a local calibration movie.

from zenodozipdownloader import ZenodoZipDownloader

import massphoto as mp

files = ZenodoZipDownloader(
    doi="10.5281/zenodo.15800729",
    download_dir="example_data",
).download(inner_pattern="005_mfp1_500x.mp")
if not files:
    raise RuntimeError(
        "Failed to download example data. Please download it manually from https://zenodo.org/record/15800729 "
        "or set path_calib to your own calibration movie."
    )
path_calib = files[0]

print(f"Using calibration movie: {path_calib}")
Using calibration movie: /home/runner/work/massphoto/massphoto/docs/source/_examples/example_data/005_mfp1_500x.mp

Inputs#

expected_masses_kDa should match the ladder present in the calibration movie. contrast_slope_guess is only used to choose sensible histogram bins and initial peak locations (kDa/contrast).

# Expected masses for the first N peaks (kDa). Adjust to match your ladder.
expected_masses_kDa = np.arange(1, 8) * 86.0

# A rough contrast-to-mass slope estimate used to choose sensible binning and
# initial peak locations (kDa/contrast). If unsure, keep the default.
contrast_slope_guess = 3e4

Run detection + fitting#

This is the main pipeline step. The output is a DataFrame with fitted event positions and a contrasts column.

movie, _metadata = mp.read_movie(path_calib)
fitres_calib = mp.fit(movie, movie_correct_drift=True)

print(f"Detected+fit events used for calibration: n={len(fitres_calib)}")
fitres_calib[["frame", "x", "y", "contrasts"]].head()
process movie chunks:   0%|          | 0/36 [00:00<?, ?it/s]
process movie chunks: 100%|██████████| 36/36 [00:00<00:00, 2060.44it/s]

process movie chunks:   0%|          | 0/31 [00:00<?, ?it/s]
process movie chunks:   3%|▎         | 1/31 [00:00<00:10,  2.97it/s]
process movie chunks:  29%|██▉       | 9/31 [00:00<00:01, 15.02it/s]
process movie chunks:  55%|█████▍    | 17/31 [00:01<00:00, 19.08it/s]
process movie chunks:  74%|███████▍  | 23/31 [00:01<00:00, 25.04it/s]
process movie chunks:  87%|████████▋ | 27/31 [00:01<00:00, 22.07it/s]
process movie chunks: 100%|██████████| 31/31 [00:01<00:00, 22.53it/s]

process movie chunks:   0%|          | 0/289 [00:00<?, ?it/s]
process movie chunks:   4%|▍         | 11/289 [00:00<00:11, 24.53it/s]
process movie chunks:  16%|█▋        | 47/289 [00:00<00:04, 60.50it/s]
process movie chunks:  19%|█▊        | 54/289 [00:01<00:06, 34.86it/s]
process movie chunks:  26%|██▋       | 76/289 [00:01<00:04, 44.03it/s]
process movie chunks:  29%|██▉       | 85/289 [00:01<00:04, 48.96it/s]
process movie chunks:  32%|███▏      | 92/289 [00:02<00:08, 23.26it/s]
process movie chunks:  36%|███▌      | 104/289 [00:07<00:26,  7.02it/s]
process movie chunks:  37%|███▋      | 108/289 [00:07<00:25,  7.15it/s]
process movie chunks:  51%|█████     | 148/289 [00:08<00:08, 15.77it/s]
process movie chunks:  61%|██████    | 176/289 [00:09<00:05, 21.65it/s]
process movie chunks:  62%|██████▏   | 180/289 [00:10<00:06, 16.40it/s]
process movie chunks:  67%|██████▋   | 195/289 [00:10<00:04, 19.83it/s]
process movie chunks:  69%|██████▉   | 199/289 [00:10<00:04, 20.69it/s]
process movie chunks:  70%|███████   | 203/289 [00:11<00:04, 17.43it/s]
process movie chunks:  76%|███████▌  | 220/289 [00:11<00:03, 18.89it/s]
process movie chunks:  77%|███████▋  | 223/289 [00:12<00:03, 18.66it/s]
process movie chunks:  82%|████████▏ | 237/289 [00:12<00:01, 26.54it/s]
process movie chunks:  83%|████████▎ | 241/289 [00:12<00:02, 20.54it/s]
process movie chunks:  89%|████████▉ | 258/289 [00:13<00:01, 23.46it/s]
process movie chunks:  91%|█████████▏| 264/289 [00:13<00:01, 21.95it/s]
process movie chunks:  94%|█████████▍| 273/289 [00:13<00:00, 25.44it/s]
process movie chunks:  96%|█████████▌| 277/289 [00:14<00:00, 14.99it/s]
process movie chunks:  99%|█████████▉| 286/289 [00:14<00:00, 19.02it/s]
process movie chunks: 100%|██████████| 289/289 [00:16<00:00,  9.40it/s]
process movie chunks: 100%|██████████| 289/289 [00:16<00:00, 17.78it/s]
Detected+fit events used for calibration: n=2281
frame x y contrasts
0 20 94.167944 32.324291 0.002724
1 38 123.995974 20.961641 0.010899
2 40 93.441404 21.917409 0.002605
3 42 141.535097 40.692372 0.005699
4 42 143.770046 52.605493 0.008198


Fit contrast histogram peaks#

We model the histogram as a sum of bin-integrated Gaussians. The fitted peak centres (mu) are used as contrast values for the calibration fit.

bins = np.arange(-20, 660, 4) / contrast_slope_guess
initial_peaks = expected_masses_kDa / contrast_slope_guess

mpfit_calib = mp.distfit.fit(
    fitres_calib["contrasts"],
    bins=bins,
    initial_peaks=initial_peaks,
    include_background=False,
)

print("\nFitted peak centres (contrast):")
mpfit_calib.peaks[["mu", "sigma", "A"]]
Fitted peak centres (contrast):
mu sigma A
0 0.002924 0.000348 803.369926
1 0.005812 0.000433 518.591835
2 0.008627 0.000440 282.832731
3 0.011308 0.000563 169.690019
4 0.014191 0.000526 93.389237
5 0.016910 0.000717 70.160655
6 0.019726 0.000333 26.722994


Plot contrast histogram#

This is a useful sanity check: you should see distinct peaks and a reasonable fit overlay.

plt.figure(figsize=(6, 3))
plt.hist(fitres_calib["contrasts"], bins=bins, edgecolor="none")
plt.plot(mpfit_calib.model_x, mpfit_calib.model_y, color="black", lw=1, label="fit")
plt.xlabel("Contrast c")
plt.ylabel("Counts")
plt.title("Contrast histogram")
plt.legend()
plt.tight_layout()
Contrast histogram

Fit contrast-to-mass calibration#

We fit a polynomial (default: linear) mapping contrast to mass (kDa). Often the lower-mass peaks are the cleanest, so we only use the first few for the fit.

Fitted calibration (contrast -> mass, kDa): -4.98688693 + 30677.65217447·x

Update from existing calibration (mass-domain peaks)#

If you already have a previous calibration, measured peak positions may be in mass units already. In that case, pass those values to fit with input_unit='auto' (or 'mass') and provide the previous calibration for inversion to contrast before fitting.

Updated calibration from mass-domain peak positions: -4.98688693 + 30677.65217447·x

Optionally, update the package wide defaults (massphoto.defaults.mass_calibration) with the fitted calibration.

Plot mass calibration#

This plot shows the fitted peak positions (with width estimates) and the resulting calibration curve.

plt.figure(figsize=(6, 3))
plt.errorbar(
    x=mpfit_calib.peaks["mu"][:n_values_for_fit],
    y=expected_masses_kDa[:n_values_for_fit],
    xerr=mpfit_calib.peaks["sigma"][:n_values_for_fit],
    fmt="|",
    color="black",
    capsize=0,
    linewidth=2,
    label="peaks used for fit",
)
x_fit = np.array([bins[0], bins[-1]], dtype=float)
plt.plot(x_fit, calibration(x_fit), lw=1, label="linear fit")
plt.xlabel("Contrast c")
plt.ylabel("Expected mass m (kDa)")
plt.title("Mass calibration")
plt.legend()
plt.tight_layout()
Mass calibration

Apply calibration#

Apply the mass calibration to add a masses_kDa column to the fit results.

fitres_calib = mp.mass_calibration.apply(fitres_calib, calibration)

fitres_calib[["frame", "x", "y", "contrasts", "masses_kDa"]].head()
frame x y contrasts masses_kDa
0 20 94.167944 32.324291 0.002724 78.586279
1 38 123.995974 20.961641 0.010899 329.372803
2 40 93.441404 21.917409 0.002605 74.915870
3 42 141.535097 40.692372 0.005699 169.849288
4 42 143.770046 52.605493 0.008198 246.513887


Mass distribution after calibration#

Finally, inspect the calibrated mass distribution. The expected ladder masses are shown as dashed vertical lines.

plt.figure(figsize=(6, 3))
plt.hist(
    fitres_calib["masses_kDa"],
    bins=np.arange(-100, 700, 2.5),
    edgecolor="none",
    label="Single-protein events",
)
for mass in expected_masses_kDa:
    plt.axvline(
        mass,
        color="red",
        ls="--",
        lw=1,
        label="Expected masses" if mass == expected_masses_kDa[0] else None,
    )
plt.xlabel("Mass (kDa)")
plt.ylabel("Counts")
plt.title("Mass distribution after calibration")
plt.legend()
plt.tight_layout()
Mass distribution after calibration

Total running time of the script: (0 minutes 36.692 seconds)

Gallery generated by Sphinx-Gallery