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
Open
Array-API-native k×k window filters for cosmicray_median, median_filter, background_deviation_filter and ccdmask#1010mwcraig wants to merge 14 commits into
mwcraig wants to merge 14 commits into
Conversation
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
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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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
mwcraig
force-pushed
the
window-filters-native
branch
from
September 7, 2026 21:20
d7b544a to
aa71121
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 addsccdproc/_windowfilters.py, an implementation of the four window reductionsccdproc.coreneeds, written purely in terms of the array API standard:window_rankndimage.percentile_filterwindow_medianndimage.median_filterwindow_anyndimage.maximum_filter(on a boolean mask)window_reducendimage.generic_filterThe approach is deliberately literal: the array is padded once — the standard has no
pad, soreflectandnearestare built fromflip/concatof 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 ofnanmedian'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_windowedprocesses 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_rowsstays 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 toscipy.ndimageexactly 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'smboxmedian,gboxgrowth andrboxreplacement; the publicmedian_filter;background_deviation_filter(which gains anxpargument, matchingbackground_deviation_box); andccdmask's median and its two percentile filters.ccdmask'sbyblocksbranch is untouched.median_filteron a non-numpy array now accepts onlysizeandmode. 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, andfootprint/origin/output/cval/axesraiseTypeErrornaming the argument. The numpy path stays a verbatim passthrough.cosmicray_median's CCDData branch now assigns throughnccd._mask, asccd_processalready does:NDDataArray's mask setter runs the value throughnp.asarray, which was the real source of the "cosmicray_median numpy.asarray" escape the baseline attributed to ndimage.Documented divergences from ndimage
reflectandnearestboundary modes exist; anything else raises rather than quietly behaving likereflect.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 letcosmicray_mediankeep masked pixels out of its median in PR-2.test_windowfilters.py::test_ccdmask_window_filters_exclude_nan_off_numpypins it in both directions, against an explicitsliding_window_viewrank 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.rstlimitations 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-defaultdevice1).Affected files =
test_nanfuncs.py test_windowfilters.py test_blocks.py test_ccdmask.py test_cosmicray.py test_ccdproc.py.pytest ccdprocdocs, under the escape ratchet)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-enforcejob 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_filterandcosmicray_median; after deleting them it reportsOK: no library escapes outside the baseline.Deviations from the agreed plan
test_cosmicray_median_rboxtook 202 s andtest_background_deviation_filter230 s. The cause is that slicing a chunked band intoprod(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 nowrechunk(-1)ed (guarded byis_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.PerformanceWarningabout 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.backend_xfails exposed thatadd_cosmicraysbuilt 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()ornp.array()on backend arrays.add_cosmicraysnow blends a host-side overlay in with a singlewhere; the assertions usexp.*or_to_numpy.core.pyfix:nccd.mask | crarrcoerced the namespace array to numpy oncecrarrstopped being a numpy array, so the existing mask is brought into the data's namespace first.test_cosmicray_lacosmic_detects_inconsistent_unitsbegan XPASSing on array-api-strict onceadd_cosmicraysworked (it raises before ever reaching astroscrappy), so its marker is gone, per the "prune on XPASS" note indocs/array_api.rst.Not verified
sphinx-astropyis not installed in the dev environment. The first CI run caught two nitpicky cross-references to the private_windowfiltersmodule in public docstrings; those are now spelled as literals.docs/array_api.rstwas checked withdocutils(clean apart from Sphinx-only roles) and the.. _scipy.ndimage:link target the new bullets use was added.docs/array_api.rstasks 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