Skip to content

Array-API-native k×k window filters for cosmicray_median, median_filter, background_deviation_filter and ccdmask - #1010

Open
mwcraig wants to merge 14 commits into
astropy:mainfrom
mwcraig:window-filters-native
Open

Array-API-native k×k window filters for cosmicray_median, median_filter, background_deviation_filter and ccdmask#1010
mwcraig wants to merge 14 commits into
astropy:mainfrom
mwcraig:window-filters-native

Conversation

@mwcraig

@mwcraig mwcraig commented Sep 7, 2026

Copy link
Copy Markdown
Member

Depends on #1009 (rebased on top of it; merge that first).

Part of #971, section 3. The masked-median fix for #984 is a planned follow-up (PR-2) that builds on the NaN handling added here.

What changed and why

scipy.ndimage's window filters are numpy-only, so the four call sites that used them silently copied a jax or dask array to the host, and failed outright for an array on a non-default device. This adds ccdproc/_windowfilters.py, an implementation of the four window reductions ccdproc.core needs, written purely in terms of the array API standard:

helper replaces
window_rank ndimage.percentile_filter
window_median ndimage.median_filter
window_any ndimage.maximum_filter (on a boolean mask)
window_reduce ndimage.generic_filter

The approach is deliberately literal: the array is padded once — the standard has no pad, so reflect and nearest are built from flip/concat of edge slices — and each window offset is taken as a shifted slice, with the offsets stacked into a trailing axis, which turns a window reduction into an ordinary reduction over that axis. The rank filters reduce with a new _nanfuncs._nanrank, factored out of nanmedian's sentinel-sort machinery; that is why they reproduce ndimage's rank exactly, including the upper-middle element of an even window.

The stack holds prod(size) copies of the input — 3.8 GiB for an 11x11 window over a 2048x2048 float64 image — so _windowed processes the output in bands of rows sized to a 256 MiB module-constant budget. Bands are cut from the padded array with the window overhang included, so each output pixel sees exactly the window it would have seen unbanded; band_rows stays on the private helpers (a test pins banded == unbanded) and is not exposed on any public function.

Dispatch policy: the numpy path is unchanged

Four wrappers in core.py_dispatch_median_filter, _dispatch_percentile_filter, _dispatch_maximum_filter, _dispatch_generic_filter — send numpy input to scipy.ndimage exactly as before and everything else to _windowfilters. Concentrating the choice in one place per filter also leaves the seam #984 needs, where numpy input carrying a mask will start taking the native path too.

Call sites converted: _cosmicray_median_array's mbox median, gbox growth and rbox replacement; the public median_filter; background_deviation_filter (which gains an xp argument, matching background_deviation_box); and ccdmask's median and its two percentile filters. ccdmask's byblocks branch is untouched.

median_filter on a non-numpy array now accepts only size and mode. The argument list is bound against ndimage's own signature rather than duplicated, so a repeated or unknown argument still gets ndimage's error message, and footprint/origin/output/cval/axes raise TypeError naming the argument. The numpy path stays a verbatim passthrough.

cosmicray_median's CCDData branch now assigns through nccd._mask, as ccd_process already does: NDDataArray's mask setter runs the value through np.asarray, which was the real source of the "cosmicray_median numpy.asarray" escape the baseline attributed to ndimage.

Documented divergences from ndimage

  • Integer input is promoted to the namespace's default real floating dtype; ndimage keeps an integer dtype.
  • Only ndimage's reflect and nearest boundary modes exist; anything else raises rather than quietly behaving like reflect.
  • A window needing more padding than its axis holds raises, where ndimage re-reflects.
  • Cost is O(k2 log k2) per pixel for a k-by-k window, from a sort, against ndimage's O(k**2) selection.
  • NaN handling. The rank filters exclude NaNs from a window and take the rank among the values that remain; ndimage sorts NaNs in with the values, above every real number. The one caller this reaches is ccdmask, whose input is a flat ratio that may well contain NaN — it opens by masking the non-finite pixels — so a ratio with NaN in it can give a slightly different mask off numpy. Every other caller filters finite data. The exclusion is deliberate: it is what will let cosmicray_median keep masked pixels out of its median in PR-2. test_windowfilters.py::test_ccdmask_window_filters_exclude_nan_off_numpy pins it in both directions, against an explicit sliding_window_view rank reference off numpy and against ndimage on numpy, asserting first that the two references really disagree.

All of these are in the docs/array_api.rst limitations list.

Verified backends

Dev environment: numpy 2.1.0, scipy 1.14.0, astropy 7.1.0, jax 0.5.0 (JAX_ENABLE_X64=1), dask 2025.7.0, array-api-strict 2.5 (on the non-default device1).

