Azimuthal sectors: rebin1d instead of many integrate1d#

Fibres, textured materials and single-crystal-like samples give diffraction images where the intensity depends on the azimuthal angle χ. Describing such a sample means extracting one powder pattern per azimuthal sector, not a single one averaged over the whole ring.

The straightforward way of doing this is to call integrate1d once per sector, with the azimuth_range parameter. It works, but every sector costs a complete traversal of the image: the four and a half million pixels of an Eiger 4M are read, corrected and histogrammed as many times as there are sectors, even though each sector only keeps a fraction of them.

The alternative is to perform a single fine 2D integration and to collapse the azimuthal bins of each sector afterwards, with Integrate2dResult.rebin1d(). The image is then traversed once, and a sector is obtained by summing a few rows of the 2D result — a few hundred thousand values instead of a few million pixels.

This tutorial compares the two approaches, in accuracy and in speed, on a Kevlar fibre dataset recorded with an Eiger 4M detector.

%matplotlib inline
import logging
import time
import numpy
from matplotlib.pyplot import subplots
import fabio
from silx.resources import ExternalResources
import pyFAI
from pyFAI.gui import jupyter

start_time = time.perf_counter()
print(f"Using pyFAI version {pyFAI.version}")
Using pyFAI version 2026.9.1-dev0

The dataset#

The data are available on http://www.silx.org/pub/pyFAI/kevlar/: a tomography scan of a Kevlar fibre, 2000 frames of an Eiger 4M detector. The file weights 2 GB: the first call downloads it, the following ones reuse the local copy.

downloader = ExternalResources("kevlar", "http://www.silx.org/pub/pyFAI/kevlar/")
fimg = fabio.open(downloader.getfile("kevla_tomo_68_data_000001.h5"))
frame = fimg.get_frame(698).data
print(f"{fimg.nframes} frames of shape {frame.shape} ({frame.dtype}), "
      f"i.e. {frame.size / 1e6:.1f} Mpixel per frame")
2000 frames of shape (2167, 2070) (uint16), i.e. 4.5 Mpixel per frame

The geometry of the experiment was calibrated beforehand; the PONI is given here as a dictionary, which pyFAI.load accepts as well as a .poni file.

poni = {"version": 3,
        "wavelength": 9.21815574169e-11,
        "dist": 0.162558188965,
        "poni1": 0.0963249893098,
        "poni2": 0.0863684201475,
        "rot1": 0.00459634184823,
        "rot2": 0.000845922278262,
        "rot3": -2.85542293455e-09,
        "detector": "Eiger4M",
        "detector_config": {"orientation": 3}}
ai = pyFAI.load(poni)
print(ai)
Detector Eiger 4M	 PixelSize= 75µm, 75µm	 BottomRight (3)
Wavelength= 0.921816 Å
SampleDetDist= 1.625582e-01 m	PONI= 9.632499e-02, 8.636842e-02 m	rot1=0.004596  rot2=0.000846  rot3=-0.000000 rad
DirectBeamDist= 162.560 mm	Center: x=1141.617, y=1286.167 pix	Tilt= 0.268° tiltPlanRotation= 169.572° λ= 0.922Å
fig, ax = subplots(figsize=(7, 7))
jupyter.display(frame, label="Kevlar fibre, first frame", ax=ax)
ax.set_xlim(500, 1800)
ax.set_ylim(1900, 600)
pass
../../_images/19150c172cb369f8615b8eb0106187f4cda987e23fcf3a2dc84f2406568bf8f0.png

The sample is textured#

A 2D integration (the cake) shows it immediately: the rings are not uniform, they are made of arcs. The intensity of a given ring depends on where one looks along χ — exactly the information a single 1D integration averages away.

unit = "2th_deg"
method = ("no", "csr", "cython")  # no pixel splitting: see the discussion below
radial_range = (0.5, 25.0)
npt_rad = 1000
npt_azim = 360

res2d = ai.integrate2d(frame, npt_rad, npt_azim, method=method, unit=unit,
                       radial_range=radial_range, azimuth_range=(-180, 180),
                       error_model="poisson")
fig, ax = subplots(figsize=(9, 5))
jupyter.plot2d(res2d, ax=ax)
# a few isolated Bragg spots are orders of magnitude above the rings: clip the
# colour scale, otherwise everything else is black
ax.images[0].set_clim(0, numpy.nanpercentile(res2d.intensity, 99.5))
pass
../../_images/28a87ec5389527ac0a7d757b899317b1010d8d11e475b744593b8383398baa6e.png

