Skip to content
Happy Programming Guide
Start learning
Python

How to Measure FWHM from an Image in Python

Measure full width at half maximum from an image profile in Python: the interpolation method, Gaussian fitting, and 2D fitting for star or spot images.

Servers mounted in a data centre rack

Full width at half maximum is the width of a peak measured halfway up it. In image analysis it is the standard way to describe how sharp a point source is — a star in astronomy, a bead in microscopy, a spot in a beam profile. This guide covers three ways to measure it in Python, from a quick interpolation to a proper two-dimensional Gaussian fit.

What it means#

Take a peak, find its maximum, halve that value, and measure how wide the peak is at that height. That width is the FWHM. It is preferred over other width measures because it is easy to read off real data and does not depend on where you decide the peak “ends” — a decision that noise makes arbitrary.

For a Gaussian there is an exact relationship between FWHM and the standard deviation:

Output
FWHM = 2 * sqrt(2 * ln(2)) * sigma
     = 2.3548 * sigma

Setting up#

Terminal
python -m pip install numpy scipy matplotlib pillow
Python
import numpy as np
from PIL import Image

image = np.array(Image.open("star.png").convert("L"), dtype=float)
print(image.shape, image.min(), image.max())

Converting to float matters. Integer arrays wrap around on subtraction, which silently corrupts background removal.

Method 1: interpolate the crossings#

Fast, no fitting, and good enough when the signal is clean:

Python
import numpy as np


def fwhm_from_profile(x, y):
    """FWHM of a single peak, by linear interpolation at half maximum."""
    y = np.asarray(y, dtype=float)
    x = np.asarray(x, dtype=float)

    baseline = np.median(y)
    y = y - baseline

    peak_index = int(np.argmax(y))
    half = y[peak_index] / 2.0

    # Walk left until the profile drops below half
    left = peak_index
    while left > 0 and y[left] > half:
        left -= 1

    right = peak_index
    while right < len(y) - 1 and y[right] > half:
        right += 1

    if left == peak_index or right == peak_index:
        raise ValueError("Peak does not fall below half maximum within the profile")

    x_left = np.interp(half, [y[left], y[left + 1]], [x[left], x[left + 1]])
    x_right = np.interp(half, [y[right], y[right - 1]], [x[right], x[right - 1]])

    return abs(x_right - x_left)

The interpolation is what makes this usable. Without it you would be limited to whole-pixel resolution, and typical FWHM values are only a few pixels wide.

Python
row = image[image.shape[0] // 2, :]
x = np.arange(row.size)

print("FWHM:", round(fwhm_from_profile(x, row), 3), "pixels")

Method 2: fit a Gaussian#

Noise makes the interpolation method jump around. Fitting uses every point, so it is far steadier:

Python
import numpy as np
from scipy.optimize import curve_fit


def gaussian(x, amplitude, centre, sigma, offset):
    return amplitude * np.exp(-((x - centre) ** 2) / (2 * sigma ** 2)) + offset


def fwhm_by_fit(x, y):
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)

    guess = [
        y.max() - np.median(y),      # amplitude
        x[int(np.argmax(y))],        # centre
        len(x) / 10.0,               # sigma
        np.median(y),                # offset
    ]

    params, covariance = curve_fit(gaussian, x, y, p0=guess, maxfev=10000)
    amplitude, centre, sigma, offset = params

    fwhm = 2 * np.sqrt(2 * np.log(2)) * abs(sigma)
    sigma_error = np.sqrt(np.diag(covariance))[2]

    return fwhm, 2.3548 * sigma_error, centre


fwhm, error, centre = fwhm_by_fit(x, row)
print(f"FWHM = {fwhm:.3f} +/- {error:.3f} pixels, centred at {centre:.2f}")

The covariance matrix gives you an uncertainty for free, which the interpolation method cannot provide. Reporting a width without an uncertainty is rarely useful in real work.

Method 3: two-dimensional fitting#

Taking a single row assumes the peak is centred on that row and symmetric. Fitting the whole spot is more honest:

Python
import numpy as np
from scipy.optimize import curve_fit


def gaussian_2d(coords, amplitude, x0, y0, sigma_x, sigma_y, offset):
    x, y = coords
    value = amplitude * np.exp(
        -(((x - x0) ** 2) / (2 * sigma_x ** 2) + ((y - y0) ** 2) / (2 * sigma_y ** 2))
    ) + offset
    return value.ravel()


def fwhm_2d(patch):
    ny, nx = patch.shape
    y, x = np.mgrid[0:ny, 0:nx]

    guess = [
        patch.max() - np.median(patch),
        nx / 2.0, ny / 2.0,
        nx / 6.0, ny / 6.0,
        np.median(patch),
    ]

    params, _ = curve_fit(
        gaussian_2d, (x, y), patch.ravel(), p0=guess, maxfev=20000
    )
    _, x0, y0, sigma_x, sigma_y, _ = params

    factor = 2 * np.sqrt(2 * np.log(2))
    return factor * abs(sigma_x), factor * abs(sigma_y), (x0, y0)
Python
// Cut a small window around the brightest pixel first
cy, cx = np.unravel_index(np.argmax(image), image.shape)
half = 15
patch = image[cy - half:cy + half + 1, cx - half:cx + half + 1]

fx, fy, centre = fwhm_2d(patch)
print(f"FWHM x = {fx:.2f}, y = {fy:.2f} pixels")

Cropping around the peak matters. Fitting a whole image containing several sources will not converge on any of them.

Converting to physical units#

Python
pixel_scale = 0.31        # arcseconds per pixel, or microns per pixel

print(f"FWHM = {fwhm * pixel_scale:.3f} arcsec")

The pixel value is only meaningful with a scale attached. In astronomy that is the plate scale; in microscopy it is the calibrated pixel size, which you should measure rather than assume.

Checking the result#

Python
import matplotlib.pyplot as plt

fit_y = gaussian(x, *params)

plt.plot(x, row, "o", markersize=3, label="data")
plt.plot(x, fit_y, "-", label="fit")
plt.axhline(offset + amplitude / 2, linestyle="--", label="half maximum")
plt.legend()
plt.xlabel("pixel")
plt.ylabel("intensity")
plt.show()

Always plot the fit over the data. A number from curve_fit that looks plausible can still come from a fit that missed the peak entirely, and one glance at the plot tells you.

Python
rng = np.random.default_rng(42)
x = np.arange(100)
truth = gaussian(x, 1000, 50, 4.0, 100)
noisy = truth + rng.normal(0, 20, x.size)

print("expected:", round(2.3548 * 4.0, 3))
print("measured:", round(fwhm_by_fit(x, noisy)[0], 3))

Questions people ask#

Which method should I use?

Interpolation for a quick look at clean data. A one-dimensional fit for a proper measurement of a symmetric peak. Two-dimensional fitting when the shape is elongated or you need the centre position accurately.

What if my peak is not Gaussian?

Fit the shape that matches. Astronomical point sources are often better described by a Moffat profile; spectroscopic lines are sometimes Lorentzian or Voigt. The conversion from a shape parameter to FWHM differs for each.

Why does the same star give different values on different frames?

Genuine variation — seeing, focus, tracking — plus measurement noise. Measure several sources per frame and report the median, not a single value.

Does saturation affect it?

Badly. A flat-topped peak has no meaningful maximum, so half maximum is wrong and the fit is biased. Check whether the peak reaches the detector’s maximum value and exclude it if so.

Where to go next#

Data visualization in Python, including fit overlaysRead next

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *