From c9515bbd1d60a8774044ad90762e083e96b38f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Wed, 31 May 2023 23:41:22 +0200 Subject: [PATCH] python: exposed CompressedImage and ImageView classes. --- doc/python/magnum.rst | 102 +++++++++++++++++ doc/python/pages/changelog.rst | 2 + src/python/magnum/__init__.py | 3 + src/python/magnum/magnum.cpp | 94 +++++++++++++++ src/python/magnum/test/test.py | 163 +++++++++++++++++++++++++++ src/python/magnum/test/test_trade.py | 10 +- src/python/magnum/trade.cpp | 24 ++++ 7 files changed, 396 insertions(+), 2 deletions(-) diff --git a/doc/python/magnum.rst b/doc/python/magnum.rst index 56f49a6..64e24d1 100644 --- a/doc/python/magnum.rst +++ b/doc/python/magnum.rst @@ -83,6 +83,23 @@ See :ref:`Image2D` for more information. +.. py:class:: magnum.CompressedImage1D + + See :ref:`CompressedImage2D` for more information. + +.. py:class:: magnum.CompressedImage2D + + An owning counterpart to :ref:`CompressedImageView2D` / + :ref:`MutableCompressedImageView2D`. Holds its own data buffer, thus + doesn't have an equivalent to :ref:`CompressedImageView2D.owner`. The + :ref:`data` view allows mutable access. Implicitly convertible to + :ref:`CompressedImageView2D` / :ref:`MutableCompressedImageView2D`, so all + APIs consuming image views work with this type as well. + +.. py:class:: magnum.CompressedImage3D + + See :ref:`CompressedImage2D` for more information. + .. py:class:: magnum.ImageView1D See :ref:`ImageView2D` for more information. @@ -194,3 +211,88 @@ This function is used to implement implicit conversion from :ref:`trade.ImageData3D` in the :ref:`trade` module. + +.. py:class:: magnum.CompressedImageView1D + + See :ref:`CompressedImageView2D` for more information. + +.. py:class:: magnum.CompressedImageView2D + + :TODO: remove this line once m.css stops ignoring first caption on a page + + `Memory ownership and reference counting`_ + ========================================== + + Similarly to :ref:`corrade.containers.ArrayView` (and unlike in C++), the + view keeps a reference to the original memory owner object in the + :ref:`owner` field, meaning that calling :py:`del` on the original object + will *not* invalidate the view. Slicing a view creates a new view + referencing the same original object, without any dependency on the + previous view. That means a long chained slicing operation will not cause + increased memory usage. + + The :ref:`owner` is :py:`None` if the view is empty. + +.. py:class:: magnum.CompressedImageView3D + + See :ref:`CompressedImageView2D` for more information. + +.. py:class:: magnum.MutableCompressedImageView1D + + See :ref:`CompressedImageView2D` for more information. The only difference + to the non-mutable variant is that it's possible to modify the image + through :ref:`data`. + +.. py:class:: magnum.MutableCompressedImageView2D + + See :ref:`CompressedImageView2D` for more information. The only difference + to the non-mutable variant is that it's possible to modify the image + through :ref:`data`. + +.. py:class:: magnum.MutableImageView3D + + See :ref:`CompressedImageView2D` for more information. The only difference + to the non-mutable variant is that it's possible to modify the image + through :ref:`data`. + +.. py:function:: magnum.CompressedImageView1D.__init__(self, arg0: magnum.CompressedImageView1D) + :raise RuntimeError: If :ref:`trade.ImageData1D.is_compressed` is + :py:`False` + + This function is used to implement implicit conversion from + :ref:`trade.ImageData1D` in the :ref:`trade` module. + +.. py:function:: magnum.CompressedImageView2D.__init__(self, arg0: magnum.CompressedImageView2D) + :raise RuntimeError: If :ref:`trade.ImageData2D.is_compressed` is + :py:`False` + + This function is used to implement implicit conversion from + :ref:`trade.ImageData2D` in the :ref:`trade` module. + +.. py:function:: magnum.CompressedImageView3D.__init__(self, arg0: magnum.CompressedImageView3D) + :raise RuntimeError: If :ref:`trade.ImageData3D.is_compressed` is + :py:`False` + + This function is used to implement implicit conversion from + :ref:`trade.ImageData3D` in the :ref:`trade` module. + +.. py:function:: magnum.MutableCompressedImageView1D.__init__(self, arg0: magnum.MutableCompressedImageView1D) + :raise RuntimeError: If :ref:`trade.ImageData1D.is_compressed` is + :py:`False` + + This function is used to implement implicit conversion from + :ref:`trade.ImageData1D` in the :ref:`trade` module. + +.. py:function:: magnum.MutableCompressedImageView2D.__init__(self, arg0: magnum.MutableCompressedImageView2D) + :raise RuntimeError: If :ref:`trade.ImageData2D.is_compressed` is + :py:`True` + + This function is used to implement implicit conversion from + :ref:`trade.ImageData2D` in the :ref:`trade` module. + +.. py:function:: magnum.MutableCompressedImageView3D.__init__(self, arg0: magnum.MutableCompressedImageView3D) + :raise RuntimeError: If :ref:`trade.ImageData3D.is_compressed` is + :py:`False` + + This function is used to implement implicit conversion from + :ref:`trade.ImageData3D` in the :ref:`trade` module. diff --git a/doc/python/pages/changelog.rst b/doc/python/pages/changelog.rst index 2f76798..8a71122 100644 --- a/doc/python/pages/changelog.rst +++ b/doc/python/pages/changelog.rst @@ -58,6 +58,8 @@ Changelog - Exposed the :ref:`CompressedPixelFormat` enum, various pixel-format-related helper APIs are now properties on :ref:`PixelFormat` and :ref:`CompressedPixelFormat` +- Exposed :ref:`CompressedImage2D`, :ref:`CompressedImageView2D`, + :ref:`MutableCompressedImageView2D` and their 1D and 3D counterparts - Exposed :ref:`Color3.from_xyz()`, :ref:`Color3.from_linear_rgb_int()`, :ref:`Color3.to_xyz()`, :ref:`Color3.to_linear_rgb_int()` and equivalent APIs on :ref:`Color4` diff --git a/src/python/magnum/__init__.py b/src/python/magnum/__init__.py index 26ff293..0236cc2 100644 --- a/src/python/magnum/__init__.py +++ b/src/python/magnum/__init__.py @@ -80,8 +80,11 @@ __all__ = [ 'PixelFormat', 'PixelStorage', 'CompressedPixelFormat', 'Image1D', 'Image2D', 'Image3D', + 'CompressedImage1D', 'CompressedImage2D', 'CompressedImage3D', 'ImageView1D', 'ImageView2D', 'ImageView3D', 'MutableImageView1D', 'MutableImageView2D', 'MutableImageView3D', + 'CompressedImageView1D', 'CompressedImageView2D', 'CompressedImageView3D', + 'MutableCompressedImageView1D', 'MutableCompressedImageView2D', 'MutableCompressedImageView3D', 'SamplerFilter', 'SamplerMipmap', 'SamplerWrapping', diff --git a/src/python/magnum/magnum.cpp b/src/python/magnum/magnum.cpp index 43234ef..524b001 100644 --- a/src/python/magnum/magnum.cpp +++ b/src/python/magnum/magnum.cpp @@ -170,6 +170,75 @@ template void imageViewFromMutable(py::class_>& }), "Construct from a mutable view"); } +template void compressedImage(py::class_& c) { + c + /* Constructors. Only the ones that are *not* taking an Array, as + Python has no way to "move" it in */ + .def(py::init(), "Construct an image placeholder") + + /* Properties */ + .def_property_readonly("format", &T::format, "Format of compressed pixel data") + .def_property_readonly("size", [](T& self) { + return PyDimensionTraits::from(self.size()); + }, "Image size") + .def_property_readonly("data", [](T& self) { + return Containers::pyArrayViewHolder(self.data(), self.data() ? py::cast(self) : py::none{}); + }, "Raw image data"); +} + +template void compressedImageView(py::class_>& c) { + /* + Missing APIs: + + Type, ErasedType, Dimensions + */ + + py::implicitly_convertible, T>(); + + c + /* Constructors. The variants *not* taking an array view have to be + first, otherwise things fail on systems that don't have numpy + installed. See imageView() above for details */ + .def(py::init([](CompressedPixelFormat format, const typename PyDimensionTraits::VectorType& size) { + return T{format, size}; + }), "Construct an empty view") + .def(py::init([](CompressedPixelFormat format, const typename PyDimensionTraits::VectorType& size, const Containers::ArrayView& data) { + return pyImageViewHolder(T{format, size, data}, pyObjectHolderFor(data).owner); + }), "Constructor") + .def(py::init([](CompressedImage& image) { + return pyImageViewHolder(T{image}, image.data() ? py::cast(image) : py::none{}); + }), "Construct a view on an image") + .def(py::init([](const CompressedImageView& other) { + return pyImageViewHolder(CompressedImageView(other), pyObjectHolderFor(other).owner); + }), "Construct from any type convertible to an image view") + + /* Properties */ + .def_property_readonly("format", &T::format, "Format of compressedpixel data") + .def_property_readonly("size", [](T& self) { + return PyDimensionTraits::from(self.size()); + }, "Image size") + .def_property("data", [](T& self) { + return Containers::pyArrayViewHolder(self.data(), pyObjectHolderFor(self).owner); + }, [](T& self, const Containers::ArrayView& data) { + self.setData(data); + pyObjectHolderFor(self).owner = + pyObjectHolderFor(data).owner; + }, "Raw image data") + + .def_property_readonly("owner", [](T& self) { + return pyObjectHolderFor(self).owner; + }, "Memory owner"); +} + +template void compressedImageViewFromMutable(py::class_>& c) { + py::implicitly_convertible, T>(); + + c + .def(py::init([](const BasicMutableCompressedImageView& other) { + return pyImageViewHolder(BasicCompressedImageView(other), pyObjectHolderFor(other).owner); + }), "Construct from a mutable view"); +} + void magnum(py::module_& m) { m.attr("BUILD_DEPRECATED") = #ifdef MAGNUM_BUILD_DEPRECATED @@ -540,6 +609,13 @@ void magnum(py::module_& m) { image(image2D); image(image3D); + py::class_ compressedImage1D{m, "CompressedImage1D", "One-dimensional compressed image"}; + py::class_ compressedImage2D{m, "CompressedImage2D", "Two-dimensional compressed image"}; + py::class_ compressedImage3D{m, "CompressedImage3D", "Three-dimensional compressed image"}; + compressedImage(compressedImage1D); + compressedImage(compressedImage2D); + compressedImage(compressedImage3D); + py::class_> imageView1D{m, "ImageView1D", "One-dimensional image view"}; py::class_> imageView2D{m, "ImageView2D", "Two-dimensional image view"}; py::class_> imageView3D{m, "ImageView3D", "Three-dimensional image view"}; @@ -558,6 +634,24 @@ void magnum(py::module_& m) { imageViewFromMutable(imageView2D); imageViewFromMutable(imageView3D); + py::class_> compressedImageView1D{m, "CompressedImageView1D", "One-dimensional compressed image view"}; + py::class_> compressedImageView2D{m, "CompressedImageView2D", "Two-dimensional compressed image view"}; + py::class_> compressedImageView3D{m, "CompressedImageView3D", "Three-dimensional compressed image view"}; + py::class_> mutableCompressedImageView1D{m, "MutableCompressedImageView1D", "One-dimensional mutable compressed image view"}; + py::class_> mutableCompressedImageView2D{m, "MutableCompressedImageView2D", "Two-dimensional mutable compressed image view"}; + py::class_> mutableCompressedImageView3D{m, "MutableCompressedImageView3D", "Three-dimensional mutable compressed image view"}; + + compressedImageView(compressedImageView1D); + compressedImageView(compressedImageView2D); + compressedImageView(compressedImageView3D); + compressedImageView(mutableCompressedImageView1D); + compressedImageView(mutableCompressedImageView2D); + compressedImageView(mutableCompressedImageView3D); + + compressedImageViewFromMutable(compressedImageView1D); + compressedImageViewFromMutable(compressedImageView2D); + compressedImageViewFromMutable(compressedImageView3D); + py::enum_{m, "SamplerFilter", "Texture sampler filtering"} .value("NEAREST", SamplerFilter::Nearest) .value("LINEAR", SamplerFilter::Linear); diff --git a/src/python/magnum/test/test.py b/src/python/magnum/test/test.py index c3b92a7..30c8b13 100644 --- a/src/python/magnum/test/test.py +++ b/src/python/magnum/test/test.py @@ -183,6 +183,35 @@ class Image(unittest.TestCase): with self.assertRaisesRegex(NotImplementedError, "access to this pixel format is not implemented yet, sorry"): a.pixels +class CompressedImage(unittest.TestCase): + def test_init_empty(self): + a = CompressedImage2D() + self.assertEqual(a.size, Vector2i()) + self.assertEqual(a.format, CompressedPixelFormat(0)) + + @unittest.skip("No way to create a non-empty Image at the moment") + def test_data_access(self): + # Tested in test_gl_gl.Framebuffer.test_read_image instead + a = CompressedImage2D(CompressedPixelFormat.BC1_RGB_UNORM, Vector2i(3, 17)) # TODO + a_refcount = sys.getrefcount(a) + + data = a.data + self.assertEqual(len(data), 3*17*1) + self.assertIs(data.owner, a) + self.assertEqual(sys.getrefcount(a), a_refcount + 1) + + del data + self.assertEqual(sys.getrefcount(a), a_refcount) + + def test_data_access_empty(self): + a = CompressedImage2D() + a_refcount = sys.getrefcount(a) + + data = a.data + self.assertEqual(len(data), 0) + self.assertIs(data.owner, None) + self.assertEqual(sys.getrefcount(a), a_refcount) + class ImageView(unittest.TestCase): def test_init(self): # 2x4 RGB pixels, padded for alignment @@ -513,6 +542,140 @@ class ImageView(unittest.TestCase): with self.assertRaisesRegex(NotImplementedError, "access to this pixel format is not implemented yet, sorry"): a.pixels +class CompressedImageView(unittest.TestCase): + def test_init(self): + # Four 64-bit blocks + data = (b'01234567' + b'89abcdef' + b'FEDCBA98' + b'76543210') + data_refcount = sys.getrefcount(data) + + a = CompressedImageView2D(CompressedPixelFormat.BC1_RGB_UNORM, (4, 16), data) + self.assertEqual(a.size, Vector2i(4, 16)) + self.assertEqual(a.format, CompressedPixelFormat.BC1_RGB_UNORM) + self.assertEqual(a.owner, data) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + del a + self.assertEqual(sys.getrefcount(data), data_refcount) + + def test_init_empty(self): + a = MutableCompressedImageView1D(CompressedPixelFormat.ASTC_4X4_RGBA_UNORM, 32) + self.assertEqual(a.size, 32) + self.assertEqual(a.owner, None) + + def test_init_mutable(self): + # Four 64-bit blocks + data = bytearray(b'01234567' + b'89abcdef' + b'FEDCBA98' + b'76543210') + data_refcount = sys.getrefcount(data) + + a = MutableCompressedImageView2D(CompressedPixelFormat.BC4_R_SNORM, (8, 8), data) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + # Back to immutable + b = CompressedImageView2D(a) + self.assertEqual(b.size, Vector2i(8, 8)) + self.assertEqual(b.format, CompressedPixelFormat.BC4_R_SNORM) + self.assertEqual(len(b.data), 32) + self.assertIs(b.owner, data) + self.assertEqual(sys.getrefcount(data), data_refcount + 2) + + @unittest.skip("No way to create a non-empty Image at the moment") + def test_init_image(self): + # TODO adapt from ImageView.test_init_image + pass + + def test_init_image_empty(self): + a = CompressedImage2D() + a_refcount = sys.getrefcount(a) + + view = CompressedImageView2D(a) + self.assertEqual(view.size, (0, 0)) + self.assertIs(view.owner, None) + self.assertEqual(sys.getrefcount(a), a_refcount) + + mview = MutableCompressedImageView2D(a) + self.assertEqual(mview.size, (0, 0)) + self.assertIs(mview.owner, None) + self.assertEqual(sys.getrefcount(a), a_refcount) + + def test_data_access(self): + # Two 128-bit blocks + data = (b'0123456789abcdef' + b'FEDCBA9876543210') + data_refcount = sys.getrefcount(data) + + a = CompressedImageView2D(CompressedPixelFormat.BC7_RGBA_UNORM, (8, 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], '9') + self.assertEqual(a_data[20], 'B') + 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): + # Two 128-bit blocks + data = bytearray(b'0123456789abcdef' + b'FEDCBA9876543210') + data_refcount = sys.getrefcount(data) + + a = MutableCompressedImageView2D(CompressedPixelFormat.BC6H_RGB_SFLOAT, (4, 8), 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], '9') + self.assertEqual(a_data[20], 'B') + 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'012345678_abcdef' + b'FEDC_A9876543210') + + del a_data + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + def test_set_data(self): + # Two 128-bit blocks + data = (b'0123456789ABCDEF' + b'fedcba9876543210') + data_refcount = sys.getrefcount(data) + + a = CompressedImageView2D(CompressedPixelFormat.BC3_RGBA_SRGB, (4, 8), data) + self.assertIs(a.owner, data) + self.assertEqual(sys.getrefcount(data), data_refcount + 1) + + data2 = (b'0123456789abcdef' + b'FEDCBA9876543210') + data2_refcount = sys.getrefcount(data2) + + # Replacing the data should disown the original object and point to the + # new one + a.data = data2 + self.assertEqual(bytes(a.data), + b'0123456789abcdef' + b'FEDCBA9876543210') + self.assertIs(a.owner, data2) + self.assertEqual(sys.getrefcount(data), data_refcount) + self.assertEqual(sys.getrefcount(data2), data2_refcount + 1) + class UtilityCopy(unittest.TestCase): def test_1d(self): a_data = array.array('f', [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) diff --git a/src/python/magnum/test/test_trade.py b/src/python/magnum/test/test_trade.py index a8e4803..8736435 100644 --- a/src/python/magnum/test/test_trade.py +++ b/src/python/magnum/test/test_trade.py @@ -81,14 +81,20 @@ class ImageData(unittest.TestCase): view = ImageView2D(image) mutable_view = MutableImageView2D(image) + with self.assertRaisesRegex(RuntimeError, "image is not compressed"): + CompressedImageView2D(image) + with self.assertRaisesRegex(RuntimeError, "image is not compressed"): + MutableCompressedImageView2D(image) + def test_convert_view_compressed(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__), "rgba_dxt1.dds")) image = importer.image2d(0) - # No compressed-image-related APIs exposed ATM, so just verifying the - # uncompressed ones fail properly + view = CompressedImageView2D(image) + mutable_view = MutableCompressedImageView2D(image) + with self.assertRaisesRegex(RuntimeError, "image is compressed"): view = ImageView2D(image) with self.assertRaisesRegex(RuntimeError, "image is compressed"): diff --git a/src/python/magnum/trade.cpp b/src/python/magnum/trade.cpp index 5230bc0..0868d00 100644 --- a/src/python/magnum/trade.cpp +++ b/src/python/magnum/trade.cpp @@ -177,6 +177,22 @@ template PyObject* implicitlyConvertibleToImage return r; } +template PyObject* implicitlyConvertibleToCompressedImageView(PyObject* obj, PyTypeObject*) { + py::detail::make_caster> caster; + if(!caster.load(obj, false)) { + return nullptr; + } + + Trade::ImageData& data = caster; + if(!data.isCompressed()) { + PyErr_SetString(PyExc_RuntimeError, "image is not compressed"); + throw py::error_already_set{}; + } + + auto r = pyCastButNotShitty(pyImageViewHolder(CompressedImageView(data), py::reinterpret_borrow(obj))).release().ptr(); + 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); @@ -218,6 +234,14 @@ template void imageData(py::class_)); CORRADE_INTERNAL_ASSERT(tinfo); tinfo->implicit_conversions.push_back(implicitlyConvertibleToImageView); + } { + auto tinfo = py::detail::get_type_info(typeid(CompressedImageView)); + CORRADE_INTERNAL_ASSERT(tinfo); + tinfo->implicit_conversions.push_back(implicitlyConvertibleToCompressedImageView); + } { + auto tinfo = py::detail::get_type_info(typeid(CompressedImageView)); + CORRADE_INTERNAL_ASSERT(tinfo); + tinfo->implicit_conversions.push_back(implicitlyConvertibleToCompressedImageView); } c