The two arcs around 2θ = 13° are the Kevlar rings. Averaging them over that radial band gives the orientation distribution of the fibre: two lobes, roughly 160° apart, an order of magnitude above the weakest directions. A single integrate1d over the whole azimuth would return their average and lose that information.

band = numpy.logical_and(res2d.radial > 11, res2d.radial < 15)
profile = (res2d.sum_signal[:, band].sum(axis=1) /
           res2d.sum_normalization[:, band].sum(axis=1))
fig, ax = subplots(figsize=(9, 4))
ax.plot(res2d.azimuthal, profile)
ax.set_xlabel("Azimuthal angle χ (°)")
ax.set_ylabel("Average intensity")
ax.set_title("Intensity of the Kevlar rings (2θ ∈ [11, 15]°) versus azimuth")
pass
../../_images/50898445bf4b8fcb062be9930ea7b2a9f256e35ef1d3d10a80605b7092673acf.png

Two ways of extracting azimuthal sectors#

Let us cut the azimuthal range into sectors of equal width and extract one powder pattern per sector, first with integrate1d, then with rebin1d.

The engines are called once before every measurement, so that the timings compare the steady state and not the construction of the sparse matrices.

NSECTORS = 8
edges = numpy.linspace(-180, 180, NSECTORS + 1)
sectors = [(float(low), float(high)) for low, high in zip(edges[:-1], edges[1:])]
print(f"{NSECTORS} sectors of {360 / NSECTORS:.0f}°: {sectors}")
8 sectors of 45°: [(-180.0, -135.0), (-135.0, -90.0), (-90.0, -45.0), (-45.0, 0.0), (0.0, 45.0), (45.0, 90.0), (90.0, 135.0), (135.0, 180.0)]
def by_integrate1d(image, sectors, algorithm=method):
    "One call to integrate1d per azimuthal sector"
    return [ai.integrate1d(image, npt_rad, method=algorithm, unit=unit,
                           radial_range=radial_range, azimuth_range=sector,
                           error_model="poisson")
            for sector in sectors]

def by_rebin1d(image, sectors, algorithm=method):
    "One fine 2D integration, then one rebin1d per azimuthal sector"
    res2d = ai.integrate2d(image, npt_rad, npt_azim, method=algorithm, unit=unit,
                           radial_range=radial_range, azimuth_range=(-180, 180),
                           error_model="poisson")
    return [res2d.rebin1d(azimuth_range=sector) for sector in sectors]

# warm-up: build the sparse matrices once
by_integrate1d(frame, sectors[:1])
by_rebin1d(frame, sectors[:1])
pass
t0 = time.perf_counter()
reference = by_integrate1d(frame, sectors)
t_1d = time.perf_counter() - t0
print(f"{NSECTORS} x integrate1d: {t_1d * 1000:.0f} ms, i.e. {t_1d / NSECTORS * 1000:.0f} ms per sector")
8 x integrate1d: 1330 ms, i.e. 166 ms per sector
t0 = time.perf_counter()
rebinned = by_rebin1d(frame, sectors)
t_rebin = time.perf_counter() - t0
print(f"1 x integrate2d + {NSECTORS} x rebin1d: {t_rebin * 1000:.0f} ms")
print(f"Speed-up: {t_1d / t_rebin:.1f}x")
1 x integrate2d + 8 x rebin1d: 101 ms
Speed-up: 13.2x

Where does the time go?#

pyFAI caches the sparse matrix which maps the pixels onto the bins, and reuses it as long as the integration parameters do not change. Changing azimuth_range invalidates that cache, so each new sector pays for a rebuild — but that is only part of the story: even when the matrix is reused, a sector still costs a full pass over the image.

def timeit(function, *args, **kwargs):
    "Return the execution time, in milliseconds"
    t0 = time.perf_counter()
    function(*args, **kwargs)
    return (time.perf_counter() - t0) * 1000

ai.reset()  # drop every cached engine
common = dict(method=method, unit=unit, radial_range=radial_range, error_model="poisson")
print(f"first call, the matrix has to be built:  "
      f"{timeit(ai.integrate1d, frame, npt_rad, azimuth_range=sectors[0], **common):5.0f} ms")
print(f"same sector again, the matrix is cached: "
      f"{timeit(ai.integrate1d, frame, npt_rad, azimuth_range=sectors[0], **common):5.0f} ms")
print(f"another sector, the matrix is rebuilt:   "
      f"{timeit(ai.integrate1d, frame, npt_rad, azimuth_range=sectors[1], **common):5.0f} ms")