Affected files = test_nanfuncs.py test_windowfilters.py test_blocks.py test_ccdmask.py test_cosmicray.py test_ccdproc.py.

backend affected files full pytest ccdproc
numpy 591 passed 1203 passed, 5 skipped
jax 586 passed, 5 skipped 1196 passed, 36 skipped
dask 586 passed, 5 skipped 1197 passed, 16 skipped (run with docs, under the escape ratchet)
array-api-strict 560 passed, 31 xfailed 1166 passed, 10 skipped, 32 xfailed

No failures and no XPASS on any backend. Against the pre-PR branch point, array-api-strict loses 11 xfails, all of them tests that only failed because they went through ndimage.

The escape-baseline ratchet was run the way CI's py312-alldeps-dask-enforce job runs it (CCDPROC_ARRAY_LIBRARY=dask CCDPROC_LOG_ARRAY_ESCAPES=1 CCDPROC_ENFORCE_ESCAPE_BASELINE=1 pytest ccdproc docs). Before the baseline edit it reported exactly three entries as "not hit this run" — _cosmicray_median_array, background_deviation_filter and cosmicray_median; after deleting them it reports OK: no library escapes outside the baseline.

Deviations from the agreed plan

  1. Banding is not skipped on dask. The plan said to skip it and let dask's chunking bound memory. That made the dask suite unusable: test_cosmicray_median_rbox took 202 s and test_background_deviation_filter 230 s. The cause is that slicing a chunked band into prod(size) differently-offset windows makes dask realign every one of them — a 21x21 window over a 100x100 image builds a graph of 585,149 tasks, against 912 when the band is first collapsed to a single chunk. So each band is now rechunk(-1)ed (guarded by is_dask_namespace), and banding stays on for dask, since it is the band budget that makes collapsing safe. The dask affected-file run went from 496 s to ~15 s.
  2. dask's PerformanceWarning about the chunk-count multiplication is suppressed inside _stack_from_padded, matched by message so dask need not be imported. That multiplication is the algorithm and no caller can act on it, and ccdproc's pytest configuration turns warnings into errors, so leaving it would fail every dask test touching a window filter. Documented in the module docstring.
  3. Test-side numpy-isms had to be fixed for the marker removals to mean anything. Dropping the ndimage backend_xfails exposed that add_cosmicrays built its rays by copying the image to numpy — impossible for an array on a non-default device — and that several assertions used .sum(), .mean(), .std() or np.array() on backend arrays. add_cosmicrays now blends a host-side overlay in with a single where; the assertions use xp.* or _to_numpy.
  4. One extra core.py fix: nccd.mask | crarr coerced the namespace array to numpy once crarr stopped being a numpy array, so the existing mask is brought into the data's namespace first.
  5. One extra marker pruned: test_cosmicray_lacosmic_detects_inconsistent_units began XPASSing on array-api-strict once add_cosmicrays worked (it raises before ever reaching astroscrappy), so its marker is gone, per the "prune on XPASS" note in docs/array_api.rst.
  6. The helpers are N-D rather than 2-D-only. The padding was already axis-generic, so generality cost nothing.

Not verified

  • The Sphinx docs build was not run locally. sphinx-astropy is not installed in the dev environment. The first CI run caught two nitpicky cross-references to the private _windowfilters module in public docstrings; those are now spelled as literals. docs/array_api.rst was checked with docutils (clean apart from Sphinx-only roles) and the .. _scipy.ndimage: link target the new bullets use was added.
  • CuPy. Not installed, so untested, as usual for this repo.
  • The XPASS-driven marker removals were confirmed only on macOS, not in CI — docs/array_api.rst asks for CI confirmation before deleting a marker, so that one is worth a second look on the CI logs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN

mwcraig and others added 4 commits September 7, 2026 15:27
astropy.nddata's block functions start with numpy.asanyarray, so on a
non-numpy array library they hand back a numpy array (dask, jax) or fail
outright when the data are on a device numpy cannot reach
(array-api-strict, cupy).

ccdproc/_blocks.py does the same work using only array API operations --
reshape, permute_dims, repeat and slicing -- so the result stays in the
caller's namespace and on the caller's device. block_size validation is
pure Python and reproduces astropy's three checks, in astropy's order,
with astropy's messages. Both functions are decorated with
astropy.nddata.support_nddata, as astropy's own are, so a CCDData
argument is unpacked and the "following attributes were set ... will be
ignored" warning is emitted identically.

The one deliberate divergence is dtype: block_replicate(conserve_sum=True)
promotes integer and boolean input to the namespace's default real
floating dtype before dividing, because array-api-strict rejects integer
true division rather than promoting. numpy returns float64 there anyway.

_nanfuncs._fill_doc gained a positional-only template argument so the new
module can reuse the docstring templating with its own parameter block.

