- Introduce a standard way to bind numpy arrays: py::array_t<T> and .noconvert() on the argument - Take py::array_t by value since it's just a small wrapper - Update bindings
6.7 KiB
Numpy arrays
Most Python functions in aare take NumPy arrays as input. The
bindings in python/src/ receive them as
py::array_t<T> and wrap the buffer in an
NDView before calling the C++ implementation. No data is
copied, so the C++ code reads and writes the memory that the caller's
array owns. This page explains the choices we make when declaring such
an argument and what they mean for the user.
The preferred pattern
Declare the argument with only the element type, take it by value,
mark it noconvert in the py::arg list and let
make_view check the rest:
m.def(
"push",
[](Pedestal &self, py::array_t<uint16_t> frame) {
auto view = make_view_2d(frame);
self.push(view);
},
py::arg("frame").noconvert());The responsibilities are split between pybind11 and
make_view:
- pybind11 checks that the argument is a NumPy array with exactly the
requested dtype. Anything else fails with a
TypeError, and since the argument isnoconvertpybind11 never tries to build a matching array from it. make_view(python/src/np_helper.hpp) checks that the array has the expected number of dimensions and is C-contiguous. It raises aValueErrorwith a message that tells the user what to fix, for exampleArray is not C-contiguous. Use np.ascontiguousarray(arr). Writing functions usemake_viewwhich also rejects read-only arrays. Read-only input goes throughmake_const_view.
The result is a binding that either operates on the caller's memory or refuses the call. It never silently works on a temporary copy.
Take the array by value
py::array_t is a handle to the Python object, not the
data. Passing it by value copies the handle, which increments the
reference count of the NumPy array, and nothing else. The pixel data
stays where it is. We therefore declare the parameter as
py::array_t<T> frame rather than
py::array_t<T> &frame:
- A reference saves one reference-count increment per call, which is not measurable next to the work done on a frame.
- A by-value parameter is a plain lvalue inside the lambda, so
make_view, which callsmutable_data(), works without further qualification. Aconst py::array_t<T> &does not compile with it. - It matches the usual pybind11 style for
py::objectand its subclasses, so the binding reads the same as examples in the pybind11 documentation.
Why not let pybind11 convert
Without noconvert, pybind11 tries every overload once
with conversions disabled and then once more with conversions enabled.
In the second pass a py::array_t<T> argument accepts
anything NumPy can turn into an array of type T: an array
with another dtype, a non-contiguous array, or even a Python list.
pybind11 creates a new array, hands the converted copy to the binding
and drops it when the call returns. For detector data this has several
consequences:
- The copy costs time and memory, and the user gets no indication that it happened. For a stack of frames this can be gigabytes.
- Writes are lost. A function that fills or updates its input, for example a pedestal subtraction in place, writes into the temporary copy.
- Values are cast without warning.
forcecastallows unsafe casts, so afloat64frame is truncated touint16instead of being rejected. - Overloads stop working as intended.
apply_calibrationis registered for bothdoubleandfloat. With conversions allowed the first overload would accept everything by casting and thefloatoverload would never be chosen.
Argument conversion is only worth it for small inputs where convenience beats control, for example the parameter vector passed to a fit. Even then the function must not write into the argument.
Note
When a binding intentionally lets pybind11 convert, say so with a
comment next to the argument. Otherwise a missing noconvert
looks like an oversight and the next person to touch the binding will
add it.
// Conversion allowed: small read-only vector, accept lists and any dtype
py::array_t<double, py::array::c_style | py::array::forcecast> parWhat the template flags mean
py::array_t<T, Flags> takes an optional second
template argument. The flags do two things: they add requirements that
the array must fulfil for the non-converting check to pass, and
they tell NumPy what to produce when a conversion is
allowed.
py::array::c_style-
The array must be C-contiguous. With
noconverta non-contiguous array is rejected by pybind11 with aTypeErrorlisting the accepted signatures. Withoutnoconvertpybind11 makes a contiguous copy. Sincemake_viewalready enforces contiguity and gives a more helpfulValueError, the flag is redundant in the preferred pattern. It is still present in some older bindings and is harmless there. py::array::f_style-
Same as above for Fortran order.
NDViewassumes C order, so we do not use it. py::array::forcecast-
Allows NumPy to perform unsafe casts, such as
float64touint16, when converting. It is the default value of the template argument, sopy::array_t<uint16_t>andpy::array_t<uint16_t, py::array::forcecast>are the same type. The flag has no effect together withnoconvertbecause no conversion takes place. It only matters when conversions are allowed, where it decides whether a lossy cast is accepted or the call is rejected.
The common spelling
py::array_t<T, py::array::c_style | py::array::forcecast>
therefore means: when converting, produce a contiguous array of type
T and cast whatever you were given. Combined with
noconvert it collapses to "C-contiguous array of exactly
type T", which is what the plain
py::array_t<T> plus make_view already
gives us.
Summary
- Declare the argument as
py::array_t<T>by value and add.noconvert()to itspy::arg. The handle is reference counted, so passing it by value does not copy the data. - Create the view with
make_view_1d/2d/3dormake_const_view_*and let it validate dimensions and contiguity. Add an explicit check only when the binding accepts several shapes, asapply_calibrationdoes for 2D and 3D pedestals. - Return data to Python with
return_image_dataorreturn_vectorso that the NumPy array owns the C++ allocation. - Only allow conversion for small, read-only inputs where accepting lists or other dtypes is a real convenience for the user, and mark it with a comment in the code.