print(f"one rebin1d on the 2D result:            "
      f"{timeit(res2d.rebin1d, azimuth_range=sectors[1]):5.2f} ms")
print(f"\n{frame.size / 1e6:.1f} Mpixel are traversed by an integrate1d, against "
      f"{res2d.intensity.size / 1e3:.0f} k values by a rebin1d")
first call, the matrix has to be built:    495 ms
same sector again, the matrix is cached:   128 ms
another sector, the matrix is rebuilt:     187 ms
one rebin1d on the 2D result:             0.96 ms

4.5 Mpixel are traversed by an integrate1d, against 360 k values by a rebin1d

The irreducible cost of a sector done with integrate1d is therefore the traversal of the image, which no caching can avoid. rebin1d works on the 2D result, more than ten times smaller here, and is three orders of magnitude cheaper.

Are the two results the same?#

They are, provided two conditions are met:

  • the boundaries of the sectors fall on boundaries of the azimuthal bins of the 2D integration — rebin1d can only select whole bins, and reports in azimuth_range the range it actually covered. Choosing npt_azim as a multiple of the number of sectors guarantees this;

  • no pixel splitting is used. With bbox or full splitting, a pixel straddling the limit of a sector is shared between the neighbouring bins, and the sharing differs between a 1D integration restricted to the sector and a 2D integration followed by a rebinning.

fig, ax = subplots(figsize=(10, 6))
for sector, res in zip(sectors, rebinned):
    ax.plot(res.radial, res.intensity, label=f"χ ∈ [{sector[0]:.0f}, {sector[1]:.0f}]°")
ax.set_ylim(0, 1.02 * numpy.nanmax(numpy.concatenate([r.intensity for r in rebinned])))
ax.set_xlabel(f"Scattering angle ({unit})")
ax.set_ylabel("Intensity")
ax.set_title("Powder pattern of every azimuthal sector, obtained with rebin1d")
ax.legend(fontsize="small")
pass
../../_images/9ae066d8b3e5ae43213b0cc21a26f23b888ca6bea0917dacdf62ea0b5e52c699.png
for sector, ref, obt in zip(sectors, reference, rebinned):
    assert numpy.allclose(ref.radial, obt.radial)
    delta = numpy.nanmax(abs(ref.intensity - obt.intensity))
    print(f"χ ∈ [{sector[0]:7.1f}, {sector[1]:6.1f}]° -> "
          f"covered {obt.azimuth_range[0]:7.1f} .. {obt.azimuth_range[1]:6.1f}°, "
          f"max |ΔI| = {delta:.3g}")
χ ∈ [ -180.0, -135.0]° -> covered  -180.0 .. -135.0°, max |ΔI| = 0
χ ∈ [ -135.0,  -90.0]° -> covered  -135.0 ..  -90.0°, max |ΔI| = 0
χ ∈ [  -90.0,  -45.0]° -> covered   -90.0 ..  -45.0°, max |ΔI| = 0
χ ∈ [  -45.0,    0.0]° -> covered   -45.0 ..    0.0°, max |ΔI| = 0
χ ∈ [    0.0,   45.0]° -> covered     0.0 ..   45.0°, max |ΔI| = 0
χ ∈ [   45.0,   90.0]° -> covered    45.0 ..   90.0°, max |ΔI| = 0
χ ∈ [   90.0,  135.0]° -> covered    90.0 ..  135.0°, max |ΔI| = 0
χ ∈ [  135.0,  180.0]° -> covered   135.0 ..  180.0°, max |ΔI| = 0

How does it scale with the number of sectors?#

The cost of the integrate1d approach is proportional to the number of sectors: one pass over the image each. The cost of the rebin1d approach is dominated by the single 2D integration, which does not depend on the number of sectors at all.

# the cell above reset the engines: warm them up again before timing
by_integrate1d(frame, sectors[:1])
by_rebin1d(frame, sectors[:1])

csr = ("no", "csr", "cython")
histogram = ("no", "histogram", "cython")
approaches = {"integrate1d, CSR": (by_integrate1d, csr),
              "integrate1d, histogram": (by_integrate1d, histogram),
              "rebin1d, CSR": (by_rebin1d, csr),
              "rebin1d, histogram": (by_rebin1d, histogram)}
counts = (2, 4, 8, 18, 36, 72)
timings = {key: [] for key in approaches}
for nsec in counts:
    bounds = numpy.linspace(-180, 180, nsec + 1)
    sect = [(float(low), float(high)) for low, high in zip(bounds[:-1], bounds[1:])]
    for key, (function, algorithm) in approaches.items():
        t0 = time.perf_counter()
        function(frame, sect, algorithm)
        timings[key].append(time.perf_counter() - t0)
    print(f"{nsec:3d} sectors: " + ", ".join(f"{key} {timings[key][-1]:6.3f} s"
                                             for key in approaches))
  2 sectors: integrate1d, CSR  0.579 s, integrate1d, histogram  0.575 s, rebin1d, CSR  0.095 s, rebin1d, histogram  0.331 s
  4 sectors: integrate1d, CSR  1.002 s, integrate1d, histogram  1.123 s, rebin1d, CSR  0.092 s, rebin1d, histogram  0.333 s
  8 sectors: integrate1d, CSR  1.709 s, integrate1d, histogram  1.695 s, rebin1d, CSR  0.094 s, rebin1d, histogram  0.333 s
 18 sectors: integrate1d, CSR  3.717 s, integrate1d, histogram  4.629 s, rebin1d, CSR  0.109 s, rebin1d, histogram  0.337 s
 36 sectors: integrate1d, CSR  5.606 s, integrate1d, histogram  7.679 s, rebin1d, CSR  0.099 s, rebin1d, histogram  0.343 s
 72 sectors: integrate1d, CSR 10.699 s, integrate1d, histogram 15.285 s, rebin1d, CSR  0.111 s, rebin1d, histogram  0.349 s
fig, ax = subplots(1, 2, figsize=(12, 4.5))
for key in approaches:
    ax[0].plot(counts, timings[key], "o-", label=key)
ax[0].set_xlabel("Number of azimuthal sectors")
ax[0].set_ylabel("Time (s)")
ax[0].set_yscale("log")
ax[0].set_title("Cost of the sectorization of one frame")
ax[0].legend(fontsize="small")
for key in ("integrate1d, CSR", "integrate1d, histogram"):
    ax[1].plot(counts, numpy.array(timings[key]) / numpy.array(timings["rebin1d, CSR"]),
               "o-", label=f"{key} / rebin1d, CSR")
ax[1].set_xlabel("Number of azimuthal sectors")
ax[1].set_ylabel("Speed-up")
ax[1].set_title("How much faster rebin1d is")
ax[1].legend(fontsize="small")
pass
../../_images/f9afe0c0cf6e814c16fb3abd1431daaeab58cb15b7f6b7817011db31accd0301.png

Memory: what the CSR engine keeps alive#

The speed of the CSR algorithm comes from a sparse matrix, which pyFAI builds once and keeps alive between two integrations, together with a copy of the pixel positions. The histogram algorithm keeps nothing: it is slower per integration, but it leaves no persistent engine behind, which matters on a large detector, on a machine under memory pressure, or when several geometries are juggled at once.

Here the CSR cache is also less profitable than usual, since azimuth_range is part of the matrix: a new sector means a new matrix.

def engine_memory(ai):
    "Size of the arrays held by the cached integration engines, in MB"
    # walking over every attribute triggers the deprecated aliases of the engines:
    # silence them, and count an array reached twice only once
    depreclog = logging.getLogger("pyFAI.DEPRECATION")
    level = depreclog.level
    depreclog.setLevel(logging.ERROR)
    arrays = {}
    try:
        for engine in ai.engines.values():
            for name in dir(engine.engine):
                if name.startswith("_"):
                    continue
                try:
                    value = getattr(engine.engine, name)
                except Exception:
                    continue
                if isinstance(value, numpy.ndarray):
                    arrays[id(value)] = value.nbytes
    finally:
        depreclog.setLevel(level)
    return sum(arrays.values()) / 1e6

print(f"{'approach':38s} {'engines':>8s} {'held':>11s} {'time':>9s}")
for key, (function, algorithm) in approaches.items():
    ai.reset()
    function(frame, sectors, algorithm)  # warm-up, then measure the steady state
    t0 = time.perf_counter()
    function(frame, sectors, algorithm)
    print(f"{key:38s} {len(ai.engines):8d} {engine_memory(ai):8.0f} MB "
          f"{(time.perf_counter() - t0) * 1000:6.0f} ms")
approach                                engines        held      time
integrate1d, CSR                              1       78 MB   1329 ms
integrate1d, histogram                        0        0 MB   1712 ms
rebin1d, CSR                                  1       89 MB    119 ms
rebin1d, histogram                            0        0 MB    334 ms

So the histogram algorithm does sit halfway — but not where one would expect. Sector by sector it is the slowest of the three, since it redoes the whole histogramming at every call without the sparse matrix to help. Its merit is that it holds nothing between two calls.