Part of astropy#971.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
ccdproc.core.block_reduce/block_average/block_replicate now resolve the
array namespace first and keep calling astropy.nddata only when it is
numpy; every other namespace gets ccdproc._blocks. The numpy branch is
the code that was there before, so numpy results are unchanged, and the
CCDData rebuild and the ignored-attribute warning are untouched.
block_replicate gained the xp= argument the other two already had.

With the escape gone, drop the three block_* lines from the array-escape
baseline and the three array-api-strict xfail markers on the block tests.
Those tests then failed one step later on xp.zeros(..., dtype=bool),
which array-api-strict rejects; they now ask for xp.bool, as the rest of
the file does.

Part of astropy#971.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
numpy's mean promotes an integer array on its own, and jax and dask
follow it, but array-api-strict refuses a non-floating mean outright, so
an integer image averaged fine on three backends and raised on the
fourth. ccdproc._blocks.block_average now promotes integer and boolean
input to the namespace's default real floating dtype before reducing
with xp.mean, the same treatment block_replicate already had, and
core.block_average dispatches to it. The numpy path is unchanged and
still goes straight to astropy.nddata.

block_average has no astropy counterpart, so it lives here as ccdproc's
own thin wrapper; only block_reduce and block_replicate are candidates
for the upstream lift.

The docstring parameter template gained an empty-``extra`` form, since
block_average has no function-specific parameter between block_size and
xp.

