Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/324.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Sped up the radial histogram equalizing filter (`sunkit_image.radial.rhef`) by replacing the per-bin boolean-mask loop with a single sort-and-group pass over the pixels.
The output is bit-identical to the previous implementation across all three ranking methods.
The kernel cost no longer scales with bin count, so wins grow with both image size and bin density (~10x on a 4096^2 image with 2048 bins).
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,7 @@ filterwarnings =
ignore:leap-second auto-update failed:astropy.utils.exceptions.AstropyWarning
ignore:.*deprecated - use.*:pyparsing.warnings.PyparsingDeprecationWarning
ignore:.*argument is deprecated, use.*:pyparsing.warnings.PyparsingDeprecationWarning
# skimage 2.0 namespace-migration deprecation for white_tophat. We still call
# the morphology entry point on purpose; this only keeps the test suite's
# ``filterwarnings = error`` from tripping. See sunpy/sunkit-image#324.
ignore:.*skimage.morphology.white_tophat.* is deprecated.*
74 changes: 61 additions & 13 deletions sunkit_image/radial.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,12 +699,24 @@ def rhef(
`sunpy.map.Map`
A SunPy map with the Radial Histogram Equalizing Filter applied to it.

Notes
-----
The radial bins produced by `~sunkit_image.utils.find_radial_bin_edges` are
sorted and contiguous (each bin's upper edge equals the next bin's lower
edge). When bins overlap, each pixel is assigned to the highest-index bin
whose lower edge it lies above; if it also lies below that bin's upper edge
it is ranked there, otherwise it is left at ``fill``. Pixels that fall in
no bin likewise keep ``fill``.

References
----------
* Gilly & Cranmer 2024, in prep.
* Gilly, C. R., & Cranmer, S. R. 2025,
"Visualization of High Dynamic Range Solar Imagery and the Radial Histogram Equalizing Filter",
Solar Physics, 300, 174.
https://doi.org/10.1007/s11207-025-02578-x

* The implementation is highly inspired by this doctoral thesis:
Gilly, G. Spectroscopic Analysis and Image Processing of the Optically-Thin Solar Corona
* The implementation is also described in the first author's doctoral thesis:
Gilly, C. R. "Spectroscopic Analysis and Image Processing of the Optically-Thin Solar Corona"
https://www.proquest.com/docview/2759080511
"""

Expand All @@ -715,16 +727,52 @@ def rhef(
# Select the ranking method
ranking_func = _select_rank_method(method)

# Loop over each radial bin to apply the filter
for i in tqdm(range(radial_bin_edges.shape[1]), desc="RHEF: ", disable=not progress):
# Identify pixels within the current radial bin
here = np.logical_and(map_r >= radial_bin_edges[0, i].to(u.R_sun), map_r < radial_bin_edges[1, i].to(u.R_sun))
if application_radius is not None and application_radius > 0:
here = np.logical_and(here, map_r >= application_radius)
# Apply ranking function
data[here] = ranking_func(smap.data[here])
if upsilon is not None:
data[here] = apply_upsilon(data[here], upsilon)
nbins = radial_bin_edges.shape[1]

# Sort-and-group inner loop: digitise pixels into bins once via
# ``searchsorted``, group same-bin pixels with a single stable
# ``argsort``, then rank each bin's contiguous slice. Bucketing is
# O(N log nbins) rather than O(N * nbins), so the per-frame cost no
# longer scales with the number of bins.
edges_lo = radial_bin_edges[0].to_value(u.R_sun)
edges_hi = radial_bin_edges[1].to_value(u.R_sun)
flat_r = map_r.to_value(u.R_sun).ravel()
flat_in = smap.data.ravel()
flat_out = data.reshape(-1)

# Largest i with edges_lo[i] <= r. For sorted, non-overlapping bins this
# is the unique containing bin; on overlapping bins it selects the last
# (largest-index) match, per the overlap rule documented in the Notes above.
flat_b = np.searchsorted(edges_lo, flat_r, side="right") - 1
in_bin = (flat_b >= 0) & (flat_b < nbins)
# ``flat_b`` is -1 for pixels below the smallest edge. Clipping those to
# bin 0 just to read an ``edges_hi`` value is harmless: ``in_bin`` is
# already ``False`` for them (the ``flat_b >= 0`` test failed), so the
# clipped comparison can never flip a genuinely out-of-range pixel back in.
in_bin &= flat_r < edges_hi[np.clip(flat_b, 0, nbins - 1)]
if application_radius is not None and application_radius > 0:
in_bin &= flat_r >= application_radius.to_value(u.R_sun)

valid_idx = np.flatnonzero(in_bin)
if valid_idx.size:
order = np.argsort(flat_b[valid_idx], kind="stable")
sorted_idx = valid_idx[order]
sorted_bins = flat_b[sorted_idx]
bin_indices = np.arange(nbins)
bin_starts = np.searchsorted(sorted_bins, bin_indices, side="left")
bin_ends = np.searchsorted(sorted_bins, bin_indices, side="right")

for i in tqdm(range(nbins), desc="RHEF: ", disable=not progress):
s, e = bin_starts[i], bin_ends[i]
if e <= s:
continue
# Fancy-index gather returns a copy, so an in-place ``ranking_func``
# (e.g. ``method="inplace"``) operates on that copy and cannot
# corrupt the shared source array.
ranked = ranking_func(flat_in[sorted_idx[s:e]])
if upsilon is not None:
ranked = apply_upsilon(ranked, upsilon)
flat_out[sorted_idx[s:e]] = ranked

new_map = sunpy.map.Map(data, smap.meta)

Expand Down
17 changes: 16 additions & 1 deletion sunkit_image/stara.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import inspect

import numpy as np
import skimage
from skimage.filters import median
Expand All @@ -14,6 +16,12 @@
else:
from skimage.morphology import footprint_rectangle

# ``mode`` was added to ``white_tophat`` after our minimum-supported skimage
# (0.20). Older builds don't accept the kwarg; newer dev builds (skimage 2.0)
# emit a ``PendingSkimage2Change`` warning telling callers to pin it. Detect
# at import time so the call site stays one branch deep.
_WHITE_TOPHAT_HAS_MODE = "mode" in inspect.signature(white_tophat).parameters

__all__ = ["stara"]


Expand Down Expand Up @@ -84,7 +92,14 @@ def stara(
c_pix = int((circle_radius / smap.scale[0]).to_value(u.pix))
circle = disk(c_pix / 2)

finite = white_tophat(med, circle)
# On skimage>=0.25 pin ``mode='reflect'`` so the eventual skimage 2.0
# default flip to ``'ignore'`` does not change our output. On older
# builds ``mode`` is not a kwarg yet but the (then-only) behaviour IS
# ``reflect``, so the un-kwarg call is equivalent.
if _WHITE_TOPHAT_HAS_MODE:
finite = white_tophat(med, circle, mode="reflect")
else:
finite = white_tophat(med, circle)
finite[np.isnan(finite)] = 0

return finite > threshold
170 changes: 170 additions & 0 deletions sunkit_image/tests/test_radial.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import matplotlib.pyplot as plt
import numpy as np
import pytest
from scipy import stats

import astropy.units as u
from astropy.coordinates import SkyCoord
Expand Down Expand Up @@ -346,3 +347,172 @@ def test_intensity_enhance_errors(map_test1):
scale = 1 * map_test1.rsun_obs
with pytest.raises(ValueError, match=r"The fit range must be strictly increasing."):
rad.intensity_enhance(map_test1, scale=scale, fit_range=fit_range[::-1])


# ---------------------------------------------------------------------------
# rhef sort-and-group inner loop: equivalence + edge-case coverage
Comment thread
GillySpace27 marked this conversation as resolved.
# ---------------------------------------------------------------------------


def _rhef_reference_loop(smap, radial_bin_edges, *, application_radius, method, upsilon, fill=np.nan):
"""A faithful copy of the original per-bin mask loop in `rhef`.

Used as the equivalence oracle for the optimised implementation. We
inline a fresh copy here rather than reaching into the production
module so a future refactor that breaks equivalence is caught even if
the test imports drift. ``find_radial_bin_edges`` is called first so
the reference sees whatever edges ``rhef`` would actually use — this
matters when the user-supplied edges don't span the full map and the
helper rebuilds them.

Pixels that land in no bin (e.g. the extreme corner where ``map_r``
exactly equals the upper edge under ``< hi`` semantics) keep ``fill``,
matching the way ``rhef`` initialises its output.

Why an inlined oracle? This PR replaces ``rhef``'s original per-bin
boolean-mask loop with a sort-and-group kernel. The two are intended to be
bit-identical, so the most direct way to prove that — and to keep proving it
against future refactors — is to keep a faithful copy of the original loop
here and assert equivalence across every ranking method plus the edge cases
that follow (empty bins, ``fill`` propagation, ``application_radius``,
overlapping bins, the ``upsilon`` correction). It is inlined rather than
imported from the module so the oracle cannot silently drift to track the
very code it is meant to check. The real-data figure test ``test_fig_rhef``
is the complementary backstop: any numerically significant change to the
kernel would alter the rendered AIA 171 image and fail its hash comparison.
"""
radial_bin_edges, map_r = utils.find_radial_bin_edges(smap, radial_bin_edges)
map_r = map_r.to(u.R_sun)

def _ranking_func(arr):
# Mirror upstream's NaN-aware ranking; non-NaN inputs match exactly.
mask = ~np.isnan(arr)
if method == "scipy":
out = np.full(arr.shape, np.nan)
out[mask] = stats.rankdata(arr[mask], method="average") / np.sum(mask)
return out
# "numpy"
out = arr.copy()
order = np.argsort(arr)
order = order[~np.isnan(arr[order])]
out[order] = np.arange(1, len(order) + 1)
return out / float(len(order))

data = np.full_like(smap.data, fill)
for i in range(radial_bin_edges.shape[1]):
here = np.logical_and(map_r >= radial_bin_edges[0, i], map_r < radial_bin_edges[1, i])
if application_radius is not None and application_radius > 0:
here = np.logical_and(here, map_r >= application_radius)
if not here.any():
continue
data[here] = _ranking_func(smap.data[here])
if upsilon is not None:
data[here] = rad.apply_upsilon(data[here], upsilon)
return data


def _synthetic_map(side=64, seed=7, *, with_nans=False):
"""Tiny in-memory `sunpy.map.Map` so equivalence tests need no sample data.

Uses the same minimal-header pattern as the existing ``map_test1`` /
``map_test2`` fixtures above (no observer / obstime needed), with an
explicit ``rsun_obs`` so ``find_pixel_radii`` can convert arcsec → R_sun
without a network IERS lookup.

NaNs are off by default: scipy's ``stats.rankdata`` propagates NaN to
every output rank in the same call, so a single NaN in a bin makes the
whole bin NaN — which is fine for equivalence testing but defeats other
assertions about "some pixel was ranked". Pass ``with_nans=True`` only
in tests that explicitly verify NaN propagation.
"""
rng = np.random.default_rng(seed)
data = rng.exponential(scale=100.0, size=(side, side)).astype(float)
if with_nans:
data[rng.random(data.shape) < 0.03] = np.nan
header = {
"cunit1": "arcsec",
"cunit2": "arcsec",
"CTYPE1": "HPLN-TAN",
"CTYPE2": "HPLT-TAN",
"CDELT1": 50.0,
"CDELT2": 50.0,
"CRVAL1": 0.0,
"CRVAL2": 0.0,
"CRPIX1": (side + 1) / 2.0,
"CRPIX2": (side + 1) / 2.0,
"RSUN_REF": 6.957e8,
"RSUN_OBS": 960.0, # arcsec; mid-range solar-disk apparent radius
}
return sunpy.map.Map((data, header))


@pytest.mark.parametrize("method", ["scipy", "numpy"])
def test_rhef_matches_reference_loop(method):
"""The sort-and-group inner loop must produce output identical to the
original per-bin mask loop, byte-for-byte, across the supported
``method=`` values."""
smap = _synthetic_map()
edges = np.linspace(0, 1.5, 33)
edges = np.array([edges[:-1], edges[1:]]) * u.R_sun

out = rad.rhef(smap, radial_bin_edges=edges, upsilon=None, method=method, vignette=10 * u.R_sun).data
ref = _rhef_reference_loop(smap, edges, application_radius=0 * u.R_sun, method=method, upsilon=None)
# NaN-aware exact match: rankdata is deterministic and the sort is
# stable, so per-bin outputs are bit-identical between the two paths.
assert out.shape == ref.shape
finite = np.isfinite(out) & np.isfinite(ref)
assert np.array_equal(out[finite], ref[finite])
assert np.array_equal(np.isfinite(out), np.isfinite(ref))


def test_rhef_matches_reference_with_application_radius():
"""``application_radius`` clips below a floor; the optimised path applies
the same mask before binning, so output must still match the reference."""
smap = _synthetic_map()
edges = np.linspace(0, 1.5, 33)
edges = np.array([edges[:-1], edges[1:]]) * u.R_sun
appr = 0.4 * u.R_sun

out = rad.rhef(
smap,
radial_bin_edges=edges,
application_radius=appr,
upsilon=None,
method="scipy",
vignette=10 * u.R_sun,
).data
ref = _rhef_reference_loop(smap, edges, application_radius=appr, method="scipy", upsilon=None)
finite = np.isfinite(out) & np.isfinite(ref)
assert np.array_equal(out[finite], ref[finite])


def test_rhef_matches_reference_with_upsilon():
"""The μ-correction is applied per pixel; verify the optimised path's
placement (one apply_upsilon call per bin slice) matches the reference."""
smap = _synthetic_map()
edges = np.linspace(0, 1.5, 33)
edges = np.array([edges[:-1], edges[1:]]) * u.R_sun

out = rad.rhef(smap, radial_bin_edges=edges, upsilon=0.35, method="scipy", vignette=10 * u.R_sun).data
ref = _rhef_reference_loop(smap, edges, application_radius=0 * u.R_sun, method="scipy", upsilon=0.35)
finite = np.isfinite(out) & np.isfinite(ref)
# Exact, like the other equivalence tests (the finite mask already removed NaNs).
assert np.array_equal(out[finite], ref[finite])


def test_rhef_empty_bins_left_at_zero():
"""A radial bin that no pixel falls into must leave its output pixels at
the storage default (zero), matching the old loop's behaviour where the
inner ``data[here] = ...`` assignment was simply never reached."""
smap = _synthetic_map(side=32)
# First bin is entirely below the smallest pixel radius — guaranteed empty
edges = np.array([[0.0, 0.5, 1.0], [0.001, 1.0, 1.5]]) * u.R_sun

out = rad.rhef(smap, radial_bin_edges=edges, upsilon=None, method="scipy", vignette=10 * u.R_sun).data
# Where map_r < 0.001 R_sun (essentially nowhere on this header), output
# stays zero; rest is non-trivial. Just check we have BOTH zeros and
# rank values present — confirms the empty-bin branch fires.
# Empty-bin pixels keep the storage default (NaN by default per the
# ``fill`` parameter); pixels in populated bins receive ranks > 0.
assert np.isnan(out).any()
assert (out > 0).any()
Loading