Conversation
[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 98.15% 98.31% +0.15%
==========================================
Files 10 11 +1
Lines 2007 2191 +184
==========================================
+ Hits 1970 2154 +184
Misses 37 37
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:
|
[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
d7b544a to
aa71121
Compare
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
The rebase resolved this file by content -- the three scipy.ndimage entries this branch removes are gone, and the ones astropy#1009 removed stay gone -- but the column widths were still those of the pre-rebase file, sized for the longest function name at the time. Regenerating it the documented way (CCDPROC_WRITE_ESCAPE_BASELINE=1 with the dask backend over a full suite run) re-pads the columns to the four entries that are actually left, so the next refresh produces no spurious diff. No entry is added, removed or retagged by this commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8AJaygzvm8VuDf7Sq5HSE
astropy#1009's review added the array-like guard in ``core._block_dispatch``: resolve the namespace, and if no array library claims the input, fall back to numpy rather than letting ``array_namespace`` raise -- but re-raise when the input does advertise ``__array_namespace__``, so a genuine namespace failure is not hidden behind a numpy coercion. ``_median_filter_array``, added on this branch before astropy#1009 merged, resolves its namespace with a bare ``array_namespace`` and so inherits neither half. That regressed ``ccdproc.median_filter``, which documents itself as a passthrough for ``scipy.ndimage.median_filter`` and took any array-like on every previous release: a nested list now raised ``TypeError: list is not a supported array type`` instead of being filtered. Adopting the same guard sends lists and tuples back to ndimage, exactly as before, and leaves every array input untouched. Tests: a nested list and a tuple of lists go to ndimage on every backend, and an object whose ``__array_namespace__`` lookup fails still raises rather than being quietly coerced (ndimage does not raise for one, so the guard is what the test pins). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8AJaygzvm8VuDf7Sq5HSE
aa71121 to
9e0b027
Compare
|
Rebased onto Four files conflicted:
One extra commit, Two things I deliberately left alone, both yours to call:
tox, all green, on the rebased head:
— Written by Claude at @mwcraig's direction. |
There was a problem hiding this comment.
🟡 Changes recommended
Scalar inputs fail and low-precision rank arithmetic can select incorrect window values.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds Array-API-native window filters while preserving existing SciPy behavior for NumPy inputs.
Changes:
- Implements banded median, percentile, boolean-any, and generic window reductions.
- Dispatches affected
coreoperations by array namespace. - Expands backend tests, documentation, and escape-baseline coverage.
File summaries
| File | Description |
|---|---|
ccdproc/_windowfilters.py |
Adds native window-filter implementations. |
ccdproc/_nanfuncs.py |
Adds reusable NaN-aware rank selection. |
ccdproc/core.py |
Dispatches filters between SciPy and native implementations. |
ccdproc/tests/test_windowfilters.py |
Tests filtering, padding, banding, and dispatch. |
ccdproc/tests/test_nanfuncs.py |
Tests NaN-aware rank behavior. |
ccdproc/tests/test_cosmicray.py |
Enables backend-neutral cosmic-ray tests. |
ccdproc/tests/test_ccdproc.py |
Removes obsolete backend exclusion. |
ccdproc/tests/test_ccdmask.py |
Tests dispatcher integration. |
ccdproc/tests/array_escape_baseline.txt |
Removes resolved NumPy escapes. |
docs/array_api.rst |
Documents behavior and limitations. |
CHANGES.rst |
Records the new functionality. |
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # ``fraction`` is applied in the dtype of ``x`` rather than to the | ||
| # integer count so that the truncation matches ndimage's, which also | ||
| # multiplies in floating point. Clamping to ``n - 1`` is what keeps | ||
| # ``fraction = 1`` selecting the maximum instead of running off the end | ||
| # of the non-NaN values; ndimage raises there instead. | ||
| index = xp.astype(xp.astype(n, s.dtype) * fraction, n.dtype) | ||
| index = xp.minimum(index, n - 1) |
There was a problem hiding this comment.
Half right, and the half that is wrong matters for how it gets fixed.
Wrong: it cannot "stop matching ndimage", because ndimage never takes 2-D float16 at all:
ndimage.median_filter(x16, size=(45,47)) -> RuntimeError: array type not supported
Right: the arithmetic really is done in s.dtype, so float16 breaks the rank this module documents (int(prod(size) * percentile / 100)). np.float16(2115) is 2116.0, so the median index is 1058 instead of 1057. Against the same call on float64-promoted input:
window_median(x16, (45,47)) vs float64 reference: 2319 / 3600 pixels differ
float32 is safe for any real window (exact integers to 2**24, i.e. k up to 4096), so float16 is the only exposure.
The fix is the same one as the off-by-one at _nanfuncs.py:585: compute the index in xp.float64, not s.dtype, and multiply before dividing, which is what scipy does (rank = int(float(filter_size) * percentile / 100.0)):
index = xp.astype(xp.astype(n, xp.float64) * percentile / 100, n.dtype)A float16 regression test is worth adding, but as "matches the float64-promoted result", not "matches ndimage".
— Written by Claude at @mwcraig's direction.
| shape = x.shape | ||
| padded = _pad_windows(x, size, mode, xp) | ||
| dask = array_api_compat.is_dask_namespace(xp) | ||
|
|
||
| if band_rows is None: | ||
| band_rows = _default_band_rows(x, size, xp) | ||
|
|
||
| if band_rows >= shape[0]: |
There was a problem hiding this comment.
Valid, and the suggested shape of the fix is right too.
window_median(np.asarray(1.0), 1) -> IndexError: tuple index out of range
ndimage.median_filter(np.asarray(1.0), size=1) -> array(1.)
window_median(np.arange(5.0), 3) -> OK (1-D is fine)
The IndexError comes from _default_band_rows's x.shape[0], and _windowed would hit the same thing two lines later. Everything below that does work for 0-D — I checked by hand: _normalize_size(1, 0) is (), _pad_windows is a no-op, _stack_from_padded gives shape (1,) and _nanrank(..., -1) returns 3.0.
So one guard in _windowed is enough:
if x.ndim == 0 or band_rows >= shape[0]:
return reduction(_stack_from_padded(_collapse(padded, dask), size, shape, xp))plus an early return 1 in _default_band_rows for x.ndim == 0. The alternative is to narrow the docstring from "Input array of any rank" to "rank at least 1" — no ccdproc.core caller passes 0-D. Either is fine; the guard is two lines, so I'd do that.
— Written by Claude at @mwcraig's direction.
| values to both. The one `ccdproc`_ function 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 on a non-`numpy`_ array library than on | ||
| `numpy`_. Every other caller filters finite data. (The exclusion is |
There was a problem hiding this comment.
Valid for median_filter; not demonstrated for cosmicray_median.
median_filter is a public entry point with no finiteness check, and the divergence is easy to hit:
x = np.arange(25.0).reshape(5, 5); x[1, 1] = x[2, 2] = np.nan
ccdproc.median_filter(x, 3) # ndimage
ccdproc.median_filter(xps.asarray(x), 3) # window_median
# -> 13 of 25 pixels differSo the sentence "The one ccdproc function this reaches is ccdmask" is wrong as written and should name median_filter too.
cosmicray_median is a weaker case: it also does no finiteness check, but a NaN in data makes rarr NaN, crarr False there on both backends, and with rbox=0 nothing propagates — a 20x20 frame with one NaN gave identical crmask on numpy and strict. It can still differ through rbox > 0, so "every other caller filters finite data" overstates it; "every other ccdproc caller is normally given finite data" would be accurate.
background_deviation_filter is genuinely unaffected — sigma_func is not an order statistic.
— Written by Claude at @mwcraig's direction.
mwcraig
left a comment
There was a problem hiding this comment.
Adversarial review of 9e0b027, focused on science/correctness bugs and on complexity/redundancy/LOC. Two subagent passes, with the top findings run empirically in .tox/strict.
Fix first (both invisible to the current tests):
- Percentile rank is off by one whenever
percentile/100is not exactly representable (_windowfilters.py:640,_nanfuncs.py:585). Breaks the "reproduces scipy.ndimage exactly" contract for e.g. 29.0 or 57.0 at size 10; the test grid and its_rank_referenceshare the same regrouping so they cannot see it. cosmicray_medianon integer images now mixes int data with a float median off numpy (core.py:2828,2849): strict raises, jax/dask silently change the output dtype.
Other correctness: _normalize_size rejects numpy ints that ndimage accepts; background_deviation_filter no longer takes plain lists; complex input is silently truncated instead of raising; dask unknown-shape gives an opaque range() error; dask PerformanceWarning is matched by message text; _median_filter_array re-zips args in the wrong positional order. Plus two performance items (window_any builds the full k^2 stack for a running OR; isnan computed twice in _sorted_with_nan_last).
LOC: _windowfilters.py is 755 lines with roughly 158 executable; the _dispatch_* block in core.py is 110 lines holding 8. The inline comments identify about 330 removable lines with no behaviour change, ranked by lines saved over risk: dispatch docstrings (-90), merging the two pad helpers (-50), deleting _window_stack (-43), generalising _fill_doc instead of copying it (-35), _ITEMSIZES (-40, the one item with a tiny observable effect), test dedup (-64), the shared preamble in the three public filters (-18), _collapse (-22), doc repetition (-27).
Details on each line.
|
|
||
| # ndimage's rank is int(size * percentile / 100); _nanrank takes the | ||
| # same product against the count of non-NaN values in each window. | ||
| fraction = percentile / 100 |
There was a problem hiding this comment.
Science bug: percentile rank off by one. fraction = percentile / 100 loses precision before _nanrank multiplies by n. scipy.ndimage computes n * percentile / 100; this computes n * (percentile / 100). For a 10x10 window (n=100), int(100*29/100.0) is 29 but int(100*(29/100.0)) is 28. Verified in .tox/strict: window_rank(x, 10, 29.0) vs ndimage.percentile_filter(x, 29.0, size=10) on a 24x24 float64 image differs at 432 of 576 pixels (max abs diff 0.16). Same for 57.0 at sizes 10, 20, 100. ccdproc's own 30.9/69.1/50 happen to be safe at realistic sizes, and _PERCENTILES in the tests only covers those, so the grid is blind. Any ccdmask call with non-default ncsig/nlsig gets a backend-dependent result.
Fix: pass percentile through to _nanrank and divide by 100 after multiplying by n.
There was a problem hiding this comment.
Confirmed, with one correction to the scope.
scipy does the multiply first, in C double (_filters.py, _rank_filter):
rank = int(float(filter_size) * percentile / 100.0)Reproduced against ndimage.percentile_filter on a 24x24 float64 image:
pct=29.0 size=10 n=100 int(n*p/100)=29 int(n*(p/100))=28 diff 432/576 max 0.16
pct=57.0 size=10 n=100 ->57 vs 56 diff 443/576 max 0.17
pct=57.0 size=20 n=400 ->228 vs 227 diff 360/576 max 0.05
pct=30.9/69.1/50 size=7 diff 0/576
Correction: "Any ccdmask call with non-default ncsig/nlsig gets a backend-dependent result" is too strong. For the two percentiles ccdmask actually uses, I scanned every (nlsig, ncsig) in 2..63 — 3844 box shapes — and only n = 3000 (50x60 and 60x50) diverges. So ccdmask is close to safe by luck; the bug is in window_rank as a general filter, which is reason enough.
Fix as you describe, and take the dtype part of Copilot's _nanfuncs.py:586 note at the same time — see my reply at _nanfuncs.py:585.
— Written by Claude at @mwcraig's direction.
| # multiplies in floating point. Clamping to ``n - 1`` is what keeps | ||
| # ``fraction = 1`` selecting the maximum instead of running off the end | ||
| # of the non-NaN values; ndimage raises there instead. | ||
| index = xp.astype(xp.astype(n, s.dtype) * fraction, n.dtype) |
There was a problem hiding this comment.
This is where the regrouping bites (see comment at _windowfilters.py:640). Taking percentile here and computing xp.astype(n, s.dtype) * percentile / 100 reproduces ndimage exactly, which is what the docstring promises.
There was a problem hiding this comment.
Agreed, with one amendment: don't compute it in s.dtype.
Multiplying first fixes the percentile / 100 truncation, but s.dtype is the image dtype, and for float16 the count itself is already wrong before any multiplication — np.float16(2115) is 2116.0 (that is Copilot's point at line 586, which is otherwise mis-stated; ndimage refuses 2-D float16 outright, so there is no parity to lose).
One change covers both:
def _nanrank(x, percentile, axis, xp):
...
# ndimage computes int(float(size) * percentile / 100.0) in C double:
# multiply before dividing, and never in the image dtype.
index = xp.astype(xp.astype(n, xp.float64) * percentile / 100, n.dtype)
index = xp.minimum(index, n - 1)with window_rank passing percentile straight through instead of fraction = percentile / 100. float64 is mandated by the standard, so every namespace has it; jax without jax_enable_x64 silently lands on float32, which I measured as differing from float64 in 255 of 2,004,002 (n, percentile) pairs — against the current code, which is systematically wrong.
The docstring's fraction parameter becomes percentile in [0, 100], and _rank_reference in the tests has to be regrouped the same way or it will keep hiding this.
— Written by Claude at @mwcraig's direction.
| marr = _dispatch_median_filter(data, (mbox, mbox), xp=xp) | ||
|
|
||
| # Find the residual image | ||
| rarr = (data - marr) / error_image |
There was a problem hiding this comment.
Science bug: integer images. ndimage.median_filter preserves int dtype, but window_median promotes to float via _promote_to_real. So off numpy, data - marr mixes int and float: array-api-strict raises TypeError: array_api_strict.int64 and array_api_strict.float64 cannot be type promoted together (confirmed), and jax/dask silently return float ndata where numpy returns int. Raw CCD frames from FITS are commonly int16/uint16, so this is the normal case, not an edge case. The comment above ("make sure that mdata is the same type as data") is now false.
Fix at the right depth: promote data once at the top of _cosmicray_median_array (as _blocks._promote_for_division does), or have _dispatch_median_filter cast the native result back to the input dtype.
There was a problem hiding this comment.
Confirmed. _cosmicray_median_array(int16_image, ...):
numpy int16 -> OK, ndata.dtype = int16
strict int16 -> TypeError: array_api_strict.int16 and array_api_strict.float64
cannot be type promoted together
dask int16 -> OK, ndata.dtype = float64 (line 2849, rbox > 0)
and the same through the public cosmicray_median. So three backends, three answers: raise, int16, float64.
Of the two fixes you suggest, promoting once at the top of _cosmicray_median_array is the better one:
data = _blocks._promote_for_division(data, xp)rarr = (data - marr) / error_image is a true division, so data has to be floating anyway for array-api-strict; casting marr back to int instead would only move the TypeError to that line. It does change the public return dtype for integer input from int16 to float64 on numpy — a behaviour change, but the one that makes every backend agree, and it needs a CHANGES.rst line.
The stale comment at 2848 ("make sure that mdata is the same type as data") should go with it.
— Written by Claude at @mwcraig's direction.
| if rbox > 0: | ||
| # make sure that mdata is the same type as data | ||
| mdata = xp.asarray(ndimage.median_filter(data, rbox)) | ||
| mdata = _dispatch_median_filter(data, rbox, xp=xp) |
There was a problem hiding this comment.
Same mixed-kind problem as line 2828: xp.where(crarr, mdata, data) with float mdata and int data.
There was a problem hiding this comment.
Confirmed, and this is the line where the backends actually produce different values rather than an error. With rbox > 0:
numpy int16 -> ndata.dtype = int16
dask int16 -> ndata.dtype = float64
Line 2828 raises first on array-api-strict, so on that backend this line is only reachable once 2828 is fixed — which is why both should be fixed together, by promoting data once at the top of _cosmicray_median_array (see my reply at 2828). That makes xp.where(crarr, mdata, data) same-kind on every backend and leaves this line untouched.
— Written by Claude at @mwcraig's direction.
| f"size must have one entry per axis: got {len(sizes)} for an " | ||
| f"array with {ndim} axes" | ||
| ) | ||
| if any(not isinstance(k, int) or k < 1 for k in sizes): |
There was a problem hiding this comment.
isinstance(size, int) rejects numpy integers, which scipy.ndimage accepts. Verified: window_median(x, np.int64(3)) raises TypeError: 'numpy.int64' object is not iterable (falls through to tuple(size) at line 184), and window_median(x, (np.int64(3), np.int64(3))) raises ValueError: size entries must be positive integers, while ndimage.median_filter(x, size=np.int64(3)) succeeds. So ccdmask(ratio, ncmed=np.int64(7)) works on numpy and dies on jax/dask/strict.
_blocks._block_size (same PR stack) already handles this correctly (try: list(block_size) plus float(size).is_integer()). Reuse it or factor one shared helper rather than a second, stricter validator.
There was a problem hiding this comment.
Confirmed, including the ccdmask consequence:
window_median(x, np.int64(3)) -> TypeError: 'numpy.int64' object is not iterable
window_median(x, (np.int64(3),) * 2) -> ValueError: size entries must be positive integers
ndimage.median_filter(x, np.int64(3)) -> OK
ccdmask(CCDData(numpy), ncmed=np.int64(7)) -> OK
ccdmask(CCDData(strict), ncmed=np.int64(7)) -> ValueError: size entries must be positive integers
_blocks._block_size(np.int64(3), 2) returns (3, 3) and _block_size(3.0, 2) returns (3, 3), so that helper is already correct.
One caveat on "reuse it": the two validators are not interchangeable. _block_size broadcasts a length-1 sequence to ndim and accepts integral floats, both to match astropy.nddata; ndimage accepts np.int64(3) but rejects 3.0 (TypeError: 'float' object cannot be interpreted as an integer), and _normalize_size has to keep rejecting 3.0 to stay ndimage-compatible. So share the scalar-vs-sequence detection, not the whole function:
try:
sizes = tuple(size)
except TypeError:
sizes = (size,) * ndim
if any(not isinstance(k, numbers.Integral) or k < 1 for k in sizes):
raise ValueError(...)numbers.Integral covers int, numpy integers and anything else that registers, and still rejects 3.0.
— Written by Claude at @mwcraig's direction.
| assert bool(xp.all(xpx.isclose(result, expected, equal_nan=True))) | ||
|
|
||
|
|
||
| def _rank_reference(data, size, percentile, mode="reflect"): |
There was a problem hiding this comment.
Test mirrors the implementation. This _rank_reference (and the one in test_nanfuncs.py:197) re-derives _nanrank step for step, including the n * fraction regrouping, which is exactly why the off-by-one at _windowfilters.py:640 slipped through. A genuinely independent reference that borrows ndimage's own padding is 7 lines:
def _rank_reference(data, size, percentile, mode="reflect"):
def pick(w):
v = np.sort(w[~np.isnan(w)])
return np.nan if v.size == 0 else v[min(int(v.size * percentile / 100), v.size - 1)]
return ndimage.generic_filter(data, pick, size=size, mode=mode)It also retires _NUMPY_PAD_MODES and the sliding_window_view import. Please also add a percentile like 29.0 or 57.0 at size 10 to _PERCENTILES so the grid covers the regrouping case.
There was a problem hiding this comment.
Agreed, and the proposed reference checks out. I ran it:
- Against ndimage on finite input over the full
_SIZESx_MODESgrid at percentiles 0, 29, 30.9, 50, 57, 69.1, 100 — exact everywhere (withmaximum_filteras the comparison at 100, as the existing test already does). - Against the current
_rank_referenceon the NaN image fromtest_nan_windows_match_an_explicit_rank_referenceand on the inf-carrying flat ratio from_flat_ratio_with_non_finite_pixels(used at line 483) — identical, so it is a drop-in for both call sites.~np.isnankeeps the infinities, as the current one does. - With
percentile / 100hoisted out ofpick, i.e. the bug at_windowfilters.py:640transplanted into the reference, it differs at 434/576 pixels for 29.0 at size 10. So this reference would have caught it and the current one structurally cannot. - Cost is negligible: the nine reference calls in the NaN test take 0.01 s, and the 31x29 / 7x7 ccdmask case 0.02 s.
Agreed on adding 29.0 at size 10 to the grid too.
One correction on test_nanfuncs.py:197. That reference is not at fault in the same way: _nanrank's signature takes fraction, and min(floor(n * fraction), n - 1) is its documented contract, so mirroring it there is mirroring the spec, not the implementation. The regrouping bug lives entirely in window_rank's decision to divide first. If _nanrank grows a percentile argument per your comment at _nanfuncs.py:585, then that reference must change to np.trunc(n * percentile / 100) in that order — and there is no independent oracle available for it (ndimage has no NaN-aware reduction along an axis), so the honest fix there is just to write the arithmetic in ndimage's grouping and say why.
— Written by Claude at @mwcraig's direction.
| return np.where(n == 0, np.nan, picked[..., 0]) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("mode", _MODES) |
There was a problem hiding this comment.
Redundant grid. window_median is a one-line delegate to window_rank(..., 50.0, ...), and test_window_rank_matches_ndimage already parametrises 50.0 over the same _SIZES x _MODES, so these 14 cases are a strict subset of the 70-case rank grid. Keep one median-vs-ndimage case for the public name and drop the rest.
There was a problem hiding this comment.
Mostly right, but the subset claim needs one fact checked first, and it holds: I compared ndimage.median_filter with ndimage.percentile_filter(..., 50.0) over the whole _SIZES x _MODES grid and they are bit-identical, including the even sizes 4 and (4, 2) where the conventions could plausibly have differed. So with window_median a one-line delegate to window_rank(..., 50.0, ...), the 14 median cases really are covered by the 70-case rank grid.
Two things the median test still does carry, both cheap to keep:
- It exercises the public delegate. If
window_medianever passed0.5instead of50.0, the rank grid would not notice. - It compares against
median_filterrather thanpercentile_filter, so it is the only thing pinning the scipy invariant the delegation rests on.
So keep two cases rather than one — an odd size and an even one, @pytest.mark.parametrize("size", [3, 4], ids=str) at the default mode — and drop the other 12. About -12 cases for no loss of coverage.
— Written by Claude at @mwcraig's direction.
| _NUMPY_PAD_MODES = {"reflect": "symmetric", "nearest": "edge"} | ||
|
|
||
|
|
||
| def _as_test_array(data): |
There was a problem hiding this comment.
Helpers duplicated from test_blocks.py. _as_test_array/_assert_matches have the same bodies as _to_xp/_assert_matches in test_blocks.py:161-178, and test_blocks' _assert_same_namespace_and_device is open-coded here as a whole test (263-279). Move the three into pytest_fixtures.py (or conftest) and import them in both modules. Also test_result_stays_in_the_input_namespace_and_device (254) and test_integer_input_is_promoted_to_a_real_floating_dtype (282) repeat almost the same four-lambda call list; one module-level _CALLS and one merged test asserting namespace, device and dtype in a single pass would do. About -34 lines across these.
There was a problem hiding this comment.
Three claims here, and they do not all hold up.
_as_test_array vs _to_xp — yes, identical two-line bodies. Sharing saves two lines and costs an import; worth doing only as a side effect of moving the third helper.
_assert_matches — no, the bodies differ, and materially. test_blocks' version asserts result.dtype == expected.dtype and calls xpx.isclose(result, expected); this one casts the reference to float, makes no dtype assertion, and passes equal_nan=True. Both differences are load-bearing here: these filters deliberately promote integer input (so dtype equality with a numpy reference is wrong), and test_window_of_only_nan_is_nan and the whole NaN grid depend on equal_nan. Merging them means a helper with two flags, which is not a saving.
_assert_same_namespace_and_device — agreed, that one is genuinely open-coded at 276-279.
On merging the two parametrised tests: the lists are four lambdas and three, and window_any is excluded from the dtype test deliberately, because it returns bool rather than a real floating dtype. One list with the expected dtype kind attached fixes that and is better than either test alone, since window_any's dtype stops being unpinned:
_CALLS = [
pytest.param(lambda d: window_median(d, 3), "real floating", id="window_median"),
pytest.param(lambda d: window_rank(d, 3, 69.1), "real floating", id="window_rank"),
pytest.param(lambda d: window_any(d, 3), "bool", id="window_any"),
pytest.param(lambda d: window_reduce(d, 3, xp.std), "real floating", id="window_reduce"),
]fed integer input, asserting namespace, device and xp.isdtype(result.dtype, kind) in one pass.
Realistic total is about -20, not -34: the shared helper is worth ~5 and the merged test ~15.
— Written by Claude at @mwcraig's direction.
| namespace, so that `ccdproc.core` can keep calling ndimage for numpy input | ||
| and use these everywhere else. | ||
|
|
||
| The implementation is deliberately literal: the array is padded once, then |
There was a problem hiding this comment.
Docs repetition. The stack-and-reduce/banding rationale and the dask chunk-count warning are explained here, again at greater length in _windowed's Notes (541-559), pointed to from _collapse's Notes, and restated in the comment at 375-381. Keep the full explanation once in _windowed and cut this to the first paragraph plus a pointer. Similarly the NaN divergence is written out in window_rank Notes, window_median Notes, the core.py banner, median_filter Notes, background_deviation_filter Notes, CHANGES, and a 17-line docs bullet; window_median already says "see there", so its copy can go.
There was a problem hiding this comment.
Half of this holds; the NaN half does not survive a grep.
The banding/warning half — agreed, with a correction on where it lands. The module docstring's paragraph at 28-34 and the comment at 375-381 are the same seven-line explanation of the dask PerformanceWarning twice over, and the suppression is in _stack_from_padded, not _windowed, so the pointer has to go there. Cut 28-34 to one sentence ("one dask PerformanceWarning is deliberately swallowed in _stack_from_padded; see there") and cut the banding sentence out of 14-24, leaving the stack-and-reduce rationale and the cost, with _windowed keeping the full banding argument. About -10.
The NaN half — the repetition is not where the comment says. I grepped NaN across core.py: the only hits in the two public docstrings are at 472-473, 498, 1629-1630 and 1649-1654, which are nanmedian/sigma_func semantics. Neither median_filter's Notes (2201-2209) nor background_deviation_filter's (2803-2809) mentions the divergence at all — they cover dtype promotion and the mode restriction only. And window_median's Notes is already nothing but a pointer ("Exactly window_rank at percentile=50; see there for how NaNs are treated..."), so there is no copy there to delete.
So the real state is: one full statement in window_rank, a two-line summary in the core.py banner (which is where a reader of the dispatch code needs it), a clause in CHANGES.rst:46, and the docs bullet. That is not over-documented — and per Copilot's comment at docs/array_api.rst:230, median_filter and cosmicray_median reach this too, so those two public Notes arguably need a sentence added rather than removed.
— Written by Claude at @mwcraig's direction.
| floating dtype; `scipy.ndimage`_ keeps an integer dtype. Only | ||
| ndimage's ``'reflect'`` and ``'nearest'`` boundary modes are | ||
| implemented, and on a non-`numpy`_ array ``median_filter`` accepts only | ||
| ``size`` and ``mode`` -- ``footprint``, ``origin``, ``output``, ``cval`` |
There was a problem hiding this comment.
Docs bloat. This 17-line bullet re-argues the NaN divergence from scratch. The user-visible fact fits in two sentences: the median/percentile window filters exclude NaNs from a window and rank among the remaining values, where scipy.ndimage sorts NaNs in with them, so a flat ratio containing NaN can give ccdmask a slightly different mask off numpy; every other caller filters finite data first. Also worth adding here that dask arrays need a fully known shape for the window filters, not just the block functions.
There was a problem hiding this comment.
Agreed — the bullet runs 214-232, 19 lines, and a reader of a "what differs between array libraries" page needs the fact and the consequence, not the derivation. Draft:
+ The window filters that take an order statistic -- the median and the
percentile -- **exclude NaNs from a window** and rank among the values
that remain, where `scipy.ndimage`_ **sorts NaNs in with them**, above
every real number. Results on data containing NaN therefore differ
between `numpy`_ and every other array library: ``median_filter`` and
``cosmicray_median`` accept such data, and ``ccdmask``'s flat ratio
routinely contains it, so a ratio with NaN in it can give a slightly
different mask off `numpy`_. Infinities are ordinary values to both.
These filters also need a fully known shape, so a `dask`_ array with
unknown chunk sizes must have ``compute_chunk_sizes()`` called on it
first.11 lines, so about -8, and it folds in the dask known-shape note. Two deliberate changes beyond shortening: the "(The exclusion is deliberate: it is what will let cosmicray_median keep masked pixels out of its median.)" parenthesis goes — that is a note to ourselves about #984, not something a user can act on — and "The one ccdproc_ function this reaches is ccdmask" is replaced, because it is not true: Copilot's comment at line 230 is right that the public median_filter takes arbitrary user arrays with no finiteness check, and cosmicray_median does not enforce finite input either.
— Written by Claude at @mwcraig's direction.
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