Part of astropy#971.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
mwcraig added a commit to mwcraig/ccdproc that referenced this pull request Sep 7, 2026
[astropy#1007] was a guess made before the PR existed; it is astropy#1010.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.20%. Comparing base (9d18599) to head (aa71121).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1010      +/-   ##
==========================================
+ Coverage   97.97%   98.20%   +0.23%     
==========================================
  Files           9       11       +2     
  Lines        1927     2177     +250     
==========================================
+ Hits         1888     2138     +250     
  Misses         39       39              
Flag Coverage Δ
dask 97.19% <97.52%> (+<0.01%) ⬆️
jax 97.37% <97.52%> (+0.02%) ⬆️
numpy 97.38% <94.34%> (-0.50%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

mwcraig and others added 10 commits September 7, 2026 16:18
Codecov flagged two untested branches in astropy#1009: the early return in
_block_namespace when the caller passes xp, and the int() failure
branch in _block_size that turns a NaN or infinite block size into
astropy's "must be integers" error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
nanmedian's sentinel-sort machinery -- replace NaN with +inf, sort,
count the non-NaN entries, gather at a computed index -- is now two
helpers, _sorted_with_nan_last and _gather_at_index, so that a second
order statistic can reuse it.

_nanrank is that second statistic: the element at rank
min(floor(n * fraction), n - 1) among a slice's non-NaN values, which is
exactly the rank scipy.ndimage.percentile_filter uses (and, at
fraction=0.5, median_filter's size // 2 upper-middle element). It is what
the array-API-native window filters will be built on. nanmedian keeps its
averaging form and its behaviour is unchanged.

_gather_at_index prefers take_along_axis where the namespace has one
(numpy, jax, array-api-strict) instead of the where/sum gather nanmedian
used, which allocates a temporary the size of the array being gathered
from -- affordable for a combiner stack, not for a k**2-deep window
stack. array-api-compat's dask wrapper has no take_along_axis, so the
where/sum form stays as the fallback, and a zero-length axis takes it
too, having no in-range index to clamp to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
New private module ccdproc._windowfilters with the four window reductions
ccdproc.core takes from scipy.ndimage: window_rank (percentile_filter),
window_median (median_filter), window_any (maximum_filter on a boolean
mask) and window_reduce (generic_filter). Nothing calls them yet; the
call sites move in the next commit.

They are written only in terms of the array API: the array is padded once
-- there is no pad in the standard, so reflect and nearest are built from
flip/concat of edge slices -- and each window offset is taken as a shifted
slice, with the offsets stacked into a trailing axis so that a window
reduction becomes an ordinary reduction over that axis. The rank filters
reduce with _nanfuncs._nanrank, which is why they reproduce ndimage's
rank exactly, including the upper-middle element of an even window, and
why they exclude NaNs instead of sorting them in with the values.

The stack holds prod(size) copies of the input -- 3.8 GiB for an 11x11
window over a 2048x2048 float64 image -- so _windowed processes the
output in bands of rows, sized to a 256 MiB module-constant budget. The
bands are cut from the padded array with the window overhang included, so
each output pixel sees exactly the window it would have seen unbanded;
the tests pin that.

Two deliberate divergences from ndimage, both documented on the
functions: integer input is promoted to a floating dtype, and only the
'reflect' and 'nearest' boundary modes exist. A window needing more
padding than its axis holds raises rather than re-reflecting as ndimage
does.

dask needs two accommodations. Each band is collapsed into one chunk
before it is sliced: the window offsets each land differently across
chunk boundaries otherwise, and dask realigns them all -- a 21x21 window
over a 100x100 image builds 585,000 tasks that way against 912 this way,
a minute against a tenth of a second. And the PerformanceWarning about
the stack multiplying the chunk count is suppressed, since that
multiplication is the algorithm and no caller can act on it.

_fill_doc gains a template argument so this module can share the
mechanism with _nanfuncs without sharing its parameter text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
The four scipy.ndimage window-filter call sites now go through four
dispatch wrappers -- _dispatch_median_filter, _dispatch_percentile_filter,
_dispatch_maximum_filter and _dispatch_generic_filter -- which send numpy
input to ndimage exactly as before and everything else to
ccdproc._windowfilters. Concentrating the choice in one place per filter
also leaves the seam issue astropy#984 needs, where numpy input carrying a mask
will start taking the native path too.

Sites: _cosmicray_median_array's mbox median, gbox growth and rbox
replacement; the public median_filter; background_deviation_filter, which
gains an xp argument like background_deviation_box; and ccdmask's median
and its two percentile filters (the byblocks branch is untouched).

On a non-numpy array median_filter now accepts only size and mode. The
argument list is bound against ndimage's own signature rather than
duplicated, so a repeated or unknown argument still gets ndimage's error
message, and footprint/origin/output/cval/axes raise TypeError naming the
argument. The numpy path stays a verbatim passthrough.

cosmicray_median's CCDData branch assigns the output mask through
nccd._mask, as ccd_process does: NDDataArray's mask setter runs the value
through np.asarray, which is the real source of the "cosmicray_median
numpy.asarray" escape the baseline attributed to ndimage. The union with
an existing mask brings that mask into the data's namespace first, since
numpy_mask | foreign_array would coerce the other way.

Tests: the eight backend_xfail markers that blamed ndimage are gone, and
so is one on cosmicray_lacosmic that now XPASSes. Making them pass needed
three test-side fixes as well: add_cosmicrays built its rays by copying
the image to numpy, which a backend on a non-default device cannot do, so
it now blends a host-side overlay in with a single where; several
assertions used numpy array methods (.sum(), .mean(), .std()) or
np.array() on backend arrays; and test_ccdmask's monkeypatches now
replace the dispatchers rather than ccdproc.core.ndimage.

The three baseline escapes those call sites produced are deleted,
confirmed by the "not hit this run" report of a full dask run under
CCDPROC_ENFORCE_ESCAPE_BASELINE=1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
A CHANGES.rst New Features entry, and three limitations bullets in
docs/array_api.rst: the O(k**2 log k**2) sort-based cost and the k**2
window stack, the int-to-float promotion plus the size/mode-only
median_filter and two boundary modes, and the NaN handling -- excluded
from a window rather than sorted in with the values, which is where
ccdmask on a ratio containing non-finite pixels can give a slightly
different mask off numpy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
Pre-wrap the dtype sentence that _fill_doc substitutes into the shared
parameter template, which was rendering as one over-long line, and spell
the window cost as O(k**2 log k**2) rather than with a superscript, since
the rest of CHANGES.rst and docs/ are ASCII.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
The native rank filters exclude NaNs from a window; scipy.ndimage sorts
them in with the values, above every real number. ccdmask is the one
caller whose input routinely carries NaN -- it opens by masking the
non-finite pixels of the flat ratio -- so it is the one place the
divergence is reachable, and it was documented but untested.

The new test pushes a flat ratio carrying an isolated 0/0, a dead block
wider than half the median window, and a divide-by-zero of each sign
through the two dispatchers ccdmask calls, at ccdmask's own default
window shapes. Off numpy the result is checked against an explicit
sliding_window_view rank reference, since ndimage cannot produce that
ordering; on numpy against ndimage. The two references are asserted to
disagree first, so the test cannot pass whichever way the dispatch went.

The docs bullet now says which way each implementation orders NaNs and
what that does to the answer, rather than only that they differ, and no
longer implies infinities are part of it -- both treat those as ordinary
large values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
[astropy#1007] was a guess made before the PR existed; it is astropy#1010.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
… docstrings

Sphinx runs nitpicky and cannot resolve cross-references to functions that
are not in the API docs, so the docs build failed on the two single-backtick
references in background_deviation_filter and median_filter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
… cases

Codecov flagged that nothing exercised ccdproc.median_filter on a
non-numpy array: test_wrapped_external_funcs only hands it numpy, so
the argument screening and the window_median call in
_median_filter_array were untested end to end. Also cover the
'nearest' padding of an empty axis, the itemsize fallback for a dtype
the band estimate does not know, and a zero-width image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6f9L1GyMHrKnvLrrgWbWN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant