Array library options in ccdproc#
Note
Who needs this? If you are currently using numpy for your image processing
there is no need to change anything about what you currently do. The changes
made in ccdproc to adopt the array API were made with the intent of
requiring no change to existing code that continues to use numpy.
What is the “Array API”?#
The Python array API specifies an interface that has been adopted by many different array libraries (e.g. jax, dask, CuPy). The API is very similar to the familiar numpy interface. The array API was constructed to allow users with specialized needs to use any of the large variety of array options available in Python.
What array libraries are supported?#
The best list of array libraries that implement the array API is at array-api-compat. ccdproc is currently regularly tested against numpy, dask, and jax. It is occasionally tested against CuPy; any errors you encounter running ccdproc on a GPU using CuPy should be reported as an issue.
Though the sparse array library supports the array API, ccdproc does not currently work with sparse. A pull request to add support for sparse would be a welcome contribution to the project.
For development purposes, ccdproc’s test suite can also be run against
array-api-strict, a thin wrapper around numpy that strictly enforces
the array API, rejecting any usage outside the standard, and simulates
multiple devices. This
is set with the environment variable CCDPROC_ARRAY_LIBRARY=array-api-strict
(array_api_strict is also accepted). By default the test suite creates
arrays on one of array-api-strict’s non-default devices, which causes
numpy.asarray to raise an error, the same way it would on an array still
resident on a CuPy GPU device. This makes array-api-strict a convenient
CPU-only proxy for catching places where ccdproc silently (and
incorrectly) converts a non-numpy array back to numpy. The device used can be
overridden with the CCDPROC_ARRAY_DEVICE environment variable (its value
is passed to array_api_strict.Device); set it to default to use the
library’s normal CPU device instead.
A few more developer tools help triage failures on non-numpy backends:
Setting
CCDPROC_TRIAGE_ESCAPES=1prints a summary at the end of the test session that groups failures by “escape site” – the innermost frame inside ccdproc (but outside its test suite) in each failure’s traceback – so a large batch of backend failures collapses to a short list of root-cause call sites.Setting
CCDPROC_LOG_ARRAY_ESCAPES=1logs a warning whenever a non-numpy array-API array is passed tonumpy.asarray,numpy.asanyarrayornumpy.ma.asanyarray. This catches backends like dask and jax where the conversion succeeds silently and the test passes anyway. Because the messages go through Python’sloggingand pytest only shows captured logs for failing tests, run with-o log_cli=trueto see escapes from passing tests (the log level is already configured inpyproject.toml).The
backend_xfail(*backends, reason=...)marker marks a test as an expected (non-strict) failure only whenCCDPROC_ARRAY_LIBRARYmatches one of the named backends. Thebackend_skip(*backends, reason=...)marker skips a test entirely for the named backends. Because these xfails are non-strict (unlike the suite-widexfail_strict = truedefault), a backend bug that later gets fixed becomes a silent XPASS rather than a failure, and the stale marker lingers forever – check for XPASSes occasionally (run the backend suite with-rX) and prune the markers that no longer fail. Before deleting one, confirm the XPASS in the CI logs rather than only on your own machine: backend behavior can be platform-dependent (see issue #943, where a jax-marked test passes on macOS but still fails on Linux CI).Setting
CCDPROC_ENFORCE_ESCAPE_BASELINE=1fails the test session if a new library escape site appears that is not in the checked-in baseline, and settingCCDPROC_WRITE_ESCAPE_BASELINE=1regenerates that baseline. Both are described in “The escape-baseline ratchet” below.
The escape-baseline ratchet#
The file ccdproc/tests/array_escape_baseline.txt is a checked-in list of
the known places in the ccdproc library where a non-numpy array is still
silently converted to numpy, as reported by the escape logger. Each entry is
one line of <file> <function> <coercion> followed by an optional
free-text reason (e.g. TODO for sites still to migrate, or BOUNDARY
for calls into numpy-only dependencies such as scipy that will never leave).
Setting CCDPROC_ENFORCE_ESCAPE_BASELINE=1 turns that list into a
ratchet: at the end of the test session, any library escape site not in
the baseline fails the session, so new numpy escapes cannot creep in while
the existing ones are migrated. Enforcement checks the sites recorded by the
escape logger, so it is only meaningful with CCDPROC_LOG_ARRAY_ESCAPES=1
and a non-numpy CCDPROC_ARRAY_LIBRARY (on numpy there are no foreign
arrays to escape); the session errors out if these are not set. The
enforce tox factor sets the required test-tooling variables and composes
with a backend factor, e.g.:
tox -e py312-alldeps-dask-enforce
To regenerate the baseline, run the full test suite from the source tree
(not under tox, which runs the tests against an installed copy of the
package from a temporary directory) with all three of
CCDPROC_WRITE_ESCAPE_BASELINE=1, CCDPROC_LOG_ARRAY_ESCAPES=1 and a
non-numpy backend set – write mode errors out if any of them is missing:
CCDPROC_ARRAY_LIBRARY=dask CCDPROC_LOG_ARRAY_ESCAPES=1 \
CCDPROC_WRITE_ESCAPE_BASELINE=1 pytest
The file is rewritten from the escapes actually observed during the run, so a partial run (a subset of the tests) silently drops the entries for code that was not exercised – always regenerate over the whole suite. Hand-written reasons on entries that are still observed are preserved.
If the enforce CI job (e.g. py312-alldeps-dask-enforce) fails on your
pull request, look for the “NEW escapes” list in the ccdproc array-API
escape baseline section of the pytest terminal summary. For each new site,
either fix the call site so the data stays in the array-API world
(preferred), or – if the escape is a deliberate boundary with a numpy-only
dependency – add the new <file> <function> <coercion> line, with a
reason, to ccdproc/tests/array_escape_baseline.txt. The same summary
also lists baseline entries that were not hit this run; delete them if your
change removed the escape.
Reading the strict CI signal#
The ubuntu-py313-strict job in the main CI matrix runs the test suite
against array-api-strict on a non-default device, a CPU-only proxy for
GPU-style device behavior. It is a regular matrix job: a test failure fails
the job and the pull-request checks rollup, like any other backend. A
failure there usually means a numpy-ism (a numpy-only method or type, a
missing device=) crept into a code path that the more permissive
backends accept silently; reproduce it locally with tox -e strict.
What limitations should I be aware of?#
The NaN-aware reductions
nansum,nanmean,nanstdandnanmedianare not part of the array API, but most array libraries do provide them. When combining images, ccdproc uses the versions from bottleneck for numpy arrays if bottleneck is installed; otherwise it uses the ones the selected array library provides. If that library has none (for examplearray-api-strict), ccdproc falls back to implementations written purely in terms of the array API standard. They are correct but slower: the sum, mean and standard deviation take a few extra passes over the data, and the median is sort-based (O(n log n) along the combination axis rather than O(n)). The fallbacks promote integer and boolean input to the library’s default real floating dtype and yield NaN silently for slices that are entirely NaN.medianis not part of the array API either.subtract_overscanuses themedianof the selected array library when there is one, and otherwise the same sort-based fallback, propagating NaNs asnumpy.mediandoes.sigma_func, the default uncertainty estimate ofmedian_combine, is a median absolute deviation. For numpy arrays it callsastropy.stats.median_absolute_deviation, which is numpy-only; for every other array library it uses a version written purely in terms of the array API standard, preferring the library’s ownmedian/nanmedianand falling back to the sort-based medians above only when the library has neither, so the extra sort cost and the silence on all-NaN slices apply only there.Combiner.sigma_clippingusesastropy.stats.sigma_cliponly for numpy arrays. For any other array library it uses an implementation written in terms of the array API standard that reproduces astropy’s result up to floating-point rounding of the reductions: a value lying exactly on a bound can be classified differently from astropy. Astropy-only options such asgroware not available there and raiseTypeError;maskedandreturn_boundsare not accepted bysigma_clippingat all, on any array library, because the wrapper always asks astropy for the mask itself. On a lazy library such as dask, prefer an integermaxiters:maxiters=Nonehas to compute the data after every iteration to find out whether anything else was rejected.block_reduce,block_averageandblock_replicatecallastropy.nddata, which is numpy-only, for numpy arrays and an implementation written purely in terms of the array API standard for every other array library, so the result stays in the array library you passed in. The two differ only in dtype handling. First,block_averageandblock_replicate(..., conserve_sum=True)promote integer and boolean input to the array library’s default real floating dtype, because some libraries (array-api-strict) refuse to divide or average integers rather than promoting them. numpy returnsfloat64in both cases anyway, so this differs only for a library whose default real dtype is notfloat64. Second, on non-NumPy backendsblock_replicate(..., conserve_sum=True)preserves a real floating input’s dtype (float32staysfloat32), whichastropy.nddataonly does from the fix for astropy/astropy#20360 (astropy 7.2.3 and 8.0.2); earlier versions upcastfloat32andfloat16input tofloat64there. Note also that the defaultblock_reducesum of integer or boolean input uses the array library’s default integer width, which isint32on jax unless 64-bit mode is enabled, so large blocks can overflow there. All three functions need a fully known shape, so a dask array with unknown chunk sizes must havecompute_chunk_sizes()called on it first.The local window filters –
median_filter, and the ones insidecosmicray_median,background_deviation_filterandccdmask– come from scipy.ndimage for numpy arrays, which is numpy-only. For every other array library they are computed by an implementation written purely in terms of the array API standard, which stacks each pixel’s window along a new axis and reduces over it. It agrees with scipy.ndimage exactly on finite input, but it is markedly more expensive: a k-by-k window costs O(k**2 log k**2) per pixel, from a sort, against ndimage’s O(k**2) selection, and the stack itself holds k**2 copies of the image (processed in bands of rows to bound the peak memory).Those filters promote integer input to the library’s default real floating dtype; scipy.ndimage keeps an integer dtype. Only ndimage’s
'reflect'and'nearest'boundary modes are implemented, and on a non-numpy arraymedian_filteraccepts onlysizeandmode–footprint,origin,output,cvalandaxesraiseTypeErrornaming the argument. Convert the data to numpy to use scipy.ndimage’s full interface.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_filterandcosmicray_medianaccept such data, andccdmask’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 havecompute_chunk_sizes()called on it first.
Which operations run on the CPU?#
A few ccdproc functions are built on libraries that only understand numpy arrays. Three of them are:
wcs_project, which reprojects through reproject;subtract_overscanwhen amodelis given, which fits it withastropy.modeling;cosmicray_lacosmic, which detects cosmic rays with astroscrappy.
These functions copy their input to host memory, run there, and copy every
array they return – the data and the mask – back to the array namespace
and the device of the array you passed in. You never get a numpy array
back in place of what you handed over, whatever array library you use.
Floating data also keeps its dtype, whatever dtype the library works in
(float64 for reproject and astropy.modeling, float32 for
astroscrappy); integer data comes back in a floating dtype.
This copy does not work for CuPy arrays yet: CuPy refuses the implicit
conversion to numpy that it relies on, so these three functions, and
combine with an output_file, fail on CuPy input. This is tracked
in issue #1021.
Because that round trip can be expensive – it computes a lazy dask
array, or moves data off a GPU and back – each of these functions warns
once per call site with a HostCopyWarning, naming the function that
made the copy. The warning is a subclass of AstropyUserWarning and is
silenced like any other warning:
import warnings
import ccdproc
warnings.filterwarnings("ignore", category=ccdproc.HostCopyWarning)
The warning is issued only when an array that is not already a numpy
array has to be copied to the host. When every array in a call is a
numpy array, nothing is copied and nothing warns; a numpy image passed
to cosmicray_lacosmic with a non-numpy inbkg or invar does
warn.
There is one deliberate exception to the warning. combine with an
output_file writes the combined image through astropy.io.fits, which
also needs a host copy, but nothing from that copy comes back into your
pipeline – the combined image combine returns is still in your array
namespace – so it copies silently.
ccdproc does not offer a way to turn the conversion off: without it the operation would simply fail. Nor is a native reimplementation planned; wcslib, astroscrappy’s C code and astropy’s fitters are outside the scope of this project.
Which array library should I use?#
If you have access to a GPU then using cupy will be noticeably faster than
using numpy. If you routinely use very large datasets, consider using dask.
The array library that the maintainers of ccdproc most often use is numpy.
How do I use the array API?#
There are two ways to use the array API in ccdproc:
Use the ccdproc functions as you normally would, but pass in an array from the array library of your choice. For example, if you want to use dask arrays, you can do this:
import dask.array as da import ccdproc from astropy.nddata import CCDData data = da.random.random((1000, 1000)) ccd = CCDData(data, unit='adu') ccd = ccdproc.trim_image(ccd[:900, :900])
The images passed to one call of
subtract_overscan,subtract_bias,subtract_dark,flat_correct,ccd_process,cosmicray_median(with an arrayerror_image),ccdproc.Combinerorccdproc.combinemust all come from the same array library and be on the same device; these functions raiseTypeErrororValueErrorotherwise. If, for example, a master bias was read as numpy arrays, read it again with the samearray_packageas the other images. Masks and uncertainties are not checked. Astropy’sCCDDataconverts any mask it is given to numpy whatever the library of its data, so checking masks would reject ordinary input. Instead, a mask is moved to the array library and device of the data, and one that cannot be moved there, such as a CuPy mask with numpy data, raises an error. Uncertainties keep their library but are not checked either.As a result, when the data are on a device that numpy cannot read, such as a GPU with cupy, so is the mask of an image that ccdproc returns, and astropy’s own
ccd[...]andccd.copy()fail for that image because they convert the mask to numpy. Slice such an image withccdproc.trim_imageand itsfits_sectionargument, passfits_sectionrather thanoverscan=ccd[...]toccdproc.subtract_overscan, and copy it withcopy.deepcopy.Use ccdproc functions to read/write data in addition to using ccdproc functions to process the data. For example, if you want to use dask arrays to process a set of images, you can do this:
import dask.array as da import ccdproc images = ccdproc.ImageFileCollection('path/to/images', array_package=da) for ccd in images.ccds(): ccd = ccdproc.trim_image(ccd[:900, :900]) # Do more processing with ccdproc functions # ...
If you do this, image combination will also be done using the array library you specified.
To combine images with the array library of your choice without doing any other processing, pass a list of file names to
ccdproc.combinewith thearray_packageargument set to that library. For example, to combine images using dask arrays, you can do this:import dask.array as da import ccdproc file_list = ccdproc.ImageFileCollection('path/to/images').files_filtered( include_path=True ) combined = ccdproc.combine(file_list, method='median', array_package=da)