From d288b904cc3a0c96b56e9fa5b2cbec9d223b3b77 Mon Sep 17 00:00:00 2001 From: Cary Phillips Date: Wed, 15 May 2024 17:15:08 -0700 Subject: [PATCH] Rewrite python bindings with pybind11 and numpy This introduces an entirely new python API for reading and writing OpenEXR files that supports all file features (or will soon): scanline, tiled, deep, multi-part, etc. It uses numpy arrays for pixel data, and it supports both separate arrays for each channel and interleaving of RGB data into a single composite channel. It leaves the existing binding API in place for backwards-compatibility; there's no overlap between the two APIs. See src/wrappers/python/README.md for examples of the new API. The API is simple: the ``File`` object holds a py::list of ``Part`` objects, each of which has a py::dict for the header and a py::dict for the channels, and each channels hold a numpy array for the pixel data. There's intentionally no support for selective scanline/time reading; reading a file reads the entire channel data for all parts. There *is*, however, an option to read just the headers and skip the pixel data entirely. A few things don't work yet: - Reading and writing of deep data isn't finished. - ID manfest attributes aren't supported yet. - For mipmaped images, it current only reads the top level. This also does not (yet) properly integrate the real Imath types. It leaves in place for now the internal "Imath.py" module, but defines its own internal set of set of Imath classes. This needs to be resolve once the Imath bindings are distributed via pypi.org The test suite downloads images from openexr-images and runs them through a battery of reading/writing tests. Currently, the download is enabled via the ``OPENEXR_TEST_IMAGE_REPO`` environment variable that is off by default but is on in the python wheel CI workflow. Signed-off-by: Cary Phillips --- .github/workflows/python-wheels.yml | 11 +- pyproject.toml | 3 +- share/ci/scripts/install_pybind11.sh | 37 + src/lib/OpenEXR/ImfKeyCode.cpp | 12 + src/lib/OpenEXR/ImfKeyCode.h | 2 + src/lib/OpenEXR/ImfPreviewImage.h | 5 + src/wrappers/python/CMakeLists.txt | 5 +- src/wrappers/python/PyOpenEXR.cpp | 1976 +++++++++++++++++ src/wrappers/python/PyOpenEXR.h | 327 +++ .../python/{OpenEXR.cpp => PyOpenEXR_old.cpp} | 59 +- src/wrappers/python/README.md | 144 +- src/wrappers/python/tests/test_exceptions.py | 156 ++ src/wrappers/python/tests/test_images.py | 292 +++ src/wrappers/python/tests/test_import.py | 10 + src/wrappers/python/tests/test_old.py | 241 ++ src/wrappers/python/tests/test_readme.py | 185 +- src/wrappers/python/tests/test_rgba.py | 207 ++ src/wrappers/python/tests/test_unittest.py | 680 ++++-- 18 files changed, 4067 insertions(+), 285 deletions(-) create mode 100755 share/ci/scripts/install_pybind11.sh create mode 100644 src/wrappers/python/PyOpenEXR.cpp create mode 100644 src/wrappers/python/PyOpenEXR.h rename src/wrappers/python/{OpenEXR.cpp => PyOpenEXR_old.cpp} (97%) create mode 100644 src/wrappers/python/tests/test_exceptions.py create mode 100755 src/wrappers/python/tests/test_images.py create mode 100644 src/wrappers/python/tests/test_import.py create mode 100644 src/wrappers/python/tests/test_old.py create mode 100644 src/wrappers/python/tests/test_rgba.py diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index 1f7336f35..e0dac9b61 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -17,6 +17,7 @@ on: branches-ignore: - RB-* paths: + - 'src/lib/**' - 'src/wrappers/python/**' - 'pyproject.toml' - '.github/workflows/python-wheels.yml' @@ -24,6 +25,7 @@ on: branches-ignore: - RB-* paths: + - 'src/lib/**' - 'src/wrappers/python/**' - 'pyproject.toml' - '.github/workflows/python-wheels.yml' @@ -42,10 +44,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@v4 - name: Install Python - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -55,7 +57,7 @@ jobs: run: pipx run build --sdist . --outdir wheelhouse - name: Build wheel - uses: pypa/cibuildwheel@8d945475ac4b1aac4ae08b2fd27db9917158b6ce # v2.17.0 + uses: pypa/cibuildwheel@v2.16 env: CIBW_ARCHS_MACOS: x86_64 arm64 universal2 # Skip python 3.6 since scikit-build-core requires 3.7+ @@ -63,9 +65,10 @@ jobs: # Also skip the PyPy builds, since they fail the unit tests CIBW_SKIP: cp36-* *-win32 *_i686 pp* CIBW_TEST_SKIP: "*-macosx*arm64" + OPENEXR_TEST_IMAGE_REPO: "https://raw.githubusercontent.com/AcademySoftwareFoundation/openexr-images/main" - name: Upload artifact - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.os }} path: | diff --git a/pyproject.toml b/pyproject.toml index 68fc95da3..0f0eeb5b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ # Copyright (c) Contributors to the OpenEXR Project. [build-system] -requires = ["scikit-build-core==0.8.1"] +requires = ["scikit-build-core==0.8.1", "pybind11", "numpy"] build-backend = "scikit_build_core.build" [project] @@ -67,6 +67,7 @@ CMAKE_POSITION_INDEPENDENT_CODE = 'ON' [tool.cibuildwheel] test-command = "pytest -s {project}/src/wrappers/python/tests" +test-requires = ["numpy"] test-extras = ["test"] test-skip = ["*universal2:arm64"] build-verbosity = 1 diff --git a/share/ci/scripts/install_pybind11.sh b/share/ci/scripts/install_pybind11.sh new file mode 100755 index 000000000..e1b5023bf --- /dev/null +++ b/share/ci/scripts/install_pybind11.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +set -ex + +PYBIND11_VERSION="$1" + +if [[ $OSTYPE == "msys" ]]; then + SUDO="" +else + SUDO="sudo" +fi + +git clone https://github.com/pybind/pybind11.git +cd pybind11 + +if [ "$PYBIND11_VERSION" == "latest" ]; then + LATEST_TAG=$(git describe --abbrev=0 --tags) + git checkout tags/${LATEST_TAG} -b ${LATEST_TAG} +else + git checkout tags/v${PYBIND11_VERSION} -b v${PYBIND11_VERSION} +fi + +mkdir build +cd build +cmake -DCMAKE_BUILD_TYPE=Release \ + -DPYBIND11_INSTALL=ON \ + -DPYBIND11_TEST=OFF \ + ../. +$SUDO cmake --build . \ + --target install \ + --config Release \ + --parallel 2 + +cd ../.. +rm -rf pybind11 diff --git a/src/lib/OpenEXR/ImfKeyCode.cpp b/src/lib/OpenEXR/ImfKeyCode.cpp index 3cc931b7c..4468c0695 100644 --- a/src/lib/OpenEXR/ImfKeyCode.cpp +++ b/src/lib/OpenEXR/ImfKeyCode.cpp @@ -61,6 +61,18 @@ KeyCode::operator= (const KeyCode& other) return *this; } +bool +KeyCode::operator== (const KeyCode& other) const +{ + return (_filmMfcCode == other._filmMfcCode && + _filmType == other._filmType && + _prefix == other._prefix && + _count == other._count && + _perfOffset == other._perfOffset && + _perfsPerFrame == other._perfsPerFrame && + _perfsPerCount == other._perfsPerCount); +} + int KeyCode::filmMfcCode () const { diff --git a/src/lib/OpenEXR/ImfKeyCode.h b/src/lib/OpenEXR/ImfKeyCode.h index 2f7b19c15..9fca7b4f0 100644 --- a/src/lib/OpenEXR/ImfKeyCode.h +++ b/src/lib/OpenEXR/ImfKeyCode.h @@ -93,6 +93,8 @@ class IMF_EXPORT_TYPE KeyCode IMF_EXPORT KeyCode& operator= (const KeyCode& other); + bool operator== (const KeyCode& other) const; + //---------------------------- // Access to individual fields //---------------------------- diff --git a/src/lib/OpenEXR/ImfPreviewImage.h b/src/lib/OpenEXR/ImfPreviewImage.h index 908940f81..d470c923b 100644 --- a/src/lib/OpenEXR/ImfPreviewImage.h +++ b/src/lib/OpenEXR/ImfPreviewImage.h @@ -36,6 +36,11 @@ struct IMF_EXPORT_TYPE PreviewRgba unsigned char a = 255) : r (r), g (g), b (b), a (a) {} + + bool operator==(const PreviewRgba& other) const + { + return r == other.r && g == other.g && b == other.b && a == other.a; + } }; class IMF_EXPORT_TYPE PreviewImage diff --git a/src/wrappers/python/CMakeLists.txt b/src/wrappers/python/CMakeLists.txt index b240eb04a..0d2926320 100644 --- a/src/wrappers/python/CMakeLists.txt +++ b/src/wrappers/python/CMakeLists.txt @@ -10,10 +10,11 @@ if(NOT "${CMAKE_PROJECT_NAME}" STREQUAL "OpenEXR") endif() find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +find_package(pybind11 CONFIG REQUIRED) -python_add_library (PyOpenEXR MODULE OpenEXR.cpp) +python_add_library (PyOpenEXR MODULE PyOpenEXR.cpp PyOpenEXR_old.cpp) -target_link_libraries (PyOpenEXR PRIVATE "${Python_LIBRARIES}" OpenEXR::OpenEXR) +target_link_libraries (PyOpenEXR PRIVATE "${Python_LIBRARIES}" OpenEXR::OpenEXR pybind11::headers) # The python module should be called "OpenEXR.so", not "PyOpenEXR.so", # but "OpenEXR" is taken as a library name by the main lib, so specify diff --git a/src/wrappers/python/PyOpenEXR.cpp b/src/wrappers/python/PyOpenEXR.cpp new file mode 100644 index 000000000..f6d02bce0 --- /dev/null +++ b/src/wrappers/python/PyOpenEXR.cpp @@ -0,0 +1,1976 @@ +// +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) Contributors to the OpenEXR Project. +// + +//#define DEBUG_VERBOSE 1 + +#define PYBIND11_DETAILED_ERROR_MESSAGES 1 + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "openexr.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace py = pybind11; +using namespace py::literals; + +using namespace OPENEXR_IMF_NAMESPACE; +using namespace IMATH_NAMESPACE; + +extern bool init_OpenEXR_old(PyObject* module); + +namespace pybind11 { +namespace detail { + + // From https://github.com/AcademySoftwareFoundation/OpenImageIO/blob/master/src/python/py_oiio.h + // + // This half casting support for numpy was all derived from discussions + // here: https://github.com/pybind/pybind11/issues/1776 + + // Similar to enums in `pybind11/numpy.h`. Determined by doing: + // python3 -c 'import numpy as np; print(np.dtype(np.float16).num)' + constexpr int NPY_FLOAT16 = 23; + + template<> struct npy_format_descriptor { + static pybind11::dtype dtype() + { + handle ptr = npy_api::get().PyArray_DescrFromType_(NPY_FLOAT16); + return reinterpret_borrow(ptr); + } + static std::string format() + { + // following: https://docs.python.org/3/library/struct.html#format-characters + return "e"; + } + static constexpr auto name = _("float16"); + }; + +} // namespace detail +} // namespace pybind11 + +namespace { + +#include "PyOpenEXR.h" + +// +// Create a PyFile out of a list of parts (i.e. a multi-part file) +// + +PyFile::PyFile(const py::list& p) + : parts(p) +{ + for (size_t part_index = 0; part_index < parts.size(); part_index++) + { + auto p = parts[part_index]; + if (!py::isinstance(p)) + throw std::invalid_argument("must be a list of OpenEXR.Part() objects"); + + auto P = p.cast(); + P.part_index = part_index; + } +} + +// +// Create a PyFile out of a single part: header, channels, +// type, and compression (i.e. a single-part file) +// + +PyFile::PyFile(const py::dict& header, const py::dict& channels) +{ + parts.append(py::cast(PyPart(header, channels, ""))); +} + +// +// Read a PyFile from the given filename. +// +// Create a 'Part' for each part in the file, even single-part files. The API +// has convenience methods for accessing the first part's header and +// channels, which for single-part files appears as the file's data. +// +// By default, read each channel into a numpy array of the appropriate pixel +// type: uint32, half, or float. +// +// If 'rgba' is true, gather 'R', 'G', 'B', and 'A' channels and interleave +// them into a 3- or 4- (if 'A' is present) element numpy array. In the case +// of raw 'R', 'G', 'B', and 'A' channels, the corresponding key in the +// channels dict is "RGB" or "RGBA". For channels with a prefix, +// e.g. "left.R", "left.G", etc, the channel key is the prefix. +// + +PyFile::PyFile(const std::string& filename, bool rgba, bool header_only) + : filename(filename), header_only(header_only) +{ + MultiPartInputFile infile(filename.c_str()); + + for (int part_index = 0; part_index < infile.parts(); part_index++) + { + const Header& header = infile.header(part_index); + + PyPart P; + + P.part_index = part_index; + + const Box2i& dw = header.dataWindow(); + auto width = static_cast(dw.max.x - dw.min.x + 1); + auto height = static_cast(dw.max.y - dw.min.y + 1); + + // + // Fill the header dict with attributes from the input file header + // + + for (auto a = header.begin(); a != header.end(); a++) + { + std::string name = a.name(); + const Attribute& attribute = a.attribute(); + P.header[py::str(name)] = get_attribute_object(name, &attribute); + } + + // + // If we're only reading the header, we're done. + // + + if (header_only) + continue; + + // + // If we're gathering RGB channels, identify which channels to gather + // by examining common prefixes. + // + + std::set rgba_channels; + if (rgba) + { + for (auto c = header.channels().begin(); c != header.channels().end(); c++) + { + std::string py_channel_name; + char channel_name; + if (P.rgba_channel(header.channels(), c.name(), py_channel_name, channel_name)) + rgba_channels.insert(c.name()); + } + } + + std::vector shape ({height, width}); + + // + // Read the channel data, different for image vs. deep + // + + auto type = header.type(); + if (type == SCANLINEIMAGE || type == TILEDIMAGE) + { + P.readPixels(infile, header.channels(), shape, rgba_channels, dw, rgba); + } + else if (type == DEEPSCANLINE || type == DEEPTILE) + { + P.readDeepPixels(infile, type, header.channels(), shape, rgba_channels, dw, rgba); + } + parts.append(py::cast(PyPart(P))); + } // for parts +} + +void +PyPart::readPixels(MultiPartInputFile& infile, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgba_channels, + const Box2i& dw, bool rgba) +{ + FrameBuffer frameBuffer; + + for (auto c = channel_list.begin(); c != channel_list.end(); c++) + { + std::string py_channel_name = c.name(); + char channel_name; + int nrgba = 0; + if (rgba) + nrgba = rgba_channel(channel_list, c.name(), py_channel_name, channel_name); + + auto py_channel_name_str = py::str(py_channel_name); + + if (!channels.contains(py_channel_name_str)) + { + // + // We haven't add a PyChannel yet, so add one now. + // + + PyChannel C; + + C.name = py_channel_name; + C.xSampling = c.channel().xSampling; + C.ySampling = c.channel().ySampling; + C.pLinear = c.channel().pLinear; + + const auto style = py::array::c_style | py::array::forcecast; + + std::vector c_shape = shape; + + // + // If this channel belongs to one of the rgba's, give + // the PyChannel the extra dimension and the proper shape. + // nrgba is 3 for RGB and 4 for RGBA. + // + + if (rgba_channels.find(c.name()) != rgba_channels.end()) + c_shape.push_back(nrgba); + + switch (c.channel().type) + { + case UINT: + C.pixels = py::array_t(c_shape); + break; + case HALF: + C.pixels = py::array_t(c_shape); + break; + case FLOAT: + C.pixels = py::array_t(c_shape); + break; + default: + throw std::runtime_error("invalid pixel type"); + } // switch c->type + + channels[py_channel_name.c_str()] = C; + +#if DEBUG_VERBOSE + std::cout << ":: creating PyChannel name=" << C.name + << " ndim=" << C.pixels.ndim() + << " size=" << C.pixels.size() + << " itemsize=" << C.pixels.dtype().itemsize() + << std::endl; +#endif + } + + // + // Add a slice to the framebuffer + // + + auto v = channels[py_channel_name.c_str()]; + auto C = v.cast(); + + py::buffer_info buf = C.pixels.request(); + auto basePtr = static_cast(buf.ptr); + + // + // Offset the pointer for the channel + // + + py::dtype dt = C.pixels.dtype(); + size_t xStride = dt.itemsize(); + if (nrgba > 0) + { + xStride *= nrgba; + switch (channel_name) + { + case 'R': + break; + case 'G': + basePtr += dt.itemsize(); + break; + case 'B': + basePtr += 2 * dt.itemsize(); + break; + case 'A': + basePtr += 3 * dt.itemsize(); + break; + default: + break; + } + } + + size_t yStride = xStride * shape[1]; + +#if DEBUG_VERBOSE + std::cout << "Creating slice from PyChannel name=" << C.name + << " ndim=" << C.pixels.ndim() + << " size=" << C.pixels.size() + << " itemsize=" << C.pixels.dtype().itemsize() + << " name=" << c.name() + << " type=" << c.channel().type + << std::endl; +#endif + + frameBuffer.insert (c.name(), + Slice::Make (c.channel().type, + (void*) basePtr, + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + } // for header.channels() + + + // + // Read the pixels + // + + InputPart part (infile, part_index); + + part.setFrameBuffer (frameBuffer); + part.readPixels (dw.min.y, dw.max.y); +} + +// WIP +void +PyPart::readDeepPixels(MultiPartInputFile& infile, const std::string& type, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgba_channels, + const Box2i& dw, bool rgba) +{ + DeepFrameBuffer frameBuffer; + + for (auto c = channel_list.begin(); c != channel_list.end(); c++) + { + std::string py_channel_name = c.name(); + char channel_name; + int nrgba = 0; + if (rgba) + nrgba = rgba_channel(channel_list, c.name(), py_channel_name, channel_name); + + auto py_channel_name_str = py::str(py_channel_name); + + if (!channels.contains(py_channel_name_str)) + { + // We haven't add a PyChannel yet, so add one one. + + PyChannel C; + + C.name = py_channel_name; + C.xSampling = c.channel().xSampling; + C.ySampling = c.channel().ySampling; + C.pLinear = c.channel().pLinear; + + const auto style = py::array::c_style | py::array::forcecast; + + std::vector c_shape = shape; + + // If this channel belongs to one of the rgba's, give + // the PyChannel the proper shape + if (rgba_channels.find(c.name()) != rgba_channels.end()) + c_shape.push_back(nrgba); + + switch (c.channel().type) + { + case UINT: + C.pixels = py::array_t(c_shape); + break; + case HALF: + C.pixels = py::array_t(c_shape); + break; + case FLOAT: + C.pixels = py::array_t(c_shape); + break; + default: + throw std::runtime_error("invalid pixel type"); + } // switch c->type + + channels[py_channel_name.c_str()] = C; + +#if DEBUG_VERBOSE + std::cout << ":: creating PyChannel name=" << C.name + << " ndim=" << C.pixels.ndim() + << " size=" << C.pixels.size() + << " itemsize=" << C.pixels.dtype().itemsize() + << std::endl; +#endif + } + + auto v = channels[py_channel_name.c_str()]; + auto C = v.cast(); + + py::buffer_info buf = C.pixels.request(); + auto basePtr = static_cast(buf.ptr); + py::dtype dt = C.pixels.dtype(); + size_t xStride = dt.itemsize(); + if (nrgba > 0) + { + xStride *= nrgba; + switch (channel_name) + { + case 'R': + break; + case 'G': + basePtr += dt.itemsize(); + break; + case 'B': + basePtr += 2 * dt.itemsize(); + break; + case 'A': + basePtr += 3 * dt.itemsize(); + break; + default: + break; + } + } + + size_t yStride = xStride * shape[1]; + size_t sampleStride = 0; + +#if DEBUG_VERBOSE + std::cout << "Creating slice from PyChannel name=" << C.name + << " ndim=" << C.pixels.ndim() + << " size=" << C.pixels.size() + << " itemsize=" << C.pixels.dtype().itemsize() + << " name=" << c.name() + << " type=" << c.channel().type + << std::endl; +#endif + + frameBuffer.insert (c.name(), + DeepSlice (c.channel().type, + (char*) basePtr, + xStride, + yStride, + sampleStride, + C.xSampling, + C.ySampling)); + } // for header.channels() + + if (type == DEEPSCANLINE) + { + DeepScanLineInputPart part (infile, part_index); + + part.setFrameBuffer (frameBuffer); + part.readPixels (dw.min.y, dw.max.y); + } +} + +// +// Return whether "name" corresponds to one of the 'R', 'G', 'B', or 'A' +// channels in a "RGBA" tuple of channels. Return 4 if there's an 'A' +// channel, 3 if it's just RGB, and 0 otherwise. +// +// py_channel_name is returned as either the prefix, e.g. "left" for +// "left.R", "left.G", "left.B", or "RGBA" if the channel names are just 'R', +// 'G', and 'B'. +// +// This means: +// +// channels["left"] = np.array((height,width,3)) +// or: +// channels["RGB"] = np.array((height,width,3)) +// +// channel_name is returned as the single character name of the channel +// + +int +PyPart::rgba_channel(const ChannelList& channel_list, const std::string& name, + std::string& py_channel_name, char& channel_name) +{ + py_channel_name = name; + channel_name = py_channel_name.back(); + if (channel_name == 'R' || + channel_name == 'G' || + channel_name == 'B' || + channel_name == 'A') + { + // has the right final character. The preceding character is either a + // '.' (in the case of "right.R", or empty (in the case of a channel + // called "R") + // + + py_channel_name.pop_back(); + if (py_channel_name.empty() || py_channel_name.back() == '.') + { + // + // It matches the pattern, but are the other channels also + // present? It's ony "RGBA" if it has all three of 'R', 'G', and + // 'B'. + // + + if (channel_list.findChannel(py_channel_name + "R") && + channel_list.findChannel(py_channel_name + "G") && + channel_list.findChannel(py_channel_name + "B")) + { + auto A = py_channel_name + "A"; + if (!py_channel_name.empty()) + py_channel_name.pop_back(); + if (py_channel_name.empty()) + { + py_channel_name = "RGB"; + if (channel_list.findChannel(A)) + py_channel_name += "A"; + } + + if (channel_list.findChannel(A)) + return 4; + return 3; + } + } + py_channel_name = name; + } + + return 0; +} + +bool +PyFile::operator==(const PyFile& other) const +{ + if (parts.size() != other.parts.size()) + { + std::cout << "PyFile:: #parts differs." << std::endl; + return false; + } + + for (size_t part_index = 0; part_index(); + auto b = other.parts[part_index].cast(); + if (a != b) + { + std::cout << "PyFile: part " << part_index << " differs." << std::endl; + return false; + } + } + + return true; +} + +void +validate_part_index(int part_index, size_t num_parts) +{ + if (part_index < 0) + { + std::stringstream s; + s << "Invalid negative part index '" << part_index << "'"; + throw std::invalid_argument(s.str()); + } + + if (static_cast(part_index) >= num_parts) + { + std::stringstream s; + s << "Invalid part index '" << part_index + << "': file has " << num_parts + << " part"; + if (num_parts != 1) + s << "s"; + s << "."; + throw std::invalid_argument(s.str()); + } +} + +py::dict& +PyFile::header(int part_index) +{ + validate_part_index(part_index, parts.size()); + return parts[part_index].cast().header; +} + +py::dict& +PyFile::channels(int part_index) +{ + validate_part_index(part_index, parts.size()); + return parts[part_index].cast().channels; +} + +// +// Write the PyFile to the given filename +// + +void +PyFile::write(const char* outfilename) +{ + std::vector
headers; + + for (size_t part_index = 0; part_index < parts.size(); part_index++) + { + const PyPart& P = parts[part_index].cast(); + + Header header; + + if (P.name().empty()) + { + std::stringstream n; + n << "Part" << part_index; + header.setName (n.str()); + } + else + header.setName (P.name()); + + // + // Add attributes from the py::dict to the output header + // + + for (auto a : P.header) + { + auto name = py::str(a.first); + py::object second = py::cast(a.second); + insert_attribute(header, name, second); + } + + // + // Add required attributes to the header + // + + header.setType(P.typeString()); + + if (!P.header.contains("dataWindow")) + { + auto shape = P.shape(); + header.dataWindow().max = V2i(shape[1]-1,shape[0]-1); + } + + if (!P.header.contains("displayWindow")) + { + auto shape = P.shape(); + header.displayWindow().max = V2i(shape[1]-1,shape[0]-1); + } + + if (P.type() == EXR_STORAGE_TILED || P.type() == EXR_STORAGE_DEEP_TILED) + { + if (P.header.contains("tiles")) + { + auto td = P.header["tiles"].cast(); + header.setTileDescription (td); + } + else + std::cout << "> no tile description" << std::endl; + } + + if (P.header.contains("lineOrder")) + { + auto lo = P.header["lineOrder"].cast(); + header.lineOrder() = static_cast(lo); + } + + header.compression() = P.compression(); + + // + // Add channels to the output header + // + + for (auto c : P.channels) + { + auto C = py::cast(c.second); + auto t = static_cast(C.pixelType()); + + if (C.pixels.ndim() == 3) + { + // + // The py::dict has a single "RGB" or "RGBA" numpy array, but + // the output file gets separate channels + // + + std::string name_prefix; + if (C.name == "RGB" || C.name == "RGBA") + name_prefix = ""; + else + name_prefix = C.name + "."; + + header.channels ().insert(name_prefix + "R", Channel (t, C.xSampling, C.ySampling, C.pLinear)); + header.channels ().insert(name_prefix + "G", Channel (t, C.xSampling, C.ySampling, C.pLinear)); + header.channels ().insert(name_prefix + "B", Channel (t, C.xSampling, C.ySampling, C.pLinear)); + int nrgba = C.pixels.shape(2); + if (nrgba > 3) + header.channels ().insert(name_prefix + "A", Channel (t, C.xSampling, C.ySampling, C.pLinear)); + } + else + header.channels ().insert(C.name, Channel (t, C.xSampling, C.ySampling, C.pLinear)); + } + + + headers.push_back (header); + } + + MultiPartOutputFile outfile(outfilename, headers.data(), headers.size()); + + // + // Write the channel data: add slices to the framebuffer and write. + // + + for (size_t part_index = 0; part_index < parts.size(); part_index++) + { + const PyPart& P = parts[part_index].cast(); + + auto header = headers[part_index]; + const Box2i& dw = header.dataWindow(); + + if (P.type() == EXR_STORAGE_SCANLINE || + P.type() == EXR_STORAGE_TILED) + { + FrameBuffer frameBuffer; + + for (auto c : P.channels) + { + auto C = c.second.cast(); + + if (C.pixels.ndim() == 3) + { + // + // The py::dict has RGB or RGBA channels, but the + // framebuffer needs a slice per dimension + // + + std::string name_prefix; + if (C.name == "RGB" || C.name == "RGBA") + name_prefix = ""; + else + name_prefix = C.name + "."; + + py::buffer_info buf = C.pixels.request(); + auto basePtr = static_cast(buf.ptr); + py::dtype dt = C.pixels.dtype(); + int nrgba = C.pixels.shape(2); + size_t xStride = dt.itemsize() * nrgba; + size_t yStride = xStride * P.width(); + + auto rPtr = basePtr; + frameBuffer.insert (name_prefix + "R", + Slice::Make (static_cast(C.pixelType()), + static_cast(rPtr), + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + + auto gPtr = &basePtr[dt.itemsize()]; + frameBuffer.insert (name_prefix + "G", + Slice::Make (static_cast(C.pixelType()), + static_cast(gPtr), + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + + auto bPtr = &basePtr[2*dt.itemsize()]; + frameBuffer.insert (name_prefix + "B", + Slice::Make (static_cast(C.pixelType()), + static_cast(bPtr), + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + + if (nrgba == 4) + { + auto aPtr = &basePtr[3*dt.itemsize()]; + frameBuffer.insert (name_prefix + "A", + Slice::Make (static_cast(C.pixelType()), + static_cast(aPtr), + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + } + } + else + { + frameBuffer.insert (C.name, + Slice::Make (static_cast(C.pixelType()), + static_cast(C.pixels.request().ptr), + dw, 0, 0, + C.xSampling, + C.ySampling)); + } + } + + if (P.type() == EXR_STORAGE_SCANLINE) + { + OutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + part.writePixels (P.height()); + } + else + { + TiledOutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + part.writeTiles (0, part.numXTiles() - 1, 0, part.numYTiles() - 1); + } + } + else if (P.type() == EXR_STORAGE_DEEP_SCANLINE || + P.type() == EXR_STORAGE_DEEP_TILED) + { + DeepFrameBuffer frameBuffer; + + for (auto c : P.channels) + { + auto C = c.second.cast(); + frameBuffer.insert (C.name, + DeepSlice (static_cast(C.pixelType()), + static_cast(C.pixels.request().ptr), + 0, 0, 0, + C.xSampling, + C.ySampling)); + } + + if (P.type() == EXR_STORAGE_DEEP_SCANLINE) + { + DeepScanLineOutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + part.writePixels (P.height()); + } + else + { + DeepTiledOutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + part.writeTiles (0, part.numXTiles() - 1, 0, part.numYTiles() - 1); + } + } + else + throw std::runtime_error("invalid type"); + } + + filename = outfilename; +} + +// +// Helper routine to cast an objec to a type only if it's actually that type, +// since py::cast throws an runtime_error on unexpected type. +// + +template +const T* +py_cast(const py::object& object) +{ + if (py::isinstance(object)) + return py::cast(object); + + return nullptr; +} + +// +// Helper routine to cast an objec to a type only if it's actually that type, +// since py::cast throws an runtime_error on unexpected type. This further cast +// the resulting pointer to a second type. +// + +template +const T* +py_cast(const py::object& object) +{ + if (py::isinstance(object)) + { + auto o = py::cast(object); + return reinterpret_cast(o); + } + + return nullptr; +} + +py::object +PyFile::get_attribute_object(const std::string& name, const Attribute* a) +{ + if (auto v = dynamic_cast (a)) + return py::cast(Box2i(v->value())); + + if (auto v = dynamic_cast (a)) + return py::cast(Box2f(v->value())); + + if (auto v = dynamic_cast (a)) + { + auto L = v->value(); + auto l = py::list(); + for (auto c = L.begin (); c != L.end (); ++c) + { + auto C = c.channel(); + l.append(py::cast(PyChannel(c.name(), + C.xSampling, + C.ySampling, + C.pLinear))); + } + return l; + } + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(PyDouble(v->value())); + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::float_(v->value()); + + if (auto v = dynamic_cast (a)) + return py::int_(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(M33f(v->value()[0][0], + v->value()[0][1], + v->value()[0][2], + v->value()[1][0], + v->value()[1][1], + v->value()[1][2], + v->value()[2][0], + v->value()[2][1], + v->value()[2][2])); + + if (auto v = dynamic_cast (a)) + return py::cast(M33d(v->value()[0][0], + v->value()[0][1], + v->value()[0][2], + v->value()[1][0], + v->value()[1][1], + v->value()[1][2], + v->value()[2][0], + v->value()[2][1], + v->value()[2][2])); + + if (auto v = dynamic_cast (a)) + return py::cast(M44f(v->value()[0][0], + v->value()[0][1], + v->value()[0][2], + v->value()[0][3], + v->value()[1][0], + v->value()[1][1], + v->value()[1][2], + v->value()[1][3], + v->value()[2][0], + v->value()[2][1], + v->value()[2][2], + v->value()[2][3], + v->value()[3][0], + v->value()[3][1], + v->value()[3][2], + v->value()[3][3])); + + if (auto v = dynamic_cast (a)) + return py::cast(M44d(v->value()[0][0], + v->value()[0][1], + v->value()[0][2], + v->value()[0][3], + v->value()[1][0], + v->value()[1][1], + v->value()[1][2], + v->value()[1][3], + v->value()[2][0], + v->value()[2][1], + v->value()[2][2], + v->value()[2][3], + v->value()[3][0], + v->value()[3][1], + v->value()[3][2], + v->value()[3][3])); + + if (auto v = dynamic_cast (a)) + { + auto I = v->value(); + return py::cast(PyPreviewImage(I.width(), I.height(), I.pixels())); + } + + if (auto v = dynamic_cast (a)) + { + if (name == "type") + { + // + // The "type" attribute comes through as a string, + // but we want it to be the OpenEXR.Storage enum. + // + + exr_storage_t t = EXR_STORAGE_LAST_TYPE; + if (v->value() == SCANLINEIMAGE) // "scanlineimage") + t = EXR_STORAGE_SCANLINE; + else if (v->value() == TILEDIMAGE) // "tiledimage") + t = EXR_STORAGE_TILED; + else if (v->value() == DEEPSCANLINE) // "deepscanline") + t = EXR_STORAGE_DEEP_SCANLINE; + else if (v->value() == DEEPTILE) // "deeptile") + t = EXR_STORAGE_DEEP_TILED; + else + throw std::invalid_argument("unrecognized image 'type' attribute"); + return py::cast(t); + } + return py::str(v->value()); + } + + if (auto v = dynamic_cast (a)) + { + auto l = py::list(); + for (auto i = v->value().begin (); i != v->value().end(); i++) + l.append(py::str(*i)); + return l; + } + + if (auto v = dynamic_cast (a)) + { + auto l = py::list(); + for (auto i = v->value().begin(); i != v->value().end(); i++) + l.append(py::float_(*i)); + return l; + } + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + if (auto v = dynamic_cast (a)) + return py::cast(V2i(v->value().x, v->value().y)); + + if (auto v = dynamic_cast (a)) + return py::cast(V2f(v->value().x, v->value().y)); + + if (auto v = dynamic_cast (a)) + return py::cast(V2d(v->value().x, v->value().y)); + + if (auto v = dynamic_cast (a)) + return py::cast(V3i(v->value().x, v->value().y, v->value().z)); + + if (auto v = dynamic_cast (a)) + return py::cast(V3f(v->value().x, v->value().y, v->value().z)); + + if (auto v = dynamic_cast (a)) + return py::cast(V3d(v->value().x, v->value().y, v->value().z)); + + throw std::runtime_error("unrecognized attribute type"); + + return py::none(); +} + +void +PyFile::insert_attribute(Header& header, const std::string& name, const py::object& object) +{ + if (auto v = py_cast(object)) + header.insert(name, Box2iAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, Box2fAttribute(*v)); + else if (py::isinstance(object)) + { + auto list = py::cast(object); + auto size = list.size(); + if (size == 0) + throw std::runtime_error("invalid empty list is header: can't deduce attribute type"); + + if (py::isinstance(list[0])) + { + // float vector + std::vector v = list.cast>(); + header.insert(name, FloatVectorAttribute(v)); + } + else if (py::isinstance(list[0])) + { + // string vector + std::vector v = list.cast>(); + header.insert(name, StringVectorAttribute(v)); + } + else if (py::isinstance(list[0])) + { + // + // Channel list: don't create an explicit chlist attribute here, + // since the channels get created elswhere. + } + } + else if (auto v = py_cast(object)) + header.insert(name, ChromaticitiesAttribute(static_cast(*v))); + else if (auto v = py_cast(object)) + header.insert(name, CompressionAttribute(static_cast(*v))); + else if (auto v = py_cast(object)) + header.insert(name, EnvmapAttribute(static_cast(*v))); + else if (py::isinstance(object)) + header.insert(name, FloatAttribute(py::cast(object))); + else if (py::isinstance(object)) + header.insert(name, DoubleAttribute(py::cast(object).d)); + else if (py::isinstance(object)) + header.insert(name, IntAttribute(py::cast(object))); + else if (auto v = py_cast(object)) + header.insert(name, KeyCodeAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, LineOrderAttribute(static_cast(*v))); + else if (auto v = py_cast(object)) + header.insert(name, M33fAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, M33dAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, M44fAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, M44dAttribute(*v)); + else if (auto v = py_cast(object)) + { + py::buffer_info buf = v->pixels.request(); + auto pixels = static_cast(buf.ptr); + auto height = v->pixels.shape(0); + auto width = v->pixels.shape(1); + PreviewImage p(width, height, pixels); + header.insert(name, PreviewImageAttribute(p)); + } + else if (auto v = py_cast(object)) + header.insert(name, RationalAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, TileDescriptionAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, TimeCodeAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, V2iAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, V2fAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, V2dAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, V3iAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, V3fAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, V3dAttribute(*v)); + else if (auto v = py_cast(object)) + { + std::string type; + switch (*v) + { + case EXR_STORAGE_SCANLINE: + type = SCANLINEIMAGE; + break; + case EXR_STORAGE_TILED: + type = TILEDIMAGE; + break; + case EXR_STORAGE_DEEP_SCANLINE: + type = DEEPSCANLINE; + break; + case EXR_STORAGE_DEEP_TILED: + type = DEEPTILE; + break; + case EXR_STORAGE_LAST_TYPE: + default: + throw std::runtime_error("unknown storage type"); + break; + } + header.setType(type); + } + else if (py::isinstance(object)) + header.insert(name, StringAttribute(py::str(object))); + else + { + std::stringstream s; + s << "unknown attribute type: " << py::str(object); + throw std::runtime_error(s.str()); + } +} + +// +// Construct a part from explicit header and channel data. +// +// Used to construct a file for writing. +// + +PyPart::PyPart(const py::dict& header, const py::dict& channels, const std::string& name) + : header(header), channels(channels), part_index(0) +{ + if (name != "") + header[py::str("name")] = py::str(name); + + for (auto a : header) + { + if (!py::isinstance(a.first)) + throw std::invalid_argument("header key must be string (attribute name)"); + + // TODO: confirm it's a valid attribute value + py::object second = py::cast(a.second); + } + + // + // Validate that all channel dict keys are strings, and initialze the + // channel name field. + // + + for (auto c : channels) + { + if (!py::isinstance(c.first)) + throw std::invalid_argument("channels key must be string (channel name)"); + + c.second.cast().name = py::str(c.first); + } + + auto s = shape(); + + if (!header.contains("dataWindow")) + header["dataWindow"] = py::cast(Box2i(V2i(0,0), V2i(s[1]-1,s[0]-1))); + + if (!header.contains("displayWindow")) + header["displayWindow"] = py::cast(Box2i(V2i(0,0), V2i(s[1]-1,s[0]-1))); +} + +bool +is_required_attribute(const std::string& name) +{ + return (name == "channels" || + name == "compression" || + name == "dataWindow" || + name == "displayWindow" || + name == "lineOrder" || + name == "pixelAspectRatio" || + name == "screenWindowCenter" || + name == "screenWindowWidth" || + name == "tiles" || + name == "type" || + name == "name" || + name == "version" || + name == "chunkCount"); +} + +bool +equal_header(const py::dict& A, const py::dict& B) +{ + std::set names; + +#if DEBUG_VERBOSE + std::cout << "A:"; +#endif + for (auto a : A) + { +#if DEBUG_VERBOSE + std::cout << " " << py::str(a.first); +#endif + names.insert(py::str(a.first)); + } +#if DEBUG_VERBOSE + std::cout << std::endl; + + std::cout << "B:"; +#endif + for (auto b : B) + { +#if DEBUG_VERBOSE + std::cout << " " << py::str(b.first); +#endif + names.insert(py::str(b.first)); + } +#if DEBUG_VERBOSE + std::cout << std::endl; +#endif + + for (auto name : names) + { + if (name == "channels") + continue; + + if (!A.contains(name)) + { + if (is_required_attribute(name)) + continue; + std::cout << "lhs part does not contain " << name << std::endl; + return false; + } + + if (!B.contains(name)) + { + if (is_required_attribute(name)) + continue; + std::cout << "rhs part does not contain " << name << std::endl; + return false; + } + + py::object a = A[py::str(name)]; + py::object b = B[py::str(name)]; + if (!a.equal(b)) + { + if (py::isinstance(a)) + { + float f = py::cast(a); + float of = py::cast(b); + if (f == of) + return true; + + if (equalWithRelError(f, of, 1e-8f)) + { + float df = f - of; + std::cout << "float values are very close: " + << std::scientific << std::setprecision(12) + << f << " " + << of << " (" + << df << ")" + << std::endl; + return true; + } + } + std::cout << "attribute values differ: " << name << " lhs='" << py::str(a) << "' rhs='" << py::str(b) << "'" << std::endl; + return false; + } + } + + + return true; +} + + +bool +PyPart::operator==(const PyPart& other) const +{ + if (!equal_header(header, other.header)) + { + std::cout << "PyPart: !equal_header" << std::endl; + return false; + } + + // + // The channel dicts might not be in alphabetical order + // (they're sorted on write), so don't just compare the dicts + // directly, compare each entry by key/name. + // + + if (channels.size() != other.channels.size()) + { + std::cout << "PyPart: #channels differs." << std::endl; + return false; + } + + for (auto c : channels) + { + auto name = py::str(c.first); + auto C = c.second.cast(); + auto O = other.channels[py::str(name)].cast(); + if (C != O) + { + std::cout << "channel " << name << " differs." << std::endl; + return false; + } + } + + return true; +} + +template +bool +py_nan(T a) +{ + return std::isnan(a); +} + +template <> +bool +py_nan(half a) +{ + return a.isNan(); +} + +template <> +bool +py_nan(uint32_t a) +{ + return false; +} + +template +bool +py_inf(T a) +{ + return !std::isfinite(a); +} + +template <> +bool +py_inf(half a) +{ + return a.isInfinity(); +} + +template <> +bool +py_inf(uint32_t a) +{ + return false; +} + + +template +bool +array_equals(const py::buffer_info& a, const py::buffer_info& b, + const std::string& name, int width, int height, int depth = 1) +{ + const T* apixels = static_cast(a.ptr); + const T* bpixels = static_cast(b.ptr); + + for (int y=0; y(apixels[k]); + double bp = static_cast(bpixels[k]); + if (!equalWithRelError(ap, bp, 1e-5)) + { + std::cout << "i=" << i + << " k=" << k + << " a[" << y + << "][" << x + << "][" << j + << "]=" << apixels[k] + << " b=" << bpixels[k] + << std::endl; + return false; + } + } + } + + return true; +} + +void +PyChannel::validate_pixel_array() +{ + if (!(py::isinstance>(pixels) || + py::isinstance>(pixels) || + py::isinstance>(pixels))) + throw std::invalid_argument("invalid pixel array: unrecognized type: must be uint32, half, or float"); + + if (pixels.ndim() < 2 || pixels.ndim() > 3) + throw std::invalid_argument("invalid pixel array: must be 2D or 3D numpy array"); +} + +bool +PyChannel::operator==(const PyChannel& other) const +{ + if (name == other.name && + xSampling == other.xSampling && + ySampling == other.ySampling && + pLinear == other.pLinear && + pixels.ndim() == other.pixels.ndim() && + pixels.size() == other.pixels.size()) + { + if (pixels.size() == 0) + return true; + + py::buffer_info buf = pixels.request(); + py::buffer_info obuf = other.pixels.request(); + + int width = pixels.shape(1); + int height = pixels.shape(0); + int depth = pixels.ndim() == 3 ? pixels.shape(2) : 1; + + if (py::isinstance>(pixels) && py::isinstance>(other.pixels)) + if (array_equals(buf, obuf, name, width, height, depth)) + return true; + if (py::isinstance>(pixels) && py::isinstance>(other.pixels)) + if (array_equals(buf, obuf, name, width, height, depth)) + return true; + if (py::isinstance>(pixels) && py::isinstance>(other.pixels)) + if (array_equals(buf, obuf, name, width, height, depth)) + return true; + } + + return false; +} + +V2i +PyPart::shape() const +{ + V2i S(0, 0); + + std::string channel_name; // first channel name + + for (auto c : channels) + { + auto C = py::cast(c.second); + + if (C.pixels.ndim() < 2 || C.pixels.ndim() > 3) + throw std::invalid_argument("error: channel must have a 2D or 3D array"); + + V2i c_S(C.pixels.shape(0), C.pixels.shape(1)); + + if (S == V2i(0, 0)) + { + S = c_S; + channel_name = C.name; + } + + if (S != c_S) + { + std::stringstream s; + s << "channel shapes differ: " << channel_name + << "=" << S + << ", " << C.name + << "=" << c_S; + throw std::invalid_argument(s.str()); + } + } + + return S; +} + +size_t +PyPart::width() const +{ + return shape()[1]; +} + +size_t +PyPart::height() const +{ + return shape()[0]; +} + +std::string +PyPart::name() const +{ + if (header.contains("name")) + return py::str(header["name"]); + return ""; +} + +Compression +PyPart::compression() const +{ + if (header.contains("compression")) + return header["compression"].cast(); + return ZIP_COMPRESSION; +} + +exr_storage_t +PyPart::type() const +{ + if (header.contains("type")) + return header[py::str("type")].cast(); + return EXR_STORAGE_SCANLINE; +} + +std::string +PyPart::typeString() const +{ + switch (type()) + { + case EXR_STORAGE_SCANLINE: + return SCANLINEIMAGE; + case EXR_STORAGE_TILED: + return TILEDIMAGE; + case EXR_STORAGE_DEEP_SCANLINE: + return DEEPSCANLINE; + case EXR_STORAGE_DEEP_TILED: + return DEEPTILE; + default: + throw std::runtime_error("invalid type"); + } + return SCANLINEIMAGE; +} + +PixelType +PyChannel::pixelType() const +{ + auto buf = pybind11::array::ensure(pixels); + if (buf) + { + if (py::isinstance>(buf)) + return UINT; + if (py::isinstance>(buf)) + return HALF; + if (py::isinstance>(buf)) + return FLOAT; + } + return NUM_PIXELTYPES; +} + +template +std::string +repr(const T& v) +{ + std::stringstream s; + s << v; + return s.str(); +} + + +} // namespace + +PYBIND11_MODULE(OpenEXR, m) +{ + using namespace py::literals; + + m.doc() = "OpenEXR - read and write high-dynamic range image files"; + + m.attr("__version__") = OPENEXR_VERSION_STRING; + m.attr("OPENEXR_VERSION") = OPENEXR_VERSION_STRING; + + // + // Add symbols from the legacy implementation of the bindings for + // backwards compatibility + // + + init_OpenEXR_old(m.ptr()); + + // + // Enums + // + + py::enum_(m, "LevelRoundingMode", "Rounding mode for tiled images") + .value("ROUND_UP", ROUND_UP) + .value("ROUND_DOWN", ROUND_DOWN) + .value("NUM_ROUNDING_MODES", NUM_ROUNDINGMODES) + .export_values(); + + py::enum_(m, "LevelMode", "Level mode for tiled images") + .value("ONE_LEVEL", ONE_LEVEL) + .value("MIPMAP_LEVELS", MIPMAP_LEVELS) + .value("RIPMAP_LEVELS", RIPMAP_LEVELS) + .value("NUM_LEVEL_MODES", NUM_LEVELMODES) + .export_values(); + + py::enum_(m, "LineOrder", "Line order for scanline images") + .value("INCREASING_Y", INCREASING_Y) + .value("DECREASING_Y", DECREASING_Y) + .value("RANDOM_Y", RANDOM_Y) + .value("NUM_LINE_ORDERS", NUM_LINEORDERS) + .export_values(); + + py::enum_(m, "PixelType", "Data type for pixel arrays") + .value("UINT", UINT, "32-bit integer") + .value("HALF", HALF) + .value("FLOAT", FLOAT) + .value("NUM_PIXELTYPES", NUM_PIXELTYPES) + .export_values(); + + py::enum_(m, "Compression", "Compression method") + .value("NO_COMPRESSION", NO_COMPRESSION) + .value("RLE_COMPRESSION", RLE_COMPRESSION) + .value("ZIPS_COMPRESSION", ZIPS_COMPRESSION) + .value("ZIP_COMPRESSION", ZIP_COMPRESSION) + .value("PIZ_COMPRESSION", PIZ_COMPRESSION) + .value("PXR24_COMPRESSION", PXR24_COMPRESSION) + .value("B44_COMPRESSION", B44_COMPRESSION) + .value("B44A_COMPRESSION", B44A_COMPRESSION) + .value("DWAA_COMPRESSION", DWAA_COMPRESSION) + .value("DWAB_COMPRESSION", DWAB_COMPRESSION) + .value("NUM_COMPRESSION_METHODS", NUM_COMPRESSION_METHODS) + .export_values(); + + py::enum_(m, "Envmap", "Environment map type") + .value("ENVMAP_LATLONG", ENVMAP_LATLONG) + .value("ENVMAP_CUBE", ENVMAP_CUBE) + .value("NUM_ENVMAPTYPES", NUM_ENVMAPTYPES) + .export_values(); + + py::enum_(m, "Storage", "Image storage format") + .value("scanlineimage", EXR_STORAGE_SCANLINE) + .value("tiledimage", EXR_STORAGE_TILED) + .value("deepscanline", EXR_STORAGE_DEEP_SCANLINE) + .value("deeptile", EXR_STORAGE_DEEP_TILED) + .value("NUM_STORAGE_TYPES", EXR_STORAGE_LAST_TYPE) + .export_values(); + + // + // Classes for attribute types + // + + py::class_(m, "TileDescription", "Tile description for tiled images") + .def(py::init()) + .def("__repr__", [](TileDescription& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("xSize", &TileDescription::xSize) + .def_readwrite("ySize", &TileDescription::ySize) + .def_readwrite("mode", &TileDescription::mode) + .def_readwrite("roundingMode", &TileDescription::roundingMode) + ; + + py::class_(m, "Rational", "A number expressed as a ratio, n/d") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const Rational& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("n", &Rational::n) + .def_readwrite("d", &Rational::d) + ; + + py::class_(m, "KeyCode", "Motion picture film characteristics") + .def(py::init()) + .def(py::init()) + .def(py::self == py::self) + .def("__repr__", [](const KeyCode& v) { return repr(v); }) + .def_property("filmMfcCode", &KeyCode::filmMfcCode, &KeyCode::setFilmMfcCode) + .def_property("filmType", &KeyCode::filmType, &KeyCode::setFilmType) + .def_property("prefix", &KeyCode::prefix, &KeyCode::setPrefix) + .def_property("count", &KeyCode::count, &KeyCode::setCount) + .def_property("perfOffset", &KeyCode::perfOffset, &KeyCode::setPerfOffset) + .def_property("perfsPerFrame", &KeyCode::perfsPerFrame, &KeyCode::setPerfsPerFrame) + .def_property("perfsPerCount", &KeyCode::perfsPerCount, &KeyCode::setPerfsPerCount) + ; + + py::class_(m, "TimeCode", "Time and control code") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const TimeCode& v) { return repr(v); }) + .def(py::self == py::self) + .def_property("hours", &TimeCode::hours, &TimeCode::setHours) + .def_property("minutes", &TimeCode::minutes, &TimeCode::setMinutes) + .def_property("seconds", &TimeCode::seconds, &TimeCode::setSeconds) + .def_property("frame", &TimeCode::frame, &TimeCode::setFrame) + .def_property("dropFrame", &TimeCode::dropFrame, &TimeCode::setDropFrame) + .def_property("colorFrame", &TimeCode::colorFrame, &TimeCode::setColorFrame) + .def_property("fieldPhase", &TimeCode::fieldPhase, &TimeCode::setFieldPhase) + .def_property("bgf0", &TimeCode::bgf0, &TimeCode::setBgf0) + .def_property("bgf1", &TimeCode::bgf1, &TimeCode::setBgf1) + .def_property("bgf2", &TimeCode::bgf2, &TimeCode::setBgf2) + .def_property("binaryGroup", &TimeCode::binaryGroup, &TimeCode::setBinaryGroup) + .def_property("userData", &TimeCode::userData, &TimeCode::setUserData) + .def("timeAndFlags", &TimeCode::timeAndFlags) + .def("setTimeAndFlags", &TimeCode::setTimeAndFlags) + ; + + py::class_(m, "Chromaticities", "CIE (x,y) chromaticities of the primaries and the white point") + .def(py::init()) + .def(py::self == py::self) + .def("__repr__", [](const Chromaticities& v) { return repr(v); }) + .def_readwrite("red", &Chromaticities::red) + .def_readwrite("green", &Chromaticities::green) + .def_readwrite("blue", &Chromaticities::blue) + .def_readwrite("white", &Chromaticities::white) + ; + + py::class_(m, "PreviewRgba", "Pixel type for the preview image") + .def(py::init()) + .def(py::init()) + .def(py::self == py::self) + .def_readwrite("r", &PreviewRgba::r) + .def_readwrite("g", &PreviewRgba::g) + .def_readwrite("b", &PreviewRgba::b) + .def_readwrite("a", &PreviewRgba::a) + ; + + PYBIND11_NUMPY_DTYPE(PreviewRgba, r, g, b, a); + + py::class_(m, "PreviewImage", "Thumbnail version of the image") + .def(py::init()) + .def(py::init()) + .def(py::init>()) + .def("__repr__", [](const PyPreviewImage& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("pixels", &PyPreviewImage::pixels) + ; + + py::class_(m, "Double") + .def(py::init()) + .def("__repr__", [](const PyDouble& d) { return repr(d.d); }) + .def(py::self == py::self) + ; + + // + // Stand-in Imath classes - these should really come from the Imath module. + // + + py::class_(m, "V2i") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const V2i& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("x", &Imath::V2i::x) + .def_readwrite("y", &Imath::V2i::y) + ; + + py::class_(m, "V2f") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const V2f& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("x", &Imath::V2f::x) + .def_readwrite("y", &Imath::V2f::y) + ; + + py::class_(m, "V2d") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const V2d& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("x", &Imath::V2d::x) + .def_readwrite("y", &Imath::V2d::y) + ; + + py::class_(m, "V3i") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const V3i& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("x", &Imath::V3i::x) + .def_readwrite("y", &Imath::V3i::y) + .def_readwrite("z", &Imath::V3i::z) + ; + + py::class_(m, "V3f") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const V3f& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("x", &Imath::V3f::x) + .def_readwrite("y", &Imath::V3f::y) + .def_readwrite("z", &Imath::V3f::z) + ; + + py::class_(m, "V3d") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const V3d& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("x", &Imath::V3d::x) + .def_readwrite("y", &Imath::V3d::y) + .def_readwrite("z", &Imath::V3d::z) + ; + + py::class_(m, "Box2i") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const Box2i& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("min", &Box2i::min) + .def_readwrite("max", &Box2i::max) + ; + + py::class_(m, "Box2f") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const Box2f& v) { return repr(v); }) + .def(py::self == py::self) + .def_readwrite("min", &Box2f::min) + .def_readwrite("max", &Box2f::max) + ; + + py::class_(m, "M33f") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const M33f& m) { return repr(m); }) + .def(py::self == py::self) + ; + + py::class_(m, "M33d") + .def(py::init()) + .def(py::init()) + .def("__repr__", [](const M33d& m) { return repr(m); }) + .def(py::self == py::self) + ; + + py::class_(m, "M44f") + .def(py::init()) + .def(py::self == py::self) + .def("__repr__", [](const M44f& m) { return repr(m); }) + ; + + py::class_(m, "M44d") + .def(py::init()) + .def("__repr__", [](const M44d& m) { return repr(m); }) + .def(py::self == py::self) + ; + + // + // The File API: Channel, Part, and File + // + + py::class_(m, "Channel") + .def(py::init()) + .def(py::init(), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false) + .def(py::init()) + .def(py::init(), + py::arg("pixels"), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false) + .def(py::init(), + py::arg("name")) + .def(py::init(), + py::arg("name"), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false) + .def(py::init(), + py::arg("name"), + py::arg("pixels")) + .def(py::init(), + py::arg("name"), + py::arg("pixels"), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false) + .def("__repr__", [](const PyChannel& c) { return repr(c); }) + .def(py::self == py::self) + .def(py::self != py::self) + .def_readwrite("name", &PyChannel::name) + .def("type", &PyChannel::pixelType) + .def_readwrite("xSampling", &PyChannel::xSampling) + .def_readwrite("ySampling", &PyChannel::ySampling) + .def_readwrite("pLinear", &PyChannel::pLinear) + .def_readwrite("pixels", &PyChannel::pixels) + .def_readonly("channel_index", &PyChannel::channel_index) + ; + + py::class_(m, "Part") + .def(py::init()) + .def(py::init(), + py::arg("header"), + py::arg("channels"), + py::arg("name")="") + .def("__repr__", [](const PyPart& p) { return repr(p); }) + .def(py::self == py::self) + .def("name", &PyPart::name) + .def("type", &PyPart::type) + .def("width", &PyPart::width) + .def("height", &PyPart::height) + .def("compression", &PyPart::compression) + .def_readwrite("header", &PyPart::header) + .def_readwrite("channels", &PyPart::channels) + .def_readonly("part_index", &PyPart::part_index) + ; + + py::class_(m, "File") + .def(py::init<>()) + .def(py::init(), + py::arg("filename"), + py::arg("rgba")=false, + py::arg("header_only")=false) + .def(py::init(), + py::arg("header"), + py::arg("channels")) + .def(py::init(), + py::arg("parts")) + .def(py::self == py::self) + .def_readwrite("filename", &PyFile::filename) + .def_readwrite("parts", &PyFile::parts) + .def("header", &PyFile::header, py::arg("part_index") = 0) + .def("channels", &PyFile::channels, py::arg("part_index") = 0) + .def("write", &PyFile::write) + ; +} + diff --git a/src/wrappers/python/PyOpenEXR.h b/src/wrappers/python/PyOpenEXR.h new file mode 100644 index 000000000..6932a95c0 --- /dev/null +++ b/src/wrappers/python/PyOpenEXR.h @@ -0,0 +1,327 @@ +// +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) Contributors to the OpenEXR Project. +// + +// +// PyFile is the object that corresponds to an exr file, either for reading +// or writing, consisting of a simple list of parts. +// + +class PyPart; + +class PyFile +{ +public: + PyFile() {} + PyFile(const std::string& filename, bool rgba = false, bool header_only = false); + PyFile(const py::dict& header, const py::dict& channels); + PyFile(const py::list& parts); + + py::dict& header(int part_index = 0); + py::dict& channels(int part_index = 0); + + void write(const char* filename); + + bool operator==(const PyFile& other) const; + bool operator!=(const PyFile& other) const { return !(*this == other); } + + std::string filename; + py::list parts; + +protected: + + bool header_only; + + py::object get_attribute_object(const std::string& name, const Attribute* a); + + void insert_attribute(Header& header, + const std::string& name, + const py::object& object); + +}; + +// +// PyPart holds the information for a part of an exr file: name, type, +// dimension, compression, the list of attributes (e.g. "header") and the +// list of channels. +// + +class PyPart +{ + public: + PyPart() {} + PyPart(const py::dict& header, const py::dict& channels, const std::string& name); + + bool operator==(const PyPart& other) const; + bool operator!=(const PyPart& other) const { return !(*this == other); } + + std::string name() const; + V2i shape() const; + size_t width() const; + size_t height() const; + Compression compression() const; + exr_storage_t type() const; + std::string typeString() const; + + py::dict header; + py::dict channels; + + size_t part_index; + + void readPixels(MultiPartInputFile& infile, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgba_channels, + const Box2i& dw, bool rgba); + void readDeepPixels(MultiPartInputFile& infile, const std::string& type, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgba_channels, + const Box2i& dw, bool rgba); + int rgba_channel(const ChannelList& channel_list, const std::string& name, + std::string& py_channel_name, char& channel_name); + +}; + +// +// PyChannel holds information for a channel of a PyPart: name, type, x/y +// sampling, and the array of pixel data. +// + +class PyChannel +{ +public: + + PyChannel() + : xSampling(1), ySampling(1), pLinear(false), channel_index(0) {} + + PyChannel(int xSampling, int ySampling, bool pLinear = false) + : xSampling(xSampling), ySampling(ySampling), pLinear(pLinear), channel_index(0) {} + PyChannel(const py::array& p) + : xSampling(1), ySampling(1), pLinear(false), pixels(p), + channel_index(0) {validate_pixel_array(); } + PyChannel(const py::array& p, int xSampling, int ySampling, bool pLinear = false) + : xSampling(xSampling), ySampling(ySampling), pLinear(pLinear), pixels(p), + channel_index(0) {validate_pixel_array(); } + + PyChannel(const char* n) + : name(n), xSampling(1), ySampling(1), pLinear(false), channel_index(0) {} + PyChannel(const char* n, int xSampling, int ySampling, bool pLinear = false) + : name(n), xSampling(xSampling), ySampling(ySampling), pLinear(pLinear), + channel_index(0) {} + PyChannel(const char* n, const py::array& p) + : name(n), xSampling(1), ySampling(1), pLinear(false), pixels(p), + channel_index(0) {validate_pixel_array(); } + PyChannel(const char* n, const py::array& p, int xSampling, int ySampling, bool pLinear = false) + : name(n), xSampling(xSampling), ySampling(ySampling), pLinear(pLinear), pixels(p), + channel_index(0) {validate_pixel_array(); } + + bool operator==(const PyChannel& other) const; + bool operator!=(const PyChannel& other) const { return !(*this == other); } + + void validate_pixel_array(); + + PixelType pixelType() const; + + std::string name; + int xSampling; + int ySampling; + int pLinear; + py::array pixels; + + size_t channel_index; +}; + +template +bool +array_equals(const py::buffer_info& a, const py::buffer_info& b, const std::string& name); + +class PyPreviewImage +{ +public: + static constexpr uint32_t style = py::array::c_style | py::array::forcecast; + static constexpr size_t stride = sizeof(PreviewRgba); + + PyPreviewImage() {} + + PyPreviewImage(unsigned int width, unsigned int height, + const PreviewRgba* data = nullptr) + : pixels(py::array_t(std::vector({height, width}), + std::vector({stride*width, stride}), + data)) {} + + PyPreviewImage(const py::array_t& p) : pixels(p) {} + + inline bool operator==(const PyPreviewImage& other) const; + + py::array_t pixels; +}; + +inline std::ostream& +operator<< (std::ostream& s, const PreviewRgba& p) +{ + s << " (" << int(p.r) + << "," << int(p.g) + << "," << int(p.b) + << "," << int(p.a) + << ")"; + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const PyPreviewImage& P) +{ + auto width = P.pixels.shape(1); + auto height = P.pixels.shape(0); + + s << "PreviewImage(" << width + << ", " << height; +#if PRINT_PIXELS + s << "," << std::endl; + py::buffer_info buf = P.pixels.request(); + const PreviewRgba* rgba = static_cast(buf.ptr); + for (decltype(height) y = 0; y(buf.ptr); + const PreviewRgba* bpixels = static_cast(obuf.ptr); + for (decltype(buf.size) i = 0; i < buf.size; i++) + if (!(apixels[i] == bpixels[i])) + return false; + return true; +} + + +// +// PyDouble supports the "double" attribute. +// +// When reading an attribute of type "double", a python object of type +// PyDouble is created, so that when the header is written, it will be +// of type double, since python makes no distinction between float and +// double numerical types. +// + +class PyDouble +{ +public: + PyDouble(double x) : d(x) {} + + bool operator==(const PyDouble& other) const { return d == other.d; } + + double d; +}; + + +inline std::ostream& +operator<< (std::ostream& s, const Chromaticities& c) +{ + s << "(" << c.red + << ", " << c.green + << ", " << c.blue + << ", " << c.white + << ")"; + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const Rational& v) +{ + s << v.n << "/" << v.d; + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const KeyCode& v) +{ + s << "(" << v.filmMfcCode() + << ", " << v.filmType() + << ", " << v.prefix() + << ", " << v.count() + << ", " << v.perfOffset() + << ", " << v.perfsPerFrame() + << ", " << v.perfsPerCount() + << ")"; + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const TimeCode& v) +{ + s << "(" << v.hours() + << ", " << v.minutes() + << ", " << v.seconds() + << ", " << v.frame() + << ", " << v.dropFrame() + << ", " << v.colorFrame() + << ", " << v.fieldPhase() + << ", " << v.bgf0() + << ", " << v.bgf1() + << ", " << v.bgf2() + << ")"; + return s; +} + + +inline std::ostream& +operator<< (std::ostream& s, const TileDescription& v) +{ + s << "TileDescription(" << v.xSize + << ", " << v.ySize + << ", " << py::cast(v.mode) + << ", " << py::cast(v.roundingMode) + << ")"; + + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const Box2i& v) +{ + s << "(" << v.min << " " << v.max << ")"; + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const Box2f& v) +{ + s << "(" << v.min << " " << v.max << ")"; + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const PyChannel& C) +{ + s << "Channel(\"" << C.name + << "\", xSampling=" << C.xSampling + << ", ySampling=" << C.ySampling; + if (C.pLinear) + s << ", pLinear=True"; + s << ")"; + return s; +} + +inline std::ostream& +operator<< (std::ostream& s, const PyPart& P) +{ + auto name = P.name(); + s << "Part("; + if (name != "") + s << "\"" << name << "\""; + s << ", " << py::cast(P.compression()) + << ", width=" << P.width() + << ", height=" << P.height() + << ")"; + return s; +} + diff --git a/src/wrappers/python/OpenEXR.cpp b/src/wrappers/python/PyOpenEXR_old.cpp similarity index 97% rename from src/wrappers/python/OpenEXR.cpp rename to src/wrappers/python/PyOpenEXR_old.cpp index d7e270a3d..86caa0110 100644 --- a/src/wrappers/python/OpenEXR.cpp +++ b/src/wrappers/python/PyOpenEXR_old.cpp @@ -5,6 +5,9 @@ #define PY_SSIZE_T_CLEAN // required for Py_BuildValue("s#") for Python 3.10 #include +#include + +namespace py = pybind11; #if PY_VERSION_HEX < 0x02050000 && !defined(PY_SSIZE_T_MIN) typedef int Py_ssize_t; @@ -1442,6 +1445,18 @@ makeHeader (PyObject* self, PyObject* args) return dict_from_header (header); } +PyObject* +makeHeader_pybind (int width, int height) +{ + const char* channels = "R,G,B"; + Header header (width, height); + for (auto channel: split (channels, ',')) + { + header.channels ().insert (channel.c_str (), Channel (FLOAT)); + } + return dict_from_header (header); +} + //////////////////////////////////////////////////////////////////////// static bool @@ -1483,15 +1498,18 @@ static PyMethodDef methods[] = { {NULL, NULL}, }; -MOD_INIT (OpenEXR) +bool +init_OpenEXR_old(PyObject* module) { - PyObject *m, *d, *item; - - Imf::staticInitialize (); - - MOD_DEF (m, "OpenEXR", "", methods) - d = PyModule_GetDict (m); + PyObject* moduleDict = PyModule_GetDict (module); + for (PyMethodDef* def = methods; def->ml_name != NULL; def++) + { + PyObject *func = PyCFunction_New(def, NULL); + PyDict_SetItemString(moduleDict, def->ml_name, func); + Py_DECREF(func); + } + pModuleImath = PyImport_ImportModule ("Imath"); /* initialize module variables/constants */ @@ -1499,25 +1517,26 @@ MOD_INIT (OpenEXR) InputFile_Type.tp_init = makeInputFile; OutputFile_Type.tp_new = PyType_GenericNew; OutputFile_Type.tp_init = makeOutputFile; - if (PyType_Ready (&InputFile_Type) != 0) return MOD_ERROR_VAL; - if (PyType_Ready (&OutputFile_Type) != 0) return MOD_ERROR_VAL; - PyModule_AddObject (m, "InputFile", (PyObject*) &InputFile_Type); - PyModule_AddObject (m, "OutputFile", (PyObject*) &OutputFile_Type); + + if (PyType_Ready (&InputFile_Type) != 0) + return false; + if (PyType_Ready (&OutputFile_Type) != 0) + return false; + PyModule_AddObject (module, "InputFile", (PyObject*) &InputFile_Type); + PyModule_AddObject (module, "OutputFile", (PyObject*) &OutputFile_Type); -#if PYTHON_API_VERSION >= 1007 OpenEXR_error = PyErr_NewException ((char*) "OpenEXR.error", NULL, NULL); -#else - OpenEXR_error = PyString_FromString ("OpenEXR.error"); -#endif - PyDict_SetItemString (d, "error", OpenEXR_error); + PyDict_SetItemString (moduleDict, "error", OpenEXR_error); Py_DECREF (OpenEXR_error); - PyDict_SetItemString (d, "UINT", item = PyLong_FromLong (UINT)); + PyObject *item; + + PyDict_SetItemString (moduleDict, "UINT_old", item = PyLong_FromLong (UINT)); Py_DECREF (item); - PyDict_SetItemString (d, "HALF", item = PyLong_FromLong (HALF)); + PyDict_SetItemString (moduleDict, "HALF", item = PyLong_FromLong (HALF)); Py_DECREF (item); - PyDict_SetItemString (d, "FLOAT", item = PyLong_FromLong (FLOAT)); + PyDict_SetItemString (moduleDict, "FLOAT", item = PyLong_FromLong (FLOAT)); Py_DECREF (item); - return MOD_SUCCESS_VAL (m); + return true; } diff --git a/src/wrappers/python/README.md b/src/wrappers/python/README.md index 9d5df8965..38bc68d53 100644 --- a/src/wrappers/python/README.md +++ b/src/wrappers/python/README.md @@ -49,16 +49,13 @@ package. ## Python Module -The OpenEXR python module provides rudimentary support for reading and -writing basic scanline image data. Many features of the file format -are not yet supported, including: - -- Writing of tiled images -- Multiresoltion images -- Deep image data -- Some attribute types -- Nonunity channel sampling frequencies -- No support for interleaved channel data +The OpenEXR python module provides full support for reading and +writing all types of ``.exr`` image files, including scanline, tiled, +deep, mult-part, multi-view, and multi-resolution images with pixel +types of unsigned 32-bit integers and 16- and 32-bit floats. It +provides access to pixel data through numpy arrays, as either one +array per channel or with R, G, B, and A interleaved into a single +array RGBA array. ## Project Governance @@ -69,30 +66,113 @@ for more information. # Quick Start - - - -The "hello, world" image writer: +The "Hello, World" image writer: import OpenEXR - + import numpy as np + import random + width = 10 - height = 10 - size = width * height - - h = OpenEXR.Header(width,height) - h['channels'] = {'R' : Imath.Channel(FLOAT), - 'G' : Imath.Channel(FLOAT), - 'B' : Imath.Channel(FLOAT), - 'A' : Imath.Channel(FLOAT)} - o = OpenEXR.OutputFile("hello.exr", h) - r = array('f', [n for n in range(size*0,size*1)]).tobytes() - g = array('f', [n for n in range(size*1,size*2)]).tobytes() - b = array('f', [n for n in range(size*2,size*3)]).tobytes() - a = array('f', [n for n in range(size*3,size*4)]).tobytes() - channels = {'R' : r, 'G' : g, 'B' : b, 'A' : a} - o.writePixels(channels) - o.close() + height = 20 + R = np.ndarray((height, width), dtype='f') + G = np.ndarray((height, width), dtype='f') + B = np.ndarray((height, width), dtype='f') + for y in range(0, height): + for x in range(0, width): + R[y][x] = random.random() + G[y][x] = random.random() + B[y][x] = random.random() + + channels = { "R" : OpenEXR.Channel(R), + "G" : OpenEXR.Channel(G), + "B" : OpenEXR.Channel(B) } + + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + outfile = OpenEXR.File(header, channels) + outfile.write("readme.exr") + +Or alternatively, construct the same output file via a single RGB pixel array: + + width = 10 + height = 20 + RGB = np.ndarray((height, width, 3), dtype='f') + for y in range(0, height): + for x in range(0, width): + for i in range(0,3): + RGB[y][x][i] = random.random() + + channels = { "RGB" : OpenEXR.Channel(RGB) } + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + outfile = OpenEXR.File(header, channels) + outfile.write("readme.exr") + +The corresponding example of reading an image is: + + infile = OpenEXR.File("readme.exr") + + header = infile.header() + print(f"type={header['type']}") + print(f"compression={header['compression']}") + + R = infile.channels()["R"].pixels + G = infile.channels()["G"].pixels + B = infile.channels()["B"].pixels + width = R.shape[1] + height = R.shape[0] + for y in range(0, height): + for x in range(0, width): + print(f"pixel[{y}][{x}]=({R[y][x]}, {G[y][x]}, {B[y][x]})") + +Or alternatively, read the data as a single RGBA array: + + infile = OpenEXR.File("readme.exr", rgba=True) + + RGB = infile.channels()["RGB"].pixels + width = RGB.shape[1] + height = RGB.shape[0] + for y in range(0, height): + for x in range(0, width): + print(f"pixel[{y}][{x}]=({RGB[y][x][0]}, {RGB[y][x][1]}, {RGB[y][x][2]})") + +To modify the header metadata in a file: + + f = OpenEXR.File("readme.exr") + f.header()["displayWindow"] = OpenEXR.Box2i(OpenEXR.V2i(3,4), + OpenEXR.V2i(5,6)) + f.header()["comments"] = "test image" + f.header()["longitude"] = -122.5 + f.write("readme_modified.exr") + +To read and write a multi-part file, use a list of ``Part`` objects: + + height = 20 + width = 10 + + Z0 = np.zeros((height, width), dtype='f') + P0 = OpenEXR.Part(header={"type" : OpenEXR.scanlineimage }, + channels={"Z" : OpenEXR.Channel(Z0) }) + + Z1 = np.ones((height, width), dtype='f') + P1 = OpenEXR.Part(header={"type" : OpenEXR.scanlineimage }, + channels={"Z" : OpenEXR.Channel(Z1) }) + + f = OpenEXR.File(parts=[P0, P1]) + f.write("readme_2part.exr") + + o = OpenEXR.File("readme_2part.exr") + assert o.parts[0].name() == "Part0" + assert o.parts[0].width() == 10 + assert o.parts[0].height() == 20 + assert np.array_equal(o.parts[0].channels["Z"].pixels, Z0) + assert o.parts[1].name() == "Part1" + assert o.parts[1].width() == 10 + assert o.parts[1].height() == 20 + assert np.array_equal(o.parts[1].channels["Z"].pixels, Z1) + # Community @@ -125,7 +205,7 @@ The "hello, world" image writer: - Sign the [Contributor License Agreement](https://contributor.easycla.lfx.linuxfoundation.org/#/cla/project/2e8710cb-e379-4116-a9ba-964f83618cc5/user/564e571e-12d7-4857-abd4-898939accdd7) - + - Submit a Pull Request: https://github.com/AcademySoftwareFoundation/openexr/pulls # Resources diff --git a/src/wrappers/python/tests/test_exceptions.py b/src/wrappers/python/tests/test_exceptions.py new file mode 100644 index 000000000..9689cb492 --- /dev/null +++ b/src/wrappers/python/tests/test_exceptions.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 + +# +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenEXR Project. +# + +from __future__ import print_function +import sys +import os +import numpy as np +import unittest + +import OpenEXR + +test_dir = os.path.dirname(__file__) + +class TestExceptions(unittest.TestCase): + + def test_Channel(self): + + c = OpenEXR.Channel(1) + + def test_File(self): + + # invalid argument + with self.assertRaises(Exception): + f = OpenEXR.File(1) + + # invalid number of arguments + with self.assertRaises(Exception): + f = OpenEXR.File("foo", "bar") + + # file not found + filename = "/nonexistentfile.exr" + with self.assertRaises(Exception): + f = OpenEXR.File(filename) + + # file exists but is not an image + filename = f"{test_dir}" + with self.assertRaises(Exception): + f = OpenEXR.File(filename) + + # Empty file object (useful it's possible to assign to it later) + f = OpenEXR.File() + self.assertEqual(f.filename, "") + + # no parts + self.assertEqual(f.parts,[]) + with self.assertRaises(Exception): + f.header() + with self.assertRaises(Exception): + f.channels() + + # 1-part file + filename = f"{test_dir}/test.exr" + f = OpenEXR.File(filename) + self.assertEqual(f.filename, filename) + + # filename must be a string + with self.assertRaises(Exception): + f.filename = 1 + + # invalid part + with self.assertRaises(Exception): + f.header(-1) + with self.assertRaises(Exception): + f.header(1) + with self.assertRaises(Exception): + f.channels(-1) + with self.assertRaises(Exception): + f.channels(1) + + self.assertEqual(len(f.parts),1) + + # invalid list (should be list of parts) + with self.assertRaises(Exception): + f = OpenEXR.File([1,2]) + with self.assertRaises(Exception): + f = OpenEXR.File([OpenEXR.Part(),2]) + + # empty header, empty channels + f = OpenEXR.File({}, {}) + + # bad header dict, bad channels dict + with self.assertRaises(Exception): + f = OpenEXR.File({1:1}, {}) + with self.assertRaises(Exception): + f = OpenEXR.File({}, {1:1}) + with self.assertRaises(Exception): + f = OpenEXR.File({}, {"A":1}) # bad value, shou + + def test_Part(self): + + with self.assertRaises(Exception): + p = OpenEXR.Part(1) + + # bad header dict, bad channels dict + with self.assertRaises(Exception): + p = OpenEXR.Part({1:1}, {}, "party") + with self.assertRaises(Exception): + p = OpenEXR.Part({}, {1:1}, "party") + with self.assertRaises(Exception): + p = OpenEXR.Part({}, {"A":1}, "party") # bad value, shou + + # test default type, compression + p = OpenEXR.Part({}, {}, name="party") + self.assertEqual(p.name(), "party") + self.assertEqual(p.type(), OpenEXR.scanlineimage) + self.assertEqual(p.compression(), OpenEXR.ZIP_COMPRESSION) + self.assertEqual(p.width(), 0) + self.assertEqual(p.height(), 0) + self.assertEqual(p.channels, {}) + + # test non-default type, compression + p = OpenEXR.Part({"type" : OpenEXR.tiledimage, + "compression" : OpenEXR.NO_COMPRESSION}, {}, "party") + self.assertEqual(p.type(), OpenEXR.tiledimage) + self.assertEqual(p.compression(), OpenEXR.NO_COMPRESSION) + + def test_Channel(self): + + with self.assertRaises(Exception): + OpenEXR.Channel(1) + + with self.assertRaises(Exception): + OpenEXR.Channel("C", 2) + + C = OpenEXR.Channel("C", 2, 3) + assert C.xSampling == 2 + assert C.ySampling == 3 + + # not a 2D array + with self.assertRaises(Exception): + OpenEXR.Channel(np.array([0,0,0,0], dtype='uint32')) + with self.assertRaises(Exception): + OpenEXR.Channel("C", np.array([0,0,0,0], dtype='uint32')) + with self.assertRaises(Exception): + OpenEXR.Channel(np.array([0,0,0,0], dtype='uint32'), 2, 3) + with self.assertRaises(Exception): + OpenEXR.Channel("C", np.array([0,0,0,0], dtype='uint32'), 2, 3) + + # 2D array of unrecognized type + width = 2 + height = 2 + with self.assertRaises(Exception): + OpenEXR.Channel(np.array([0,0,0,0], dtype='uint8').reshape((height, width))) + with self.assertRaises(Exception): + OpenEXR.Channel("C", np.array([0,0,0,0], dtype='uint8').reshape((height, width))) + with self.assertRaises(Exception): + OpenEXR.Channel(np.array([0,0,0,0], dtype='uint8').reshape((height, width)), 2, 2) + with self.assertRaises(Exception): + OpenEXR.Channel("C", np.array([0,0,0,0], dtype='uint8').reshape((height, width)), 2, 2) + +if __name__ == '__main__': + unittest.main() diff --git a/src/wrappers/python/tests/test_images.py b/src/wrappers/python/tests/test_images.py new file mode 100755 index 000000000..0d281b324 --- /dev/null +++ b/src/wrappers/python/tests/test_images.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 + +# +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenEXR Project. +# + +# +# Download images from the openexr-images repo, and for each image, +# read it as both separate channels (default) and as RGB channels, +# write it, read the written file, and confirm it's identical. +# + +from __future__ import print_function +import sys +import os +import tempfile +import atexit +import unittest +import numpy as np +from subprocess import PIPE, run + +import OpenEXR + + +exr_files = [ + "TestImages/GammaChart.exr", + "TestImages/SquaresSwirls.exr", + "TestImages/GrayRampsDiagonal.exr", + "TestImages/BrightRingsNanInf.exr", + "TestImages/WideFloatRange.exr", + "TestImages/GrayRampsHorizontal.exr", + "TestImages/WideColorGamut.exr", + "TestImages/BrightRings.exr", + "TestImages/RgbRampsDiagonal.exr", + "TestImages/AllHalfValues.exr", + "Beachball/multipart.0007.exr", + "Beachball/singlepart.0007.exr", + "Beachball/singlepart.0006.exr", + "Beachball/multipart.0006.exr", + "Beachball/multipart.0004.exr", + "Beachball/singlepart.0004.exr", + "Beachball/singlepart.0005.exr", + "Beachball/multipart.0005.exr", + "Beachball/singlepart.0001.exr", + "Beachball/multipart.0001.exr", + "Beachball/singlepart.0002.exr", + "Beachball/multipart.0002.exr", + "Beachball/multipart.0003.exr", + "Beachball/singlepart.0003.exr", + "Beachball/multipart.0008.exr", + "Beachball/singlepart.0008.exr", + "DisplayWindow/t12.exr", + "DisplayWindow/t06.exr", + "DisplayWindow/t07.exr", + "DisplayWindow/t13.exr", + "DisplayWindow/t05.exr", + "DisplayWindow/t11.exr", + "DisplayWindow/t10.exr", + "DisplayWindow/t04.exr", + "DisplayWindow/t14.exr", + "DisplayWindow/t15.exr", + "DisplayWindow/t01.exr", + "DisplayWindow/t03.exr", + "DisplayWindow/t02.exr", + "DisplayWindow/t16.exr", + "DisplayWindow/t09.exr", + "DisplayWindow/t08.exr", + "Tiles/GoldenGate.exr", + "Tiles/Spirals.exr", + "Tiles/Ocean.exr", + "v2/Stereo/composited.exr", + "v2/Stereo/Trunks.exr", + "v2/Stereo/Balls.exr", + "v2/Stereo/Ground.exr", + "v2/Stereo/Leaves.exr", + "v2/LeftView/Trunks.exr", + "v2/LeftView/Balls.exr", + "v2/LeftView/Ground.exr", + "v2/LeftView/Leaves.exr", + "v2/LowResLeftView/composited.exr", + "v2/LowResLeftView/Trunks.exr", + "v2/LowResLeftView/Balls.exr", + "v2/LowResLeftView/Ground.exr", + "v2/LowResLeftView/Leaves.exr", + "MultiResolution/Kapaa.exr", + "MultiResolution/KernerEnvCube.exr", + "MultiResolution/WavyLinesLatLong.exr", + "MultiResolution/PeriodicPattern.exr", + "MultiResolution/ColorCodedLevels.exr", + "MultiResolution/MirrorPattern.exr", + "MultiResolution/Bonita.exr", + "MultiResolution/OrientationLatLong.exr", + "MultiResolution/StageEnvLatLong.exr", + "MultiResolution/WavyLinesCube.exr", + "MultiResolution/StageEnvCube.exr", + "MultiResolution/KernerEnvLatLong.exr", + "MultiResolution/WavyLinesSphere.exr", + "MultiResolution/OrientationCube.exr", + "Chromaticities/Rec709_YC.exr", + "Chromaticities/XYZ_YC.exr", + "Chromaticities/XYZ.exr", + "Chromaticities/Rec709.exr", + "ScanLines/Desk.exr", + "ScanLines/Blobbies.exr", + "ScanLines/CandleGlass.exr", + "ScanLines/PrismsLenses.exr", + "ScanLines/Tree.exr", + "ScanLines/Cannon.exr", + "ScanLines/MtTamWest.exr", + "ScanLines/StillLife.exr", + "MultiView/Fog.exr", + "MultiView/Adjuster.exr", + "MultiView/Balls.exr", + "MultiView/Impact.exr", + "MultiView/LosPadres.exr", +] + +# +# These don't work yet, so skip them. +# + +bug_files = [ + "LuminanceChroma/Flowers.exr", + "LuminanceChroma/StarField.exr", + "LuminanceChroma/CrissyField.exr", + "LuminanceChroma/MtTamNorth.exr", + "LuminanceChroma/Garden.exr", + "v2/Stereo/Trunks.exr", + "v2/Stereo/Balls.exr", + "v2/Stereo/Ground.exr", + "v2/Stereo/Leaves.exr", + "v2/LeftView/Trunks.exr", + "v2/LeftView/Balls.exr", + "v2/LeftView/Ground.exr", + "v2/LeftView/Leaves.exr", + "v2/LowResLeftView/Trunks.exr", + "v2/LowResLeftView/Balls.exr", + "v2/LowResLeftView/Ground.exr", + "v2/LowResLeftView/Leaves.exr", + "Chromaticities/Rec709_YC.exr", + "Chromaticities/XYZ_YC.exr", +] + +class TestImages(unittest.TestCase): + + def download_file(self, url, output_file): + try: + result = run(['curl', '-o', output_file, url], stdout=PIPE, stderr=PIPE, universal_newlines=True) + print(" ".join(result.args)) + if result.returncode != 0: + print(result.stderr) + return False + except Exception as e: + print(f"Download of {url} failed: {e}") + return False + return True + + def print_channel_names(self, file): + for p in file.parts: + s = f"part[{p.part_index}] name='{p.name()}', channels: [" + for c in p.channels: + s += f" {c}" + s += " ]" + print(s) + + def do_test_tiled(self, url): + + # + # Write the image as tiled, reread and confirm it's the same + # + + filename = "test_file.exr" + if not self.download_file(url, filename): + return + + print(f"Reading {url} ...") + f = OpenEXR.File(filename) + + # Set the type and tile description (default) + for P in f.parts: + P.header["type"] = OpenEXR.tiledimage + if "tiles" not in P.header: + P.header["tiles"] = OpenEXR.TileDescription() + + f.write("tiled.exr") + + t = OpenEXR.File("tiled.exr") + + # Clear the chunkCount values before comparison, since they'll + # differ on conversion from scanline to tiled. + for P in f.parts: + P.header["chunkCount"] = 0 + for P in f.parts: + self.assertEqual(P.header["chunkCount"], 0) + for P in t.parts: + P.header["chunkCount"] = 0 + self.assertEqual(P.type(), OpenEXR.tiledimage) + for P in t.parts: + self.assertEqual(P.header["chunkCount"], 0) + + self.assertEqual(f, t) + + def do_test_image(self, url): + + verbose = False + + filename = "test_file.exr" + if not self.download_file(url, filename): + return + + # Read the file as separate channels, as usual... + + print(f"Reading {url} as separate channels...") + separate_channels = OpenEXR.File(filename) + if verbose: + print_channel_names(separate_channels) + + # Write it out + + print(f"Writing separate_channels.exr...") + separate_channels.write("separate_channels.exr") + + # Read the file that was just written + print(f"Reading {url} as separate channels...") + separate_channels2 = OpenEXR.File("separate_channels.exr") + if verbose: + print_channel_names(separate_channels2) + + # Confirm that the file that was just written is identical to the original + + self.assertEqual(separate_channels, separate_channels2) + + # Read the original file as RGBA channels + + print(f"Reading {url} as rgba channels...") + rgba_channels = OpenEXR.File(filename, True) + if verbose: + print_channel_names(rgba_channels) + + # Write it out + + print(f"Writing rgba_channels.exr...") + rgba_channels.write("rgba_channels.exr") + + # Read the file that was just written (was RGBA in memory, + # should have been written as usual) + + print(f"Reading rgba_channels as separate channels...") + separate_channels2 = OpenEXR.File("rgba_channels.exr") + if verbose: + print_channel_names(separate_channels2) + + # Confirm that it, too, is the same as the original + + print(f"Comparing...") + self.assertEqual(separate_channels, separate_channels2) + print("good.") + + def test_images(self): + + # + # Run the test only if the OPENEXR_TEST_IMAGE_REPO env var is set + # + + REPO_VAR = "OPENEXR_TEST_IMAGE_REPO" + if REPO_VAR in os.environ: + + REPO = os.environ[REPO_VAR] + + for filename in exr_files: + + if filename in bug_files: + print(f"skipping bug file: {filename}") + continue + + url = f"{REPO}/{filename}" + + self.do_test_image(url) + self.do_test_tiled(url) + + print("OK") + + else: + + print(f"{sys.argv[0]}: skipping images, no repo") + +if __name__ == '__main__': + unittest.main() + print("OK") + + diff --git a/src/wrappers/python/tests/test_import.py b/src/wrappers/python/tests/test_import.py new file mode 100644 index 000000000..43560a856 --- /dev/null +++ b/src/wrappers/python/tests/test_import.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 + +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) Contributors to the OpenEXR Project. + +import pytest + +def test_import(): + import OpenEXR + assert OpenEXR.__name__ == "OpenEXR" diff --git a/src/wrappers/python/tests/test_old.py b/src/wrappers/python/tests/test_old.py new file mode 100644 index 000000000..12ac0d2e8 --- /dev/null +++ b/src/wrappers/python/tests/test_old.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 + +# +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenEXR Project. +# + +from __future__ import print_function +import sys +import os +import random +from array import array + +import OpenEXR +import Imath + +test_dir = os.path.dirname(__file__) + +FLOAT = Imath.PixelType(Imath.PixelType.FLOAT) +UINT = Imath.PixelType(Imath.PixelType.UINT) +HALF = Imath.PixelType(Imath.PixelType.HALF) + +testList = [] + +# +# Write a simple exr file, read it back and confirm the data is the same. +# + +def test_write_read(): + + width = 100 + height = 100 + size = width * height + + h = OpenEXR.Header(width,height) + h['channels'] = {'R' : Imath.Channel(FLOAT), + 'G' : Imath.Channel(FLOAT), + 'B' : Imath.Channel(FLOAT), + 'A' : Imath.Channel(FLOAT)} + o = OpenEXR.OutputFile(f"{test_dir}/write.exr", h) + r = array('f', [n for n in range(size*0,size*1)]).tobytes() + g = array('f', [n for n in range(size*1,size*2)]).tobytes() + b = array('f', [n for n in range(size*2,size*3)]).tobytes() + a = array('f', [n for n in range(size*3,size*4)]).tobytes() + channels = {'R' : r, 'G' : g, 'B' : b, 'A' : a} + o.writePixels(channels) + o.close() + + i = OpenEXR.InputFile(f"{test_dir}/write.exr") + h = i.header() + assert r == i.channel('R') + assert g == i.channel('G') + assert b == i.channel('B') + assert a == i.channel('A') + +testList.append(("test_write_read", test_write_read)) + +def test_level_modes(): + + assert Imath.LevelMode("ONE_LEVEL").v == Imath.LevelMode(Imath.LevelMode.ONE_LEVEL).v + assert Imath.LevelMode("MIPMAP_LEVELS").v == Imath.LevelMode(Imath.LevelMode.MIPMAP_LEVELS).v + assert Imath.LevelMode("RIPMAP_LEVELS").v == Imath.LevelMode(Imath.LevelMode.RIPMAP_LEVELS).v + +testList.append(("test_level_modes", test_level_modes)) + +# +# Write an image as UINT, read as FLOAT, and the reverse. +# +def test_conversion(): + codemap = { 'f': FLOAT, 'I': UINT } + original = [0, 1, 33, 79218] + for frm_code,to_code in [ ('f','I'), ('I','f') ]: + hdr = OpenEXR.Header(len(original), 1) + hdr['channels'] = {'L': Imath.Channel(codemap[frm_code])} + x = OpenEXR.OutputFile(f"{test_dir}/out.exr", hdr) + x.writePixels({'L': array(frm_code, original).tobytes()}) + x.close() + + xin = OpenEXR.InputFile(f"{test_dir}/out.exr") + assert array(to_code, xin.channel('L', codemap[to_code])).tolist() == original + +testList.append(("test_conversion", test_conversion)) + +# +# Confirm failure on reading from non-exist location +# + +def test_invalid_input(): + try: + OpenEXR.InputFile("/bad/place") + except: + pass + else: + assert 0 + +testList.append(("test_invalid_input", test_invalid_input)) + +# +# Confirm failure on writing to invalid location +# + +def test_invalid_output(): + + try: + hdr = OpenEXR.Header(640, 480) + OpenEXR.OutputFile("/bad/place", hdr) + except: + pass + else: + assert 0 + +testList.append(("test_invalid_output", test_invalid_output)) + +def test_one(): + oexr = OpenEXR.InputFile(f"{test_dir}/write.exr") + + header = oexr.header() + + default_size = len(oexr.channel('R')) + half_size = len(oexr.channel('R', Imath.PixelType(Imath.PixelType.HALF))) + float_size = len(oexr.channel('R', Imath.PixelType(Imath.PixelType.FLOAT))) + uint_size = len(oexr.channel('R', Imath.PixelType(Imath.PixelType.UINT))) + + assert default_size in [ half_size, float_size, uint_size] + assert float_size == uint_size + assert (float_size / 2) == half_size + + assert len(oexr.channel('R', + pixel_type = FLOAT, + scanLine1 = 10, + scanLine2 = 10)) == (4 * (header['dataWindow'].max.x + 1)) + + + data = b" " * (4 * 100 * 100) + h = OpenEXR.Header(100,100) + x = OpenEXR.OutputFile(f"{test_dir}/out.exr", h) + x.writePixels({'R': data, 'G': data, 'B': data}) + x.close() + +testList.append(("test_one", test_one)) + +# +# Check that the channel method and channels method return the same data +# + +def test_channel_channels(): + + aexr = OpenEXR.InputFile(f"{test_dir}/write.exr") + acl = sorted(aexr.header()['channels'].keys()) + a = [aexr.channel(c) for c in acl] + b = aexr.channels(acl) + + assert a == b + +testList.append(("test_channel_channels", test_channel_channels)) + +def test_types(): + for original in [ [0,0,0], list(range(10)), list(range(100,200,3)) ]: + for code,t in [ ('I', UINT), ('f', FLOAT) ]: + data = array(code, original).tobytes() + hdr = OpenEXR.Header(len(original), 1) + hdr['channels'] = {'L': Imath.Channel(t)} + + x = OpenEXR.OutputFile(f"{test_dir}/out.exr", hdr) + x.writePixels({'L': data}) + x.close() + + xin = OpenEXR.InputFile(f"{test_dir}/out.exr") + # Implicit type + assert array(code, xin.channel('L')).tolist() == original + # Explicit typen + assert array(code, xin.channel('L', t)).tolist() == original + # Explicit type as kwarg + assert array(code, xin.channel('L', pixel_type = t)).tolist() == original + +testList.append(("test_types", test_types)) + +def test_invalid_pixeltype(): + oexr = OpenEXR.InputFile(f"{test_dir}/write.exr") + FLOAT = Imath.PixelType.FLOAT + try: + f.channel('R',FLOAT) + except: + pass + else: + assert 0 + +testList.append(("test_invalid_pixeltype", test_invalid_pixeltype)) + +# +# Write arbitrarily named channels. +# + +def test_write_mchannels(): + hdr = OpenEXR.Header(100, 100) + for chans in [ set("a"), set(['foo', 'bar']), set("abcdefghijklmnopqstuvwxyz") ]: + hdr['channels'] = dict([(nm, Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT))) for nm in chans]) + x = OpenEXR.OutputFile(f"{test_dir}/out0.exr", hdr) + data = array('f', [0] * (100 * 100)).tobytes() + x.writePixels(dict([(nm, data) for nm in chans])) + x.close() + assert set(OpenEXR.InputFile(f"{test_dir}/out0.exr").header()['channels']) == chans + +testList.append(("test_write_mchannels", test_write_mchannels)) + +def load_red(filename): + oexr = OpenEXR.InputFile(filename) + return oexr.channel('R') + +# +# Write the pixels to two images, first as a single call, +# then as multiple calls. Verify that the images are identical. +# + +def test_write_chunk(): + for w,h,step in [(100, 10, 1), (64,48,6), (1, 100, 2), (640, 480, 4)]: + data = array('f', [ random.random() for x in range(w * h) ]).tobytes() + + hdr = OpenEXR.Header(w,h) + x = OpenEXR.OutputFile(f"{test_dir}/out0.exr", hdr) + x.writePixels({'R': data, 'G': data, 'B': data}) + x.close() + + hdr = OpenEXR.Header(w,h) + x = OpenEXR.OutputFile(f"{test_dir}/out1.exr", hdr) + for y in range(0, h, step): + subdata = data[y * w * 4:(y+step) * w * 4] + x.writePixels({'R': subdata, 'G': subdata, 'B': subdata}, step) + x.close() + + oexr0 = load_red(f"{test_dir}/out0.exr") + oexr1 = load_red(f"{test_dir}/out1.exr") + assert oexr0 == oexr1 + +testList.append(("test_write_chunk", test_write_chunk)) + +for test in testList: + funcName = test[0] + test[1]() + + diff --git a/src/wrappers/python/tests/test_readme.py b/src/wrappers/python/tests/test_readme.py index 2bf3da888..940309cc3 100644 --- a/src/wrappers/python/tests/test_readme.py +++ b/src/wrappers/python/tests/test_readme.py @@ -7,25 +7,170 @@ # This is the example code from src/wrappers/python/README.md -def test_readme(): +import OpenEXR +import numpy as np +import random - import OpenEXR, Imath - from array import array - +def test_write(): + + width = 10 + height = 20 + R = np.ndarray((height, width), dtype='f') + G = np.ndarray((height, width), dtype='f') + B = np.ndarray((height, width), dtype='f') + for y in range(0, height): + for x in range(0, width): + R[y][x] = random.random() + G[y][x] = random.random() + B[y][x] = random.random() + + channels = { "R" : OpenEXR.Channel(R), + "G" : OpenEXR.Channel(G), + "B" : OpenEXR.Channel(B) } + + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + outfile = OpenEXR.File(header, channels) + outfile.write("readme.exr") + +def test_write_RGB(): + + width = 10 + height = 20 + RGB = np.ndarray((height, width, 3), dtype='f') + for y in range(0, height): + for x in range(0, width): + for i in range(0,3): + RGB[y][x][i] = random.random() + + channels = { "RGB" : OpenEXR.Channel(RGB) } + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + outfile = OpenEXR.File(header, channels) + outfile.write("readme.exr") + +def test_read(): + + infile = OpenEXR.File("readme.exr") + + header = infile.header() + print(f"type={header['type']}") + print(f"compression={header['compression']}") + + R = infile.channels()["R"].pixels + G = infile.channels()["G"].pixels + B = infile.channels()["B"].pixels + width = R.shape[1] + height = R.shape[0] + for y in range(0, height): + for x in range(0, width): + print(f"pixel[{y}][{x}]=({R[y][x]}, {G[y][x]}, {B[y][x]})") + +def test_read_RGB(): + + infile = OpenEXR.File("readme.exr", rgba=True) + + RGB = infile.channels()["RGB"].pixels + width = RGB.shape[1] + height = RGB.shape[0] + for y in range(0, height): + for x in range(0, width): + print(f"pixel[{y}][{x}]=({RGB[y][x][0]}, {RGB[y][x][1]}, {RGB[y][x][2]})") + +def test_modify(): + + f = OpenEXR.File("readme.exr") + f.header()["displayWindow"] = OpenEXR.Box2i(OpenEXR.V2i(3,4), + OpenEXR.V2i(5,6)) + f.header()["comments"] = "test image" + f.header()["longitude"] = -122.5 + f.write("readme_modified.exr") + + o = OpenEXR.File("readme_modified.exr") + assert o.header()["displayWindow"] == OpenEXR.Box2i(OpenEXR.V2i(3,4), + OpenEXR.V2i(5,6)) + assert o.header()["comments"] == "test image" + assert o.header()["longitude"] == -122.5 + + print("ok") + +def test_multipart_write(): + + height = 20 width = 10 - height = 10 - size = width * height - - h = OpenEXR.Header(width,height) - h['channels'] = {'R' : Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT)), - 'G' : Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT)), - 'B' : Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT)), - 'A' : Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT))} - o = OpenEXR.OutputFile("hello.exr", h) - r = array('f', [n for n in range(size*0,size*1)]).tobytes() - g = array('f', [n for n in range(size*1,size*2)]).tobytes() - b = array('f', [n for n in range(size*2,size*3)]).tobytes() - a = array('f', [n for n in range(size*3,size*4)]).tobytes() - channels = {'R' : r, 'G' : g, 'B' : b, 'A' : a} - o.writePixels(channels) - o.close() + Z0 = np.zeros((height, width), dtype='f') + Z1 = np.ones((height, width), dtype='f') + + P0 = OpenEXR.Part({}, {"Z" : OpenEXR.Channel(Z0) }) + P1 = OpenEXR.Part({}, {"Z" : OpenEXR.Channel(Z1) }) + + f = OpenEXR.File([P0, P1]) + f.write("readme_2part.exr") + + o = OpenEXR.File("readme_2part.exr") + assert o.parts[0].name() == "Part0" + assert o.parts[0].width() == 10 + assert o.parts[0].height() == 20 + assert o.parts[1].name() == "Part1" + assert o.parts[1].width() == 10 + assert o.parts[1].height() == 20 + print("ok") + +def test_multipart_write(): + + height = 20 + width = 10 + + Z0 = np.zeros((height, width), dtype='f') + P0 = OpenEXR.Part(header={"type" : OpenEXR.scanlineimage }, + channels={"Z" : OpenEXR.Channel(Z0) }) + + Z1 = np.ones((height, width), dtype='f') + P1 = OpenEXR.Part(header={"type" : OpenEXR.scanlineimage }, + channels={"Z" : OpenEXR.Channel(Z1) }) + + f = OpenEXR.File(parts=[P0, P1]) + f.write("readme_2part.exr") + + o = OpenEXR.File("readme_2part.exr") + assert o.parts[0].name() == "Part0" + assert o.parts[0].type() == OpenEXR.scanlineimage + assert o.parts[0].width() == 10 + assert o.parts[0].height() == 20 + assert np.array_equal(o.parts[0].channels["Z"].pixels, Z0) + assert o.parts[1].name() == "Part1" + assert o.parts[1].type() == OpenEXR.scanlineimage + assert o.parts[1].width() == 10 + assert o.parts[1].height() == 20 + assert np.array_equal(o.parts[1].channels["Z"].pixels, Z1) + print("ok") + +def test_write_tiled(): + + height = 20 + width = 10 + Z = np.zeros((height, width), dtype='f') + + P = OpenEXR.Part({"type" : OpenEXR.tiledimage, + "tiles" : OpenEXR.TileDescription() }, + {"Z" : OpenEXR.Channel(Z) }) + + f = OpenEXR.File([P]) + f.write("readme_tiled.exr") + + o = OpenEXR.File("readme_tiled.exr") + assert o.parts[0].name() == "Part0" + assert o.parts[0].type() == OpenEXR.tiledimage + +if __name__ == '__main__': + + test_multipart_write() + test_write() + test_write_RGB() + test_read() + test_read_RGB() + test_modify() + test_write_tiled() + diff --git a/src/wrappers/python/tests/test_rgba.py b/src/wrappers/python/tests/test_rgba.py new file mode 100644 index 000000000..c0b5cf747 --- /dev/null +++ b/src/wrappers/python/tests/test_rgba.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 + +# +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenEXR Project. +# + +from __future__ import print_function +import sys +import os +import tempfile +import atexit +import unittest +import numpy as np + +import OpenEXR + +class TestRGBA(unittest.TestCase): + + def do_rgb(self, array_dtype): + + # Construct an RGB channel + + height = 5 + width = 4 + nrgba = 3 + size = width * height * nrgba + RGB = np.array([i for i in range(0,size)], dtype=array_dtype).reshape((height, width, nrgba)) + channels = { "RGB" : OpenEXR.Channel(RGB) } + + header = {} + outfile = OpenEXR.File(header, channels) + + outfile.write("out.exr") + + # + # Read as separate channels + # + + infile = OpenEXR.File("out.exr") + + R = infile.channels()["R"].pixels + G = infile.channels()["G"].pixels + B = infile.channels()["B"].pixels + + shape = R.shape + width = shape[1] + height = shape[0] + for y in range(0,height): + for x in range(0,width): + r = R[y][x] + g = G[y][x] + b = B[y][x] + self.assertEqual(r, RGB[y][x][0]) + self.assertEqual(g, RGB[y][x][1]) + self.assertEqual(b, RGB[y][x][2]) + + # + # Read as RGB channel + # + + infile = OpenEXR.File("out.exr", rgba=True) + + inRGB = infile.channels()["RGB"].pixels + shape = inRGB.shape + width = shape[1] + height = shape[0] + self.assertEqual(shape[2], 3) + + self.assertTrue(np.array_equal(inRGB, RGB)) + + def do_rgba(self, array_dtype): + + # Construct an RGB channel + + height = 6 + width = 5 + nrgba = 4 + size = width * height * nrgba + RGBA = np.array([i for i in range(0,size)], dtype=array_dtype).reshape((height, width, nrgba)) + channels = { "RGBA" : OpenEXR.Channel(RGBA) } + + header = {} + outfile = OpenEXR.File(header, channels) + + outfile.write("out.exr") + + # + # Read as separate channels + # + + infile = OpenEXR.File("out.exr") + + R = infile.channels()["R"].pixels + G = infile.channels()["G"].pixels + B = infile.channels()["B"].pixels + A = infile.channels()["A"].pixels + + shape = R.shape + width = shape[1] + height = shape[0] + for y in range(0,height): + for x in range(0,width): + r = R[y][x] + g = G[y][x] + b = B[y][x] + a = A[y][x] + self.assertEqual(r, RGBA[y][x][0]) + self.assertEqual(g, RGBA[y][x][1]) + self.assertEqual(b, RGBA[y][x][2]) + self.assertEqual(a, RGBA[y][x][3]) + + # + # Read as RGBA channel + # + + infile = OpenEXR.File("out.exr", rgba=True) + + inRGBA = infile.channels()["RGBA"].pixels + shape = inRGBA.shape + width = shape[1] + height = shape[0] + self.assertEqual(shape[2], 4) + + self.assertTrue(np.array_equal(inRGBA, RGBA)) + + def do_rgba_prefix(self, array_dtype): + + # Construct an RGB channel + + height = 6 + width = 5 + nrgba = 4 + size = width * height + RGBA = np.array([i for i in range(0,size*nrgba)], dtype=array_dtype).reshape((height, width, nrgba)) + Z = np.array([i for i in range(0,size)], dtype=array_dtype).reshape((height, width)) + channels = { "left" : OpenEXR.Channel(RGBA), "left.Z" : OpenEXR.Channel(Z) } + + header = {} + outfile = OpenEXR.File(header, channels) + + print(f"write out.exr") + outfile.write("out.exr") + + # + # Read as separate channels + # + + print(f"read out.exr as single channels") + infile = OpenEXR.File("out.exr") + + R = infile.channels()["left.R"].pixels + G = infile.channels()["left.G"].pixels + B = infile.channels()["left.B"].pixels + A = infile.channels()["left.A"].pixels + Z = infile.channels()["left.Z"].pixels + + shape = R.shape + width = shape[1] + height = shape[0] + for y in range(0,height): + for x in range(0,width): + r = R[y][x] + g = G[y][x] + b = B[y][x] + a = A[y][x] + self.assertEqual(r, RGBA[y][x][0]) + self.assertEqual(g, RGBA[y][x][1]) + self.assertEqual(b, RGBA[y][x][2]) + self.assertEqual(a, RGBA[y][x][3]) + + # + # Read as RGBA channel + # + + print(f"read out.exr as rgba channels") + infile = OpenEXR.File("out.exr", rgba=True) + + inRGBA = infile.channels()["left"].pixels + shape = inRGBA.shape + width = shape[1] + height = shape[0] + self.assertEqual(shape[2], 4) + inZ = infile.channels()["left.Z"].pixels + + self.assertTrue(np.array_equal(inRGBA, RGBA)) + + def test_rgb_uint32(self): + self.do_rgb('uint32') + + def test_rgb_f(self): + self.do_rgb('f') + + def test_rgba_uint32(self): + self.do_rgba('uint32') + + def test_rgba_prefix_uint32(self): + self.do_rgba_prefix('uint32') + + def test_rgba_f(self): + self.do_rgba('f') + +if __name__ == '__main__': + unittest.main() + print("OK") + + diff --git a/src/wrappers/python/tests/test_unittest.py b/src/wrappers/python/tests/test_unittest.py index e08a44b0b..3b20aa29f 100644 --- a/src/wrappers/python/tests/test_unittest.py +++ b/src/wrappers/python/tests/test_unittest.py @@ -8,256 +8,524 @@ from __future__ import print_function import sys import os -import random -from array import array +import tempfile +import atexit +import unittest +import numpy as np import OpenEXR -import Imath -FLOAT = Imath.PixelType(Imath.PixelType.FLOAT) -UINT = Imath.PixelType(Imath.PixelType.UINT) -HALF = Imath.PixelType(Imath.PixelType.HALF) +test_dir = os.path.dirname(__file__) + +outfilenames = [] +def mktemp_outfilename(): + fd, outfilename = tempfile.mkstemp(".exr") + os.close(fd) + global outfilenames + outfilenames += outfilename + return outfilename + +def cleanup(): + for outfilename in outfilenames: + if os.path.isfile(outfilename): + print(f"deleting {outfilename}") + os.unlink(outfilename) +atexit.register(cleanup) + + +def equalWithRelError (x1, x2, e): + return ((x1 - x2) if (x1 > x2) else (x2 - x1)) <= e * (x1 if (x1 > 0) else -x1) + +def required_attribute(name): + return (name == "channels" or + name == "compression" or + name == "dataWindow" or + name == "displayWindow" or + name == "lineOrder" or + name == "pixelAspectRatio" or + name == "screenWindowCenter" or + name == "screenWindowWidth" or + name == "tiles" or + name == "type" or + name == "name" or + name == "version" or + name == "chunkCount") + +def compare_files(A, B): + + if len(A.parts) != len(B.parts): + print(f"#parts differs: {len(A.parts)} {len(B.parts)}") + return False + + for PA, PB in zip(A.parts,B.parts): + if compare_parts(PA, PB): + return False -testList = [] + return True + +def compare_parts(A, B): + + akeys = set(A.header.keys()) + bkeys = set(B.header.keys()) + + for k in akeys-bkeys: + if not required_attribute(k): + print("Attribute {k} is not in both headers") + return False + + for k in bkeys-akeys: + if not required_attribute(k): + print("Attribute {k} is not in both headers") + return False + + for k in akeys.intersection(bkeys): + if k == "preview" or k == "float": + continue + if A.header[k] != B.header[k]: + print(f"attribute {k} {type(A.header[k])} differs: {A.header[k]} {B.header[k]}") + return False -# -# Write a simple exr file, read it back and confirm the data is the same. -# + if len(A.channels) != len(B.channels): + print(f"#channels in {A.name} differs: {len(A.channels)} {len(B.channels)}") + return False -def test_write_read(): - - width = 100 - height = 100 - size = width * height - - h = OpenEXR.Header(width,height) - h['channels'] = {'R' : Imath.Channel(FLOAT), - 'G' : Imath.Channel(FLOAT), - 'B' : Imath.Channel(FLOAT), - 'A' : Imath.Channel(FLOAT)} - o = OpenEXR.OutputFile("write.exr", h) - r = array('f', [n for n in range(size*0,size*1)]).tobytes() - g = array('f', [n for n in range(size*1,size*2)]).tobytes() - b = array('f', [n for n in range(size*2,size*3)]).tobytes() - a = array('f', [n for n in range(size*3,size*4)]).tobytes() - channels = {'R' : r, 'G' : g, 'B' : b, 'A' : a} - o.writePixels(channels) - o.close() - - i = OpenEXR.InputFile("write.exr") - h = i.header() - assert r == i.channel('R') - assert g == i.channel('G') - assert b == i.channel('B') - assert a == i.channel('A') - - print("write_read ok") - -testList.append(("test_write_read", test_write_read)) - -def test_level_modes(): - - assert Imath.LevelMode("ONE_LEVEL").v == Imath.LevelMode(Imath.LevelMode.ONE_LEVEL).v - assert Imath.LevelMode("MIPMAP_LEVELS").v == Imath.LevelMode(Imath.LevelMode.MIPMAP_LEVELS).v - assert Imath.LevelMode("RIPMAP_LEVELS").v == Imath.LevelMode(Imath.LevelMode.RIPMAP_LEVELS).v - - print("level modes ok") - -testList.append(("test_level_modes", test_level_modes)) + for c in A.channels.keys(): + if compare_channels(A.channels[c], B.channels[c]): + return False -# -# Write an image as UINT, read as FLOAT, and the reverse. -# -def test_conversion(): - codemap = { 'f': FLOAT, 'I': UINT } - original = [0, 1, 33, 79218] - for frm_code,to_code in [ ('f','I'), ('I','f') ]: - hdr = OpenEXR.Header(len(original), 1) - hdr['channels'] = {'L': Imath.Channel(codemap[frm_code])} - x = OpenEXR.OutputFile("out.exr", hdr) - x.writePixels({'L': array(frm_code, original).tobytes()}) - x.close() - - xin = OpenEXR.InputFile("out.exr") - assert array(to_code, xin.channel('L', codemap[to_code])).tolist() == original - - print("conversion ok") - -testList.append(("test_conversion", test_conversion)) + return True -# -# Confirm failure on reading from non-exist location -# +def compare_channels(A, B): -def test_invalid_input(): - try: - OpenEXR.InputFile("/bad/place") - except: - pass - else: - assert 0 + if (A.name != B.name or + A.type() != B.type() or + A.xSampling != B.xSampling or + A.ySampling != B.ySampling): + print(f"channel {A.name} differs: {A.__repr__()} {B.__repr__()}") + return False -testList.append(("test_invalid_input", test_invalid_input)) + return True -# -# Confirm failure on writing to invalid location -# +def print_file(f, print_pixels = False): -def test_invalid_output(): + print(f"file {f.filename}") + print(f"parts:") + parts = f.parts + for p in parts: + print(f" part: {p.name()} {p.type()} {p.compression()} height={p.height()} width={p.width()}") + h = p.header + for a in h: + print(f" header[{a}] {h[a]}") + for n,c in p.channels.items(): + print(f" channel[{c.name}] shape={c.pixels.shape} strides={c.pixels.strides} {c.type()} {c.pixels.dtype}") + if print_pixels: + for y in range(0,c.pixels.shape[0]): + s = f" {c.name}[{y}]:" + for x in range(0,c.pixels.shape[1]): + s += f" {c.pixels[y][x]}" + print(s) - try: - hdr = OpenEXR.Header(640, 480) - OpenEXR.OutputFile("/bad/place", hdr) - except: - pass - else: - assert 0 +def preview_pixels_equal(a, b): - print("invalid output ok") - -testList.append(("test_invalid_output", test_invalid_output)) + if a.shape != b.shape: + return False -def test_one(): - oexr = OpenEXR.InputFile("write.exr") + for y in range(0,a.shape[0]): + for x in range(0,a.shape[1]): + if len(a[y][x]) != len(b[y][x]): + for i in range(0,len(a[y][x])): + if a[y][x][i] != b[y][x][i]: + return False - header = oexr.header() + return True - default_size = len(oexr.channel('R')) - half_size = len(oexr.channel('R', Imath.PixelType(Imath.PixelType.HALF))) - float_size = len(oexr.channel('R', Imath.PixelType(Imath.PixelType.FLOAT))) - uint_size = len(oexr.channel('R', Imath.PixelType(Imath.PixelType.UINT))) +class TestOpenEXR(unittest.TestCase): - assert default_size in [ half_size, float_size, uint_size] - assert float_size == uint_size - assert (float_size / 2) == half_size + def test_read_write(self): - assert len(oexr.channel('R', - pixel_type = FLOAT, - scanLine1 = 10, - scanLine2 = 10)) == (4 * (header['dataWindow'].max.x + 1)) + # + # Read a file and write it back out, then read the freshly-written + # file to validate it's the same. + # + infilename = f"{test_dir}/test.exr" + infile = OpenEXR.File(infilename) - data = b" " * (4 * 100 * 100) - h = OpenEXR.Header(100,100) - x = OpenEXR.OutputFile("out.exr", h) - x.writePixels({'R': data, 'G': data, 'B': data}) - x.close() + outfilename = mktemp_outfilename() + infile.write(outfilename) - print("one ok") - -testList.append(("test_one", test_one)) + outfile = OpenEXR.File(outfilename) -# -# Check that the channel method and channels method return the same data -# + assert outfile == infile -def test_channel_channels(): + def test_keycode(self): - aexr = OpenEXR.InputFile("write.exr") - acl = sorted(aexr.header()['channels'].keys()) - a = [aexr.channel(c) for c in acl] - b = aexr.channels(acl) + filmMfcCode = 1 + filmType = 2 + prefix = 3 + count = 4 + perfOffset = 5 + perfsPerFrame = 6 + perfsPerCount = 20 - assert a == b + k = OpenEXR.KeyCode(filmMfcCode, filmType, prefix, count, perfOffset, perfsPerFrame, perfsPerCount) - print("channels ok") + assert (k.filmMfcCode == filmMfcCode and + k.filmType == filmType and + k.prefix == prefix and + k.count == count and + k.perfOffset == perfOffset and + k.perfsPerFrame == perfsPerFrame and + k.perfsPerCount == perfsPerCount) -testList.append(("test_channel_channels", test_channel_channels)) + def test_rational(self): -def test_types(): - for original in [ [0,0,0], list(range(10)), list(range(100,200,3)) ]: - for code,t in [ ('I', UINT), ('f', FLOAT) ]: - data = array(code, original).tobytes() - hdr = OpenEXR.Header(len(original), 1) - hdr['channels'] = {'L': Imath.Channel(t)} - - x = OpenEXR.OutputFile("out.exr", hdr) - x.writePixels({'L': data}) - x.close() + r = OpenEXR.Rational(1,2) - xin = OpenEXR.InputFile("out.exr") - # Implicit type - assert array(code, xin.channel('L')).tolist() == original - # Explicit typen - assert array(code, xin.channel('L', t)).tolist() == original - # Explicit type as kwarg - assert array(code, xin.channel('L', pixel_type = t)).tolist() == original + self.assertEqual(r.n, 1) + self.assertEqual(r.d, 2) - print("types ok") + def test_empty_header(self): -testList.append(("test_types", test_types)) + # Construct a file from scratch and write it. -def test_invalid_pixeltype(): - oexr = OpenEXR.InputFile("write.exr") - FLOAT = Imath.PixelType.FLOAT - try: - f.channel('R',FLOAT) - except: - pass - else: - assert 0 + width = 10 + height = 20 + size = width * height + Z = np.array([i for i in range(0,size)], dtype='uint32').reshape((height, width)) + channels = { "Z" : OpenEXR.Channel(Z, 1, 1) } - print("invalid pixeltype ok") - -testList.append(("test_invalid_pixeltype", test_invalid_pixeltype)) + header = {} -# -# Write arbitrarily named channels. -# + outfile = OpenEXR.File(header, channels) -def test_write_mchannels(): - hdr = OpenEXR.Header(100, 100) - for chans in [ set("a"), set(['foo', 'bar']), set("abcdefghijklmnopqstuvwxyz") ]: - hdr['channels'] = dict([(nm, Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT))) for nm in chans]) - x = OpenEXR.OutputFile("out0.exr", hdr) - data = array('f', [0] * (100 * 100)).tobytes() - x.writePixels(dict([(nm, data) for nm in chans])) - x.close() - assert set(OpenEXR.InputFile('out0.exr').header()['channels']) == chans + outfilename = mktemp_outfilename() + outfile.write(outfilename) - print("mchannels ok") - -testList.append(("test_write_mchannels", test_write_mchannels)) + infile = OpenEXR.File(outfilename) -def load_red(filename): - oexr = OpenEXR.InputFile(filename) - return oexr.channel('R') + def test_write_uint(self): -# -# Write the pixels to two images, first as a single call, -# then as multiple calls. Verify that the images are identical. -# + # Construct a file from scratch and write it. + + width = 5 + height = 10 + size = width * height + R = np.array([i for i in range(0,size)], dtype='uint32').reshape((height, width)) + G = np.array([i*2 for i in range(0,size)], dtype='uint32').reshape((height, width)) + B = np.array([i*3 for i in range(0,size)], dtype='uint32').reshape((height, width)) + A = np.array([i*5 for i in range(0,size)], dtype='uint32').reshape((height, width)) + channels = { + "R" : OpenEXR.Channel(R, 1, 1), + "G" : OpenEXR.Channel(G, 1, 1), + "B" : OpenEXR.Channel(B, 1, 1), + "A" : OpenEXR.Channel(A, 1, 1), + } + + header = {} + + outfile = OpenEXR.File(header, channels) + + # confirm that the write assigned names to the channels + self.assertEqual(outfile.channels()['A'].name, "A") + + outfilename = mktemp_outfilename() + outfile.write(outfilename) + + # Verify reading it back gives the same data + infile = OpenEXR.File(outfilename) + + compare_files(infile, outfile) + + assert infile == outfile + + def test_write_half(self): + + # Construct a file from scratch and write it. + + width = 10 + height = 20 + size = width * height + R = np.array([i for i in range(0,size)], dtype='e').reshape((height, width)) + G = np.array([i*10 for i in range(0,size)], dtype='e').reshape((height, width)) + B = np.array([i*100 for i in range(0,size)], dtype='e').reshape((height, width)) + A = np.array([i/size for i in range(0,size)], dtype='e').reshape((height, width)) + channels = { + "A" : OpenEXR.Channel("A", A, 1, 1), + "B" : OpenEXR.Channel("B", B, 1, 1), + "G" : OpenEXR.Channel("G", G, 1, 1), + "R" : OpenEXR.Channel("R", R, 1, 1) + } + + header = {} + + outfile = OpenEXR.File(header, channels) + outfilename = mktemp_outfilename() + outfile.write(outfilename) + + # Verify reading it back gives the same data + infile = OpenEXR.File(outfilename) + + compare_files(infile, outfile) + + assert infile == outfile + + def test_write_tiles(self): + + # Construct a file from scratch and write it. + + width = 10 + height = 20 + size = width * height + R = np.array([i for i in range(0,size)], dtype='e').reshape((height, width)) + G = np.array([i*10 for i in range(0,size)], dtype='e').reshape((height, width)) + B = np.array([i*100 for i in range(0,size)], dtype='e').reshape((height, width)) + A = np.array([i/size for i in range(0,size)], dtype='e').reshape((height, width)) + channels = { + "A" : OpenEXR.Channel("A", A, 1, 1), + "B" : OpenEXR.Channel("B", B, 1, 1), + "G" : OpenEXR.Channel("G", G, 1, 1), + "R" : OpenEXR.Channel("R", R, 1, 1) + } -def test_write_chunk(): - for w,h,step in [(100, 10, 1), (64,48,6), (1, 100, 2), (640, 480, 4)]: - data = array('f', [ random.random() for x in range(w * h) ]).tobytes() + header = { "type" : OpenEXR.tiledimage, + "tiles" : OpenEXR.TileDescription() } - hdr = OpenEXR.Header(w,h) - x = OpenEXR.OutputFile("out0.exr", hdr) - x.writePixels({'R': data, 'G': data, 'B': data}) - x.close() + outfile = OpenEXR.File(header, channels) + outfilename = mktemp_outfilename() + outfile.write(outfilename) - hdr = OpenEXR.Header(w,h) - x = OpenEXR.OutputFile("out1.exr", hdr) - for y in range(0, h, step): - subdata = data[y * w * 4:(y+step) * w * 4] - x.writePixels({'R': subdata, 'G': subdata, 'B': subdata}, step) - x.close() + # Verify reading it back gives the same data + infile = OpenEXR.File(outfilename) - oexr0 = load_red("out0.exr") - oexr1 = load_red("out1.exr") - assert oexr0 == oexr1 + compare_files(infile, outfile) - print("chunk ok") - -testList.append(("test_write_chunk", test_write_chunk)) + assert infile == outfile -for test in testList: - funcName = test[0] - print ("") - print ("Running {}".format (funcName)) - test[1]() + def test_modify_in_place(self): -print() -print("all ok") + # + # Test modifying header attributes in place + # + infilename = f"{test_dir}/test.exr" + f = OpenEXR.File(infilename) + + # set the value of an existing attribute + par = 2.3 + f.parts[0].header["pixelAspectRatio"] = par + + # add a new attribute + f.parts[0].header["foo"] = "bar" + + dt = np.dtype({ + "names": ["r", "g", "b", "a"], + "formats": ["u4", "u4", "u4", "u4"], + "offsets": [0, 4, 8, 12], + }) + pwidth = 3 + pheight = 3 + psize = pwidth * pheight + P = np.array([ [(0,0,0,0), (1,1,1,1), (2,2,2,2) ], + [(3,3,3,3), (4,4,4,4), (5,5,5,5) ], + [(6,6,6,6), (7,7,7,7), (8,8,8,8) ] ], dtype=dt).reshape((pwidth,pheight)) + f.parts[0].header["preview"] = OpenEXR.PreviewImage(P) + + # Modify a pixel value + f.parts[0].channels["R"].pixels[0][1] = 42.0 + f.channels()["G"].pixels[2][3] = 666.0 + + # write to a new file + outfilename = mktemp_outfilename() + f.write(outfilename) + + # read the new file + m = OpenEXR.File(outfilename) + + # validate the values are the same + eps = 1e-5 + mpar = m.parts[0].header["pixelAspectRatio"] + assert equalWithRelError(m.parts[0].header["pixelAspectRatio"], par, eps) + assert m.parts[0].header["foo"] == "bar" + + assert preview_pixels_equal(m.parts[0].header["preview"].pixels, P) + + assert equalWithRelError(m.parts[0].channels["R"].pixels[0][1], 42.0, eps) + assert equalWithRelError(m.parts[0].channels["G"].pixels[2][3], 666.0, eps) + + def test_preview_image(self): + + width = 5 + height = 10 + size = width * height + Z = np.array([i*5 for i in range(0,size)], dtype='uint32').reshape((height, width)) + channels = { "Z" : OpenEXR.Channel("Z", Z, 1, 1) } + + dt = np.dtype({ + "names": ["r", "g", "b", "a"], + "formats": ["u4", "u4", "u4", "u4"], + "offsets": [0, 4, 8, 12], + }) + pwidth = 3 + pheight = 3 + psize = pwidth * pheight + P = np.array([(i,i,i,i) for i in range(0,psize)], dtype=dt).reshape((pwidth,pheight)) + + header = {} + header["preview"] = OpenEXR.PreviewImage(P) + + outfile = OpenEXR.File(header, channels) + + outfilename = mktemp_outfilename() + outfile.write(outfilename) + + infile = OpenEXR.File(outfilename) + + Q = infile.header()["preview"].pixels + + assert preview_pixels_equal(P, Q) + + assert infile == outfile + + def test_write_float(self): + + # Construct a file from scratch and write it. + + width = 50 + height = 1 + size = width * height + R = np.array([i for i in range(0,size)], dtype='f').reshape((height, width)) + G = np.array([i*10 for i in range(0,size)], dtype='f').reshape((height, width)) + B = np.array([i*100 for i in range(0,size)], dtype='f').reshape((height, width)) + A = np.array([i*1000 for i in range(0,size)], dtype='f').reshape((height, width)) + channels = { + "R" : OpenEXR.Channel("R", R, 1, 1), + "G" : OpenEXR.Channel("G", G, 1, 1), + "B" : OpenEXR.Channel("B", B, 1, 1), + "A" : OpenEXR.Channel("A", A, 1, 1) + } + + header = {} + header["floatvector"] = [1.0, 2.0, 3.0] + header["stringvector"] = ["do", "re", "me"] + header["chromaticities"] = OpenEXR.Chromaticities(OpenEXR.V2f(1.0,2.0), + OpenEXR.V2f(3.0,4.0), + OpenEXR.V2f(5.0,6.0), + OpenEXR.V2f(7.0,8.0)) + header["box2i"] = OpenEXR.Box2i(OpenEXR.V2i(0,1),OpenEXR.V2i(2,3)) + header["box2f"] = OpenEXR.Box2f(OpenEXR.V2f(0,1),OpenEXR.V2f(2,3)) + header["compression"] = OpenEXR.ZIPS_COMPRESSION + header["double"] = OpenEXR.Double(42000) + header["float"] = 4.2 + header["int"] = 42 + header["keycode"] = OpenEXR.KeyCode(0,0,0,0,0,4,64) + header["lineorder"] = OpenEXR.INCREASING_Y + header["m33f"] = OpenEXR.M33f(1,0,0,0,1,0,0,0,1) + header["m33d"] = OpenEXR.M33d(1,0,0,0,1,0,0,0,1) + header["m44f"] = OpenEXR.M44f(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1) + header["m44d"] = OpenEXR.M44d(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1) + header["rational"] = OpenEXR.Rational(1,3) + header["string"] = "stringy" + header["timecode"] = OpenEXR.TimeCode(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18) + header["v2i"] = OpenEXR.V2i(1,2) + header["v2f"] = OpenEXR.V2f(1.2,3.4) + header["v2d"] = OpenEXR.V2d(1.2,3.4) + header["v3i"] = OpenEXR.V3i(1,2,3) + header["v3f"] = OpenEXR.V3f(1.2,3.4,5.6) + header["v3d"] = OpenEXR.V3d(1.2,3.4,5.6) + + outfile = OpenEXR.File(header, channels) + + outfilename = mktemp_outfilename() + outfile.write(outfilename) + + # Verify reading it back gives the same data + + infile = OpenEXR.File(outfilename) + + compare_files(infile, outfile) + + assert infile == outfile + + def test_write_2part(self): + + # + # Construct a 2-part file by replicating the header and channels + # + + width = 10 + height = 20 + size = width * height + R = np.array([i for i in range(0,size)], dtype='f').reshape((height, width)) + G = np.array([i*10 for i in range(0,size)], dtype='f').reshape((height, width)) + B = np.array([i*100 for i in range(0,size)], dtype='f').reshape((height, width)) + A = np.array([i*1000 for i in range(0,size)], dtype='f').reshape((height, width)) + channels = { + "R" : OpenEXR.Channel("R", R, 1, 1), + "G" : OpenEXR.Channel("G", G, 1, 1), + "B" : OpenEXR.Channel("B", B, 1, 1), + "A" : OpenEXR.Channel("A", A, 1, 1) + } + + pwidth = 3 + pheight = 3 + psize = pwidth * pheight + + dt = np.dtype({ + "names": ["r", "g", "b", "a"], + "formats": ["u4", "u4", "u4", "u4"], + "offsets": [0, 4, 8, 12], + }) + P = np.array([(i,i,i,i) for i in range(0,psize)], dtype=dt).reshape((pwidth,pheight)) + + def make_header(): + header = {} + header["floatvector"] = [1.0, 2.0, 3.0] + return header + header["stringvector"] = ["do", "re", "me"] + header["chromaticities"] = OpenEXR.Chromaticities(OpenEXR.V2f(1.0,2.0), + OpenEXR.V2f(3.0,4.0), + OpenEXR.V2f(5.0,6.0), + OpenEXR.V2f(7.0,8.0)) + header["box2i"] = OpenEXR.Box2i(OpenEXR.V2i(0,1),OpenEXR.V2i(2,3)) + header["box2f"] = OpenEXR.Box2f(OpenEXR.V2f(0,1),OpenEXR.V2f(2,3)) + header["compression"] = OpenEXR.ZIPS_COMPRESSION + header["double"] = OpenEXR.Double(42000) + header["float"] = 4.2 + header["int"] = 42 + header["keycode"] = OpenEXR.KeyCode(0,0,0,0,0,4,64) + header["lineorder"] = OpenEXR.INCREASING_Y + header["m33f"] = OpenEXR.M33f(1,0,0,0,1,0,0,0,1) + header["m33d"] = OpenEXR.M33d(1,0,0,0,1,0,0,0,1) + header["m44f"] = OpenEXR.M44f(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1) + header["m44d"] = OpenEXR.M44d(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1) + header["preview"] = OpenEXR.PreviewImage(P) + header["rational"] = OpenEXR.Rational(1,3) + header["string"] = "stringy" + header["timecode"] = OpenEXR.TimeCode(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18) + header["v2i"] = OpenEXR.V2i(1,2) + header["v2f"] = OpenEXR.V2f(1.2,3.4) + header["v2d"] = OpenEXR.V2d(1.2,3.4) + header["v3i"] = OpenEXR.V3i(1,2,3) + header["v3f"] = OpenEXR.V3f(1.2,3.4,5.6) + header["v3d"] = OpenEXR.V3d(1.2,3.4,5.6) + return header + + header1 = make_header() + header2 = make_header() + + P1 = OpenEXR.Part(header1, channels, "Part1") + P2 = OpenEXR.Part(header2, channels, "Part2") + + parts = [P1, P2] + outfile2 = OpenEXR.File(parts) + + outfilename = mktemp_outfilename() + outfile2.write(outfilename) + + # Verify reading it back gives the same data + i = OpenEXR.File(outfilename) + assert i == outfile2 + +if __name__ == '__main__': + unittest.main()