#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
FOURIER SHELL CORRELATION modules (optimized)
"""
# standard library
import itertools
import os
import re
import time
import warnings
# third party package
import h5py
import numpy as np
from scipy.fft import fftshift, ifftshift
from scipy.fft import fftn as _fftn, fftfreq as _fftfreq, ifftn as _ifftn
from scipy.interpolate import RegularGridInterpolator
from ..utils import tqdm
# local packages
from ..utils.FFT_utils import fastfftn
from ..utils.funcutils import checkhostname
from ..utils.plot_utils import (
show_fsc_images,
show_fsc_curve,
show_ssnr_curve,
show_random_fsc_curve,
show_resolution_map,
)
__all__ = ["FourierShellCorr", "FSCPlot", "FRCPlot", "SSNRPlot", "LocalFSC", "RandomFSC"]
[docs]
class FourierShellCorr:
"""
Computes the Fourier Shell Correlation [1]_ between image1 and image2,
and estimate the resolution based on the threshold funcion T of 1 or 1/2 bit.
Parameters
----------
img1 : ndarray
A 2-dimensional array containing the first image
img2 : ndarray
A 2-dimensional array containing the second image
threshold : str, optional
The option `onebit` means 1 bit threshold with ``SNRt = 0.5``, which
should be used for two independent measurements. The option `halfbit`
means 1/2 bit threshold with ``SNRt = 0.2071``, which should be
use for split tomogram. The default option is ``half-bit``.
ring_thick : int, optional
Thickness of the frequency rings. Normally the pixels get
assined to the closest integer pixel ring in Fourier Domain.
With ring_thick, each ring gets more pixels and more statistics.
The default value is ``1``.
apod_width : int, optional
Width in pixel of the edges apodization. It applies a Hanning
window of the size of the data to the data before the Fourier
transform calculations to attenuate the border effects. The
default value is ``20``.
Returns
-------
FSC : ndarray
Fourier Shell correlation curve
T : ndarray
Threshold curve
Notes
-----
If 3D images, the first axis is the number of slices, ie., ``[slices, rows, cols]``
References
----------
.. [1] M. van Heel, M. Schatzb, `Fourier shell correlation threshold criteria,`
Journal of Structural Biology 151, 250-262 (2005)
"""
@checkhostname
def __init__(self, img1, img2, threshold="halfbit", ring_thick=1, apod_width=20):
print("Calling the class FourierShellCorr")
self.img1 = np.array(img1)
self.img2 = np.array(img2)
if self.img1.shape != self.img2.shape:
raise ValueError("Images must have the same size")
# get dimensions and indices of the images
self.n = self.img1.shape
self.ndim = self.img1.ndim
if self.ndim == 2:
self.nr, self.nc = self.n
elif self.img1.ndim == 3:
self.ns, self.nr, self.nc = self.n
else:
raise SystemExit("Number of dimensions is different from 2 or 3. Exiting...")
self.Y, self.X = np.indices((self.nr, self.nc))
self.Y -= np.round(self.nr / 2).astype(int)
self.X -= np.round(self.nc / 2).astype(int)
self.ring_thick = ring_thick # ring thickness
print("Using ring thickness of {} pixels".format(ring_thick))
self.apod_width = apod_width
if threshold == "halfbit" or threshold == "half-bit":
print("Using half-bit threshold")
self.snrt = 0.2071
elif threshold == "onebit" or threshold == "one-bit":
print("Using 1-bit threshold")
self.snrt = 0.5
else:
raise ValueError(
"You need to choose a between 'halfbit' or 'onebit' threshold"
)
print("Using SNRt = {}".format(self.snrt))
print("Input images have {} dimensions".format(self.img1.ndim))
[docs]
def nyquist(self):
"""
Evaluate the Nyquist frequency and the corresponding frequency array.
The Nyquist ring index is ``nmax // 2`` (integer division), where
``nmax`` is the largest image dimension. For an even-length DFT of
size N the highest unambiguous positive-frequency bin is exactly N/2,
so integer division gives the correct result for both even and odd N.
Returns
-------
f : ndarray of int32
Integer ring indices from ``0`` (DC) to ``fnyquist`` (inclusive).
fnyquist : int
Ring index of the Nyquist frequency (``nmax // 2``).
"""
nmax = np.max(self.n)
fnyquist = nmax // 2
f = np.arange(fnyquist + 1, dtype=np.int32)
return f, fnyquist
[docs]
def ringthickness(self):
"""
Compute the shell index for each voxel in Fourier space.
Each dimension's frequency axis is built with ``scipy.fft.fftfreq``
(which returns values in cycles/pixel, already in FFT layout) and
then scaled so that every dimension's Nyquist maps to the global
Nyquist ring index ``nmax // 2``. This is equivalent to the
previous manual construction
``ifftshift(np.arange(-fix(n/2), ceil(n/2))) * (nmax//2) / (n//2)``
but requires no ``ifftshift``, ``np.fix``, or ``np.ceil`` calls.
Uses broadcasting instead of ``np.meshgrid`` to avoid large
temporary arrays.
Returns
-------
index : ndarray of int32
Array of the same shape as the input image containing the
integer shell index (rounded radius in scaled Fourier pixels)
for each voxel.
"""
nmax = np.max(self.n)
fnyquist = nmax // 2 # global Nyquist ring index
def _axis(n):
# fftfreq(n) * n → integer bin indices in FFT layout
# scaling by fnyquist / (n // 2) maps each dimension's
# Nyquist bin to the global fnyquist ring index.
return _fftfreq(n) * n * fnyquist / (n // 2)
x = _axis(self.nc)
y = _axis(self.nr)
if self.ndim == 2:
sumsquares = y[:, None] ** 2 + x[None, :] ** 2
elif self.ndim == 3:
z = _axis(self.ns)
sumsquares = (
z[:, None, None] ** 2
+ y[None, :, None] ** 2
+ x[None, None, :] ** 2
)
index = np.round(np.sqrt(sumsquares)).astype(np.int32)
return index
[docs]
def apodization(self):
"""
Compute a Hanning apodization window matching the image dimensions.
The 3-D window is built via an ``einsum`` outer product instead of
nested list-comprehensions, reducing memory allocations and Python
overhead.
Returns
-------
window : ndarray
Hanning window array of shape ``(nr, nc)`` for 2-D images or
``(ns, nr, nc)`` for 3-D volumes.
"""
if self.ndim == 2:
window = np.outer(np.hanning(self.nr), np.hanning(self.nc))
elif self.ndim == 3:
w1 = np.hanning(self.ns) # (ns,)
w2 = np.hanning(self.nr) # (nr,)
w3 = np.hanning(self.nc) # (nc,)
# einsum outer product: result shape (ns, nr, nc)
window = np.einsum("i,j,k->ijk", w1, w2, w3)
else:
raise SystemExit(
"Number of dimensions is different from 2 or 3. Exiting..."
)
return window
[docs]
def circle(self):
"""
Create a circular mask with apodized (cosine-tapered) edges.
Returns
-------
t : ndarray, shape (nr, nc)
2-D mask that is ``1`` inside the central circle, smoothly tapered
to ``0`` over ``apod_width`` pixels at the edges.
"""
self.axial_apod = self.apod_width
R = np.sqrt(self.X ** 2 + self.Y ** 2)
Rmax = np.round(np.max(R.shape) / 2.0)
maskout = R < Rmax
t = (
maskout
* (1 - np.cos(np.pi * (R - Rmax - 2 * self.axial_apod) / self.axial_apod))
/ 2.0
)
t[R < (Rmax - self.axial_apod)] = 1
return t
def _make_1d_tukey(self, n, apod):
"""
Build a 1-D tapered Hanning (Tukey-like) window in fftshift order.
Extracted to avoid code duplication between the 2-D and 3-D paths
of :meth:`transverse_apodization`.
Parameters
----------
n : int
Window length (number of samples).
apod : int
Number of pixels of cosine taper on each side of the flat-top.
Returns
-------
w : ndarray, shape (n,)
Window array in fftshift order: flat ``1`` in the centre,
cosine-tapered to ``0`` over the outer ``apod`` pixels on each side.
"""
N = fftshift(np.arange(n))
centre = np.floor((n - 2 * apod - 1) / 2)
w = (1.0 + np.cos(2 * np.pi * (N - centre) / (1 + 2 * apod))) / 2.0
w[apod:-apod] = 1.0
return w
[docs]
def transverse_apodization(self):
"""
Compute a tapered Hanning-like (Tukey) apodization window.
The 1-D window construction is delegated to :meth:`_make_1d_tukey`
to avoid duplication. The 3-D window is assembled with broadcasting
instead of per-column list-comprehensions with ``swapaxes`` calls.
Returns
-------
window : ndarray or list of ndarray
For 2-D images: a single 2-D window array of shape ``(nr, nc)``.
For 3-D volumes: a list ``[outer(w_row, w_col), outer(w_sli, w_col)]``
matching the original API expected by :meth:`fouriercorr`.
"""
print("Calculating the transverse apodization")
self.transv_apod = self.apod_width
if self.ndim == 2:
w1 = self._make_1d_tukey(self.nr, self.transv_apod)
w2 = self._make_1d_tukey(self.nc, self.transv_apod)
window = np.outer(w1, w2)
elif self.ndim == 3:
w1 = self._make_1d_tukey(self.ns, self.transv_apod) # axial (ns,)
w2 = self._make_1d_tukey(self.nr, self.transv_apod) # rows (nr,)
w3 = self._make_1d_tukey(self.nc, self.transv_apod) # cols (nc,)
# Return as list matching original API: [outer(w1,w2), outer(w1,w3)]
# Used by fouriercorr to multiply circle3D and sagittal slices.
window = [np.outer(w2, w3), np.outer(w1, w3)]
return window
[docs]
def fouriercorr(self):
"""
Compute the Fourier Shell Correlation (FSC) and its threshold curve.
Optimizations applied:
* 3-D apodization window assembled with broadcasting instead of
nested list-comprehensions and ``swapaxes`` calls.
* Ring-shell loop: boolean mask computed once per shell and reused
for both F1 and F2 extractions, halving the number of ``np.where``
calls.
* ``np.where`` replaced by direct boolean indexing.
* Cross/auto-correlation sums use ``np.dot`` on flat views, which is
faster than ``.sum()`` on fancy-indexed complex arrays for large rings.
Returns
-------
FSC : ndarray, shape (n_shells,)
Fourier Shell Correlation values for each frequency shell.
T : ndarray, shape (n_shells,)
Threshold curve (half-bit or one-bit) for each frequency shell.
"""
# ------------------------------------------------------------------
# Apodization
# ------------------------------------------------------------------
print("Performing the apodization")
circular_region = self.circle()
if self.ndim == 2:
print("Apodization in 2D")
if self.snrt == 0.2071:
self.window = circular_region
else:
self.window = self.transverse_apodization()
img1_apod = self.img1 * self.window
img2_apod = self.img2 * self.window
elif self.ndim == 3:
if self.apod_width == 0:
self.window = 1
else:
print("Apodization in 3D. This takes time and memory...")
p0 = time.time()
# --- Optimized 3-D window construction ---
# transverse_apodization now returns [outer(w_row,w_col),
# outer(w_sli,w_col)]
window3D = self.transverse_apodization()
w_axial = window3D[0] # (nr, nc) — axial plane taper
w_sagit = window3D[1] # (ns, nc) — sagittal plane taper
# circle3D: (ns, nr, nc) — broadcast circular mask along slices
circle3D = circular_region[None, :, :] # (1, nr, nc) → broadcast
# Combine: circle × axial-taper (both shape (nr,nc)) then
# multiply sagittal taper broadcast over the row axis.
# All operations are fully vectorized / in-place where possible.
self.window = (
circle3D * w_axial[None, :, :] # (ns, nr, nc)
* w_sagit[:, None, :] # (ns, 1, nc) broadcast
)
print("Done. Time elapsed: {:.02f}s".format(time.time() - p0))
# sagittal slice for display
slicenum = np.round(self.nr / 2).astype("int")
img1_apod = (self.window * self.img1)[:, slicenum, :]
img2_apod = (self.window * self.img2)[:, slicenum, :]
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
show_fsc_images(img1_apod, img2_apod)
# ------------------------------------------------------------------
# FSC computation
# ------------------------------------------------------------------
print("Calling method fouriercorr from the class FourierShellCorr")
p1 = time.time()
F1 = fastfftn(self.img1 * self.window) # FFT of image 1
F2 = fastfftn(self.img2 * self.window) # FFT of image 2
index = self.ringthickness() # per-voxel shell index
f, fnyquist = self.nyquist()
# Flatten index and FFT arrays once — avoids repeated ravel inside loop
index_flat = index.ravel()
F1_flat = F1.ravel()
F2_flat = F2.ravel()
# Pre-sort by shell index so we can use np.searchsorted for fast slicing
sort_order = np.argsort(index_flat, kind="stable")
index_sorted = index_flat[sort_order]
F1_sorted = F1_flat[sort_order]
F2_sorted = F2_flat[sort_order]
# Initialise output arrays
C = np.empty(len(f), dtype=np.float32)
C1 = np.empty(len(f), dtype=np.float32)
C2 = np.empty(len(f), dtype=np.float32)
npts = np.zeros(len(f), dtype=np.float32)
half_thick = self.ring_thick / 2.0
use_thick = self.ring_thick > 1
print("Calculating the correlation...")
for ii in tqdm(f, desc="Computing FSC shells"):
# --- Fast shell extraction via searchsorted on the sorted index ---
if use_thick:
lo = np.searchsorted(index_sorted, ii - half_thick, side="left")
hi = np.searchsorted(index_sorted, ii + half_thick, side="right")
else:
lo = np.searchsorted(index_sorted, ii, side="left")
hi = np.searchsorted(index_sorted, ii + 1, side="left")
f1 = F1_sorted[lo:hi]
f2 = F2_sorted[lo:hi]
# Cross-correlation and auto-correlations
# np.vdot operates on flattened arrays and conjugates the first arg
C[ii] = abs(np.vdot(f2, f1)) # Σ f1 · conj(f2)
C1[ii] = abs(np.vdot(f1, f1)) # Σ |f1|²
C2[ii] = abs(np.vdot(f2, f2)) # Σ |f2|²
npts[ii] = hi - lo
# ------------------------------------------------------------------
# Correlation and threshold
# ------------------------------------------------------------------
FSC = C / np.sqrt(C1 * C2)
eps = np.spacing(1)
sqrt_npts = np.sqrt(npts + eps)
Tnum = self.snrt + 2 * np.sqrt(self.snrt) / sqrt_npts + 1 / np.sqrt(npts)
Tden = self.snrt + 2 * np.sqrt(self.snrt) / sqrt_npts + 1
T = Tnum / Tden
print("Done. Time elapsed: {:.02f}s".format(time.time() - p1))
return FSC, T
[docs]
def ssnr(self):
"""
Compute the Spectral Signal-to-Noise Ratio (SSNR) from the FSC curve.
The SSNR is a direct transformation of the FSC:
.. math::
\\mathrm{SSNR}(r) = \\frac{2 \\cdot \\mathrm{FSC}(r)}{1 - \\mathrm{FSC}(r)}
SSNR > 1 indicates signal-dominated shells; SSNR = 1 corresponds to the
resolution limit. This method calls :meth:`fouriercorr` internally if
the FSC has not already been computed.
Returns
-------
f : ndarray
Integer shell indices (same as :meth:`nyquist`).
fnyquist : float
Nyquist frequency (in pixels).
FSC : ndarray
Fourier Shell Correlation curve.
SSNR : ndarray
Spectral Signal-to-Noise Ratio as a function of spatial frequency.
References
----------
.. [1] M. Unser, B. L. Trus, and A. C. Steven, "A new resolution
criterion based on spectral signal-to-noise ratios", Ultramicroscopy
23, 39-52 (1987).
"""
FSC, _ = FourierShellCorr.fouriercorr(self)
f, fnyquist = FourierShellCorr.nyquist(self)
eps = np.spacing(1)
SSNR = 2.0 * FSC / np.maximum(1.0 - FSC, eps)
return f, fnyquist, FSC, SSNR
[docs]
class FSCPlot(FourierShellCorr):
"""
Upper level object to plot the FSC and threshold curves
Parameters
----------
img1 : ndarray
A 2-dimensional array containing the first image
img2 : ndarray
A 2-dimensional array containing the second image
threshold : str, optional
The option `onebit` means 1 bit threshold with ``SNRt = 0.5``, which
should be used for two independent measurements. The option `halfbit`
means 1/2 bit threshold with ``SNRt = 0.2071``, which should be
use for split tomogram. The default option is ``half-bit``.
ring_thick : int, optional
Thickness of the frequency rings. Normally the pixels get
assined to the closest integer pixel ring in Fourier Domain.
With ring_thick, each ring gets more pixels and more statistics.
The default value is ``1``.
apod_width : int, optional
Width in pixel of the edges apodization. It applies a Hanning
window of the size of the data to the data before the Fourier
transform calculations to attenuate the border effects. The
default value is ``20``.
pixel_size : float, optional
Physical size of one pixel/voxel in any consistent unit (e.g. metres,
nanometres). Used to compute :attr:`resolution_full` and
:attr:`resolution_half` in physical units. Default ``1.0`` (result
in pixels).
Attributes
----------
fn_res : float or None
Resolution crossing as a fraction of the Nyquist frequency
(dimensionless, range ``[0, 1]``). ``None`` if FSC never exceeds
the threshold.
fn_res_cpx : float or None
Resolution crossing frequency in cycles/pixel
(``fn_res * 0.5``). ``None`` if FSC never exceeds the threshold.
resolution_full : float or None
Full-period resolution in physical units (van Heel / FSC convention):
``pixel_size / fn_res_cpx``. This is the spatial period of the
finest resolvable grating — the standard quantity reported in FSC
analyses. ``None`` if FSC never exceeds the threshold.
resolution_half : float or None
Half-period resolution in physical units (Rayleigh / feature-size
convention): ``resolution_full / 2``. This equals the smallest
resolvable feature size and is the quantity most often reported in
optical microscopy. ``None`` if FSC never exceeds the threshold.
Returns
-------
fn : ndarray
A 1-dimensional array containing the frequencies normalized by
the Nyquist frequency
FSC : ndarray
A 1-dimensional array containing the Fourier Shell correlation curve
T : ndarray
A 1-dimensional array containing the threshold curve
"""
def __init__(self, img1, img2, threshold="halfbit", ring_thick=1, apod_width=20,
pixel_size=1.0):
print("calling the class FSCplot")
super().__init__(img1, img2, threshold, ring_thick, apod_width)
self.pixel_size = float(pixel_size)
self.FSC, self.T = FourierShellCorr.fouriercorr(self)
self.f, self.fnyquist = FourierShellCorr.nyquist(self)
# Resolution crossing: last shell where FSC > T
# fn_res — normalised frequency [0, 1] (fraction of Nyquist)
# fn_res_cpx — in cycles/pixel (= fn_res * 0.5)
# resolution_full — full grating period in physical units (van Heel convention)
# resolution_half — half-period / feature size in physical units (Rayleigh convention)
above = self.FSC.real > self.T
if np.any(above):
idx = int(np.where(above)[0][-1])
self.fn_res = float(self.f[idx] / self.fnyquist)
self.fn_res_cpx = self.fn_res * 0.5 # cycles/pixel
self.resolution_full = self.pixel_size / self.fn_res_cpx
self.resolution_half = self.resolution_full / 2.0
else:
self.fn_res = None
self.fn_res_cpx = None
self.resolution_full = None
self.resolution_half = None
[docs]
def plot(self):
"""
Plot the FSC and threshold curves and return the underlying data.
Delegates the actual plotting to
:func:`~toupy.utils.plot_utils.show_fsc_curve`.
Returns
-------
fn : ndarray
Spatial frequencies normalised by the Nyquist frequency
(range ``[0, 1]``).
T : ndarray
Threshold curve (half-bit or one-bit).
FSC : ndarray
Real part of the Fourier Shell Correlation values.
fn_res_cpx : float or None
Resolution crossing frequency in cycles/pixel.
Use :attr:`resolution_full` or :attr:`resolution_half` for
results already converted to physical units.
``None`` if FSC never exceeds the threshold.
"""
print("calling method plot from the class FSCplot")
fn = self.f / self.fnyquist
FSC = self.FSC.real
T = self.T
show_fsc_curve(fn, FSC, T, self.snrt, self.img1.ndim)
if self.fn_res_cpx is not None:
print(f" Resolution (full period) : {self.resolution_full:.6g} "
f"[in units of pixel_size]")
print(f" Resolution (half period) : {self.resolution_half:.6g} "
f"[in units of pixel_size]")
return fn, T, FSC, self.fn_res_cpx
[docs]
class FRCPlot(FSCPlot):
"""
Fourier Ring Correlation (FRC) — the 2-D analogue of the FSC.
Identical to :class:`FSCPlot` but enforces two-dimensional input and
uses "FRC" in all labels and output filenames. FRC is standard in
cryo-EM and increasingly used in X-ray ptychography and nano-tomography.
Parameters
----------
img1 : ndarray
A 2-dimensional array containing the first image.
img2 : ndarray
A 2-dimensional array containing the second image.
threshold : str, optional
``'halfbit'`` (default) or ``'onebit'``.
ring_thick : int, optional
Ring thickness in pixels. Default ``1``.
apod_width : int, optional
Apodization width in pixels. Default ``20``.
Returns
-------
fn : ndarray
Spatial frequencies normalised by the Nyquist frequency.
FRC : ndarray
Fourier Ring Correlation curve.
T : ndarray
Threshold curve.
Raises
------
ValueError
If either input image is not 2-dimensional.
Notes
-----
The mathematical definition and threshold criteria are identical to the
FSC; only the geometry changes (rings in 2-D vs shells in 3-D).
References
----------
.. [1] M. van Heel and M. Schatz, "Fourier shell correlation threshold
criteria", Journal of Structural Biology 151, 250-262 (2005).
"""
def __init__(self, img1, img2, threshold="halfbit", ring_thick=1, apod_width=20,
pixel_size=1.0):
if np.asarray(img1).ndim != 2 or np.asarray(img2).ndim != 2:
raise ValueError("FRCPlot requires 2-D images (use FSCPlot for 3-D).")
print("Calling the class FRCPlot")
super().__init__(img1, img2, threshold, ring_thick, apod_width, pixel_size)
[docs]
def plot(self):
"""
Plot the FRC and threshold curves and return the underlying data.
Returns
-------
fn : ndarray
Spatial frequencies normalised by the Nyquist frequency.
T : ndarray
Threshold curve (half-bit or one-bit).
FRC : ndarray
Fourier Ring Correlation curve (real part).
fn_res_cpx : float or None
Resolution crossing frequency in cycles/pixel.
Use :attr:`resolution_full` or :attr:`resolution_half` for
results already converted to physical units.
``None`` if FRC never exceeds the threshold.
"""
print("Calling method plot from the class FRCPlot")
fn = self.f / self.fnyquist
FRC = self.FSC.real
T = self.T
# Reuse show_fsc_curve but relabel via the ndim=2 path
show_fsc_curve(fn, FRC, T, self.snrt, ndim=2)
if self.fn_res_cpx is not None:
print(f" Resolution (full period) : {self.resolution_full:.6g} "
f"[in units of pixel_size]")
print(f" Resolution (half period) : {self.resolution_half:.6g} "
f"[in units of pixel_size]")
return fn, T, FRC, self.fn_res_cpx
[docs]
class SSNRPlot(FourierShellCorr):
"""
Compute and plot the Spectral Signal-to-Noise Ratio (SSNR).
The SSNR is derived from the FSC via:
.. math::
\\mathrm{SSNR}(r) = \\frac{2 \\cdot \\mathrm{FSC}(r)}{1 - \\mathrm{FSC}(r)}
The resolution limit is defined by a **frequency-dependent** SSNR
threshold curve obtained by applying the same transformation to the
half-bit (or one-bit) FSC threshold :math:`T(r)`:
.. math::
\\mathrm{SSNR}_T(r) = \\frac{2 \\cdot T(r)}{1 - T(r)}
This curve is *not* a horizontal line — it starts very high at low
spatial frequencies (few Fourier coefficients, large :math:`T`) and
asymptotes to a fixed value at high frequencies:
* **half-bit** threshold: asymptote :math:`\\approx 0.414`
* **one-bit** threshold: asymptote :math:`= 1.0`
Using a horizontal line at SSNR = 1 as the resolution criterion is
therefore only correct for the one-bit case (two fully independent
measurements). For a **split dataset** (``threshold='halfbit'``), the
correct criterion is the frequency-dependent :math:`\\mathrm{SSNR}_T(r)`
curve, whose asymptote is ≈ 0.414.
Parameters
----------
img1 : ndarray
First half-dataset (2-D or 3-D array).
img2 : ndarray
Second half-dataset, same shape as ``img1``.
threshold : str, optional
``'halfbit'`` (default) or ``'onebit'``.
ring_thick : int, optional
Ring thickness in pixels. Default ``1``.
apod_width : int, optional
Apodization width in pixels. Default ``20``.
Attributes
----------
FSC : ndarray
Fourier Shell/Ring Correlation curve.
SSNR : ndarray
Spectral Signal-to-Noise Ratio curve.
SSNR_T : ndarray
Frequency-dependent SSNR threshold derived from the FSC threshold
curve :math:`T(r)`.
f : ndarray
Shell indices.
fnyquist : float
Nyquist frequency in pixels.
fn_res : float or None
Resolution crossing as a fraction of the Nyquist frequency
(dimensionless, range ``[0, 1]``). ``None`` if SSNR never exceeds
the threshold.
fn_res_cpx : float or None
Resolution crossing frequency in cycles/pixel
(``fn_res * 0.5``). ``None`` if SSNR never exceeds the threshold.
resolution_full : float or None
Full-period resolution in physical units (van Heel / FSC convention):
``pixel_size / fn_res_cpx``. This is the spatial period of the
finest resolvable grating — the standard quantity reported in FSC
analyses. ``None`` if SSNR never exceeds the threshold.
resolution_half : float or None
Half-period resolution in physical units (Rayleigh / feature-size
convention): ``resolution_full / 2``. This equals the smallest
resolvable feature size and is the quantity most often reported in
optical microscopy. ``None`` if SSNR never exceeds the threshold.
References
----------
.. [1] M. Unser, B. L. Trus, and A. C. Steven, "A new resolution
criterion based on spectral signal-to-noise ratios",
Ultramicroscopy 23, 39-52 (1987).
.. [2] M. van Heel and M. Schatz, "Fourier shell correlation threshold
criteria", Journal of Structural Biology 151, 250-262 (2005).
"""
def __init__(self, img1, img2, threshold="halfbit", ring_thick=1, apod_width=20,
pixel_size=1.0):
print("Calling the class SSNRPlot")
super().__init__(img1, img2, threshold, ring_thick, apod_width)
self.pixel_size = float(pixel_size)
# Compute FSC and the FSC threshold curve in a single call
self.FSC, self.T = FourierShellCorr.fouriercorr(self)
self.f, self.fnyquist = FourierShellCorr.nyquist(self)
eps = np.spacing(1)
fsc = self.FSC.real
# SSNR from the FSC curve
self.SSNR = 2.0 * fsc / np.maximum(1.0 - fsc, eps)
# Frequency-dependent SSNR threshold derived from T(r)
self.SSNR_T = 2.0 * self.T / np.maximum(1.0 - self.T, eps)
# Resolution crossing: last shell where SSNR > SSNR_T
# fn_res — normalised frequency [0, 1] (fraction of Nyquist)
# fn_res_cpx — in cycles/pixel (= fn_res * 0.5)
# resolution_full — full grating period in physical units (van Heel convention)
# resolution_half — half-period / feature size in physical units (Rayleigh convention)
fn = self.f / self.fnyquist
above = self.SSNR > self.SSNR_T
if np.any(above):
idx = int(np.where(above)[0][-1])
self.fn_res = float(fn[idx])
self.fn_res_cpx = self.fn_res * 0.5 # cycles/pixel
self.resolution_full = self.pixel_size / self.fn_res_cpx
self.resolution_half = self.resolution_full / 2.0
else:
self.fn_res = None
self.fn_res_cpx = None
self.resolution_full = None
self.resolution_half = None
[docs]
def plot(self):
"""
Plot the SSNR curve with its frequency-dependent threshold and return
the underlying data.
The plot shows:
* The SSNR curve (blue).
* The frequency-dependent SSNR threshold :math:`\\mathrm{SSNR}_T(r)`
(red solid curve), derived by transforming the FSC threshold
:math:`T(r)` through :math:`\\mathrm{SSNR}_T = 2T/(1-T)`.
* A dotted horizontal line at the asymptotic threshold value
(≈ 0.414 for half-bit, 1.0 for one-bit).
* A vertical dashed line at the estimated resolution (last frequency
where ``SSNR > SSNR_T``).
Returns
-------
fn : ndarray
Spatial frequencies normalised by the Nyquist frequency.
FSC : ndarray
Fourier Shell/Ring Correlation curve.
SSNR : ndarray
Spectral Signal-to-Noise Ratio curve.
SSNR_T : ndarray
Frequency-dependent SSNR threshold curve.
fn_res_cpx : float or None
Resolution crossing frequency in cycles/pixel
(last frequency where ``SSNR > SSNR_T``).
Divide ``pixel_size`` by this value to obtain the resolution
in physical units. ``None`` if SSNR never exceeds the threshold.
"""
print("Calling method plot from the class SSNRPlot")
fn = self.f / self.fnyquist
FSC = self.FSC.real
SSNR = self.SSNR
SSNR_T = self.SSNR_T
show_ssnr_curve(fn, FSC, SSNR, SSNR_T, self.snrt, self.ndim)
if self.fn_res_cpx is not None:
print(f" Resolution (full period) : {self.resolution_full:.6g} "
f"[in units of pixel_size]")
print(f" Resolution (half period) : {self.resolution_half:.6g} "
f"[in units of pixel_size]")
return fn, FSC, SSNR, SSNR_T, self.fn_res_cpx
# ---------------------------------------------------------------------------
# LocalFSC
# ---------------------------------------------------------------------------
[docs]
class LocalFSC:
"""
Local resolution estimation via block-wise FSC (3-D) or FRC (2-D).
Divides the volume into overlapping boxes, computes an independent
Fourier Shell/Ring Correlation within each box, and assembles a
spatial map of local resolution interpolated to the input shape.
Parameters
----------
vol1 : ndarray
First independent half-reconstruction (2-D or 3-D).
vol2 : ndarray
Second independent half-reconstruction, same shape as *vol1*.
pixel_size : float, optional
Physical voxel size (any consistent unit). Default ``1.0``.
box_size : int, optional
Side length (in voxels) of the cubic (or square) analysis box.
Smaller boxes give finer spatial sampling but noisier estimates.
Default ``32``.
step : int or None, optional
Step size in voxels between adjacent box centres.
Default ``box_size // 2`` (50 % overlap).
threshold : float, optional
FSC/FRC threshold used to define the local resolution limit.
Default ``0.143`` (half-bit criterion).
Attributes
----------
resolution_map : ndarray
Local full-period resolution in pixels (van Heel convention),
same shape as *vol1*.
resolution_map_phys : ndarray
Local full-period resolution in physical units
(``resolution_map * pixel_size``).
resolution_map_half : ndarray
Local half-period resolution in pixels (Rayleigh convention):
``resolution_map / 2``.
resolution_map_phys_half : ndarray
Local half-period resolution in physical units:
``resolution_map_phys / 2``.
resolution_median : float
Median full-period local resolution in pixels.
resolution_mean : float
Mean full-period local resolution in pixels.
resolution_std : float
Standard deviation of full-period local resolution in pixels.
resolution_median_half : float
Median half-period local resolution in pixels.
resolution_mean_half : float
Mean half-period local resolution in pixels.
resolution_std_half : float
Standard deviation of half-period local resolution in pixels.
"""
def __init__(self, vol1, vol2, pixel_size=1.0, box_size=32, step=None, threshold=0.143):
print("Calling the class LocalFSC")
vol1 = np.asarray(vol1, dtype=np.float64)
vol2 = np.asarray(vol2, dtype=np.float64)
if vol1.shape != vol2.shape:
raise ValueError("LocalFSC: vol1 and vol2 must have the same shape.")
if vol1.ndim not in (2, 3):
raise ValueError("LocalFSC: input must be 2-D or 3-D.")
self.vol1 = vol1
self.vol2 = vol2
self.shape = vol1.shape
self.ndim = vol1.ndim
self.pixel_size = float(pixel_size)
self.box_size = int(box_size)
self.step = int(step) if step is not None else self.box_size // 2
self.threshold = float(threshold)
self._compute()
ps = self.pixel_size
print(f" LocalFSC median resolution (full period) : "
f"{self.resolution_median:.2f} px "
f"({self.resolution_median * ps:.4g} physical units)")
print(f" LocalFSC median resolution (half period) : "
f"{self.resolution_median_half:.2f} px "
f"({self.resolution_median_half * ps:.4g} physical units)")
print(f" LocalFSC mean resolution (full period) : "
f"{self.resolution_mean:.2f} ± {self.resolution_std:.2f} px")
def _make_window(self):
"""
Build a Hanning window matching the analysis box shape.
Returns
-------
window : ndarray
Hanning window of shape ``(box_size,) * ndim``.
"""
w1d = np.hanning(self.box_size)
window = w1d.copy()
for _ in range(self.ndim - 1):
window = np.multiply.outer(window, w1d)
return window
def _fsc_box(self, box1, box2, window):
"""
Compute local resolution (pixels) from a pair of overlapping boxes.
Subtracts the mean, applies *window*, computes the FFT, builds
radial shells using ``scipy.fft.fftfreq``, and returns the local
resolution at the shell where FSC first drops below
``self.threshold``.
Parameters
----------
box1 : ndarray
First analysis box (shape ``(box_size,) * ndim``).
box2 : ndarray
Second analysis box, same shape as *box1*.
window : ndarray
Hanning window of shape ``(box_size,) * ndim``.
Returns
-------
resolution_px : float
Local resolution in pixels. Returns ``box_size * 2.0`` when
the FSC never reaches ``self.threshold`` (bad estimate).
"""
b = self.box_size
eps = np.finfo(np.float64).tiny
b1 = (box1 - box1.mean()) * window
b2 = (box2 - box2.mean()) * window
F1 = _fftn(b1)
F2 = _fftn(b2)
# Build radial coordinate grid in cycles/pixel (FFT layout)
freqs = [_fftfreq(b) for _ in range(self.ndim)]
grids = np.meshgrid(*freqs, indexing='ij')
R = np.sqrt(sum(g ** 2 for g in grids))
# Radial shells: integer bins in units of 1/box_size
shell_idx = np.round(R * b).astype(np.int32)
max_shell = b // 2
fsc_vals = []
for s in range(max_shell + 1):
mask = shell_idx == s
if not np.any(mask):
fsc_vals.append(0.0)
continue
f1 = F1[mask]
f2 = F2[mask]
cross = np.abs(np.vdot(f2, f1))
a1 = np.abs(np.vdot(f1, f1))
a2 = np.abs(np.vdot(f2, f2))
denom = np.sqrt(a1 * a2)
fsc_vals.append(float(cross / denom) if denom > eps else 0.0)
fsc_arr = np.array(fsc_vals)
# Find the highest shell where FSC >= threshold
above = np.where(fsc_arr[1:] >= self.threshold)[0] + 1 # skip DC
if len(above) == 0:
return float(b * 2.0)
r_res_shell = above[-1]
# Convert shell index to resolution in pixels: shell s = s/b cycles/pixel
r_cpx = r_res_shell / float(b)
if r_cpx < eps:
return float(b * 2.0)
return 1.0 / r_cpx
def _compute(self):
"""
Iterate over the coarse grid, compute local FSC per box, and
interpolate the result to the full volume shape.
Stores
------
resolution_map : ndarray
resolution_map_phys : ndarray
resolution_median : float
resolution_mean : float
resolution_std : float
"""
vol1 = self.vol1
vol2 = self.vol2
shape = self.shape
ndim = self.ndim
b = self.box_size
step = self.step
half = b // 2
window = self._make_window()
# Coarse grid: centre positions
axes = [np.arange(half, shape[d] - half, step) for d in range(ndim)]
for d, ax in enumerate(axes):
if len(ax) < 2:
warnings.warn(
f"LocalFSC: axis {d} has fewer than 2 grid points "
f"(box_size={b} may be too large for dimension {shape[d]}). "
"Resolution map may be unreliable.",
UserWarning,
stacklevel=2,
)
coarse_shape = tuple(len(ax) for ax in axes)
coarse_map = np.empty(coarse_shape, dtype=np.float64)
idx_ranges = [range(len(ax)) for ax in axes]
for grid_idx in itertools.product(*idx_ranges):
centres = tuple(int(axes[d][grid_idx[d]]) for d in range(ndim))
slices = tuple(
slice(max(0, centres[d] - half), min(shape[d], centres[d] + half))
for d in range(ndim)
)
box1 = vol1[slices]
box2 = vol2[slices]
# Pad to box_size if the box is smaller near the border
if box1.shape != (b,) * ndim:
pad1 = [(0, b - box1.shape[dd]) for dd in range(ndim)]
box1 = np.pad(box1, pad1)
box2 = np.pad(box2, pad1)
coarse_map[grid_idx] = self._fsc_box(box1, box2, window)
# Interpolate to full volume using RegularGridInterpolator
interp = RegularGridInterpolator(
axes,
coarse_map,
method='linear',
bounds_error=False,
fill_value=None,
)
# Build full meshgrid of integer coordinates
full_grids = np.mgrid[tuple(slice(0, shape[d]) for d in range(ndim))]
# full_grids shape: (ndim, *shape)
pts = np.stack([full_grids[d].ravel() for d in range(ndim)], axis=-1)
res_full = interp(pts).reshape(shape)
self.resolution_map = res_full
self.resolution_map_phys = res_full * self.pixel_size
self.resolution_map_half = res_full / 2.0
self.resolution_map_phys_half = self.resolution_map_phys / 2.0
self.resolution_median = float(np.median(res_full))
self.resolution_mean = float(np.mean(res_full))
self.resolution_std = float(np.std(res_full))
self.resolution_median_half = self.resolution_median / 2.0
self.resolution_mean_half = self.resolution_mean / 2.0
self.resolution_std_half = self.resolution_std / 2.0
[docs]
def plot(self, slice_idx=None, axis=0, cmap='viridis_r', vmin=None, vmax=None):
"""
Display the local resolution map and save it to ``LocalFSC_resmap.png``.
For 3-D volumes the central slice (or the slice specified by
*slice_idx*) along *axis* is shown. The colour bar is in pixels.
Parameters
----------
slice_idx : int or None, optional
Slice index along *axis* to display. Defaults to the central
slice. Ignored for 2-D input.
axis : int, optional
Volume axis along which to slice. Default ``0``.
cmap : str, optional
Matplotlib colourmap. Default ``'viridis_r'``.
vmin : float or None, optional
Lower colour-scale limit. ``None`` uses the data minimum.
vmax : float or None, optional
Upper colour-scale limit. ``None`` uses the data maximum.
Returns
-------
resolution_map : ndarray
The full local-resolution map (same shape as the input volume).
"""
show_resolution_map(
self.resolution_map,
self.ndim,
title="LocalFSC resolution map",
filename="LocalFSC_resmap.png",
slice_idx=slice_idx,
axis=axis,
cmap=cmap,
vmin=vmin,
vmax=vmax,
)
return self.resolution_map
# ---------------------------------------------------------------------------
# RandomFSC
# ---------------------------------------------------------------------------
[docs]
class RandomFSC(FourierShellCorr):
"""
Phase-randomization test for FSC validation (Chen et al., 2013).
After computing the standard FSC, the Fourier phases of both
half-volumes are independently randomized above a chosen spatial
frequency *f*_cutoff (the shell where FSC_obs first drops below
``fsc_cutoff``). The FSC computed from the two phase-randomized
volumes (FSC_rand) is the noise floor expected from model bias or
overfitting. A corrected FSC is then defined as:
.. math::
\\mathrm{FSC_{corr}}(r) =
\\frac{\\mathrm{FSC_{obs}}(r) - \\mathrm{FSC_{rand}}(r)}
{1 - \\mathrm{FSC_{rand}}(r)}
If no overfitting is present, FSC_rand drops quickly to zero beyond
*f*_cutoff and FSC_corr ≈ FSC_obs. An elevated FSC_rand indicates
that the two half-maps share information beyond *f*_cutoff via a
common external model used during iterative reconstruction.
Parameters
----------
img1 : ndarray
First half-reconstruction (2-D or 3-D).
img2 : ndarray
Second half-reconstruction, same shape as *img1*.
threshold : str, optional
``'halfbit'`` (default) or ``'onebit'``.
ring_thick : int, optional
Ring thickness in pixels. Default ``1``.
apod_width : int, optional
Apodization width in pixels. Default ``20``.
fsc_cutoff : float, optional
FSC value that defines the phase-randomization cut-off frequency.
Phases are randomized at all shells where FSC_obs < ``fsc_cutoff``.
Default ``0.8``.
random_seed : int or None, optional
Seed for the random phase generator (for reproducibility).
Default ``None`` (non-reproducible).
Attributes
----------
FSC_obs : ndarray
Standard (observed) FSC curve.
FSC_rand : ndarray
FSC of the phase-randomized half-volumes.
FSC_corr : ndarray
Phase-randomization corrected FSC.
T : ndarray
Threshold curve (same as standard FSC).
f : ndarray
Shell indices.
fnyquist : float
Nyquist frequency in pixels.
cutoff_shell : int
Shell index used as the phase-randomization boundary.
References
----------
.. [1] S. Chen, G. McMullan, A. R. Faruqi, G. N. Murshudov, J. M. Short,
S. H. Scheres, and R. Henderson, "High-resolution noise substitution to
measure overfitting and validate resolution in 3D structure determination
by single particle electron cryomicroscopy", Ultramicroscopy 135, 24-35
(2013). https://doi.org/10.1016/j.ultramic.2013.06.004
"""
def __init__(
self,
img1,
img2,
threshold="halfbit",
ring_thick=1,
apod_width=20,
fsc_cutoff=0.8,
random_seed=None,
pixel_size=1.0,
):
print("Calling the class RandomFSC")
super().__init__(img1, img2, threshold, ring_thick, apod_width)
self.fsc_cutoff = float(fsc_cutoff)
self.random_seed = random_seed
self.pixel_size = float(pixel_size)
# Compute observed FSC and threshold
self.FSC_obs, self.T = FourierShellCorr.fouriercorr(self)
self.f, self.fnyquist = FourierShellCorr.nyquist(self)
# Phase-randomization
self.cutoff_shell = self._find_cutoff_shell()
self._compute_randomized()
# Resolution at the FSC_corr × T crossing (last shell where FSC_corr > T)
fsc_corr = np.asarray(self.FSC_corr).real
above = fsc_corr > np.asarray(self.T)
if np.any(above):
idx = int(np.where(above)[0][-1])
self.fn_res = float(self.f[idx] / self.fnyquist)
self.fn_res_cpx = self.fn_res * 0.5
self.resolution_full = self.pixel_size / self.fn_res_cpx
self.resolution_half = self.resolution_full / 2.0
else:
self.fn_res = None
self.fn_res_cpx = None
self.resolution_full = None
self.resolution_half = None
print(f" Phase-randomization cutoff shell : {self.cutoff_shell}")
print(f" f_cutoff (cycles/pixel) : "
f"{self.cutoff_shell / self.fnyquist * 0.5:.4f}")
if self.fn_res_cpx is not None:
print(f" Resolution FSC_corr (full period): {self.resolution_full:.6g} "
f"[in units of pixel_size]")
print(f" Resolution FSC_corr (half period): {self.resolution_half:.6g} "
f"[in units of pixel_size]")
def _find_cutoff_shell(self):
"""
Find the last shell index where ``FSC_obs >= fsc_cutoff``.
Returns
-------
cutoff_shell : int
Shell index used as the phase-randomization boundary.
Warns
-----
UserWarning
If ``FSC_obs`` never reaches ``fsc_cutoff``.
"""
fsc = np.asarray(self.FSC_obs).real
above = np.where(fsc >= self.fsc_cutoff)[0]
if len(above) == 0:
shell = int(len(self.f) // 4)
warnings.warn(
f"RandomFSC: FSC_obs never reaches fsc_cutoff={self.fsc_cutoff}. "
f"Using shell index {shell} as fallback.",
UserWarning,
stacklevel=2,
)
return shell
return int(above[-1])
def _build_radial_grid(self, shape):
"""
Build a radial frequency grid in cycles/pixel for a volume of *shape*.
Parameters
----------
shape : tuple of int
Shape of the volume.
Returns
-------
R : ndarray
Radial frequency array of the same shape as *shape*,
in cycles/pixel (FFT layout).
"""
freqs = [_fftfreq(n) for n in shape]
grids = np.meshgrid(*freqs, indexing='ij')
return np.sqrt(sum(g ** 2 for g in grids))
def _randomize_phases_above(self, vol, f_cutoff_cpx, rng):
"""
Randomize Fourier phases above *f_cutoff_cpx* cycles/pixel.
The amplitude spectrum is preserved; only the phase is replaced by
uniform random values in ``[-π, π]``.
Parameters
----------
vol : ndarray
Real-space volume (2-D or 3-D).
f_cutoff_cpx : float
Radial frequency threshold in cycles/pixel. Shells with
R > f_cutoff_cpx are phase-randomized.
rng : numpy.random.Generator
Random number generator.
Returns
-------
vol_rand : ndarray
Real-space volume with randomized phases above *f_cutoff_cpx*.
"""
F = _fftn(vol)
R = self._build_radial_grid(vol.shape)
mask = R > f_cutoff_cpx
amp = np.abs(F)
phase_orig = np.angle(F)
rand_phase = rng.uniform(-np.pi, np.pi, size=F.shape)
new_phase = np.where(mask, rand_phase, phase_orig)
F_rand = amp * np.exp(1j * new_phase)
return np.real(_ifftn(F_rand))
def _fsc_from_arrays(self, vol1, vol2):
"""
Compute FSC between *vol1* and *vol2* using the pre-computed window.
Does not call :meth:`fouriercorr` to avoid re-triggering the
apodization display and window recomputation.
Parameters
----------
vol1 : ndarray
First volume (same shape as ``self.img1``).
vol2 : ndarray
Second volume (same shape as ``self.img2``).
Returns
-------
FSC : ndarray
Fourier Shell Correlation curve.
"""
F1 = fastfftn(vol1 * self.window)
F2 = fastfftn(vol2 * self.window)
index = self.ringthickness()
f, fnyquist = self.nyquist()
index_flat = index.ravel()
F1_flat = F1.ravel()
F2_flat = F2.ravel()
sort_order = np.argsort(index_flat, kind="stable")
index_sorted = index_flat[sort_order]
F1_sorted = F1_flat[sort_order]
F2_sorted = F2_flat[sort_order]
C = np.empty(len(f), dtype=np.float32)
C1 = np.empty(len(f), dtype=np.float32)
C2 = np.empty(len(f), dtype=np.float32)
half_thick = self.ring_thick / 2.0
use_thick = self.ring_thick > 1
for ii in f:
if use_thick:
lo = np.searchsorted(index_sorted, ii - half_thick, side="left")
hi = np.searchsorted(index_sorted, ii + half_thick, side="right")
else:
lo = np.searchsorted(index_sorted, ii, side="left")
hi = np.searchsorted(index_sorted, ii + 1, side="left")
f1 = F1_sorted[lo:hi]
f2 = F2_sorted[lo:hi]
C[ii] = abs(np.vdot(f2, f1))
C1[ii] = abs(np.vdot(f1, f1))
C2[ii] = abs(np.vdot(f2, f2))
eps = np.spacing(1)
FSC = C / np.sqrt(C1 * C2 + eps)
return FSC
def _compute_randomized(self):
"""
Randomize phases above ``cutoff_shell`` and compute FSC_rand and FSC_corr.
Stores
------
FSC_rand : ndarray
FSC_corr : ndarray
"""
f_cutoff_cpx = self.cutoff_shell / self.fnyquist * 0.5
rng = np.random.default_rng(self.random_seed)
rand1 = self._randomize_phases_above(self.img1, f_cutoff_cpx, rng)
rand2 = self._randomize_phases_above(self.img2, f_cutoff_cpx, rng)
self.FSC_rand = self._fsc_from_arrays(rand1, rand2)
eps = np.finfo(np.float64).eps
fsc_obs = np.asarray(self.FSC_obs, dtype=np.float64)
fsc_rand = np.asarray(self.FSC_rand, dtype=np.float64)
fsc_corr = (fsc_obs - fsc_rand) / np.maximum(1.0 - fsc_rand, eps)
self.FSC_corr = np.clip(fsc_corr, -1.0, 1.0)
[docs]
def plot(self):
"""
Plot FSC_obs, FSC_rand, FSC_corr, and the bias (FSC_obs − FSC_rand).
Left panel: all three FSC curves and the threshold *T*.
Right panel: ``FSC_obs − FSC_rand`` (overfitting bias).
Saves ``RandomFSC.png``.
Returns
-------
fn : ndarray
Spatial frequencies normalised by the Nyquist frequency.
FSC_obs : ndarray
Standard FSC.
FSC_rand : ndarray
Phase-randomized FSC.
FSC_corr : ndarray
Corrected FSC.
T : ndarray
Threshold curve.
"""
print("Calling method plot from the class RandomFSC")
fn = self.f / self.fnyquist
fsc_obs = np.asarray(self.FSC_obs).real
fsc_rand = np.asarray(self.FSC_rand).real
fsc_corr = np.asarray(self.FSC_corr).real
T = np.asarray(self.T)
cutoff_fn = self.cutoff_shell / self.fnyquist
show_random_fsc_curve(fn, fsc_obs, fsc_rand, fsc_corr, T, cutoff_fn, self.ndim)
return fn, fsc_obs, fsc_rand, fsc_corr, T