The middle ground is rather histogram combined with rebin1d: a single 2D integration, three times slower than its CSR counterpart but leaving nothing behind, then sectors at a millisecond each. It keeps most of the speed-up of rebin1d at no memory cost.

On a series of frames#

One may object that the sparse matrix of a given sector could be reused from one frame to the next, by looping over the sectors on the outside and over the frames on the inside. As measured above, this saves the rebuild of the matrix but not the traversal of the image, which is what actually costs. Below, a small series is processed both ways, with the loop order which is the most favourable to integrate1d.

NFRAMES = 10
SECTORS = 12
bounds = numpy.linspace(-180, 180, SECTORS + 1)
series_sectors = [(float(low), float(high)) for low, high in zip(bounds[:-1], bounds[1:])]
frames = [fimg.get_frame(698 + i).data for i in range(NFRAMES)]
print(f"{NFRAMES} frames of {frames[0].nbytes / 1e6:.0f} MB loaded in memory")
10 frames of 9 MB loaded in memory
t0 = time.perf_counter()
per_sector = [[ai.integrate1d(image, npt_rad, azimuth_range=sector, **common)
               for image in frames]
              for sector in series_sectors]
t_series_1d = time.perf_counter() - t0
print(f"integrate1d, sectors outside: {t_series_1d:6.2f} s "
      f"({t_series_1d / NFRAMES * 1000:.0f} ms per frame)")
integrate1d, sectors outside:  14.35 s (1435 ms per frame)
t0 = time.perf_counter()
per_frame = [by_rebin1d(image, series_sectors) for image in frames]
t_series_rebin = time.perf_counter() - t0
print(f"integrate2d + rebin1d:        {t_series_rebin:6.2f} s "
      f"({t_series_rebin / NFRAMES * 1000:.0f} ms per frame)")
print(f"Speed-up: {t_series_1d / t_series_rebin:.1f}x")
integrate2d + rebin1d:          1.69 s (169 ms per frame)
Speed-up: 8.5x
# the two series hold the same curves, transposed
delta = max(numpy.nanmax(abs(per_sector[j][i].intensity - per_frame[i][j].intensity))
            for i in range(NFRAMES) for j in range(SECTORS))
print(f"largest deviation over the {NFRAMES * SECTORS} curves: {delta:.3g}")
largest deviation over the 120 curves: 0

Restricting the radial range as well#

rebin1d also accepts a radial_range. Unlike integrate1d, it does not re-bin: it keeps the bins of the 2D integration, hence their size, and simply drops those falling outside the requested range. The number of points of the result therefore depends on the requested range.

Both ranges are expressed in the unit of the matching axis of the 2D result, and the range which was actually covered is reported by the radial_range and azimuth_range attributes.

zoom = res2d.rebin1d(radial_range=(5, 15), azimuth_range=(-45, 45))
print(f"radial: {zoom.radial.size} bins covering {zoom.radial_range[0]:.2f} .. "
      f"{zoom.radial_range[1]:.2f} {unit}")
print(f"azimuthal: collapsed over {zoom.azimuth_range[0]:.1f} .. {zoom.azimuth_range[1]:.1f}°")

fig, ax = subplots(figsize=(9, 4))
jupyter.plot1d(zoom, ax=ax)
pass
radial: 408 bins covering 5.01 .. 15.00 2th_deg
azimuthal: collapsed over -45.0 .. 45.0°
../../_images/9f09a7a1fe2c915935b35e1dc0dd99cc10d2151263c0754577647db130dc4856.png

Conclusion#

Extracting azimuthal sectors with rebin1d is one to two orders of magnitude faster than calling integrate1d once per sector, and the two give the same numbers as long as the sectors are aligned on the azimuthal bins of the 2D integration and no pixel splitting is involved.

The rule of thumb: as soon as more than one or two azimuthal sectors are needed on a given image, integrate once in 2D with a fine azimuthal sampling, then rebin. npt_azim sets the granularity at which the sectors can be defined; picking a multiple of the number of sectors guarantees that their boundaries fall on bin boundaries.

rebin1d is also the tool of choice to explore a dataset interactively: once the 2D integration is computed, any sector is available in a few milliseconds.

If the memory held by the CSR engine is a concern — a large detector, many geometries, a shared machine — pair rebin1d with the histogram algorithm: the single 2D integration costs three times more, nothing stays allocated, and the sectors remain as cheap.

print(f"Total execution time: {time.perf_counter() - start_time:.1f} s")
Total execution time: 96.5 s