From 55d5445ebf354f3c617ec8abbd0dbb4e05f9ad2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Thu, 16 Mar 2023 21:49:00 +0100 Subject: [PATCH] python: typed access to Image*.pixels. Basically mirroring what's done for MeshData and SceneData already. --- doc/python/magnum.rst | 48 +++- doc/python/magnum.trade.rst | 35 +++ .../StridedArrayViewPythonBindings.h | 1 - src/Magnum/StridedArrayViewPythonBindings.h | 7 +- src/python/magnum/acessorsForPixelFormat.h | 194 ++++++++++++++ src/python/magnum/magnum.cpp | 23 +- .../magnum/test/dxt10-depth32f-stencil8ui.dds | Bin 0 -> 196 bytes src/python/magnum/test/test.py | 251 +++++++++++++++--- src/python/magnum/test/test_gl_gl.py | 13 +- src/python/magnum/test/test_trade.py | 113 +++++++- src/python/magnum/trade.cpp | 38 ++- 11 files changed, 659 insertions(+), 64 deletions(-) create mode 100644 src/python/magnum/acessorsForPixelFormat.h create mode 100644 src/python/magnum/test/dxt10-depth32f-stencil8ui.dds diff --git a/doc/python/magnum.rst b/doc/python/magnum.rst index da14f88..4cf11dc 100644 --- a/doc/python/magnum.rst +++ b/doc/python/magnum.rst @@ -46,7 +46,8 @@ An owning counterpart to :ref:`ImageView2D` / :ref:`MutableImageView2D`. Holds its own data buffer, thus doesn't have an equivalent to - :ref:`ImageView2D.owner`. Implicitly convertible to :ref:`ImageView2D` / + :ref:`ImageView2D.owner`. The :ref:`data` and :ref:`pixels` views allow + mutable access. Implicitly convertible to :ref:`ImageView2D` / :ref:`MutableImageView2D`, so all APIs consuming image views work with this type as well. @@ -75,21 +76,60 @@ The :ref:`owner` is :py:`None` if the view is empty. + `Pixel data access`_ + ==================== + + The class makes use of Python's dynamic nature and provides direct access + to pixel data in their concrete types via :ref:`pixels`. The returned views + point to the underlying image data, element access coverts to a type + corresponding to a particular :ref:`PixelFormat` and for + performance-oriented access the view implements a buffer protocol with a + corresponding type annotation. + + Normalized formats (such as :ref:`PixelFormat.RGB8_UNORM` but also + :ref:`PixelFormat.RGBA8_SRGB`) are unpacked to a corresponding + floating-point representation in element access and packed from a + floating-point representation in mutable acess. The type annotation is + however still matching the original type (such as :py:`'3B'` / :py:`'4B'` + in these cases), so code consuming these via the buffer protocol needs to + handle the normalization explicitly if needed. + + .. + >>> from magnum import * + >>> import numpy as np + >>> import array + + .. code:: pycon + + >>> data = array.array('B', [0xf3, 0x2a, 0x80, 0x23, 0x00, 0xff, 0x00, 0xff]) + >>> image = ImageView2D(PixelFormat.RGBA8_SRGB, (2, 1), data) + >>> image.pixels[0, 0] # sRGB -> float conversion + Vector(0.896269, 0.0231534, 0.215861, 0.137255) + >>> np.array(image.pixels, copy=False)[0] + array([[243, 42, 128, 35], + [ 0, 255, 0, 255]], dtype=uint8) + .. py:class:: magnum.ImageView3D See :ref:`ImageView2D` for more information. .. py:class:: magnum.MutableImageView1D - See :ref:`ImageView2D` for more information. + See :ref:`ImageView2D` for more information. The only difference to the + non-mutable variant is that it's possible to modify the image through + :ref:`data` and :ref:`pixels`. .. py:class:: magnum.MutableImageView2D - See :ref:`ImageView2D` for more information. + See :ref:`ImageView2D` for more information. The only difference to the + non-mutable variant is that it's possible to modify the image through + :ref:`data` and :ref:`pixels`. .. py:class:: magnum.MutableImageView3D - See :ref:`ImageView2D` for more information. + See :ref:`ImageView2D` for more information. The only difference to the + non-mutable variant is that it's possible to modify the image through + :ref:`data` and :ref:`pixels`. .. py:function:: magnum.ImageView1D.__init__(self, arg0: magnum.ImageView1D) :raise RuntimeError: If :ref:`trade.ImageData1D.is_compressed` is :py:`True` diff --git a/doc/python/magnum.trade.rst b/doc/python/magnum.trade.rst index 50bc73b..a30e05a 100644 --- a/doc/python/magnum.trade.rst +++ b/doc/python/magnum.trade.rst @@ -34,6 +34,18 @@ :ref:`ImageView2D` / :ref:`MutableImageView2D`, so all APIs consuming image views work with this type as well. + `Pixel data access`_ + ==================== + + The class makes use of Python's dynamic nature and provides direct access + to pixel data in their concrete types via :ref:`pixels`. See + :ref:`ImageView2D` documentation for more information and usage example. + + Compared to :ref:`Image2D` and :ref:`ImageView2D` / :ref:`MutableImageView2D`, + the :ref:`data` and :ref:`pixels` views are immutable and mutable access + is provided depending on the value of :ref:`data_flags` via + :ref:`mutable_data` and :ref:`mutable_pixels`. + .. py:class:: magnum.trade.ImageData3D See :ref:`ImageData2D` for more information. @@ -45,6 +57,16 @@ .. py:property:: magnum.trade.ImageData3D.storage :raise AttributeError: If :ref:`is_compressed` is :py:`True` +.. py:property:: magnum.trade.ImageData1D.mutable_data + :raise AttributeError: If :ref:`data_flags` doesn't contain + :ref:`DataFlags.MUTABLE` +.. py:property:: magnum.trade.ImageData2D.mutable_data + :raise AttributeError: If :ref:`data_flags` doesn't contain + :ref:`DataFlags.MUTABLE` +.. py:property:: magnum.trade.ImageData3D.mutable_data + :raise AttributeError: If :ref:`data_flags` doesn't contain + :ref:`DataFlags.MUTABLE` + .. py:property:: magnum.trade.ImageData1D.format :raise AttributeError: If :ref:`is_compressed` is :py:`True` .. py:property:: magnum.trade.ImageData2D.format @@ -66,6 +88,19 @@ .. py:property:: magnum.trade.ImageData3D.pixels :raise AttributeError: If :ref:`is_compressed` is :py:`True` +.. py:property:: magnum.trade.ImageData1D.mutable_pixels + :raise AttributeError: If :ref:`is_compressed` is :py:`True` + :raise AttributeError: If :ref:`data_flags` doesn't contain + :ref:`DataFlags.MUTABLE` +.. py:property:: magnum.trade.ImageData2D.mutable_pixels + :raise AttributeError: If :ref:`is_compressed` is :py:`True` + :raise AttributeError: If :ref:`data_flags` doesn't contain + :ref:`DataFlags.MUTABLE` +.. py:property:: magnum.trade.ImageData3D.mutable_pixels + :raise AttributeError: If :ref:`is_compressed` is :py:`True` + :raise AttributeError: If :ref:`data_flags` doesn't contain + :ref:`DataFlags.MUTABLE` + .. py:enum:: magnum.trade.MeshAttribute The equivalent to C++ :dox:`Trade::meshAttributeCustom()` is creating an diff --git a/src/Corrade/Containers/StridedArrayViewPythonBindings.h b/src/Corrade/Containers/StridedArrayViewPythonBindings.h index 7252ebb..6d2154f 100644 --- a/src/Corrade/Containers/StridedArrayViewPythonBindings.h +++ b/src/Corrade/Containers/StridedArrayViewPythonBindings.h @@ -51,7 +51,6 @@ template<> constexpr const char* pythonFormatString() { return "I template<> constexpr const char* pythonFormatString() { return "q"; } template<> constexpr const char* pythonFormatString() { return "Q"; } /** @todo how to represent std::size_t? conflicts with uint32_t/uint64_t above */ -/** @todo half? take from Magnum? */ template<> constexpr const char* pythonFormatString() { return "f"; } template<> constexpr const char* pythonFormatString() { return "d"; } diff --git a/src/Magnum/StridedArrayViewPythonBindings.h b/src/Magnum/StridedArrayViewPythonBindings.h index 6394781..1aa3c2a 100644 --- a/src/Magnum/StridedArrayViewPythonBindings.h +++ b/src/Magnum/StridedArrayViewPythonBindings.h @@ -25,9 +25,10 @@ DEALINGS IN THE SOFTWARE. */ -#include #include +#include "Corrade/Containers/StridedArrayViewPythonBindings.h" + namespace Corrade { namespace Containers { namespace Implementation { /* This expands Containers::Implementation::pythonFormatString() with Magnum @@ -37,7 +38,9 @@ namespace Corrade { namespace Containers { namespace Implementation { #define _c(type, string) \ template<> constexpr const char* pythonFormatString() { return string; } +_c(Half, "e") _c(Vector2, "2f") +_c(Vector2h, "2e") _c(Vector2d, "2d") _c(Vector2ub, "2B") _c(Vector2b, "2b") @@ -46,6 +49,7 @@ _c(Vector2s, "2h") _c(Vector2ui, "2I") _c(Vector2i, "2i") _c(Vector3, "3f") +_c(Vector3h, "3e") _c(Vector3d, "3d") _c(Vector3ub, "3B") _c(Vector3b, "3b") @@ -54,6 +58,7 @@ _c(Vector3s, "3h") _c(Vector3ui, "3I") _c(Vector3i, "3i") _c(Vector4, "4f") +_c(Vector4h, "4e") _c(Vector4d, "4d") _c(Vector4ub, "4B") _c(Vector4b, "4b") diff --git a/src/python/magnum/acessorsForPixelFormat.h b/src/python/magnum/acessorsForPixelFormat.h new file mode 100644 index 0000000..1cb5859 --- /dev/null +++ b/src/python/magnum/acessorsForPixelFormat.h @@ -0,0 +1,194 @@ +#ifndef magnum_accessorsForPixelFormat_h +#define magnum_accessorsForPixelFormat_h +/* + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, + 2020, 2021, 2022 Vladimír Vondruš + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +#include +#include +#include +#include +#include +#include + +#include "Magnum/StridedArrayViewPythonBindings.h" + +#include "magnum/bootstrap.h" + +/* This is used by both the root magnum module (Image, ImageView) and + magnum.trade.ImageData. There's no easy way to call an exported symbol of + another module due to Python isolating their namespaces so it's duplicated + in both modules. */ +namespace magnum { namespace { + +Containers::Triple accessorsForPixelFormat(const PixelFormat format) { + switch(format) { + #define _c(format, type) \ + case PixelFormat::format: return { \ + Containers::Implementation::pythonFormatString(), \ + [](const char* item) { \ + return py::cast(*reinterpret_cast(item)); \ + }, \ + [](char* item, py::handle object) { \ + *reinterpret_cast(item) = py::cast(object); \ + }}; + /* Types (such as half-floats) that need to be cast before passed + from/to pybind that doesn't understand the type directly */ + #define _cc(format, type, castType) \ + case PixelFormat::format: return { \ + Containers::Implementation::pythonFormatString(), \ + [](const char* item) { \ + return py::cast(castType(*reinterpret_cast(item))); \ + }, \ + [](char* item, py::handle object) { \ + *reinterpret_cast(item) = type(py::cast(object)); \ + }}; + /* Normalized types that need to be packed/unpacked before passed + from/to pybind */ + #define _cNormalized(format, type, unpackType) \ + case PixelFormat::format: return { \ + Containers::Implementation::pythonFormatString(), \ + [](const char* item) { \ + return py::cast(Math::unpack(*reinterpret_cast(item))); \ + }, \ + [](char* item, py::handle object) { \ + *reinterpret_cast(item) = Math::pack(py::cast(object)); \ + }}; + /* LCOV_EXCL_START */ + _cNormalized(R8Unorm, UnsignedByte, Float) + _cNormalized(RG8Unorm, Vector2ub, Vector2) + _cNormalized(RGB8Unorm, Vector3ub, Vector3) + _cNormalized(RGBA8Unorm, Vector4ub, Vector4) + _cNormalized(R8Snorm, Byte, Float) + _cNormalized(RG8Snorm, Vector2b, Vector2) + _cNormalized(RGB8Snorm, Vector3b, Vector3) + _cNormalized(RGBA8Snorm, Vector4b, Vector4) + /* LCOV_EXCL_STOP */ + case PixelFormat::R8Srgb: return { + Containers::Implementation::pythonFormatString(), + /** @todo have an (internal) API to convert just R/RG sRGB channels */ + [](const char* item) { + return py::cast(Color3::fromSrgb(Vector3ub{*reinterpret_cast(item), 0, 0}).r()); + }, + [](char* item, py::handle object) { + *reinterpret_cast(item) = Color3{py::cast(object), 0.0f, 0.0f}.toSrgb().r(); + }}; + case PixelFormat::RG8Srgb: return { + Containers::Implementation::pythonFormatString(), + /** @todo have an (internal) API to convert just R/RG sRGB channels */ + [](const char* item) { + return py::cast(Color3::fromSrgb(Vector3ub{*reinterpret_cast(item), 0}).rg()); + }, + [](char* item, py::handle object) { + *reinterpret_cast(item) = Color3{py::cast(object), 0.0f}.toSrgb().rg(); + }}; + case PixelFormat::RGB8Srgb: return { + Containers::Implementation::pythonFormatString(), + [](const char* item) { + return py::cast(Color3::fromSrgb(*reinterpret_cast(item))); + }, + [](char* item, py::handle object) { + *reinterpret_cast(item) = py::cast(object).toSrgb(); + }}; + case PixelFormat::RGBA8Srgb: return { + Containers::Implementation::pythonFormatString(), + [](const char* item) { + return py::cast(Color4::fromSrgbAlpha(*reinterpret_cast(item))); + }, + [](char* item, py::handle object) { + *reinterpret_cast(item) = py::cast(object).toSrgbAlpha(); + }}; + /* LCOV_EXCL_START */ + _cc(R8UI, UnsignedByte, UnsignedInt) + _cc(RG8UI, Vector2ub, Vector2ui) + _cc(RGB8UI, Vector3ub, Vector3ui) + _cc(RGBA8UI, Vector4ub, Vector4ui) + _cc(R8I, Byte, Int) + _cc(RG8I, Vector2b, Vector2i) + _cc(RGB8I, Vector3b, Vector3i) + _cc(RGBA8I, Vector4b, Vector4i) + _cNormalized(R16Unorm, UnsignedShort, Float) + _cNormalized(RG16Unorm, Vector2us, Vector2) + _cNormalized(RGB16Unorm, Vector3us, Vector3) + _cNormalized(RGBA16Unorm, Vector4us, Vector4) + _cNormalized(R16Snorm, Short, Float) + _cNormalized(RG16Snorm, Vector2s, Vector2) + _cNormalized(RGB16Snorm, Vector3s, Vector3) + _cNormalized(RGBA16Snorm, Vector4s, Vector4) + _cc(R16UI, UnsignedShort, UnsignedInt) + _cc(RG16UI, Vector2us, Vector2ui) + _cc(RGB16UI, Vector3us, Vector3ui) + _cc(RGBA16UI, Vector4us, Vector4ui) + _cc(R16I, Short, Int) + _cc(RG16I, Vector2s, Vector2i) + _cc(RGB16I, Vector3s, Vector3i) + _cc(RGBA16I, Vector4s, Vector4i) + _c(R32UI, UnsignedInt) + _c(RG32UI, Vector2ui) + _c(RGB32UI, Vector3ui) + _c(RGBA32UI, Vector4ui) + _c(R32I, Int) + _c(RG32I, Vector2i) + _c(RGB32I, Vector3i) + _c(RGBA32I, Vector4i) + _cc(R16F, Half, Float) + _cc(RG16F, Vector2h, Vector2) + _cc(RGB16F, Vector3h, Vector3) + _cc(RGBA16F, Vector4h, Vector4) + _c(R32F, Float) + _c(RG32F, Vector2) + _c(RGB32F, Vector3) + _c(RGBA32F, Vector4) + /* LCOV_EXCL_STOP */ + #undef _c + #undef _cc + #undef _cNormalized + + /** @todo handle depth/stencil types (yes, i'm lazy) */ + default: + return {}; + } +} + +template Containers::StridedArrayView flattenPixelView(const Containers::ArrayView data, const Containers::StridedArrayView& pixels) { + /** @todo have some builtin API for this, this is awful (pixels()? + flatten() that asserts the last dimension is contiguous and + of a POD type? transpose<3, 0, 1, 2>()[0], differently named to avoid + clashes, taking dimension order?) */ + Containers::Size size{NoInit}; + Containers::Stride stride{NoInit}; + for(std::size_t i = 0; i != dimensions - 1; ++i) { + size[i] = pixels.size()[i]; + stride[i] = pixels.stride()[i]; + } + return Containers::StridedArrayView{ + data, + static_cast(pixels.data()), + size, + stride}; +} + +}} + +#endif diff --git a/src/python/magnum/magnum.cpp b/src/python/magnum/magnum.cpp index f6d6312..2e23943 100644 --- a/src/python/magnum/magnum.cpp +++ b/src/python/magnum/magnum.cpp @@ -39,6 +39,7 @@ #include "Corrade/Containers/StridedArrayViewPythonBindings.h" #include "Magnum/PythonBindings.h" +#include "magnum/acessorsForPixelFormat.h" #include "magnum/bootstrap.h" #ifdef MAGNUM_BUILD_STATIC @@ -68,8 +69,15 @@ template void image(py::class_& c) { return Containers::pyArrayViewHolder(self.data(), self.data() ? py::cast(self) : py::none{}); }, "Raw image data") .def_property_readonly("pixels", [](T& self) { - return Containers::pyArrayViewHolder(Containers::PyStridedArrayView{self.pixels()}, self.data() ? py::cast(self) : py::none{}); - }, "View on pixel data"); + const PixelFormat format = self.format(); + const std::size_t itemsize = pixelFormatSize(format); + const Containers::Triple formatStringGetitemSetitem = accessorsForPixelFormat(format); + if(!formatStringGetitemSetitem.first()) { + PyErr_SetString(PyExc_NotImplementedError, "access to this pixel format is not implemented yet, sorry"); + throw py::error_already_set{}; + } + return Containers::pyArrayViewHolder(Containers::PyStridedArrayView{flattenPixelView(self.data(), self.pixels()), formatStringGetitemSetitem.first(), itemsize, formatStringGetitemSetitem.second(), formatStringGetitemSetitem.third()}, self.data() ? py::cast(self) : py::none{}); + }, "Pixel data"); } template void imageView(py::class_>& c) { @@ -138,8 +146,15 @@ template void imageView(py::class_>& c) { pyObjectHolderFor(data).owner; }, "Raw image data") .def_property_readonly("pixels", [](T& self) { - return Containers::pyArrayViewHolder(Containers::PyStridedArrayView{self.pixels()}, pyObjectHolderFor(self).owner); - }, "View on pixel data") + const PixelFormat format = self.format(); + const std::size_t itemsize = pixelFormatSize(format); + const Containers::Triple formatStringGetitemSetitem = accessorsForPixelFormat(format); + if(!formatStringGetitemSetitem.first()) { + PyErr_SetString(PyExc_NotImplementedError, "access to this pixel format is not implemented yet, sorry"); + throw py::error_already_set{}; + } + return Containers::pyArrayViewHolder(Containers::PyStridedArrayView{flattenPixelView(self.data(), self.pixels()), formatStringGetitemSetitem.first(), itemsize, formatStringGetitemSetitem.second(), formatStringGetitemSetitem.third()}, pyObjectHolderFor(self).owner); + }, "Pixel data") .def_property_readonly("owner", [](T& self) { return pyObjectHolderFor(self).owner; diff --git a/src/python/magnum/test/dxt10-depth32f-stencil8ui.dds b/src/python/magnum/test/dxt10-depth32f-stencil8ui.dds new file mode 100644 index 0000000000000000000000000000000000000000..e363cfcf556ee327da40b9fb227fb835cbf3f9cf GIT binary patch literal 196 zcmZ>930A0KU|`@EU}9hb(#$|C0mO_@45Ww#3P6=CKXn;);!=a_u}l=t<|+ZbIQw};eVYyAwB>~iW@=z literal 0 HcmV?d00001 diff --git a/src/python/magnum/test/test.py b/src/python/magnum/test/test.py index c6dc5b8..09b33e1 100644 --- a/src/python/magnum/test/test.py +++ b/src/python/magnum/test/test.py @@ -23,6 +23,7 @@ # DEALINGS IN THE SOFTWARE. # +import array import sys import unittest @@ -48,23 +49,21 @@ class PixelStorage_(unittest.TestCase): self.assertEqual(a.skip, Vector3i(3, 1, 2)) class Image(unittest.TestCase): - def test_init(self): + def test_init_empty(self): storage = PixelStorage() storage.alignment = 1 a = Image2D(storage, PixelFormat.RGB8_UNORM) self.assertEqual(a.storage.alignment, 1) self.assertEqual(a.size, Vector2i()) self.assertEqual(a.format, PixelFormat.RGB8_UNORM) - self.assertEqual(len(a.data), 0) b = Image2D(PixelFormat.R8I) self.assertEqual(b.storage.alignment, 4) self.assertEqual(b.size, Vector2i()) self.assertEqual(b.format, PixelFormat.R8I) - self.assertEqual(len(b.data), 0) @unittest.skip("No way to create a non-empty Image at the moment") - def test_data(self): + def test_data_access(self): # Tested in test_gl_gl.Framebuffer.test_read_image instead a = Image2D(PixelFormat.R8I, Vector2i(3, 17)) # TODO a_refcount = sys.getrefcount(a) @@ -77,7 +76,7 @@ class Image(unittest.TestCase): del data self.assertEqual(sys.getrefcount(a), a_refcount) - def test_data_empty(self): + def test_data_access_empty(self): a = Image2D(PixelFormat.R8I) a_refcount = sys.getrefcount(a) @@ -87,28 +86,38 @@ class Image(unittest.TestCase): self.assertEqual(sys.getrefcount(a), a_refcount) @unittest.skip("No way to create a non-empty Image at the moment") - def test_pixels(self): + def test_pixels_access(self): # Tested in test_gl_gl.Framebuffer.test_read_image instead a = Image2D(PixelFormat.RG32UI, Vector2i(3, 17)) # TODO a_refcount = sys.getrefcount(a) pixels = a.pixels - self.assertEqual(pixels.size, (3, 17, 8)) + self.assertEqual(pixels.size, (3, 17)) + self.assertEqual(pixels.stride, (17*8, 8)) + self.assertEqual(pixels.format, '2I') self.assertIs(pixels.owner, a) self.assertEqual(sys.getrefcount(a), a_refcount + 1) del pixels self.assertEqual(sys.getrefcount(a), a_refcount) - def test_pixels_empty(self): - a = Image2D(PixelFormat.R8I) + def test_pixels_access_empty(self): + a = Image2D(PixelFormat.RGB8I) a_refcount = sys.getrefcount(a) pixels = a.pixels - self.assertEqual(pixels.size, (0, 0, 1)) + self.assertEqual(pixels.size, (0, 0)) + self.assertEqual(pixels.stride, (0, 3)) + self.assertEqual(pixels.format, '3b') self.assertIs(pixels.owner, None) self.assertEqual(sys.getrefcount(a), a_refcount) + def test_pixels_access_unsupported_format(self): + a = Image2D(PixelFormat.DEPTH32F) + + with self.assertRaisesRegex(NotImplementedError, "access to this pixel format is not implemented yet, sorry"): + a.pixels + class ImageView(unittest.TestCase): def test_init(self): # 2x4 RGB pixels, padded for alignment @@ -123,16 +132,11 @@ class ImageView(unittest.TestCase): self.assertEqual(a.size, Vector2i(2, 4)) self.assertEqual(a.format, PixelFormat.RGB8_UNORM) self.assertEqual(a.pixel_size, 3) - self.assertEqual(len(a.data), 32) - self.assertIs(a.owner, data) + self.assertEqual(a.owner, data) self.assertEqual(sys.getrefcount(data), data_refcount + 1) - # Third row, second pixel, green channel - self.assertEqual(a.pixels[2][1][1], 'E') - - # Deleting the original data shouldn't make the image invalid - del data - self.assertEqual(a.pixels[2][1][1], 'E') + del a + self.assertEqual(sys.getrefcount(data), data_refcount) def test_init_storage(self): # 2x2x2 RGB pixels @@ -147,16 +151,18 @@ class ImageView(unittest.TestCase): a = ImageView3D(storage, PixelFormat.RGB8_UNORM, (2, 2, 2), data) self.assertEqual(a.storage.alignment, 2) self.assertEqual(a.size, Vector3i(2, 2, 2)) - self.assertEqual(len(a.data), 24) + self.assertEqual(a.format, PixelFormat.RGB8_UNORM) + self.assertEqual(a.pixel_size, 3) + self.assertEqual(a.owner, data) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) - # Second image, first row, second pixel, green channel - self.assertEqual(a.pixels[1][0][1][1], 'E') + del a + self.assertEqual(sys.getrefcount(data), data_refcount) def test_init_empty(self): a = MutableImageView1D(PixelFormat.RG16UI, 32) self.assertEqual(a.storage.alignment, 4) self.assertEqual(a.size, 32) - self.assertEqual(len(a.data), 0) self.assertEqual(a.owner, None) storage = PixelStorage() @@ -164,7 +170,6 @@ class ImageView(unittest.TestCase): b = ImageView2D(storage, PixelFormat.R32F, (8, 8)) self.assertEqual(b.storage.alignment, 2) self.assertEqual(b.size, Vector2i(8, 8)) - self.assertEqual(len(b.data), 0) self.assertEqual(b.owner, None) def test_init_mutable(self): @@ -178,16 +183,6 @@ class ImageView(unittest.TestCase): a = MutableImageView2D(PixelFormat.RGB8_UNORM, (2, 4), data) self.assertEqual(sys.getrefcount(data), data_refcount + 1) - a.pixels[1, 1, 1] = '_' - a.pixels[1, 0, 1] = '_' - a.pixels[2, 1, 1] = '_' - a.pixels[2, 0, 1] = '_' - - self.assertEqual(data, b'rgbRGB ' - b'a_cA_C ' - b'd_fD_F ' - b'ijkIJK ') - # Back to immutable b = ImageView2D(a) self.assertEqual(b.size, Vector2i(2, 4)) @@ -233,6 +228,61 @@ class ImageView(unittest.TestCase): self.assertIs(mview.owner, None) self.assertEqual(sys.getrefcount(a), a_refcount) + def test_data_access(self): + # 2x4 RGB pixels, padded for alignment + data = (b'rgbRGB ' + b'abcABC ' + b'defDEF ' + b'ijkIJK ') + data_refcount = sys.getrefcount(data) + + a = ImageView2D(PixelFormat.RGB8_UNORM, (2, 4), data) + a_refcount = sys.getrefcount(a) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + a_data = a.data + self.assertEqual(len(a_data), 32) + self.assertEqual(a_data[9], 'b') + self.assertEqual(a_data[20], 'E') + self.assertIs(a_data.owner, data) + # The data references the original data as an owner, not the view + self.assertEqual(sys.getrefcount(a), a_refcount) + self.assertEqual(sys.getrefcount(data), data_refcount + 2) + + del a_data + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + def test_mutable_data_access(self): + # 2x4 RGB pixels, padded for alignment + data = bytearray(b'rgbRGB ' + b'abcABC ' + b'defDEF ' + b'ijkIJK ') + data_refcount = sys.getrefcount(data) + + a = MutableImageView2D(PixelFormat.RGB8_UNORM, (2, 4), data) + a_refcount = sys.getrefcount(a) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + a_data = a.data + self.assertEqual(len(a_data), 32) + self.assertEqual(a_data[9], 'b') + self.assertEqual(a_data[20], 'E') + self.assertIs(a_data.owner, data) + # The data references the original data as an owner, not the view + self.assertEqual(sys.getrefcount(a), a_refcount) + self.assertEqual(sys.getrefcount(data), data_refcount + 2) + + a_data[9] = '_' + a_data[20] = '_' + self.assertEqual(data, b'rgbRGB ' + b'a_cABC ' + b'defD_F ' + b'ijkIJK ') + + del a_data + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + def test_set_data(self): # 2x4 RGB pixels, padded for alignment data = (b'rgbRGB ' @@ -262,3 +312,138 @@ class ImageView(unittest.TestCase): self.assertIs(a.owner, data2) self.assertEqual(sys.getrefcount(data), data_refcount) self.assertEqual(sys.getrefcount(data2), data2_refcount + 1) + + def test_pixels_access(self): + # 2x4 RGB pixels, padded for alignment + data = (b'rgbRGB ' + b'abcABC ' + b'defDEF ' + b'ijkIJK ') + data_refcount = sys.getrefcount(data) + + a = ImageView2D(PixelFormat.RGB8UI, (2, 4), data) + a_refcount = sys.getrefcount(a) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + pixels = a.pixels + self.assertEqual(pixels.size, (4, 2)) + self.assertEqual(pixels.stride, (8, 3)) + self.assertEqual(pixels.format, '3B') + self.assertEqual(pixels[1, 0].g, ord('b')) + self.assertEqual(pixels[2, 1].g, ord('E')) + self.assertIs(pixels.owner, data) + # The data references the original data as an owner, not the view + self.assertEqual(sys.getrefcount(a), a_refcount) + self.assertEqual(sys.getrefcount(data), data_refcount + 2) + + del pixels + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + def test_mutable_pixels_access(self): + # 2x4 RGB pixels, padded for alignment + data = bytearray(b'rgbRGB ' + b'abcABC ' + b'defDEF ' + b'ijkIJK ') + data_refcount = sys.getrefcount(data) + + a = MutableImageView2D(PixelFormat.RGB8UI, (2, 4), data) + a_refcount = sys.getrefcount(a) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + pixels = a.pixels + self.assertEqual(pixels.size, (4, 2)) + self.assertEqual(pixels.stride, (8, 3)) + self.assertEqual(pixels.format, '3B') + self.assertEqual(pixels[1, 0].g, ord('b')) + self.assertEqual(pixels[2, 1].g, ord('E')) + self.assertIs(pixels.owner, data) + # The data references the original data as an owner, not the view + self.assertEqual(sys.getrefcount(a), a_refcount) + self.assertEqual(sys.getrefcount(data), data_refcount + 2) + + pixels[1, 0] = Vector3(ord('a'), ord('_'), ord('c')) + pixels[2, 1] = Vector3(ord('D'), ord('_'), ord('F')) + self.assertEqual(data, b'rgbRGB ' + b'a_cABC ' + b'defD_F ' + b'ijkIJK ') + + del pixels + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + def test_pixels_access_direct(self): + data = array.array('f', [1.0, 2.0, 3.0, 4.0]) + a = MutableImageView2D(PixelFormat.RG32F, (2, 1), data) + + pixels = a.pixels + self.assertEqual(pixels.size, (1, 2)) + self.assertEqual(pixels.stride, (16, 8)) + self.assertEqual(pixels.format, '2f') + self.assertEqual(pixels[0, 1], Vector2(3.0, 4.0)) + + pixels[0, 1] *= 0.25 + self.assertEqual(pixels[0, 1], Vector2(0.75, 1.0)) + + def test_pixels_access_cast(self): + # 0.0, 1.0, 2.0, 3.0; values taken from Magnum's HalfTest.cpp + # TODO clean up once array supports half-floats (ugh) + data = array.array('H', [0x0000, 0x3c00, 0x4000, 0x4200]) + a = MutableImageView2D(PixelFormat.RG16F, (2, 1), data) + + pixels = a.pixels + self.assertEqual(pixels.size, (1, 2)) + self.assertEqual(pixels.stride, (8, 4)) + self.assertEqual(pixels.format, '2e') + self.assertEqual(pixels[0, 1], Vector2(2.0, 3.0)) + + pixels[0, 1] *= 0.25 + self.assertEqual(pixels[0, 1], Vector2(0.5, 0.75)) + + def test_pixels_access_srgb(self): + # Values taken from Magnum's ColorTest::fromIntegralSrgb() + data1 = array.array('B', [0xf3, 0x2a, 0x80, 0x23, 0xff, 0x00, 0xff, 0x00]) + data2 = array.array('B', [0xf3, 0x2a, 0x80, 0xff, 0x00, 0xff, 0, 0]) + data3 = array.array('B', [0xf3, 0x2a, 0xff, 0x00, 0, 0, 0, 0]) + data4 = array.array('B', [0xf3, 0xff, 0, 0, 0, 0, 0, 0]) + rgba = MutableImageView2D(PixelFormat.RGBA8_SRGB, (2, 1), data1) + rgb = MutableImageView2D(PixelFormat.RGB8_SRGB, (2, 1), data2) + rg = MutableImageView2D(PixelFormat.RG8_SRGB, (2, 1), data3) + r = MutableImageView2D(PixelFormat.R8_SRGB, (2, 1), data4) + + rgba_pixels = rgba.pixels + rgb_pixels = rgb.pixels + rg_pixels = rg.pixels + r_pixels = r.pixels + self.assertEqual(rgba_pixels.format, '4B') + self.assertEqual(rgb_pixels.format, '3B') + self.assertEqual(rg_pixels.format, '2B') + self.assertEqual(r_pixels.format, 'B') + self.assertEqual(rgba_pixels[0, 0], Vector4(0.896269, 0.0231534, 0.215861, 0.137255)) + self.assertEqual(rgb_pixels[0, 0], Vector3(0.896269, 0.0231534, 0.215861)) + self.assertEqual(rg_pixels[0, 0], Vector2(0.896269, 0.0231534)) + # Python compares floats with an unnecessary precision compared to + # Magnum's op== on vector types + self.assertEqual(r_pixels[0, 0], 0.8962693810462952) + self.assertEqual(rgba_pixels[0, 1], Vector4(1.0, 0.0, 1.0, 0.0)) + self.assertEqual(rgb_pixels[0, 1], Vector3(1.0, 0.0, 1.0)) + self.assertEqual(rg_pixels[0, 1], Vector2(1.0, 0.0)) + self.assertEqual(r_pixels[0, 1], 1.0) + + rgba_pixels[0, 0] *= 0.5 + rgb_pixels[0, 0] *= 0.5 + rg_pixels[0, 0] *= 0.5 + r_pixels[0, 0] *= 0.5 + self.assertEqual(rgba_pixels[0, 0], Vector4(0.450786, 0.0116122, 0.107023, 0.0705882)) + self.assertEqual(rgb_pixels[0, 0], Vector3(0.450786, 0.0116122, 0.107023)) + self.assertEqual(rg_pixels[0, 0], Vector2(0.450786, 0.0116122)) + # Python compares floats with an unnecessary precision compared to + # Magnum's op== on vector types + self.assertEqual(r_pixels[0, 0], 0.4507858455181122) + + def test_pixels_access_unsupported_format(self): + data = array.array('f', [1.0, 2.0, 3.0, 4.0]) + a = ImageView2D(PixelFormat.DEPTH32F, (2, 2), data) + + with self.assertRaisesRegex(NotImplementedError, "access to this pixel format is not implemented yet, sorry"): + a.pixels diff --git a/src/python/magnum/test/test_gl_gl.py b/src/python/magnum/test/test_gl_gl.py index 4dcf0b6..5632460 100644 --- a/src/python/magnum/test/test_gl_gl.py +++ b/src/python/magnum/test/test_gl_gl.py @@ -221,11 +221,13 @@ class Framebuffer(GLTestCase): self.assertEqual(sys.getrefcount(a), a_refcount) pixels = a.pixels + self.assertEqual(pixels.size, (2, 2)) + self.assertEqual(pixels.stride, (8, 4)) + self.assertEqual(pixels.format, '4B') self.assertIs(pixels.owner, a) + # Rounding errors in the 8-bit representation + self.assertEqual(pixels[0, 0], Color4(1, 0.501961, 0.74902)) self.assertEqual(sys.getrefcount(a), a_refcount + 1) - self.assertEqual(ord(a.pixels[0, 0, 0]), 0xff) - self.assertEqual(ord(a.pixels[0, 1, 1]), 0x80) - self.assertEqual(ord(a.pixels[1, 0, 2]), 0xbf) del pixels self.assertEqual(sys.getrefcount(a), a_refcount) @@ -259,9 +261,8 @@ class Framebuffer(GLTestCase): a = MutableImageView2D(PixelFormat.RGBA8_UNORM, (2, 2), bytearray(16)) framebuffer.read(Range2Di.from_size((1, 1), (2, 2)), a) self.assertEqual(a.size, Vector2i(2, 2)) - self.assertEqual(ord(a.pixels[0, 0, 0]), 0xff) - self.assertEqual(ord(a.pixels[0, 1, 1]), 0x80) - self.assertEqual(ord(a.pixels[1, 0, 2]), 0xbf) + # Rounding errors in the 8-bit representation + self.assertEqual(a.pixels[0, 0], Color4(1, 0.501961, 0.74902)) class Mesh(GLTestCase): def test_init(self): diff --git a/src/python/magnum/test/test_trade.py b/src/python/magnum/test/test_trade.py index c994d4e..f910aea 100644 --- a/src/python/magnum/test/test_trade.py +++ b/src/python/magnum/test/test_trade.py @@ -41,23 +41,11 @@ class ImageData(unittest.TestCase): importer.open_file(os.path.join(os.path.dirname(__file__), "rgb.png")) image = importer.image2d(0) - image_refcount = sys.getrefcount(image) self.assertFalse(image.is_compressed) self.assertEqual(image.storage.alignment, 1) # libPNG has 4 tho self.assertEqual(image.format, PixelFormat.RGB8_UNORM) self.assertEqual(image.pixel_size, 3) self.assertEqual(image.size, Vector2i(3, 2)) - # TODO: ugh, report as bytes, not chars - self.assertEqual(ord(image.pixels[1, 2, 2]), 181) - self.assertEqual(ord(image.data[9 + 6 + 2]), 181) # libPNG has 12 + - - data = image.data - self.assertEqual(len(data), 3*3*2) - self.assertIs(data.owner, image) - self.assertEqual(sys.getrefcount(image), image_refcount + 1) - - del data - self.assertEqual(sys.getrefcount(image), image_refcount) def test_compressed(self): # The only way to get an image instance is through a manager @@ -101,6 +89,107 @@ class ImageData(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "image is compressed"): mutable_view = MutableImageView2D(image) + def test_data_access(self): + # The only way to get an image instance is through a manager + importer = trade.ImporterManager().load_and_instantiate('StbImageImporter') + importer.open_file(os.path.join(os.path.dirname(__file__), "rgb.png")) + + image = importer.image2d(0) + image_refcount = sys.getrefcount(image) + self.assertEqual(image.storage.alignment, 1) # libPNG has 4 tho + self.assertEqual(image.format, PixelFormat.RGB8_UNORM) + self.assertEqual(image.size, Vector2i(3, 2)) + + data = image.data + self.assertEqual(len(data), 3*3*2) + self.assertEqual(ord(data[9 + 6 + 2]), 181) # libPNG has 12 + + self.assertIs(data.owner, image) + self.assertEqual(sys.getrefcount(image), image_refcount + 1) + + del data + self.assertEqual(sys.getrefcount(image), image_refcount) + + mutable_data = image.data + self.assertEqual(len(mutable_data), 3*3*2) + self.assertEqual(ord(mutable_data[9 + 6 + 2]), 181) # libPNG has 12 + + self.assertIs(mutable_data.owner, image) + self.assertEqual(sys.getrefcount(image), image_refcount + 1) + + del mutable_data + self.assertEqual(sys.getrefcount(image), image_refcount) + + def test_mutable_data_access(self): + # The only way to get an image instance is through a manager + importer = trade.ImporterManager().load_and_instantiate('StbImageImporter') + importer.open_file(os.path.join(os.path.dirname(__file__), "rgb.png")) + + image = importer.image2d(0) + self.assertEqual(image.data_flags, trade.DataFlags.OWNED|trade.DataFlags.MUTABLE) + + data = image.data + mutable_data = image.mutable_data + # TODO: ugh, report as bytes, not chars + self.assertEqual(ord(data[13]), 254) + self.assertEqual(ord(mutable_data[13]), 254) + + mutable_data[13] = chr(76) + self.assertEqual(data[13], chr(76)) + + def test_pixels_access(self): + # The only way to get an image instance is through a manager + importer = trade.ImporterManager().load_and_instantiate('StbImageImporter') + importer.open_file(os.path.join(os.path.dirname(__file__), "rgb.png")) + + image = importer.image2d(0) + image_refcount = sys.getrefcount(image) + self.assertEqual(image.storage.alignment, 1) # libPNG has 4 tho + self.assertEqual(image.format, PixelFormat.RGB8_UNORM) + self.assertEqual(image.size, Vector2i(3, 2)) + + pixels = image.pixels + self.assertEqual(pixels.size, (2, 3)) + self.assertEqual(pixels.stride, (9, 3)) + self.assertEqual(pixels.format, '3B') + self.assertEqual(pixels[0, 2], Color3(0.792157, 0.996078, 0.466667)) + self.assertEqual(pixels[1, 0], Color3(0.870588, 0.678431, 0.709804)) + self.assertIs(pixels.owner, image) + self.assertEqual(sys.getrefcount(image), image_refcount + 1) + + del pixels + self.assertEqual(sys.getrefcount(image), image_refcount) + + def test_mutable_pixels_access(self): + # The only way to get an image instance is through a manager + importer = trade.ImporterManager().load_and_instantiate('StbImageImporter') + importer.open_file(os.path.join(os.path.dirname(__file__), "rgb.png")) + + image = importer.image2d(0) + self.assertEqual(image.data_flags, trade.DataFlags.OWNED|trade.DataFlags.MUTABLE) + + pixels = image.pixels + mutable_pixels = image.mutable_pixels + self.assertEqual(pixels[0, 2], Color3(0.792157, 0.996078, 0.466667)) + self.assertEqual(mutable_pixels[0, 2], Color3(0.792157, 0.996078, 0.466667)) + + mutable_pixels[0, 2] *= 0.5 + self.assertEqual(pixels[0, 2], Color3(0.396078, 0.498039, 0.235294)) + + def test_data_access_not_mutable(self): + pass + # TODO implement once there's a way to get immutable ImageData, either + # by "deserializing" a binary blob, or by mmapping a KTX file etc. + + def test_pixels_access_unsupported_format(self): + # The only way to get an image instance is through a manager + importer = trade.ImporterManager().load_and_instantiate('DdsImporter') + importer.open_file(os.path.join(os.path.dirname(__file__), "dxt10-depth32f-stencil8ui.dds")) + + image = importer.image2d(0) + self.assertEqual(image.format, PixelFormat.DEPTH32F_STENCIL8UI) + + with self.assertRaisesRegex(NotImplementedError, "access to this pixel format is not implemented yet, sorry"): + image.pixels + class MeshData(unittest.TestCase): def test_custom_attribute(self): # Creating a custom attribute diff --git a/src/python/magnum/trade.cpp b/src/python/magnum/trade.cpp index e09578e..3b9804f 100644 --- a/src/python/magnum/trade.cpp +++ b/src/python/magnum/trade.cpp @@ -50,6 +50,7 @@ #include "corrade/EnumOperators.h" #include "corrade/pluginmanager.h" +#include "magnum/acessorsForPixelFormat.h" #include "magnum/bootstrap.h" #ifdef CORRADE_TARGET_WINDOWS @@ -173,6 +174,17 @@ template PyObject* implicitlyConvertibleToImage return r; } +template Containers::PyArrayViewHolder> imagePixelsView(Trade::ImageData& image, const Containers::ArrayView data, const Containers::StridedArrayView& pixels) { + const PixelFormat format = image.format(); + const std::size_t itemsize = pixelFormatSize(format); + const Containers::Triple formatStringGetitemSetitem = accessorsForPixelFormat(format); + if(!formatStringGetitemSetitem.first()) { + PyErr_SetString(PyExc_NotImplementedError, "access to this pixel format is not implemented yet, sorry"); + throw py::error_already_set{}; + } + return Containers::pyArrayViewHolder(Containers::PyStridedArrayView{flattenPixelView(data, pixels), formatStringGetitemSetitem.first(), itemsize, formatStringGetitemSetitem.second(), formatStringGetitemSetitem.third()}, py::cast(image)); +} + template void imageData(py::class_>& c) { /* Missing APIs: @@ -209,6 +221,9 @@ template void imageData(py::class_& self) { + return Trade::DataFlag(Containers::enumCastUnderlyingType(self.dataFlags())); + }, "Data flags") /* Properties */ .def_property_readonly("is_compressed", &Trade::ImageData::isCompressed, "Whether the image is compressed") @@ -242,14 +257,31 @@ template void imageData(py::class_& self) { return Containers::pyArrayViewHolder(self.data(), py::cast(self)); }, "Raw image data") + .def_property_readonly("mutable_data", [](Trade::ImageData& self) { + if(!(self.dataFlags() & Trade::DataFlag::Mutable)) { + PyErr_SetString(PyExc_AttributeError, "image data is not mutable"); + throw py::error_already_set{}; + } + return Containers::pyArrayViewHolder(self.mutableData(), py::cast(self)); + }, "Mutable raw image data") .def_property_readonly("pixels", [](Trade::ImageData& self) { if(self.isCompressed()) { PyErr_SetString(PyExc_AttributeError, "image is compressed"); throw py::error_already_set{}; } - - return Containers::pyArrayViewHolder(Containers::PyStridedArrayView{self.pixels()}, py::cast(self)); - }, "View on pixel data"); + return imagePixelsView(self, self.data(), self.pixels()); + }, "Pixel data") + .def_property_readonly("mutable_pixels", [](Trade::ImageData& self) { + if(self.isCompressed()) { + PyErr_SetString(PyExc_AttributeError, "image is compressed"); + throw py::error_already_set{}; + } + if(!(self.dataFlags() & Trade::DataFlag::Mutable)) { + PyErr_SetString(PyExc_AttributeError, "image data is not mutable"); + throw py::error_already_set{}; + } + return imagePixelsView(self, self.mutableData(), self.mutablePixels()); + }, "Mutable pixel data"); } /* For some reason having ...Args as the second (and not last) template