PyAPI: add ImBuf buffer methods & context manager for pixel data access

Provide buffer access to pixel data, as well as methods to ensure/clear
float/byte buffers as previously it wasn't possible to create new
float images.

- ImBuf.with_buffer(): context manager yielding a memoryview of pixel
  data (byte or float, with optional region and write support).
- ImBuf.ensure_buffer(), has_buffer(), clear_buffer():
  manage byte/float pixel buffers.
- Export blocking: resize/crop/free raise BufferError while
  pixel buffers are active.
- PyC_ParseRectI, PyC_ParseOptionalRectI:
  reusable O& converters for rcti argument parsing.
- Additional tests.

Ref !155710
This commit is contained in:
Campbell Barton 2026-03-16 12:47:23 +00:00
parent 31c32237d8
commit 8cf8973f69
4 changed files with 860 additions and 0 deletions

View file

@ -10,6 +10,7 @@
#include <Python.h>
#include "BLI_rect.h"
#include "BLI_string.h"
#include "BLI_utildefines.h"
@ -33,6 +34,33 @@ static PyObject *BPyInit_imbuf_types();
static PyObject *Py_ImBuf_CreatePyObject(ImBuf *ibuf);
struct Py_ImBuf;
#define PY_IMBUF_BUFFER_TYPE_DOC \
" :param type: The buffer type.\n" \
" :type type: Literal['BYTE', 'FLOAT']\n"
static const PyC_StringEnumItems py_imbuf_buffer_mode_items[] = {
{IB_byte_data, "BYTE"},
{IB_float_data, "FLOAT"},
{0, nullptr},
};
struct Py_ImBufBuffer {
PyObject_HEAD
/** Reference to the #ImBuf this came from (prevents freeing while in use). */
Py_ImBuf *py_ibuf;
/** Whether this wraps byte/float pixel data (#IB_byte_data, #IB_float_data). */
int mode;
/** Set by `__enter__`, cleared by `__exit__` (managed #Py_ImBuf.buffer_users). */
bool is_entered;
/** When false the `memoryview` is read-only. */
bool writable;
/** Sub-region bounds (min inclusive, max exclusive). */
std::optional<rcti> region;
};
extern PyTypeObject Py_ImBufBuffer_Type;
/* -------------------------------------------------------------------- */
/** \name Type & Utilities
* \{ */
@ -41,6 +69,8 @@ struct Py_ImBuf {
PyObject_VAR_HEAD
/* can be nullptr */
ImBuf *ibuf;
/** Number of active buffer protocol exports (via #Py_ImBufBuffer). */
int buffer_users;
};
static int py_imbuf_valid_check(Py_ImBuf *self)
@ -65,6 +95,34 @@ static int py_imbuf_valid_check(Py_ImBuf *self)
} \
((void)0)
#define PY_IMBUF_CHECK_BUFFER_USERS(obj) \
if (UNLIKELY((obj)->buffer_users > 0)) { \
PyErr_SetString(PyExc_BufferError, \
"ImBuf cannot be modified while pixel buffers are exported"); \
return nullptr; \
} \
((void)0)
/**
* Clamp a region to image bounds.
*
* \param ibuf: The image buffer (used for clamping).
* \param region: Region to clamp in-place (inverted ranges are clamped to zero area).
* \param r_use_region: Set to false when the region covers the entire image.
*/
static bool py_imbuf_region_sanitize(const ImBuf *ibuf, rcti *region)
{
BLI_rcti_sanitize(region);
CLAMP(region->xmin, 0, ibuf->x);
CLAMP(region->xmax, 0, ibuf->x);
CLAMP(region->ymin, 0, ibuf->y);
CLAMP(region->ymax, 0, ibuf->y);
return !((region->xmin == 0 && region->ymin == 0) &&
(region->xmax == ibuf->x && region->ymax == ibuf->y));
}
/** \} */
/* -------------------------------------------------------------------- */
@ -85,6 +143,7 @@ PyDoc_STRVAR(
static PyObject *py_imbuf_resize(Py_ImBuf *self, PyObject *args, PyObject *kw)
{
PY_IMBUF_CHECK_OBJ(self);
PY_IMBUF_CHECK_BUFFER_USERS(self);
int size[2];
@ -141,6 +200,7 @@ PyDoc_STRVAR(
static PyObject *py_imbuf_crop(Py_ImBuf *self, PyObject *args, PyObject *kw)
{
PY_IMBUF_CHECK_OBJ(self);
PY_IMBUF_CHECK_BUFFER_USERS(self);
rcti crop;
@ -205,6 +265,233 @@ static PyObject *py_imbuf_deepcopy(Py_ImBuf *self, PyObject *args)
return py_imbuf_copy(self);
}
PyDoc_STRVAR(
/* Wrap. */
py_imbuf_with_buffer_doc,
".. method:: with_buffer(type, *, write=False, region=None)\n"
"\n"
" Return a context manager that yields a :class:`memoryview` "
"of the image's pixel data.\n"
"\n"
" Usage::\n"
"\n"
" with image.with_buffer('BYTE', write=True) as buf:\n"
" buf[0] = 255 # set red channel of first pixel\n"
"\n" PY_IMBUF_BUFFER_TYPE_DOC
" :param write: When true the buffer is writable.\n"
" :type write: bool\n"
" :param region: Optional sub-region ``((x_min, y_min), (x_max, y_max))``.\n"
" When set the memoryview is 2D (rows x columns), "
"clamped to image bounds.\n"
" :type region: tuple[tuple[int, int], tuple[int, int]] | None\n"
" :return: A context manager yielding a :class:`memoryview` of pixel data.\n"
" :rtype: :class:`ImBufBuffer`\n");
static PyObject *py_imbuf_with_buffer(Py_ImBuf *self, PyObject *args, PyObject *kw)
{
PY_IMBUF_CHECK_OBJ(self);
PyC_StringEnum type = {py_imbuf_buffer_mode_items, -1};
bool write = false;
std::optional<rcti> region;
static const char *_keywords[] = {
"type",
"write",
"region",
nullptr,
};
static _PyArg_Parser _parser = {
"O&" /* `type` (required) */
"|$" /* Optional keyword only arguments. */
"O&" /* `write` */
"O&" /* `region` */
":with_buffer",
_keywords,
nullptr,
};
if (!_PyArg_ParseTupleAndKeywordsFast(args,
kw,
&_parser,
PyC_ParseStringEnum,
&type,
PyC_ParseBool,
&write,
PyC_ParseOptionalRectI,
&region))
{
return nullptr;
}
ImBuf *ibuf = self->ibuf;
const int mode = type.value_found;
if (mode == IB_byte_data) {
if (ibuf->byte_buffer.data == nullptr) {
PyErr_SetString(PyExc_RuntimeError, "ImBuf has no byte pixel data");
return nullptr;
}
}
else {
if (ibuf->float_buffer.data == nullptr) {
PyErr_SetString(PyExc_RuntimeError, "ImBuf has no float pixel data");
return nullptr;
}
}
bool use_region = false;
if (region.has_value()) {
use_region = py_imbuf_region_sanitize(ibuf, &region.value());
}
Py_ImBufBuffer *ctx = PyObject_GC_New(Py_ImBufBuffer, &Py_ImBufBuffer_Type);
if (UNLIKELY(ctx == nullptr)) {
return nullptr;
}
Py_INCREF(self);
ctx->py_ibuf = self;
ctx->is_entered = false;
ctx->mode = mode;
ctx->writable = write;
ctx->region = use_region ? region : std::nullopt;
return reinterpret_cast<PyObject *>(ctx);
}
PyDoc_STRVAR(
/* Wrap. */
py_imbuf_ensure_buffer_doc,
".. method:: ensure_buffer(type)\n"
"\n"
" Ensure the image has pixel data of the given type.\n"
" If absent, it is allocated and converted from the other buffer when available.\n"
"\n" PY_IMBUF_BUFFER_TYPE_DOC);
static PyObject *py_imbuf_ensure_buffer(Py_ImBuf *self, PyObject *args, PyObject *kw)
{
PY_IMBUF_CHECK_OBJ(self);
PY_IMBUF_CHECK_BUFFER_USERS(self);
PyC_StringEnum type = {py_imbuf_buffer_mode_items, -1};
static const char *_keywords[] = {
"type",
nullptr,
};
static _PyArg_Parser _parser = {
"O&" /* `type` (required) */
":ensure_buffer",
_keywords,
nullptr,
};
if (!_PyArg_ParseTupleAndKeywordsFast(args, kw, &_parser, PyC_ParseStringEnum, &type)) {
return nullptr;
}
ImBuf *ibuf = self->ibuf;
if (type.value_found == IB_byte_data) {
if (ibuf->byte_buffer.data == nullptr) {
if (ibuf->float_buffer.data != nullptr) {
IMB_byte_from_float(ibuf);
}
else {
IMB_alloc_byte_pixels(ibuf);
}
if (UNLIKELY(ibuf->byte_buffer.data == nullptr)) {
PyErr_SetString(PyExc_MemoryError, "failed to allocate byte buffer");
return nullptr;
}
}
}
else {
if (ibuf->float_buffer.data == nullptr) {
if (ibuf->byte_buffer.data != nullptr) {
IMB_float_from_byte(ibuf);
}
else {
IMB_alloc_float_pixels(ibuf, 4);
}
if (UNLIKELY(ibuf->float_buffer.data == nullptr)) {
PyErr_SetString(PyExc_MemoryError, "failed to allocate float buffer");
return nullptr;
}
}
}
Py_RETURN_NONE;
}
PyDoc_STRVAR(
/* Wrap. */
py_imbuf_has_buffer_doc,
".. method:: has_buffer(type)\n"
"\n"
" Return whether the image has pixel data of the given type.\n"
"\n" PY_IMBUF_BUFFER_TYPE_DOC
" :return: True if the buffer exists.\n"
" :rtype: bool\n");
static PyObject *py_imbuf_has_buffer(Py_ImBuf *self, PyObject *args, PyObject *kw)
{
PY_IMBUF_CHECK_OBJ(self);
PyC_StringEnum type = {py_imbuf_buffer_mode_items, -1};
static const char *_keywords[] = {
"type",
nullptr,
};
static _PyArg_Parser _parser = {
"O&" /* `type` (required) */
":has_buffer",
_keywords,
nullptr,
};
if (!_PyArg_ParseTupleAndKeywordsFast(args, kw, &_parser, PyC_ParseStringEnum, &type)) {
return nullptr;
}
ImBuf *ibuf = self->ibuf;
if (type.value_found == IB_byte_data) {
return PyBool_FromLong(ibuf->byte_buffer.data != nullptr);
}
return PyBool_FromLong(ibuf->float_buffer.data != nullptr);
}
PyDoc_STRVAR(
/* Wrap. */
py_imbuf_clear_buffer_doc,
".. method:: clear_buffer(type)\n"
"\n"
" Free pixel data of the given type.\n"
"\n" PY_IMBUF_BUFFER_TYPE_DOC);
static PyObject *py_imbuf_clear_buffer(Py_ImBuf *self, PyObject *args, PyObject *kw)
{
PY_IMBUF_CHECK_OBJ(self);
PY_IMBUF_CHECK_BUFFER_USERS(self);
PyC_StringEnum type = {py_imbuf_buffer_mode_items, -1};
static const char *_keywords[] = {
"type",
nullptr,
};
static _PyArg_Parser _parser = {
"O&" /* `type` (required) */
":clear_buffer",
_keywords,
nullptr,
};
if (!_PyArg_ParseTupleAndKeywordsFast(args, kw, &_parser, PyC_ParseStringEnum, &type)) {
return nullptr;
}
ImBuf *ibuf = self->ibuf;
if (type.value_found == IB_byte_data) {
IMB_free_byte_pixels(ibuf);
}
else {
IMB_free_float_pixels(ibuf);
}
Py_RETURN_NONE;
}
PyDoc_STRVAR(
/* Wrap. */
py_imbuf_free_doc,
@ -213,6 +500,7 @@ PyDoc_STRVAR(
" Clear image data immediately (causing an error on re-use).\n");
static PyObject *py_imbuf_free(Py_ImBuf *self)
{
PY_IMBUF_CHECK_BUFFER_USERS(self);
if (self->ibuf) {
IMB_freeImBuf(self->ibuf);
self->ibuf = nullptr;
@ -239,6 +527,22 @@ static PyMethodDef Py_ImBuf_methods[] = {
reinterpret_cast<PyCFunction>(py_imbuf_crop),
METH_VARARGS | METH_KEYWORDS,
const_cast<char *>(py_imbuf_crop_doc)},
{"with_buffer",
reinterpret_cast<PyCFunction>(py_imbuf_with_buffer),
METH_VARARGS | METH_KEYWORDS,
py_imbuf_with_buffer_doc},
{"ensure_buffer",
reinterpret_cast<PyCFunction>(py_imbuf_ensure_buffer),
METH_VARARGS | METH_KEYWORDS,
py_imbuf_ensure_buffer_doc},
{"has_buffer",
reinterpret_cast<PyCFunction>(py_imbuf_has_buffer),
METH_VARARGS | METH_KEYWORDS,
py_imbuf_has_buffer_doc},
{"clear_buffer",
reinterpret_cast<PyCFunction>(py_imbuf_clear_buffer),
METH_VARARGS | METH_KEYWORDS,
py_imbuf_clear_buffer_doc},
{"free", reinterpret_cast<PyCFunction>(py_imbuf_free), METH_NOARGS, py_imbuf_free_doc},
{"copy", reinterpret_cast<PyCFunction>(py_imbuf_copy), METH_NOARGS, py_imbuf_copy_doc},
{"__copy__", reinterpret_cast<PyCFunction>(py_imbuf_copy), METH_NOARGS, py_imbuf_copy_doc},
@ -555,11 +859,239 @@ static PyObject *Py_ImBuf_CreatePyObject(ImBuf *ibuf)
{
Py_ImBuf *self = PyObject_New(Py_ImBuf, &Py_ImBuf_Type);
self->ibuf = ibuf;
self->buffer_users = 0;
return reinterpret_cast<PyObject *>(self);
}
/** \} */
/* -------------------------------------------------------------------- */
/** \name Buffer, Type & Implementation
* \{ */
static void py_imbuf_buffer_dealloc(Py_ImBufBuffer *self)
{
if (self->is_entered && self->py_ibuf) {
self->py_ibuf->buffer_users--;
BLI_assert(self->py_ibuf->buffer_users >= 0);
}
PyObject_GC_UnTrack(self);
Py_CLEAR(self->py_ibuf);
PyObject_GC_Del(self);
}
static int py_imbuf_buffer_traverse(Py_ImBufBuffer *self, visitproc visit, void *arg)
{
Py_VISIT(self->py_ibuf);
return 0;
}
static int py_imbuf_buffer_clear(Py_ImBufBuffer *self)
{
Py_CLEAR(self->py_ibuf);
return 0;
}
static PyObject *py_imbuf_buffer_repr(Py_ImBufBuffer *self)
{
const ImBuf *ibuf = self->py_ibuf ? self->py_ibuf->ibuf : nullptr;
if (ibuf != nullptr) {
return PyUnicode_FromFormat(
"<ImBufBuffer: size=(%d, %d), entered=%d>", ibuf->x, ibuf->y, int(self->is_entered));
}
return PyUnicode_FromString("<ImBufBuffer: invalid>");
}
static PyObject *py_imbuf_buffer_enter(Py_ImBufBuffer *self)
{
if (UNLIKELY(self->is_entered)) {
PyErr_SetString(PyExc_RuntimeError, "ImBufBuffer context is already entered");
return nullptr;
}
ImBuf *ibuf = self->py_ibuf->ibuf;
if (UNLIKELY(ibuf == nullptr)) {
PyErr_SetString(PyExc_ReferenceError, "ImBuf data has been freed");
return nullptr;
}
const bool is_byte = (self->mode == IB_byte_data);
if (is_byte) {
if (UNLIKELY(ibuf->byte_buffer.data == nullptr)) {
PyErr_SetString(PyExc_RuntimeError, "ImBuf has no byte pixel data");
return nullptr;
}
}
else {
if (UNLIKELY(ibuf->float_buffer.data == nullptr)) {
PyErr_SetString(PyExc_RuntimeError, "ImBuf has no float pixel data");
return nullptr;
}
}
const int channels = is_byte ? 4 : (ibuf->channels ? ibuf->channels : 4);
const Py_ssize_t itemsize = is_byte ? 1 : Py_ssize_t(sizeof(float));
const char *format = is_byte ? "B" : "f";
PyObject *memview;
if (self->region == std::nullopt) {
Py_ssize_t num_items = Py_ssize_t(ibuf->x) * ibuf->y * channels;
Py_buffer pybuf;
memset(&pybuf, 0, sizeof(pybuf));
pybuf.buf = is_byte ? static_cast<void *>(ibuf->byte_buffer.data) :
static_cast<void *>(ibuf->float_buffer.data);
pybuf.len = num_items * itemsize;
pybuf.itemsize = itemsize;
pybuf.readonly = !self->writable;
pybuf.format = const_cast<char *>(format);
pybuf.ndim = 1;
pybuf.shape = &num_items;
pybuf.strides = &pybuf.itemsize;
memview = PyMemoryView_FromBuffer(&pybuf);
}
else {
const rcti &region = *self->region;
const int xmin = region.xmin;
const int ymin = region.ymin;
const int xmax = region.xmax;
const int ymax = region.ymax;
const Py_ssize_t row_stride = Py_ssize_t(ibuf->x) * channels * itemsize;
const Py_ssize_t offset = (Py_ssize_t(ymin) * ibuf->x + xmin) * channels * itemsize;
Py_ssize_t shape[2] = {ymax - ymin, (xmax - xmin) * channels};
Py_ssize_t strides[2] = {row_stride, itemsize};
Py_buffer pybuf;
memset(&pybuf, 0, sizeof(pybuf));
pybuf.buf = is_byte ? static_cast<void *>(ibuf->byte_buffer.data + offset) :
static_cast<void *>(reinterpret_cast<char *>(ibuf->float_buffer.data) +
offset);
pybuf.len = shape[0] * shape[1] * itemsize;
pybuf.itemsize = itemsize;
pybuf.readonly = !self->writable;
pybuf.format = const_cast<char *>(format);
pybuf.ndim = 2;
pybuf.shape = shape;
pybuf.strides = strides;
memview = PyMemoryView_FromBuffer(&pybuf);
}
if (UNLIKELY(memview == nullptr)) {
return nullptr;
}
self->is_entered = true;
self->py_ibuf->buffer_users++;
return memview;
}
static PyObject *py_imbuf_buffer_exit(Py_ImBufBuffer *self, PyObject * /*args*/)
{
if (self->is_entered && self->py_ibuf) {
self->is_entered = false;
self->py_ibuf->buffer_users--;
BLI_assert(self->py_ibuf->buffer_users >= 0);
if (self->writable) {
ImBuf *ibuf = self->py_ibuf->ibuf;
if (ibuf != nullptr) {
if (self->mode == IB_byte_data) {
if (ibuf->float_buffer.data != nullptr) {
IMB_float_from_byte(ibuf);
}
}
else {
if (ibuf->byte_buffer.data != nullptr) {
IMB_byte_from_float(ibuf);
}
}
}
}
}
Py_RETURN_NONE;
}
#ifdef __GNUC__
# ifdef __clang__
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wcast-function-type"
# else
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wcast-function-type"
# endif
#endif
static PyMethodDef Py_ImBufBuffer_methods[] = {
{"__enter__", reinterpret_cast<PyCFunction>(py_imbuf_buffer_enter), METH_NOARGS, nullptr},
{"__exit__", reinterpret_cast<PyCFunction>(py_imbuf_buffer_exit), METH_VARARGS, nullptr},
{nullptr, nullptr, 0, nullptr},
};
#ifdef __GNUC__
# ifdef __clang__
# pragma clang diagnostic pop
# else
# pragma GCC diagnostic pop
# endif
#endif
PyTypeObject Py_ImBufBuffer_Type = {
/*ob_base*/ PyVarObject_HEAD_INIT(nullptr, 0)
/*tp_name*/ "ImBufBuffer",
/*tp_basicsize*/ sizeof(Py_ImBufBuffer),
/*tp_itemsize*/ 0,
/*tp_dealloc*/ reinterpret_cast<destructor>(py_imbuf_buffer_dealloc),
/*tp_vectorcall_offset*/ 0,
/*tp_getattr*/ nullptr,
/*tp_setattr*/ nullptr,
/*tp_as_async*/ nullptr,
/*tp_repr*/ reinterpret_cast<reprfunc>(py_imbuf_buffer_repr),
/*tp_as_number*/ nullptr,
/*tp_as_sequence*/ nullptr,
/*tp_as_mapping*/ nullptr,
/*tp_hash*/ PyObject_HashNotImplemented,
/*tp_call*/ nullptr,
/*tp_str*/ nullptr,
/*tp_getattro*/ nullptr,
/*tp_setattro*/ nullptr,
/*tp_as_buffer*/ nullptr,
/*tp_flags*/ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC,
/*tp_doc*/ nullptr,
/*tp_traverse*/ reinterpret_cast<traverseproc>(py_imbuf_buffer_traverse),
/*tp_clear*/ reinterpret_cast<inquiry>(py_imbuf_buffer_clear),
/*tp_richcompare*/ nullptr,
/*tp_weaklistoffset*/ 0,
/*tp_iter*/ nullptr,
/*tp_iternext*/ nullptr,
/*tp_methods*/ Py_ImBufBuffer_methods,
/*tp_members*/ nullptr,
/*tp_getset*/ nullptr,
/*tp_base*/ nullptr,
/*tp_dict*/ nullptr,
/*tp_descr_get*/ nullptr,
/*tp_descr_set*/ nullptr,
/*tp_dictoffset*/ 0,
/*tp_init*/ nullptr,
/*tp_alloc*/ nullptr,
/*tp_new*/ nullptr,
/*tp_free*/ nullptr,
/*tp_is_gc*/ nullptr,
/*tp_bases*/ nullptr,
/*tp_mro*/ nullptr,
/*tp_cache*/ nullptr,
/*tp_subclasses*/ nullptr,
/*tp_weaklist*/ nullptr,
/*tp_del*/ nullptr,
/*tp_version_tag*/ 0,
/*tp_finalize*/ nullptr,
/*tp_vectorcall*/ nullptr,
};
/** \} */
/* -------------------------------------------------------------------- */
/** \name Module Functions
* \{ */
@ -984,6 +1516,7 @@ PyObject *BPyInit_imbuf_types()
}
PyModule_AddType(submodule, &Py_ImBuf_Type);
PyModule_AddType(submodule, &Py_ImBufBuffer_Type);
return submodule;
}

View file

@ -17,6 +17,8 @@
#include "BLI_utildefines.h" /* for bool */
#include "DNA_vec_types.h" /* for rcti */
#include "py_capi_utils.hh"
#include "python_utildefines.hh"
@ -606,6 +608,30 @@ int PyC_ParseOptionalBool(PyObject *o, void *p)
return 1;
}
int PyC_ParseRectI(PyObject *o, void *p)
{
rcti *rect = static_cast<rcti *>(p);
if (!PyArg_ParseTuple(o, "(ii)(ii)", &rect->xmin, &rect->ymin, &rect->xmax, &rect->ymax)) {
return 0;
}
return 1;
}
int PyC_ParseOptionalRectI(PyObject *o, void *p)
{
std::optional<rcti> *value_p = static_cast<std::optional<rcti> *>(p);
if (o == Py_None) {
value_p->reset();
return 1;
}
rcti rect;
if (!PyC_ParseRectI(o, &rect)) {
return 0;
}
*value_p = rect;
return 1;
}
int PyC_ParseStringEnum(PyObject *o, void *p)
{
PyC_StringEnum *e = static_cast<PyC_StringEnum *>(p);

View file

@ -18,6 +18,8 @@
#include "BLI_span.hh"
#include "BLI_sys_types.h"
#include "DNA_vec_types.h"
namespace blender {
/** Useful to print Python objects while debugging. */
@ -355,6 +357,18 @@ struct PyC_TypeOrNone {
*/
[[nodiscard]] int PyC_ParseOptionalBool(PyObject *o, void *p);
/**
* Use with PyArg_ParseTuple's "O&" formatting.
*
* Parse `((x1, y1), (x2, y2))` into an `rcti`.
*/
[[nodiscard]] int PyC_ParseRectI(PyObject *o, void *p);
/**
* A version of #PyC_ParseRectI that accepts None
* (leaving the `std::optional<rcti>` empty).
*/
[[nodiscard]] int PyC_ParseOptionalRectI(PyObject *o, void *p);
/**
* Cast a pointer of a PyObject-derived type to `PyObject *`.
*

View file

@ -19,6 +19,14 @@ from imbuf.types import ImBuf
DEFAULT_SIZE = 32, 32
# Test color in linear float, sRGB byte, and the expected conversions between them.
COLOR_FLOAT = (1.0, 0.5, 0.25, 1.0)
COLOR_BYTE = (255, 128, 196, 255)
# sRGB byte -> linear float.
COLOR_BYTE_AS_FLOAT = (1.0, 0.2159, 0.5520, 1.0)
# Linear float -> sRGB byte.
COLOR_FLOAT_AS_BYTE = (255, 188, 137, 255)
# NOTE: an invalid file format & missing files is aborting on the build-bot.
# Disable as it makes tests fail, although we could consider demoting this to a warning
# since it prevents valid code-paths from being tested.
@ -407,6 +415,285 @@ class TestImBufPlanes(unittest.TestCase):
imbuf.new((2, 2), planes=p)
class TestImBufPixelsBytes(unittest.TestCase):
def test_read_only_by_default(self):
ibuf = imbuf.new((2, 2))
with ibuf.with_buffer('BYTE') as buf:
self.assertEqual(buf.readonly, True)
with self.assertRaises(TypeError):
buf[0] = 0xFF
ibuf.free()
def test_write_mode(self):
ibuf = imbuf.new((2, 2))
with ibuf.with_buffer('BYTE', write=True) as buf:
self.assertEqual(buf.readonly, False)
buf[0] = 42
self.assertEqual(buf[0], 42)
ibuf.free()
def test_buffer_length(self):
w, h = 8, 6
ibuf = imbuf.new((w, h))
with ibuf.with_buffer('BYTE') as buf:
self.assertEqual(len(buf), w * h * 4)
ibuf.free()
def test_freed_image(self):
ibuf = imbuf.new((2, 2))
ibuf.free()
with self.assertRaises(ReferenceError):
ibuf.with_buffer('BYTE')
def test_exports_block_resize(self):
ibuf = imbuf.new((4, 4))
with ibuf.with_buffer('BYTE') as buf:
with self.assertRaises(BufferError):
ibuf.resize((2, 2))
# After context exit, resize works.
ibuf.resize((2, 2))
self.assertEqual(ibuf.size, (2, 2))
ibuf.free()
def test_exports_block_crop(self):
ibuf = imbuf.new((4, 4))
with ibuf.with_buffer('BYTE') as buf:
with self.assertRaises(BufferError):
ibuf.crop((0, 0), (1, 1))
ibuf.free()
def test_exports_block_free(self):
ibuf = imbuf.new((4, 4))
with ibuf.with_buffer('BYTE') as buf:
with self.assertRaises(BufferError):
ibuf.free()
ibuf.free()
def test_double_enter_blocked(self):
ibuf = imbuf.new((2, 2))
ctx = ibuf.with_buffer('BYTE')
ctx.__enter__()
with self.assertRaises(RuntimeError):
ctx.__enter__()
ctx.__exit__(None, None, None)
ibuf.free()
def test_reentry_after_exit(self):
ibuf = imbuf.new((2, 2))
ctx = ibuf.with_buffer('BYTE', write=True)
buf1 = ctx.__enter__()
buf1[0] = 10
ctx.__exit__(None, None, None)
buf2 = ctx.__enter__()
self.assertEqual(buf2[0], 10)
ctx.__exit__(None, None, None)
ibuf.free()
class TestImBufPixelsRegion(unittest.TestCase):
def setUp(self):
self.ibuf = imbuf.new((8, 6))
with self.ibuf.with_buffer('BYTE', write=True) as buf:
for i in range(8 * 6):
buf[i * 4] = i % 256
def tearDown(self):
self.ibuf.free()
def test_full_image_1d(self):
with self.ibuf.with_buffer('BYTE') as buf:
self.assertEqual(buf.ndim, 1)
self.assertEqual(len(buf), 8 * 6 * 4)
def test_region_none_is_1d(self):
with self.ibuf.with_buffer('BYTE', region=None) as buf:
self.assertEqual(buf.ndim, 1)
def test_sub_region_2d(self):
with self.ibuf.with_buffer('BYTE', region=((2, 1), (6, 4))) as buf:
self.assertEqual(buf.ndim, 2)
self.assertEqual(buf.shape, (3, 16))
def test_region_pixel_values(self):
with self.ibuf.with_buffer('BYTE', region=((2, 1), (6, 4))) as buf:
# Pixel (2, 1) has index 1*8+2 = 10.
self.assertEqual(buf[0, 0], 10)
# Pixel (2, 2) has index 2*8+2 = 18.
self.assertEqual(buf[1, 0], 18)
def test_full_image_region_collapses_to_1d(self):
with self.ibuf.with_buffer('BYTE', region=((0, 0), (8, 6))) as buf:
self.assertEqual(buf.ndim, 1)
def test_oversized_region_collapses_to_1d(self):
with self.ibuf.with_buffer('BYTE', region=((-5, -5), (100, 100))) as buf:
self.assertEqual(buf.ndim, 1)
def test_zero_area_region(self):
with self.ibuf.with_buffer('BYTE', region=((100, 100), (200, 200))) as buf:
self.assertEqual(buf.ndim, 2)
self.assertEqual(buf.shape[0] * buf.shape[1], 0)
with self.ibuf.with_buffer('BYTE', region=((5, 5), (2, 2))) as buf:
self.assertEqual(buf.shape[0] * buf.shape[1], 0)
with self.ibuf.with_buffer('BYTE', region=((3, 3), (3, 3))) as buf:
self.assertEqual(buf.shape[0] * buf.shape[1], 0)
def test_region_write_does_not_corrupt_neighbors(self):
with self.ibuf.with_buffer('BYTE', write=True, region=((1, 1), (3, 3))) as buf:
for r in range(buf.shape[0]):
for c in range(buf.shape[1]):
buf[r, c] = 0xFF
with self.ibuf.with_buffer('BYTE') as buf:
self.assertEqual(buf[0], 0)
inside = (1 * 8 + 1) * 4
self.assertEqual(buf[inside], 0xFF)
class TestImBufPixelsFloat(unittest.TestCase):
def test_no_float_buffer_error(self):
ibuf = imbuf.new((2, 2))
with self.assertRaises(RuntimeError):
ibuf.with_buffer('FLOAT')
ibuf.free()
def test_ensure_and_access(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('FLOAT')
with ibuf.with_buffer('FLOAT') as buf:
self.assertEqual(buf.format, "f")
ibuf.free()
def test_float_write(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('FLOAT')
with ibuf.with_buffer('FLOAT', write=True) as buf:
buf[0] = 0.5
self.assertAlmostEqual(buf[0], 0.5, places=5)
ibuf.free()
class TestImBufEnsureBuffers(unittest.TestCase):
def test_ensure_float_from_byte(self):
ibuf = imbuf.new((2, 2))
with ibuf.with_buffer('BYTE', write=True) as buf:
buf[0:4] = bytes(COLOR_BYTE)
ibuf.ensure_buffer('FLOAT')
with ibuf.with_buffer('FLOAT') as buf:
for i, expected in enumerate(COLOR_BYTE_AS_FLOAT):
self.assertAlmostEqual(buf[i], expected, places=3)
ibuf.free()
def test_ensure_byte_from_float(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('FLOAT')
with ibuf.with_buffer('FLOAT', write=True) as buf:
for i, v in enumerate(COLOR_FLOAT):
buf[i] = v
ibuf.clear_buffer('BYTE')
self.assertFalse(ibuf.has_buffer('BYTE'))
ibuf.ensure_buffer('BYTE')
self.assertTrue(ibuf.has_buffer('BYTE'))
with ibuf.with_buffer('BYTE') as buf:
for i, expected in enumerate(COLOR_FLOAT_AS_BYTE):
self.assertEqual(buf[i], expected)
ibuf.free()
def test_ensure_noop_when_present(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('BYTE')
ibuf.ensure_buffer('FLOAT')
ibuf.ensure_buffer('FLOAT')
ibuf.free()
def test_ensure_blocked_while_exported(self):
ibuf = imbuf.new((2, 2))
with ibuf.with_buffer('BYTE') as buf:
with self.assertRaises(BufferError):
ibuf.ensure_buffer('FLOAT')
ibuf.free()
class TestImBufHasAndClearBuffer(unittest.TestCase):
def test_has_buffer_byte(self):
ibuf = imbuf.new((2, 2))
self.assertTrue(ibuf.has_buffer('BYTE'))
self.assertFalse(ibuf.has_buffer('FLOAT'))
ibuf.free()
def test_clear_buffer_byte(self):
ibuf = imbuf.new((2, 2))
self.assertTrue(ibuf.has_buffer('BYTE'))
ibuf.clear_buffer('BYTE')
self.assertFalse(ibuf.has_buffer('BYTE'))
ibuf.free()
def test_clear_buffer_float(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('FLOAT')
self.assertTrue(ibuf.has_buffer('FLOAT'))
ibuf.clear_buffer('FLOAT')
self.assertFalse(ibuf.has_buffer('FLOAT'))
ibuf.free()
def test_clear_buffer_blocked_while_exported(self):
ibuf = imbuf.new((2, 2))
with ibuf.with_buffer('BYTE') as buf:
with self.assertRaises(BufferError):
ibuf.clear_buffer('BYTE')
ibuf.free()
def test_clear_already_absent(self):
ibuf = imbuf.new((2, 2))
ibuf.clear_buffer('FLOAT')
ibuf.free()
class TestImBufExitSync(unittest.TestCase):
def test_byte_write_syncs_to_float(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('FLOAT')
with ibuf.with_buffer('BYTE', write=True) as buf:
for i, v in enumerate(COLOR_BYTE):
buf[i] = v
with ibuf.with_buffer('FLOAT') as buf:
for i, expected in enumerate(COLOR_BYTE_AS_FLOAT):
self.assertAlmostEqual(buf[i], expected, places=3)
ibuf.free()
def test_float_write_syncs_to_byte(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('FLOAT')
with ibuf.with_buffer('FLOAT', write=True) as buf:
for i, v in enumerate(COLOR_FLOAT):
buf[i] = v
with ibuf.with_buffer('BYTE') as buf:
for i, expected in enumerate(COLOR_FLOAT_AS_BYTE):
self.assertEqual(buf[i], expected)
ibuf.free()
def test_read_only_does_not_sync(self):
ibuf = imbuf.new((2, 2))
ibuf.ensure_buffer('FLOAT')
with ibuf.with_buffer('FLOAT', write=True) as buf:
for i, v in enumerate(COLOR_FLOAT):
buf[i] = v
# Read-only byte access should NOT overwrite the float buffer.
with ibuf.with_buffer('BYTE') as buf:
_ = buf[0]
with ibuf.with_buffer('FLOAT') as buf:
for i, expected in enumerate(COLOR_FLOAT):
self.assertAlmostEqual(buf[i], expected, places=3)
ibuf.free()
def main():
import sys
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])