diff --git a/.github/workflows/python-wheels-publish-test.yml b/.github/workflows/python-wheels-publish-test.yml index 214bedb6e3..44427a0ef8 100644 --- a/.github/workflows/python-wheels-publish-test.yml +++ b/.github/workflows/python-wheels-publish-test.yml @@ -54,10 +54,12 @@ jobs: env: CIBW_ARCHS_LINUX: x86_64 CIBW_ARCHS_MACOS: x86_64 arm64 universal2 + # Build Python 3.7 through 3.11. # Skip python 3.6 since scikit-build-core requires 3.7+ # Skip 32-bit wheels builds on Windows # Also skip the PyPy builds, since they fail the unit tests - CIBW_SKIP: cp36-* *-win32 *_i686 pp* + CIBW_BUILD: "cp37-* cp38-* cp39-* cp310-* cp311-*" + CIBW_SKIP: "*-win32 *_i686" CIBW_TEST_SKIP: "*-macosx_universal2:arm64" CIBW_ENVIRONMENT: OPENEXR_RELEASE_CANDIDATE_TAG="${{ github.ref_name }}" diff --git a/.github/workflows/python-wheels-publish.yml b/.github/workflows/python-wheels-publish.yml index 061c066c70..b9fac3dadd 100644 --- a/.github/workflows/python-wheels-publish.yml +++ b/.github/workflows/python-wheels-publish.yml @@ -46,13 +46,14 @@ jobs: with: output-dir: wheelhouse env: - CIBW_BUILD: cp312-* CIBW_ARCHS_LINUX: x86_64 CIBW_ARCHS_MACOS: x86_64 arm64 universal2 + # Build Python 3.7 through 3.11. # Skip python 3.6 since scikit-build-core requires 3.7+ # Skip 32-bit wheels builds on Windows # Also skip the PyPy builds, since they fail the unit tests - CIBW_SKIP: cp36-* *-win32 *_i686 pp* + CIBW_BUILD: "cp37-* cp38-* cp39-* cp310-* cp311-*" + CIBW_SKIP: "*-win32 *_i686" CIBW_TEST_SKIP: "*arm64" - name: Upload artifact diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index 7f479df02d..ea00fa5c11 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' @@ -33,7 +35,7 @@ permissions: jobs: build_wheels: - name: Python Wheels - ${{ matrix.os }} + name: Python Wheels - ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -58,11 +60,14 @@ jobs: uses: pypa/cibuildwheel@bd033a44476646b606efccdd5eed92d5ea1d77ad # v2.20.0 env: CIBW_ARCHS_MACOS: x86_64 arm64 universal2 + # Build Python 3.7 through 3.11. # Skip python 3.6 since scikit-build-core requires 3.7+ # Skip 32-bit wheels builds on Windows # Also skip the PyPy builds, since they fail the unit tests - CIBW_SKIP: cp36-* *-win32 *_i686 pp* + CIBW_BUILD: "cp37-* cp38-* cp39-* cp310-* cp311-*" + CIBW_SKIP: "*-win32 *_i686" CIBW_TEST_SKIP: "*-macosx*arm64" + OPENEXR_TEST_IMAGE_REPO: "https://raw.githubusercontent.com/AcademySoftwareFoundation/openexr-images/main" - name: Upload artifact uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 @@ -71,4 +76,3 @@ jobs: path: | ./wheelhouse/*.whl ./wheelhouse/*.tar.gz - diff --git a/pyproject.toml b/pyproject.toml index 68fc95da37..40536f0667 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"] 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 0000000000..e1b5023bfe --- /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 3cc931b7c5..4468c06955 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 2f7b19c153..9fca7b4f01 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 908940f818..d470c923b5 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 b240eb04a5..0d2926320b 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/Imath.py b/src/wrappers/python/Imath.py index a3fccc2634..374b77d219 100644 --- a/src/wrappers/python/Imath.py +++ b/src/wrappers/python/Imath.py @@ -2,12 +2,12 @@ # Copyright (c) Contributors to the OpenEXR Project. """ -:mod:`Imath` --- Support types for OpenEXR library -================================================== +:mod:`Imath` --- Deprecated component of OpenEXR +================================================ """ class chromaticity(object): - """Store chromaticity coordinates in *x* and *y*.""" + """This class is deprecated and will be removed in a future release""" def __init__(self, x, y): self.x = x self.y = y @@ -17,7 +17,7 @@ def __eq__(self, other): return (self.x, self.y) == (other.x, other.y) class point(object): - """Point is a 2D point, with members *x* and *y*.""" + """This class is deprecated and will be removed in a future release""" def __init__(self, x, y): self.x = x; self.y = y; @@ -27,15 +27,15 @@ def __eq__(self, other): return (self.x, self.y) == (other.x, other.y) class V2i(point): - """V2i is a 2D point, with members *x* and *y*.""" + """This class is deprecated and will be removed in a future release""" pass class V2f(point): - """V2f is a 2D point, with members *x* and *y*.""" + """This class is deprecated and will be removed in a future release""" pass class Box: - """Box is a 2D box, specified by its two corners *min* and *max*, both of which are :class:`point` """ + """This class is deprecated and will be removed in a future release""" def __init__(self, min = None, max = None): self.min = min self.max = max @@ -45,18 +45,15 @@ def __eq__(self, other): return (self.min, self.max) == (other.min, other.max) class Box2i(Box): - """Box2i is a 2D box, specified by its two corners *min* and *max*.""" + """This class is deprecated and will be removed in a future release""" pass class Box2f(Box): - """Box2f is a 2D box, specified by its two corners *min* and *max*.""" + """This class is deprecated and will be removed in a future release""" pass class Chromaticities: - """ - Chromaticities holds the set of chromaticity coordinates for *red*, *green*, *blue*, and *white*. - Each primary is a :class:`chromaticity`. - """ + """This class is deprecated and will be removed in a future release""" def __init__(self, red = None, green = None, blue = None, white = None): self.red = red self.green = green @@ -79,47 +76,14 @@ def __eq__(self, other): return self.v == other.v class LineOrder(Enumerated): - """ - .. index:: INCREASING_Y, DECREASING_Y, RANDOM_Y - - LineOrder can have three possible values: - ``INCREASING_Y``, - ``DECREASING_Y``, - ``RANDOM_Y``. - - .. doctest:: - - >>> import Imath - >>> print Imath.LineOrder(Imath.LineOrder.DECREASING_Y) - DECREASING_Y - """ + """This class is deprecated and will be removed in a future release""" INCREASING_Y = 0 DECREASING_Y = 1 RANDOM_Y = 2 names = ["INCREASING_Y", "DECREASING_Y", "RANDOM_Y"] class Compression(Enumerated): - """ - .. index:: NO_COMPRESSION, RLE_COMPRESSION, ZIPS_COMPRESSION, ZIP_COMPRESSION, PIZ_COMPRESSION, PXR24_COMPRESSION, B44_COMPRESSION, B44A_COMPRESSION, DWAA_COMPRESSION, DWAB_COMPRESSION, - - Compression can have possible values: - ``NO_COMPRESSION``, - ``RLE_COMPRESSION``, - ``ZIPS_COMPRESSION``, - ``ZIP_COMPRESSION``, - ``PIZ_COMPRESSION``, - ``PXR24_COMPRESSION``, - ``B44_COMPRESSION``, - ``B44A_COMPRESSION``, - ``DWAA_COMPRESSION``, - ``DWAB_COMPRESSION``. - - .. doctest:: - - >>> import Imath - >>> print Imath.Compression(Imath.Compression.RLE_COMPRESSION) - RLE_COMPRESSION - """ + """This class is deprecated and will be removed in a future release""" NO_COMPRESSION = 0 RLE_COMPRESSION = 1 ZIPS_COMPRESSION = 2 @@ -136,40 +100,14 @@ class Compression(Enumerated): ] class PixelType(Enumerated): - """ - .. index:: UINT, HALF, FLOAT - - PixelType can have possible values ``UINT``, ``HALF``, ``FLOAT``. - - .. doctest:: - - >>> import Imath - >>> print Imath.PixelType(Imath.PixelType.HALF) - HALF - """ + """This class is deprecated and will be removed in a future release""" UINT = 0 HALF = 1 FLOAT = 2 names = ["UINT", "HALF", "FLOAT"] class Channel: - """ - Channel defines the type and spatial layout of a channel. - *type* is a :class:`PixelType`. - *xSampling* is the number of X-axis pixels between samples. - *ySampling* is the number of Y-axis pixels between samples. - - .. doctest:: - - >>> import Imath - >>> print Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT), 4, 4) - FLOAT (4, 4) - >>> print Imath.Channel(Imath.PixelType.FLOAT, 4, 4) - Traceback (most recent call last): - ... - TypeError: type needs to be a PixelType. - """ - + """This class is deprecated and will be removed in a future release""" def __init__(self, type = PixelType(PixelType.HALF), xSampling = 1, ySampling = 1): self.type = type self.xSampling = xSampling @@ -235,30 +173,7 @@ def __eq__(self, other): return self.__dict__ == other.__dict__ class PreviewImage: - """ - .. index:: RGBA, thumbnail, preview, JPEG, PIL, Python Imaging Library - - PreviewImage is a small preview image, intended as a thumbnail version of the full image. - The image has size (*width*, *height*) and 8-bit pixel values are - given by string *pixels* in RGBA order from top-left to bottom-right. - - For example, to create a preview image from a JPEG file using the popular - `Python Imaging Library `_: - - .. doctest:: - - >>> import Image - >>> import Imath - >>> im = Image.open("lena.jpg").resize((100, 100)).convert("RGBA") - >>> print Imath.PreviewImage(im.size[0], im.size[1], im.tostring()) - - """ - def __init__(self, width, height, pixels): - self.width = width - self.height = height - self.pixels = pixels - def __repr__(self): - return "" % (self.width, self.height) + """This class is deprecated and will be removed in a future release""" class LevelMode(Enumerated): ONE_LEVEL = 0 diff --git a/src/wrappers/python/PyOpenEXR.cpp b/src/wrappers/python/PyOpenEXR.cpp new file mode 100644 index 0000000000..416c8950c2 --- /dev/null +++ b/src/wrappers/python/PyOpenEXR.cpp @@ -0,0 +1,2755 @@ +// +// 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 "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 +#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& parts) + : parts(parts) +{ + int part_index = 0; + for (auto p : this->parts) + { + if (!py::isinstance(p)) + throw std::invalid_argument("must be a list of OpenEXR.Part() objects"); + + PyPart& 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 'separate_channels' is false, 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 separate_channels, 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)] = getAttributeObject(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 rgbaChannels; + if (!separate_channels) + { + for (auto c = header.channels().begin(); c != header.channels().end(); c++) + { + std::string py_channel_name; + char channel_name; + if (P.channelNameToRGBA(header.channels(), c.name(), py_channel_name, channel_name) > 0) + rgbaChannels.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, rgbaChannels, dw, separate_channels); + } + else if (type == DEEPSCANLINE || type == DEEPTILE) + { + P.readDeepPixels(infile, type, header.channels(), shape, rgbaChannels, dw, separate_channels); + } + parts.append(py::cast(PyPart(P))); + } // for parts +} + +void +PyPart::readPixels(MultiPartInputFile& infile, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgbaChannels, + const Box2i& dw, bool separate_channels) +{ + 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 (!separate_channels) + nrgba = channelNameToRGBA(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 (rgbaChannels.find(c.name()) != rgbaChannels.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; + } + + // + // 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] / C.xSampling; + + 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); +} + +void +PyChannel::createDeepPixelArrays(size_t height, size_t width, const Array2D& sampleCount) +{ + // + // Create the py::array of the appropriate type and shape to hold the + // samples for each pixel + // + + std::vector shape; + shape.push_back(0); + if (_nrgba > 0) + shape.push_back(_nrgba); // shape=(count,3) for RGB; shape=(count,4) for RGBA + + py::object* pixel_objects = static_cast(pixels.mutable_data()); + + for (size_t y=0; y(shape); + break; + case HALF: + pixel_objects[i] = py::array_t(shape); + break; + case FLOAT: + pixel_objects[i] = py::array_t(shape); + break; + default: + throw std::runtime_error("invalid pixel type"); + } // switch _type + } + } +} + +void +PyPart::setDeepSliceData(const ChannelList& channel_list, size_t height, size_t width, + SliceDataMap& sliceDataMap, + std::map& rgbaChannelMap, + const Array2D& sampleCount) +{ + + // + // Now that we know the sample counts, create the sample array for + // each pixel. For RGB images, the sample arrays are of shape + // (count, 3), or (count,4) if there's an A channel. For separate + // channels, the arrays are 1D. The arrays are None for pixels with + // no samples. + // + + for (auto c : channels) + { + auto C = py::cast(c.second); + C.createDeepPixelArrays(height, width, sampleCount); + } + + for (auto c = channel_list.begin(); c != channel_list.end(); c++) + { + // + // Set the slice data pointers to point into the pixel sample + // arrays, with the proper offset/stride when coalescing channels + // into RGB/RGBA. + // + + auto &sliceData = *sliceDataMap[c.name()]; + + PyChannel& C = *rgbaChannelMap[c.name()]; + py::object* pixel_objects = static_cast(C.pixels.mutable_data()); + + size_t channel_offset = 0; + if (C._nrgba > 0) + { + if (!strcmp(c.name(), "G")) + channel_offset = 1; + else if (!strcmp(c.name(), "B")) + channel_offset = 2; + else if (!strcmp(c.name(), "A")) + channel_offset = 3; + } + + for (size_t y=0; y(pixel_objects[i]); + switch (C._type) + { + case UINT: + { + auto d = static_cast(a.request().ptr); + sliceData[y][x] = static_cast(&d[channel_offset]); + } + break; + case HALF: + { + auto d = static_cast(a.request().ptr); + sliceData[y][x] = static_cast(&d[channel_offset]); + } + break; + case FLOAT: + { + auto d = static_cast(a.request().ptr); + sliceData[y][x] = static_cast(&d[channel_offset]); + } + break; + case NUM_PIXELTYPES: + break; + } + } + } +} + +void +PyPart::readDeepPixels(MultiPartInputFile& infile, const std::string& type, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgbaChannels, + const Box2i& dw, bool separate_channels) +{ + size_t width = dw.max.x - dw.min.x + 1; + size_t height = dw.max.y - dw.min.y + 1; + auto dw_offset = dw.min.y * width + dw.min.x; + + Array2D sampleCount (height, width); + + DeepFrameBuffer frameBuffer; + + frameBuffer.insertSampleCountSlice (Slice (UINT, + (char*) (&sampleCount[0][0] - dw_offset), + sizeof (unsigned int) * 1, // xStride + sizeof (unsigned int) * width)); // yStride + + // + // Map from channel name to 2D array of pointers to sample arrays for + // each slice. + + SliceDataMap sliceDataMap; + + // + // The channel_list argument is the Imf::Header's list of channels. + // + // When building a py::dict of channels that coalesces "R", "G", "B", and + // 'A' channels into "RGBA", the PyPart's channels py::dict has a single + // entry for all 4 (or 3 if no alpha). rgbaChannelMap maps the + // channel_list names (e.g. "R") to the PyChannel name (e.g. "RGBA"). + // + // + + std::map rgbaChannelMap; + + 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 (!separate_channels) + nrgba = channelNameToRGBA(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. + + channels[py_channel_name.c_str()] = PyChannel(); + PyChannel& C = channels[py_channel_name.c_str()].cast(); + C.name = py_channel_name; + C.xSampling = c.channel().xSampling; + C.ySampling = c.channel().ySampling; + C.pLinear = c.channel().pLinear; + + C.pixels = py::array(py::dtype("O"), {height,width}); + + C._type = c.channel().type; + C._nrgba = nrgba; + } + + auto v = channels[py_channel_name.c_str()]; + rgbaChannelMap[c.name()] = v.cast(); + + size_t xStride = sizeof(void*); + size_t yStride = xStride * shape[1]; + size_t sampleStride; + switch (c.channel().type) + { + case UINT: + sampleStride = sizeof(uint32_t); + break; + case HALF: + sampleStride = sizeof(half); + break; + case FLOAT: + sampleStride = sizeof(float); + break; + default: + sampleStride = 0; + break; + } + + // + // If coalescing RGBA, the sampleStride strides all of RGBA. + // + + if (nrgba > 0) + sampleStride *= nrgba; + + sliceDataMap[c.name()] = std::unique_ptr(new Array2DVoidPtr(height, width)); + Array2DVoidPtr* sliceData = sliceDataMap[c.name()].get(); + + auto base = &(*sliceData)[0][0] - dw_offset; + frameBuffer.insert (c.name(), + DeepSlice (c.channel().type, + (char*) base, + xStride, + yStride, + sampleStride, + c.channel().xSampling, + c.channel().ySampling)); + } // for header.channels() + + if (type == DEEPSCANLINE) + { + DeepScanLineInputPart part (infile, part_index); + part.setFrameBuffer (frameBuffer); + part.readPixelSampleCounts (dw.min.y, dw.max.y); + + setDeepSliceData(channel_list, height, width, sliceDataMap, rgbaChannelMap, sampleCount); + + part.readPixels (dw.min.y, dw.max.y); + } + else if (type == DEEPTILE) + { + DeepTiledInputPart part (infile, part_index); + part.setFrameBuffer (frameBuffer); + + int numXTiles = part.numXTiles (0); + int numYTiles = part.numYTiles (0); + + part.readPixelSampleCounts (0, numXTiles - 1, 0, numYTiles - 1); + + setDeepSliceData(channel_list, height, width, sliceDataMap, rgbaChannelMap, sampleCount); + + part.readTiles (0, numXTiles - 1, 0, numYTiles - 1); + } +} + +void +PyPart::writePixels(MultiPartOutputFile& outfile, const Box2i& dw) const +{ + FrameBuffer frameBuffer; + + for (auto c : channels) + { + auto C = c.second.cast(); + + auto pixelType = C.pixelType(); + + 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 * width() / C.xSampling; + + auto rPtr = basePtr; + frameBuffer.insert (name_prefix + "R", + Slice::Make (pixelType, + static_cast(rPtr), + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + + auto gPtr = &basePtr[dt.itemsize()]; + frameBuffer.insert (name_prefix + "G", + Slice::Make (pixelType, + static_cast(gPtr), + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + + auto bPtr = &basePtr[2*dt.itemsize()]; + frameBuffer.insert (name_prefix + "B", + Slice::Make (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 (pixelType, + static_cast(aPtr), + dw, xStride, yStride, + C.xSampling, + C.ySampling)); + } + } + else + { + frameBuffer.insert (C.name, + Slice::Make (pixelType, + static_cast(C.pixels.request().ptr), + dw, 0, 0, + C.xSampling, + C.ySampling)); + } + } + + if (type() == EXR_STORAGE_SCANLINE) + { + OutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + part.writePixels (height()); + } + else + { + TiledOutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + part.writeTiles (0, part.numXTiles() - 1, 0, part.numYTiles() - 1); + } +} + +template +void +PyChannel::setSliceDataPtr(Array2DVoidPtr& sliceData,const py::array& a, size_t y, size_t x, + int channel_offset, PixelType type) const +{ + auto s = py::cast>(a); + auto d = static_cast(s.request().ptr); + const void* v = static_cast(&d[channel_offset]); + sliceData[y][x] = const_cast(v); + + if (_type == NUM_PIXELTYPES) + _type = type; + else if (_type != type) + { + std::stringstream err; + err << "invalid deep pixel array at " << y << "," << x + << ": all pixels must have same type of samples"; + throw std::invalid_argument(err.str()); + } +} + +int +get_deep_nrgba(const py::array& pixels) +{ + const py::object* pixel_objects = static_cast(pixels.data()); + + size_t height = pixels.shape(0); + size_t width = pixels.shape(1); + for (size_t y = 0; y(pixel_objects[i])) + { + auto a = py::cast(pixel_objects[i]); + if (a.ndim() == 2) + return a.shape(1); + return 0; + } + } + + return 0; +} + +void +PyChannel::insertDeepSlice(DeepFrameBuffer& frameBuffer, const std::string& slice_name, + size_t height, size_t width, int nrgba, int dw_offset, int channel_offset, + Array2D& sampleCount, + std::vector>& sliceDatas) const +{ + Array2DVoidPtr* sliceDataPtr = new Array2DVoidPtr(height, width); + Array2DVoidPtr& sliceData(*sliceDataPtr); + sliceDatas.push_back(std::shared_ptr(sliceDataPtr)); + + auto pixel_objects = static_cast(pixels.data()); + + for (size_t y=0; y(object)) + { + auto a = object.cast(); + if (sampleCount[y][x] == 0) + sampleCount[y][x] = a.shape(0); + else if (sampleCount[y][x] != a.shape(0)) + { + std::stringstream err; + err << "invalid sample count at pixel " << y << "," << x + << ": all channels must have the same number of samples"; + throw std::invalid_argument(err.str()); + } + + if (py::isinstance>(a)) + setSliceDataPtr(sliceData, a, y, x, channel_offset, UINT); + else if (py::isinstance>(a)) + setSliceDataPtr(sliceData, a, y, x, channel_offset, HALF); + else if (py::isinstance>(a)) + setSliceDataPtr(sliceData, a, y, x, channel_offset, FLOAT); + else + { + std::stringstream err; + err << "invalid deep pixel array at " << y << "," << x + << ": unrecognized array type"; + throw std::invalid_argument(err.str()); + } + } + else + { + std::stringstream err; + err << "invalid deep pixel array at " << y << "," << x + << ": unrecognized object type"; + throw std::invalid_argument(err.str()); + } + } + + size_t xStride = sizeof(void*); + size_t yStride = xStride * width; + size_t sampleStride; + switch (_type) + { + case UINT: + sampleStride = sizeof(uint32_t); + break; + case HALF: + sampleStride = sizeof(half); + break; + case FLOAT: + sampleStride = sizeof(float); + break; + default: + sampleStride = 0; + break; + } + if (nrgba > 0) + sampleStride *= nrgba; + + void* base_ptr = &sliceData[0][0] - dw_offset; + frameBuffer.insert (slice_name, + DeepSlice (_type, + static_cast(base_ptr), + xStride, + yStride, + sampleStride, + xSampling, + ySampling)); +} + +void +PyPart::writeDeepPixels(MultiPartOutputFile& outfile, const Box2i& dw) const +{ + size_t width = dw.max.x - dw.min.x + 1; + size_t height = dw.max.y - dw.min.y + 1; + + auto dw_offset = dw.min.y * width + dw.min.x; + + DeepFrameBuffer frameBuffer; + + Array2D sampleCount (height, width); + for (size_t y=0; y> sliceDatas; + + for (auto c : channels) + { + const PyChannel& C = c.second.cast(); + + if (C.pixels.dtype().kind() != 'O') + throw std::runtime_error("Expected deep pixel array with dtype 'O'"); + + C._type = NUM_PIXELTYPES; + + int nrgba = get_deep_nrgba(C.pixels); + if (nrgba == 0) + C.insertDeepSlice(frameBuffer, C.name, height, width, nrgba, dw_offset, 0, sampleCount, sliceDatas); + else + { + std::string name_prefix = ""; + if (C.name != "RGB" && C.name != "RGBA") + name_prefix = C.name + "."; + + C.insertDeepSlice(frameBuffer, name_prefix+"R", height, width, nrgba, dw_offset, 0, sampleCount, sliceDatas); + C.insertDeepSlice(frameBuffer, name_prefix+"G", height, width, nrgba, dw_offset, 1, sampleCount, sliceDatas); + C.insertDeepSlice(frameBuffer, name_prefix+"B", height, width, nrgba, dw_offset, 2, sampleCount, sliceDatas); + if (nrgba == 4) + C.insertDeepSlice(frameBuffer, name_prefix+"A", height, width, nrgba, dw_offset, 3, sampleCount, sliceDatas); + } + } + + if (type() == EXR_STORAGE_DEEP_SCANLINE) + { + DeepScanLineOutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + part.writePixels (height); + } + else + { + DeepTiledOutputPart part(outfile, part_index); + part.setFrameBuffer (frameBuffer); + + for (int y = 0; y < part.numYTiles (0); y++) + for (int x = 0; x < part.numXTiles (0); x++) + part.writeTile (x, y, 0); + } +} + +// +// 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::channelNameToRGBA(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') + { + // It 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; +} + +py::object +PyFile::__enter__() +{ + return py::cast(this); +} + +void +PyFile::__exit__(py::args args) +{ + for (auto p : parts) + { + PyPart& P = p.cast(); + P.header.clear(); + + for (auto c : P.channels) + { + auto C = py::cast(c.second); + C.pixels = py::none(); + } + P.channels.clear(); + } + parts = py::list(); +} + +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); + insertAttribute(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); + } + } + + 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 pixelType = C.pixelType(); + + int nrgba; + if (C.pixels.dtype().kind() == 'O') + nrgba = get_deep_nrgba(C.pixels); + else if (C.pixels.ndim() == 2) + nrgba = 0; + else + nrgba = C.pixels.shape(2); + + if (nrgba > 0) + { + // + // 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 (pixelType, C.xSampling, C.ySampling, C.pLinear)); + header.channels ().insert(name_prefix + "G", Channel (pixelType, C.xSampling, C.ySampling, C.pLinear)); + header.channels ().insert(name_prefix + "B", Channel (pixelType, C.xSampling, C.ySampling, C.pLinear)); + if (nrgba > 3) + header.channels ().insert(name_prefix + "A", Channel (pixelType, C.xSampling, C.ySampling, C.pLinear)); + } + else + header.channels ().insert(C.name, Channel (pixelType, 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) + { + P.writePixels(outfile, dw); + } + else if (P.type() == EXR_STORAGE_DEEP_SCANLINE || + P.type() == EXR_STORAGE_DEEP_TILED) + { + P.writeDeepPixels(outfile, dw); + } + 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; +} + +template <> +const double* +py_cast(const py::object& object) +{ + // + // Recognize a 1-element array of double as a DoubleAttribute + // + + if (py::isinstance>(object)) + { + auto a = object.cast>(); + if (a.size() == 1) + { + py::buffer_info buf = a.request(); + return static_cast(buf.ptr); + } + } + return nullptr; +} + +template +py::array +make_v2(const Vec2& v) +{ + std::vector shape ({2}); + const auto style = py::array::c_style | py::array::forcecast; + auto npa = py::array_t(shape); + auto d = static_cast(npa.request().ptr); + d[0] = v[0]; + d[1] = v[1]; + return npa; +} + +template +py::array +make_v3(const Vec3& v) +{ + std::vector shape ({3}); + const auto style = py::array::c_style | py::array::forcecast; + auto npa = py::array_t(shape); + auto d = static_cast(npa.request().ptr); + d[0] = v[0]; + d[1] = v[1]; + d[2] = v[2]; + return npa; +} + +py::object +PyFile::getAttributeObject(const std::string& name, const Attribute* a) +{ + if (auto v = dynamic_cast (a)) + { + auto min = make_v2(v->value().min); + auto max = make_v2(v->value().max); + return py::make_tuple(min, max); + } + + if (auto v = dynamic_cast (a)) + { + auto min = make_v2(v->value().min); + auto max = make_v2(v->value().max); + return py::make_tuple(min, max); + } + + 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)) + { + auto c = v->value(); + return py::make_tuple(c.red.x, c.red.y, + c.green.x, c.green.y, + c.blue.x, c.blue.y, + c.white.x, c.white.y); + } + + if (auto v = dynamic_cast (a)) + return py::cast(v->value()); + + // + // Convert Double attribute to a single-element numpy array, so + // its type is preserved. + // + + if (auto v = dynamic_cast (a)) + return py::array_t(1, &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)) + { + std::vector shape ({3,3}); + const auto style = py::array::c_style | py::array::forcecast; + auto npa = py::array_t(shape); + auto m = static_cast(npa.request().ptr); + m[0] = v->value()[0][0]; + m[1] = v->value()[0][1]; + m[2] = v->value()[0][2]; + m[3] = v->value()[1][0]; + m[4] = v->value()[1][1]; + m[5] = v->value()[1][2]; + m[6] = v->value()[2][0]; + m[7] = v->value()[2][1]; + m[8] = v->value()[2][2]; + return npa; + } + + if (auto v = dynamic_cast (a)) + { + std::vector shape ({3,3}); + const auto style = py::array::c_style | py::array::forcecast; + auto npa = py::array_t(shape); + auto m = static_cast(npa.request().ptr); + m[0] = v->value()[0][0]; + m[1] = v->value()[0][1]; + m[2] = v->value()[0][2]; + m[3] = v->value()[1][0]; + m[4] = v->value()[1][1]; + m[5] = v->value()[1][2]; + m[6] = v->value()[2][0]; + m[7] = v->value()[2][1]; + m[8] = v->value()[2][2]; + return npa; + } + + if (auto v = dynamic_cast (a)) + { + std::vector shape ({4,4}); + const auto style = py::array::c_style | py::array::forcecast; + auto npa = py::array_t(shape); + auto m = static_cast(npa.request().ptr); + m[0] = v->value()[0][0]; + m[1] = v->value()[0][1]; + m[2] = v->value()[0][2]; + m[3] = v->value()[0][3]; + m[4] = v->value()[1][0]; + m[5] = v->value()[1][1]; + m[6] = v->value()[1][2]; + m[7] = v->value()[1][3]; + m[8] = v->value()[2][0]; + m[9] = v->value()[2][1]; + m[10] = v->value()[2][2]; + m[11] = v->value()[2][3]; + m[12] = v->value()[3][0]; + m[13] = v->value()[3][1]; + m[14] = v->value()[3][2]; + m[15] = v->value()[3][3]; + return npa; + } + + if (auto v = dynamic_cast (a)) + { + std::vector shape ({4,4}); + const auto style = py::array::c_style | py::array::forcecast; + auto npa = py::array_t(shape); + auto m = static_cast(npa.request().ptr); + m[0] = v->value()[0][0]; + m[1] = v->value()[0][1]; + m[2] = v->value()[0][2]; + m[3] = v->value()[0][3]; + m[4] = v->value()[1][0]; + m[5] = v->value()[1][1]; + m[6] = v->value()[1][2]; + m[7] = v->value()[1][3]; + m[8] = v->value()[2][0]; + m[9] = v->value()[2][1]; + m[10] = v->value()[2][2]; + m[11] = v->value()[2][3]; + m[12] = v->value()[3][0]; + m[13] = v->value()[3][1]; + m[14] = v->value()[3][2]; + m[15] = v->value()[3][3]; + return npa; + } + + 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)) + { + py::module fractions = py::module::import("fractions"); + py::object Fraction = fractions.attr("Fraction"); + return Fraction(v->value().n, v->value().d); + } + + 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 make_v2(v->value()); + + if (auto v = dynamic_cast (a)) + return make_v2(v->value()); + + if (auto v = dynamic_cast (a)) + return make_v2(v->value()); + + if (auto v = dynamic_cast (a)) + return make_v3(v->value()); + + if (auto v = dynamic_cast (a)) + return make_v3(v->value()); + + if (auto v = dynamic_cast (a)) + return make_v3(v->value()); + + std::stringstream err; + err << "unsupported attribute type: " << a->typeName(); + throw std::runtime_error(err.str()); + + return py::none(); +} + +template +bool +objectToV2(const py::object& object, Vec2& v) +{ + if (py::isinstance(object)) + { + auto tup = object.cast(); + if (tup.size() == 2 && + py::isinstance

(tup[0]) && + py::isinstance

(tup[1])) + { + v.x = P(tup[0]); + v.y = P(tup[1]); + return true; + } + } + else if (py::isinstance>(object)) + { + auto a = object.cast>(); + if (a.ndim() == 1 && a.size() == 2) + { + auto p = static_cast(a.request().ptr); + v.x = p[0]; + v.y = p[1]; + return true; + } + } + + return false; +} + +bool +objectToV2i(const py::object& object, V2i& v) +{ + return objectToV2(object, v); +} + +bool +objectToV2f(const py::object& object, V2f& v) +{ + return objectToV2(object, v); +} + +bool +objectToV2d(const py::object& object, V2d& v) +{ + if (py::isinstance>(object)) + { + auto a = object.cast>(); + if (a.ndim() == 1 && a.size() == 2) + { + auto p = static_cast(a.request().ptr); + v.x = p[0]; + v.y = p[1]; + return true; + } + } + return false; +} + +template +bool +objectToV3(const py::object& object, Vec3& v) +{ + if (py::isinstance(object)) + { + auto tup = object.cast(); + if (tup.size() == 3 && + py::isinstance

(tup[0]) && + py::isinstance

(tup[1]) && + py::isinstance

(tup[2])) + { + v.x = P(tup[0]); + v.y = P(tup[1]); + v.z = P(tup[2]); + return true; + } + } + else if (py::isinstance>(object)) + { + auto a = object.cast>(); + if (a.ndim() == 1 && a.size() == 2) + { + auto p = static_cast(a.request().ptr); + v.x = p[0]; + v.y = p[1]; + v.z = p[2]; + return true; + } + } + + return false; +} + +bool +objectToV3i(const py::object& object, V3i& v) +{ + return objectToV3(object, v); +} + +bool +objectToV3f(const py::object& object, V3f& v) +{ + return objectToV3(object, v); +} + +bool +objectToV3d(const py::object& object, V3d& v) +{ + if (py::isinstance>(object)) + { + auto a = object.cast>(); + if (a.ndim() == 1 && a.size() == 3) + { + auto p = static_cast(a.request().ptr); + v.x = p[0]; + v.y = p[1]; + v.z = p[2]; + return true; + } + } + return false; +} + +template +bool +objectToM33(const py::object& object, Matrix33& m) +{ + if (py::isinstance>(object)) + { + auto a = object.cast>(); + if (a.ndim() == 2 && a.shape(0) == 3 && a.shape(1) == 3) + { + py::buffer_info buf = a.request(); + auto v = static_cast(buf.ptr); + m = Matrix33(v[0], v[1], v[2], + v[3], v[4], v[5], + v[6], v[7], v[8]); + return true; + } + } + return false; +} + +template +bool +objectToM44(const py::object& object, Matrix44& m) +{ + if (py::isinstance>(object)) + { + auto a = object.cast>(); + if (a.ndim() == 2 && a.shape(0) == 4 && a.shape(1) == 4) + { + py::buffer_info buf = a.request(); + auto v = static_cast(buf.ptr); + m = Matrix44(v[0], v[1], v[2], v[3], + v[4], v[5], v[6], v[7], + v[8], v[9], v[10], v[11], + v[12], v[13], v[14], v[15]); + return true; + } + } + return false; +} + + +bool +objectToBox2i(const py::object& object, Box2i& b) +{ + if (py::isinstance(object)) + { + auto tup = object.cast(); + if (tup.size() == 2) + if (objectToV2i(tup[0], b.min) && objectToV2i(tup[1], b.max)) + return true; + } + + return false; +} + +bool +objectToBox2f(const py::object& object, Box2f& b) +{ + if (py::isinstance(object)) + { + auto tup = object.cast(); + if (tup.size() == 2) + { + Box2f box; + if (objectToV2f(tup[0], box.min) && objectToV2f(tup[1], box.max)) + return true; + } + } + + return false; +} + +bool +objectToChromaticities(const py::object& object, Chromaticities& v) +{ + if (py::isinstance(object)) + { + auto tup = object.cast(); + if (tup.size() == 8 && + py::isinstance(tup[0]) && + py::isinstance(tup[1]) && + py::isinstance(tup[2]) && + py::isinstance(tup[3]) && + py::isinstance(tup[4]) && + py::isinstance(tup[5]) && + py::isinstance(tup[6]) && + py::isinstance(tup[7])) + { + v.red.x = py::float_(tup[0]); + v.red.y = py::float_(tup[1]); + v.green.x = py::float_(tup[2]); + v.green.y = py::float_(tup[3]); + v.blue.x = py::float_(tup[4]); + v.blue.y = py::float_(tup[5]); + v.white.x = py::float_(tup[6]); + v.white.y = py::float_(tup[7]); + return true; + } + } + return false; +} + +void +PyFile::insertAttribute(Header& header, const std::string& name, const py::object& object) +{ + std::stringstream err; + + // + // If the attribute is standard/required, its type is fixed, so + // cast the rhs to the appropriate type if possible, or reject it + // as an error if not. + // + + if (name == "dataWindow" || + name == "displayWindow" || + name == "originalDataWindow" || + name == "sensorAcquisitionRectangle") + { + Box2i b; + if (objectToBox2i(object, b)) + { + header.insert(name, Box2iAttribute(b)); + return; + } + err << "invalid value for attribute '" << name << "': expected a box2i tuple, got " << py::str(object); + throw std::invalid_argument(err.str()); + } + + // Required to be V2f? + + if (name == "screenWindowCenter" || + name == "sensorCenterOffset" || + name == "sensorOverallDimensions" || + name == "cameraColorBalance" || + name == "adoptedNeutral") + { + V2f v; + if (objectToV2f(object, v)) + { + header.insert(name, V2fAttribute(v)); + return; + } + err << "invalid value for attribute '" << name << "': expected a v2f, got " << py::str(object); + throw std::invalid_argument(err.str()); + } + + // Required to be chromaticities? + + if (name == "chromaticities") + { + Chromaticities c; + if (objectToChromaticities(object, c)) + { + header.insert(name, ChromaticitiesAttribute(c)); + return; + } + err << "invalid value for attribute '" << name << "': expected a 6-tuple, got " << py::str(object); + throw std::invalid_argument(err.str()); + } + + // + // Recognize tuples and arrays as V2/V3 i/f/d or M33/M44 f/d + // + + V2i v2i; + if (objectToV2i(object, v2i)) + { + header.insert(name, V2iAttribute(v2i)); + return; + } + + V2f v2f; + if (objectToV2f(object, v2f)) + { + header.insert(name, V2fAttribute(v2f)); + return; + } + + V2d v2d; + if (objectToV2d(object, v2d)) + { + header.insert(name, V2dAttribute(v2d)); + return; + } + + V3i v3i; + if (objectToV3i(object, v3i)) + { + header.insert(name, V3iAttribute(v3i)); + return; + } + + V3f v3f; + if (objectToV3f(object, v3f)) + { + header.insert(name, V3fAttribute(v3f)); + return; + } + + V3d v3d; + if (objectToV3d(object, v3d)) + { + header.insert(name, V3dAttribute(v3d)); + return; + } + + M33f m33f; + if (objectToM33(object, m33f)) + { + header.insert(name, M33fAttribute(m33f)); + return; + } + + M33d m33d; + if (objectToM33(object, m33d)) + { + header.insert(name, M33dAttribute(m33d)); + return; + } + + M44f m44f; + if (objectToM44(object, m44f)) + { + header.insert(name, M44fAttribute(m44f)); + return; + } + + M44d m44d; + if (objectToM44(object, m44d)) + { + header.insert(name, M44dAttribute(m44d)); + return; + } + + // + // Recognize 2-tuples of 2-vectors as boxes + // + + Box2i box2i; + if (objectToBox2i(object, box2i)) + { + header.insert(name, Box2iAttribute(box2i)); + return; + } + + Box2f box2f; + if (objectToBox2f(object, box2f)) + { + header.insert(name, Box2fAttribute(box2f)); + return; + } + + // + // Recognize an 8-tuple as chromaticities + // + + Chromaticities c; + if (objectToChromaticities(object, c)) + { + header.insert(name, ChromaticitiesAttribute(c)); + return; + } + + // + // Inspect the rhs type + // + + py::module fractions = py::module::import("fractions"); + py::object Fraction = fractions.attr("Fraction"); + + 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, 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, IntAttribute(py::cast(object))); + else if (py::isinstance(object)) + header.insert(name, FloatAttribute(py::cast(object))); + else if (auto v = py_cast(object)) + header.insert(name, DoubleAttribute(*v)); + 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)) + { + 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, TileDescriptionAttribute(*v)); + else if (auto v = py_cast(object)) + header.insert(name, TimeCodeAttribute(*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 if (py::isinstance(object, Fraction)) + { + int n = py::int_(object.attr("numerator")); + int d = py::int_(object.attr("denominator")); + Rational r(n, d); + header.insert(name, RationalAttribute(r)); + } + else + { + auto t = py::str(object.attr("__class__").attr("__name__")); + err << "unrecognized type of attribute '" << name << "': type=" << t << " value=" << py::str(object); + if (py::isinstance(object)) + { + auto a = object.cast(); + err << " dtype=" << py::str(a.dtype()); + } + throw std::runtime_error(err.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)"); + + // + // Accept a py::array as the py::dict value, but replace it with a PyChannel object. + // + + if (py::isinstance(c.second)) + { + std::string channel_name = py::str(c.first); + py::array a = c.second.cast(); + channels[channel_name.c_str()] = PyChannel(channel_name.c_str(), a); + } + else if (py::isinstance(c.second)) + { + c.second.cast().name = py::str(c.first); + } + else + throw std::invalid_argument("Channel value must be a Channel() object or a numpy pixel array"); + } + + auto s = shape(); + + if (!header.contains("dataWindow")) + { + auto min = make_v2(V2i(0, 0)); + auto max = make_v2(V2i(s[1]-1,s[0]-1)); + header["dataWindow"] = py::make_tuple(min, max); + } + + if (!header.contains("displayWindow")) + { + auto min = make_v2(V2i(0, 0)); + auto max = make_v2(V2i(s[1]-1,s[0]-1)); + header["displayWindow"] = py::make_tuple(min, max); + } +} + +void +PyChannel::validatePixelArray() +{ + if (pixels.ndim() < 2 || pixels.ndim() > 3) + throw std::invalid_argument("invalid pixel array: must be 2D or 3D numpy array"); + + if (pixels.dtype().kind() == 'O') + { + auto height = pixels.shape(0); + auto width = pixels.shape(1); + + auto pixel_objects = static_cast(pixels.mutable_data()); + + for (decltype(height) y=0; y>(pixel_objects[i]) || + py::isinstance>(pixel_objects[i]) || + py::isinstance>(pixel_objects[i]) || + pixel_objects[i].is(py::none()))) + { + std::stringstream err; + if (py::isinstance(pixel_objects[i])) + err << "invalid deep pixel array: entry at " << y << "," << x << " is array of unsupported type '" << py::str(pixel_objects[i].cast().dtype()) << "'"; + else + err << "invalid deep pixel array: entry at " << y << "," << x << " is of unsupported type '" << py::str(pixel_objects[i].attr("__class__").attr("__name__")) << "'"; + throw std::invalid_argument(err.str()); + } + } + } + else + { + if (!(py::isinstance>(pixels) || + py::isinstance>(pixels) || + py::isinstance>(pixels))) + { + std::stringstream err; + err << "invalid pixel array: unsupported type " << py::str(pixels.attr("__class__").attr("__name__")); + throw std::invalid_argument(err.str()); + } + } +} + +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 = py::array::ensure(pixels); + if (buf) + { + if (py::isinstance>(buf)) + return UINT; + if (py::isinstance>(buf)) + return HALF; + if (py::isinstance>(buf)) + return FLOAT; + + if (py::isinstance(pixels.dtype())) + { + auto height = pixels.shape(0); + auto width = pixels.shape(1); + + auto object_array = pixels.unchecked(); + + for (decltype(height) y=0; y>(*object)) + return UINT; + if (py::isinstance>(*object)) + return HALF; + if (py::isinstance>(*object)) + 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() = "Read and write EXR 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, "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) + ; + + // + // The File API: Channel, Part, and File + // + + py::class_(m, "Channel", R"pbdoc( + The class object representing a channel in an EXR image file. + Example + ------- + >>> import OpenEXR + >>> f = OpenEXR.File("image.exr") + >>> f.channels()['A'] + Channel("A", xSampling=1, ySampling=1) + )pbdoc") + .def(py::init(), + R"pbdoc( + Construct an empty Channel object. + )pbdoc") + .def(py::init(), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false, + R"pbdoc( + Construct Channel object with the given parameters. + + Parameters: + int : xSampling + The x subsampling value + int : ySampling + The y subsampling value + int : pLinear + The pLinear value + )pbdoc") + .def(py::init(), + py::arg("pixels"), + R"pbdoc( + Construct Channel object with the given pixel array + + Parameters: + np.array : pixels + The numpy array of pixels. Supported types are uin32, float16, float32 + )pbdoc") + .def(py::init(), + py::arg("pixels"), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false, + R"pbdoc( + Construct Channel object with the given parameters. + + Parameters: + ----------- + np.array : pixels + The numpy array of pixels. Supported types are uin32, float16, float32 + int : xSampling + The x subsampling value + int : ySampling + The y subsampling value + int : pLinear + The pLinear value + )pbdoc") + .def(py::init(), + py::arg("name"), + R"pbdoc( + Construct Channel object with the given name. + + Parameters: + ----------- + str : name + The name of the channel. + )pbdoc") + .def(py::init(), + py::arg("name"), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false, + R"pbdoc( + Construct Channel object with the given parameters. + + Parameters: + ----------- + str : name + The name of the channel. + int : xSampling + The x subsampling value + int : ySampling + The y subsampling value + int : pLinear + The pLinear value + )pbdoc") + .def(py::init(), + py::arg("name"), + py::arg("pixels"), + R"pbdoc( + Construct Channel object with the given parameters. + + Parameters: + ----------- + str : name + The name of the channel. + np.array : pixels + The numpy array of pixels. Supported types are uin32, float16, float32 + )pbdoc") + .def(py::init(), + py::arg("name"), + py::arg("pixels"), + py::arg("xSampling"), + py::arg("ySampling"), + py::arg("pLinear")=false, + R"pbdoc( + Construct Channel object with the given parameters. + + Parameters: + ----------- + str : name + The name of the channel. + np.array : pixels + The numpy array of pixels. Supported types are uin32, float16, float32 + int : xSampling + The x subsampling value + int : ySampling + The y subsampling value + int : pLinear + The pLinear value + )pbdoc") + .def("__repr__", [](const PyChannel& c) { return repr(c); }) + .def_readwrite("name", &PyChannel::name, + R"pbdoc( + str : The channel name. + )pbdoc") + .def("type", &PyChannel::pixelType, + R"pbdoc( + OpenEXR.PixelType : The pixel type (UINT, HALF, FLOAT) + )pbdoc") + .def_readwrite("xSampling", &PyChannel::xSampling, + R"pbdoc( + int : The x subsampling value + )pbdoc") + .def_readwrite("ySampling", &PyChannel::ySampling, + R"pbdoc( + int : The y subsampling value + )pbdoc") + .def_readwrite("pLinear", &PyChannel::pLinear, + R"pbdoc( + bool : The pLinear value, used for DWA compression. + )pbdoc") + .def_readwrite("pixels", &PyChannel::pixels, + R"pbdoc( + np.array : The channel pixel array. + )pbdoc") + .def_readonly("channel_index", &PyChannel::channel_index, + R"pbdoc( + int : The index of the channel. + )pbdoc") + ; + + py::class_(m, "Part", R"pbdoc( + The class object representing a part in a EXR image file. + + Example + ------- + >>> import OpenEXR + >>> Z = np.zeros((200,100), dtype='f') + >>> P = OpenEXR.Part({}, {"Z" : Z }) + >>> f = OpenEXR.File([P]) + >>> f.parts() + [Part("Part0", Compression.ZIPS_COMPRESSION, width=100, height=200)] + )pbdoc") + .def(py::init(), + R"pbdoc( + Create an empty Part object + )pbdoc") + .def(py::init(), + py::arg("header"), + py::arg("channels"), + py::arg("name")="", + R"pbdoc( + Create a Part object from dicts for the header and channels. + + Parameters + ---------- + header : dict + Dict of header metadata, with attribute name as key. + channels : list + List of `Channel` objects, which hold pixel numpy arrays. + name : str + The name of the part + + Example + ------- + >>> Z = np.zeros((200,100), dtype='f') + >>> P = OpenEXR.Part({}, {"Z" : Z }, "left") + )pbdoc") + .def("__repr__", [](const PyPart& p) { return repr(p); }) + .def("name", &PyPart::name, + R"pbdoc( + str : The part name. + )pbdoc") + .def("type", &PyPart::type, + R"pbdoc( + OpenEXR.Storage : The type of the part: scanlineimage, tiledimage, deepscanline, deeptile + )pbdoc") + .def("width", &PyPart::width, + R"pbdoc( + int : The width of the image, in pixels. + )pbdoc") + .def("height", &PyPart::height, + R"pbdoc( + int : The height of the image, in pixels. + )pbdoc") + .def("compression", &PyPart::compression, + R"pbdoc( + OpenEXR.Compression : The compression method: + NO_COMPRESSION + RLE_COMPRESSION + ZIPS_COMPRESSION + ZIP_COMPRESSION + PIZ_COMPRESSION + PXR24_COMPRESSION + B44_COMPRESSION + B44A_COMPRESSION + DWAA_COMPRESSION + DWAB_COMPRESSION + )pbdoc") + .def_readwrite("header", &PyPart::header, + R"pbdoc( + dict : The header metadata. + )pbdoc") + .def_readwrite("channels", &PyPart::channels, + R"pbdoc( + dict : The channels. + )pbdoc") + .def_readonly("part_index", &PyPart::part_index, + R"pbdoc( + int : The index of the part. + )pbdoc") + ; + + py::class_(m, "File", R"pbdoc( + The class object representing an EXR image file. + + This class is the interface for reading and writing image + header and pixel data. + + Example + ------- + >>> import OpenEXR + >>> f = OpenEXR.File("image.exr") + >>> f.header()["comment"] = "Hello, image." + >>> f.write("out.exr") + )pbdoc") + .def(py::init<>()) + .def(py::init(), + py::arg("filename"), + py::arg("separate_channels")=false, + py::arg("header_only")=false, + R"pbdoc( + Initialize a File by reading the image from the given filename. + + Parameters + ---------- + filename : str + The path to the image file on disk. + separate_channels : bool + If True, read each channel into a separate 2D numpy array + if False (default), read pixel data into a single "RGB" or "RGBA" numpy array of dimension (height,width,3) or (height,width,4); + header_only : bool + If True, read only the header metadata, not the image pixel data. + + Example + ------- + >>> f = OpenEXR.File("image.exr", separate_channels=False, header_only=False) + )pbdoc") + .def(py::init(), + py::arg("header"), + py::arg("channels"), + R"pbdoc( + Initialize a File with metdata and pixels. Creates a single-part EXR file. + + Parameters + ---------- + header : dict + Dict of header metadata, with attribute name as key. + channels : list + List of `Channel` objects, which hold pixel numpy arrays. + + Example + ------- + >>> height, width = (20, 10) + >>> R = np.random.rand(height, width).astype('f') + >>> G = np.random.rand(height, width).astype('f') + >>> B = np.random.rand(height, width).astype('f') + >>> channels = { "R" : R, "G" : G, "B" : B } + >>> header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + >>> f = OpenEXR.File(header, channels) + )pbdoc") + .def(py::init(), + py::arg("parts"), + R"pbdoc( + Initialize a File with a list of Part objects. + + Parameters + ---------- + parts : list + List of Part objects + + Example + ------- + >>> height, width = (20, 10) + >>> Z0 = np.zeros((height, width), dtype='f') + >>> Z1 = np.ones((height, width), dtype='f') + >>> P0 = OpenEXR.Part({}, {"Z" : Z0 }) + >>> P1 = OpenEXR.Part({}, {"Z" : Z1 }) + >>> f = OpenEXR.File([P0, P1]) + )pbdoc") + .def("__enter__", &PyFile::__enter__) + .def("__exit__", &PyFile::__exit__) + .def_readwrite("filename", &PyFile::filename, + R"pbdoc( + str : The filename the File was read from. + + Example + ------- + >>> f = OpenEXR.File("image.exr") + >>> f.filename + 'image.exr' + )pbdoc") + .def_readwrite("parts", &PyFile::parts, + R"pbdoc( + list : The image parts. The list has a single element for single-part files. + Example + ------- + >>> f = OpenEXR.File("image.exr") + >>>> f.parts + [Part("Part0", Compression.ZIPS_COMPRESSION, width=10, height=20)] + )pbdoc") + .def("header", &PyFile::header, py::arg("part_index") = 0, + R"pbdoc( + dict : The header metadata for the given part if specified, or for the first part if not. + + Parameters + ---------- + part_index : int + The index of the part. Defaults to 0. + + Example + ------- + >>> f = OpenEXR.File("image.exr") + >>> f.header() + {'dataWindow': (array([0, 0], dtype=int32), array([100, 100], dtype=int32)), 'displayWindow': (array([0, 0], dtype=int32), array([100, 100], dtype=int32))} + )pbdoc") + .def("channels", &PyFile::channels, py::arg("part_index") = 0, + R"pbdoc( + Return a dict of channels given part if specified, or for the first part if not. The dict key is the channel name. + + Parameters + ---------- + part_index : int + The index of the part. Defaults to 0. + + Example + ------- + >>> f = OpenEXR.File("image.exr") + >>> f.channels(0) + {'A': Channel("A", xSampling=1, ySampling=1), 'B': Channel("B", xSampling=1, ySampling=1), 'G': Channel("G", xSampling=1, ySampling=1), 'R': Channel("R", xSampling=1, ySampling=1)} + )pbdoc") + .def("write", &PyFile::write, + R"pbdoc( + Write the File to the give file name. + + Parameters + ---------- + filename : str + The output path name. + + Example + ------- + >>> f = OpenEXR.File("image.exr") + >>> f.write("out.exr"))pbdoc") + ; +} + diff --git a/src/wrappers/python/PyOpenEXR.h b/src/wrappers/python/PyOpenEXR.h new file mode 100644 index 0000000000..2ad5fc1526 --- /dev/null +++ b/src/wrappers/python/PyOpenEXR.h @@ -0,0 +1,324 @@ +// +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) Contributors to the OpenEXR Project. +// + +typedef Array2D Array2DVoidPtr; +typedef std::map> SliceDataMap; + +// +// 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 PyChannel; + +class PyFile +{ +public: + PyFile() {} + PyFile(const std::string& filename, bool separate_channels = false, bool header_only = false); + PyFile(const py::dict& header, const py::dict& channels); + PyFile(const py::list& parts); + + py::object __enter__(); + void __exit__(py::args args); + + py::dict& header(int part_index = 0); + py::dict& channels(int part_index = 0); + + void write(const char* filename); + + std::string filename; + py::list parts; + +protected: + + bool header_only; + + py::object getAttributeObject(const std::string& name, const Attribute* a); + + void insertAttribute(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); + + 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 writePixels(MultiPartOutputFile& outfile, const Box2i& dw) const; + void writeDeepPixels(MultiPartOutputFile& outfile, const Box2i& dw) const; + + void setDeepSliceData(const ChannelList& channel_list, size_t height, size_t width, + SliceDataMap& sliceDataMap, + std::map& rgbaChannelMap, + const Array2D& sampleCount); + + void readPixels(MultiPartInputFile& infile, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgbaChannels, + const Box2i& dw, bool separate_channels); + void readDeepPixels(MultiPartInputFile& infile, const std::string& type, const ChannelList& channel_list, + const std::vector& shape, const std::set& rgbaChannels, + const Box2i& dw, bool separate_channels); + int channelNameToRGBA(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), + _type(NUM_PIXELTYPES), _nrgba(0) {} + + PyChannel(int xSampling, int ySampling, bool pLinear = false) + : xSampling(xSampling), ySampling(ySampling), pLinear(pLinear), channel_index(0), + _type(NUM_PIXELTYPES), _nrgba(0) {} + PyChannel(const py::array& p) + : xSampling(1), ySampling(1), pLinear(false), pixels(p), channel_index(0), + _type(NUM_PIXELTYPES), _nrgba(0) { validatePixelArray(); } + PyChannel(const py::array& p, int xSampling, int ySampling, bool pLinear = false) + : xSampling(xSampling), ySampling(ySampling), pLinear(pLinear), pixels(p), channel_index(0), + _type(NUM_PIXELTYPES), _nrgba(0) { validatePixelArray(); } + + PyChannel(const char* n) + : name(n), xSampling(1), ySampling(1), pLinear(false), channel_index(0), + _type(NUM_PIXELTYPES), _nrgba(0) {} + PyChannel(const char* n, int xSampling, int ySampling, bool pLinear = false) + : name(n), xSampling(xSampling), ySampling(ySampling), pLinear(pLinear), channel_index(0), + _type(NUM_PIXELTYPES), _nrgba(0) {} + PyChannel(const char* n, const py::array& p) + : name(n), xSampling(1), ySampling(1), pLinear(false), pixels(p), channel_index(0), + _type(NUM_PIXELTYPES), _nrgba(0) { validatePixelArray(); } + 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), + _type(NUM_PIXELTYPES), _nrgba(0) { validatePixelArray(); } + + PixelType pixelType() const; + + std::string name; + int xSampling; + int ySampling; + int pLinear; + py::array pixels; + size_t channel_index; + + mutable PixelType _type; + mutable int _nrgba; + + void validatePixelArray(); + template void setSliceDataPtr(Array2DVoidPtr& slice_data,const py::array& a, + size_t y, size_t x, + int channel_offset, PixelType type) const; + void createDeepPixelArrays(size_t height, size_t width, + const Array2D& sampleCount); + void insertDeepSlice(DeepFrameBuffer& frameBuffer, const std::string& slice_name, + size_t height, size_t width, int nrgba, + int dw_offset, int channel_offset, + Array2D& sampleCount, + std::vector>& slice_datas) const; +}; + +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; +} + + +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 96% rename from src/wrappers/python/OpenEXR.cpp rename to src/wrappers/python/PyOpenEXR_old.cpp index d7e270a3da..d663d8f29c 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; @@ -848,7 +851,7 @@ static PyTypeObject InputFile_Type = { Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, - "OpenEXR Input file object", + "OpenEXR Input file object - Deprecated; this class is provided for backwards compatibility and will be removed in a future release", 0, 0, @@ -1125,7 +1128,7 @@ static PyTypeObject OutputFile_Type = { Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, - "OpenEXR Output file object", + "OpenEXR Output file object - Deprecated; this class is provided for backwards compatibility and will be removed in a future release", 0, 0, @@ -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 26d0e868df..1d9c81a772 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,31 +66,135 @@ for more information. # Quick Start - - - -The "hello, world" image writer: - - import OpenEXR, Imath - from array import array - - 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() +The "Hello, World" image writer: + + # Generate a 3D NumPy array for RGB channels with random values + height, width = (20, 10) + RGB = np.random.rand(height, width, 3).astype('f') + + channels = { "RGB" : RGB } + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + with OpenEXR.File(header, channels) as outfile: + outfile.write("readme.exr") + +Or alternatively, construct the same output file via separate pixel arrays +for each channel: + + # Generate arrays for R, G, and B channels with random values + height, width = (20, 10) + R = np.random.rand(height, width).astype('f') + G = np.random.rand(height, width).astype('f') + B = np.random.rand(height, width).astype('f') + channels = { "R" : R, "G" : G, "B" : B } + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + with OpenEXR.File(header, channels) as outfile: + outfile.write("readme.exr") + +The corresponding example of reading an image is: + + with OpenEXR.File("readme.exr") as infile: + + RGB = infile.channels()["RGB"].pixels + height, width, _ = RGB.shape + for y in range(height): + for x in range(width): + pixel = tuple(RGB[y, x]) + print(f"pixel[{y}][{x}]={pixel}") + +Or alternatively, read the data as separate arrays for each channel: + + with OpenEXR.File("readme.exr", separate_channels=True) as infile: + + 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 + height, width = R.shape + for y in range(height): + for x in range(width): + pixel = (R[y, x], G[y, x], B[y, x]) + print(f"pixel[{y}][{x}]={pixel}") + +To modify the header metadata in a file: + + with OpenEXR.File("readme.exr") as f: + + f.header()["displayWindow"] = ((3,4),(5,6)) + f.header()["screenWindowCenter"] = np.array([1.0,2.0],'float32') + f.header()["comments"] = "test image" + f.header()["longitude"] = -122.5 + f.write("readme_modified.exr") + + with OpenEXR.File("readme_modified.exr") as o: + dw = o.header()["displayWindow"] + assert (tuple(dw[0]), tuple(dw[1])) == ((3,4),(5,6)) + swc = o.header()["screenWindowCenter"] + assert tuple(swc) == (1.0, 2.0) + assert o.header()["comments"] == "test image" + assert o.header()["longitude"] == -122.5 + +Note that OpenEXR's Imath-based vector and matrix attribute values +appear in the header dictionary as 2-element, 3-element, 3x3, 4x4 +numpy arrays, and bounding boxes appear as tuples of 2-element arrays, +or tuples for convenience. + +To read and write a multi-part file, use a list of ``Part`` objects: + + height, width = (20, 10) + Z0 = np.zeros((height, width), dtype='f') + Z1 = np.ones((height, width), dtype='f') + + P0 = OpenEXR.Part({}, {"Z" : Z0 }) + P1 = OpenEXR.Part({}, {"Z" : Z1 }) + + f = OpenEXR.File([P0, P1]) + f.write("readme_2part.exr") + + with OpenEXR.File("readme_2part.exr") as o: + 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 + +Deep data is stored in a numpy array whose entries are numpy +arrays. Construct a numpy array with a ``dtype`` of ``object``, and +assign each entry a numpy array holding the samples. Each pixel can +have a different number of samples, including ``None`` for no data, +but all channels in a given part must have the same number of samples. + + height, width = (20, 10) + + Z = np.empty((height, width), dtype=object) + for y in range(height): + for x in range(width): + Z[y, x] = np.array([y*width+x], dtype='uint32') + + channels = { "Z" : Z } + header = { "compression" : OpenEXR.ZIPS_COMPRESSION, + "type" : OpenEXR.deepscanline } + with OpenEXR.File(header, channels) as outfile: + outfile.write("readme_test_tiled_deep.exr") + +To read a deep file: + + with OpenEXR.File("readme_test_tiled_deep.exr") as infile: + + Z = infile.channels()["Z"].pixels + height, width = Z.shape + for y in range(height): + for x in range(width): + for z in Z[y,x]: + print(f"deep sample at {y},{x}: {z}") + # Community @@ -126,7 +227,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.exr b/src/wrappers/python/tests/test.exr new file mode 100644 index 0000000000..cc22acf908 Binary files /dev/null and b/src/wrappers/python/tests/test.exr differ diff --git a/src/wrappers/python/tests/test_deep.py b/src/wrappers/python/tests/test_deep.py new file mode 100644 index 0000000000..d18711c96e --- /dev/null +++ b/src/wrappers/python/tests/test_deep.py @@ -0,0 +1,224 @@ +#!/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 + +def print_deep(outfile): + for n,c in outfile.channels().items(): + for y in range(c.pixels.shape[0]): + for x in range(c.pixels.shape[1]): + d = c.pixels[y,x] + print(f"{n}[{y},{x}]: {d}") + +def compare_files(lhs, rhs): + + for Plhs, Prhs in zip(lhs.parts,rhs.parts): + compare_parts(Plhs, Prhs) + +def compare_parts(lhs, rhs): + + if len(lhs.channels) != len(rhs.channels): + raise Exception(f"#channels in {lhs.name()} differs: {len(lhs.channels)} {len(rhs.channels)}") + + for c in lhs.channels.keys(): + compare_channels(lhs.channels[c], rhs.channels[c]) + +def compare_channels(lhs, rhs): + + if (lhs.name != rhs.name or + lhs.type() != rhs.type() or + lhs.xSampling != rhs.xSampling or + lhs.ySampling != rhs.ySampling): + raise Exception(f"channel {lhs.name} differs: {lhs.__repr__()} {rhs.__repr__()}") + + compare_channel_pixels(lhs, rhs) + +def compare_channel_pixels(lhs, rhs): + + if lhs.pixels.shape != rhs.pixels.shape: + raise Exception(f"channel {lhs.name}: image size differs: {lhs.pixels.shape} vs. {rhs.pixels.shape}") + + height = lhs.pixels.shape[0] + width = lhs.pixels.shape[1] + for y in range(height): + for x in range(width): + l = lhs.pixels[y,x] + r = rhs.pixels[y,x] + if l is None and r is None: + continue + close = np.isclose(l, r, 1e-5) + if not np.all(close): + for i in np.argwhere(close==False): + y,x = i + if math.isfinite(lhs.pixels[y,x]) and math.isfinite(rhs.pixels[y,x]): + raise Exception(f"channel {lhs.name}: deep pixels {i} differ: {lhs.pixels[y,x]} {rhs.pixels[y,x]}") + +class TestDeep(unittest.TestCase): + + def test_deep_rgba(self): + + self.do_test_deep_rgba(False, 'uint32') + self.do_test_deep_rgba(True, 'uint32') + self.do_test_deep_rgba(False, 'e') + self.do_test_deep_rgba(True, 'e') + self.do_test_deep_rgba(False, 'float32') + self.do_test_deep_rgba(True, 'float32') + + def do_test_deep_rgba(self, do_alpha, dt): + + dataWindow = ((100,100), (120,130)) + height = dataWindow[1][1] - dataWindow[0][1] + 1 + width = dataWindow[1][0] - dataWindow[0][0] + 1 + + R = np.empty((height, width), dtype=object) + G = np.empty((height, width), dtype=object) + B = np.empty((height, width), dtype=object) + if do_alpha: + A = np.empty((height, width), dtype=object) + Z = np.empty((height, width), dtype=object) + + for y in range(height): + for x in range(width): + i = y*width+x + 2 + l = i % 2 + if l == 0: + R[y, x] = np.array([j for j in range(1,i)], dtype=dt) + G[y, x] = np.array([j*10 for j in range(1,i)], dtype=dt) + B[y, x] = np.array([j*100 for j in range(1,i)], dtype=dt) + if do_alpha: + A[y, x] = np.array([j*100 for j in range(1,i)], dtype=dt) + Z[y, x] = np.array([j*2 for j in range(1,i)], dtype=dt) + else: + R[y, x] = None + G[y, x] = None + B[y, x] = None + if do_alpha: + A[y, x] = None + Z[y, x] = None + + channels = { "B" : B, "G" : G, "R" : R, "Z" : Z } + if do_alpha: + channels["A"] = A + header = { "compression" : OpenEXR.ZIPS_COMPRESSION, + "type" : OpenEXR.deepscanline, + "dataWindow" : dataWindow} + + filename = "write_deep.exr" + with OpenEXR.File(header, channels) as outfile: + + outfile.write(filename) + + with OpenEXR.File(filename) as infile: + + channel = "RGBA" if do_alpha else "RGB" + + C = infile.channels()[channel] + for y in range(height): + for x in range(width): + p = C.pixels[y,x] + if p is None: + assert R[y,x] is None + else: + for i in range(p.shape[0]): + if do_alpha: + self.assertEqual(p[i,:].tolist(), [R[y,x][i], G[y,x][i], B[y,x][i], A[y,x][i]]) + else: + self.assertEqual(p[i,:].tolist(), [R[y,x][i], G[y,x][i], B[y,x][i]]) + + infile.write("rgb_deep.exr") + + with OpenEXR.File("rgb_deep.exr", separate_channels=True) as infile: + + compare_files(infile, outfile) + + def test_deep(self): + + dataWindow = ((100,100), (120,130)) + height = dataWindow[1][1] - dataWindow[0][1] + 1 + width = dataWindow[1][0] - dataWindow[0][0] + 1 + + U = np.empty((height, width), dtype=object) + H = np.empty((height, width), dtype=object) + F = np.empty((height, width), dtype=object) + for y in range(height): + for x in range(width): + i = y*width+x + l = i % 3 + if l == 0: + U[y, x] = np.array([i], dtype='uint32') + H[y, x] = np.array([i], dtype='float16') + F[y, x] = np.array([i], dtype='float32') + else: + U[y, x] = None + H[y, x] = None + F[y, x] = None + + channels = { "U" : U, "H" : H, "F" : F } + header = { "compression" : OpenEXR.ZIPS_COMPRESSION, + "type" : OpenEXR.deepscanline, + "dataWindow" : dataWindow} + + filename = "write_deep.exr" + with OpenEXR.File(header, channels) as outfile: + + outfile.write(filename) + + with OpenEXR.File(filename) as infile: + + compare_files(infile, outfile) + + def test_tiled_deep(self): + + dataWindow = ((100,100), (120,130)) + dataWindow = ((100,100), (103,104)) + height = dataWindow[1][1] - dataWindow[0][1] + 1 + width = dataWindow[1][0] - dataWindow[0][0] + 1 + + U = np.empty((height, width), dtype=object) + H = np.empty((height, width), dtype=object) + F = np.empty((height, width), dtype=object) + for y in range(height): + for x in range(width): + i = y*width+x + 2 + l = i % 3 + if l == 0: + U[y, x] = np.array([j for j in range(1,i)], dtype='uint32') + H[y, x] = np.array([j*10 for j in range(1,i)], dtype='float16') + F[y, x] = np.array([j*100 for j in range(1,i)], dtype='float32') + else: + U[y, x] = None + H[y, x] = None + F[y, x] = None + + channels = { "U" : U, "H" : H, "F" : F } + header = { "compression" : OpenEXR.ZIPS_COMPRESSION, + "type" : OpenEXR.deeptile, + "tiles" : OpenEXR.TileDescription(), + "dataWindow" : dataWindow} + + filename = "test_tiled_deep.exr" + with OpenEXR.File(header, channels) as outfile: + + outfile.write(filename) + + with OpenEXR.File(filename, False) as infile: + + compare_files(infile, outfile) + +if __name__ == '__main__': + unittest.main() + print("OK") + + diff --git a/src/wrappers/python/tests/test_exceptions.py b/src/wrappers/python/tests/test_exceptions.py new file mode 100644 index 0000000000..e7e493a303 --- /dev/null +++ b/src/wrappers/python/tests/test_exceptions.py @@ -0,0 +1,157 @@ +#!/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 0000000000..bee91b24fa --- /dev/null +++ b/src/wrappers/python/tests/test_images.py @@ -0,0 +1,431 @@ +#!/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 +import math +from subprocess import PIPE, run + +import OpenEXR + +def equalWithRelError (x1, x2, e): + return ((x1 - x2) if (x1 > x2) else (x2 - x1)) <= e * (x1 if (x1 > 0) else -x1) + +def compare_files(lhs, rhs): + + if len(lhs.parts) != len(rhs.parts): + raise Exception(f"#parts differs: {len(lhs.parts)} {len(rhs.parts)}") + + for Plhs, Prhs in zip(lhs.parts,rhs.parts): + compare_parts(Plhs, Prhs) + +def is_default(name, value): + + if name == "screenWindowWidth": + return value == 1.0 + + if name == "type": + return value == OpenEXR.scanlineimage + + return True + +def compare_parts(lhs, rhs): + + attributes = set(lhs.header.keys()).union(set(rhs.header.keys())) + + for a in attributes: + if a in ["channels", "chunkCount"]: + continue + + if a not in lhs.header: + if not is_default(a, rhs.header[a]): + raise Exception(f"attribute {a} not in lhs header") + elif a not in rhs.header: + if not is_default(a, lhs.header[a]): + raise Exception(f"attribute {a} not in rhs header") + else: + compare_attributes(a, lhs.header[a], rhs.header[a]) + + if len(lhs.channels) != len(rhs.channels): + raise Exception(f"#channels in {lhs.name} differs: {len(lhs.channels)} {len(rhs.channels)}") + + for c in lhs.channels.keys(): + compare_channels(lhs.channels[c], rhs.channels[c]) + +def compare_attributes(name, lhs, rhs): + + # convert tuples to array for comparison + + if isinstance(lhs, tuple): + lhs = np.array(lhs) + + if isinstance(rhs, tuple): + rhs = np.array(rhs) + + if isinstance(lhs, np.ndarray) and isinstance(rhs, np.ndarray): + if lhs.shape != rhs.shape: + raise Exception(f"attribute {name}: array shapes differ: {lhs} {rhs}") + close = np.isclose(lhs, rhs, 1e-5, equal_nan=True) + if not np.all(close): + raise Exception(f"attribute {name}: arrays differ: {lhs} {rhs}") + elif isinstance(lhs, float) and isinstance(rhs, float): + if not equalWithRelError(lhs, rhs, 1e05): + if math.isfinite(lhs) and math.isfinite(rhs): + raise Exception(f"attribute {name}: floats differ: {lhs} {rhs}") + elif lhs != rhs: + raise Exception(f"attribute {name}: values differ: {lhs} {rhs}") + +def compare_channels(lhs, rhs): + + if (lhs.name != rhs.name or + lhs.type() != rhs.type() or + lhs.xSampling != rhs.xSampling or + lhs.ySampling != rhs.ySampling): + raise Exception(f"channel {lhs.name} differs: {lhs.__repr__()} {rhs.__repr__()}") + if lhs.pixels.shape != rhs.pixels.shape: + raise Exception(f"channel {lhs.name}: image size differs: {lhs.pixels.shape} vs. {rhs.pixels.shape}") + + if lhs.pixels.dtype == np.dtype('O') and rhs.pixels.dtype == np.dtype('O'): + height, width = lhs.pixels.shape + for y in range(height): + for x in range(width): + ld = lhs.pixels[y,x] + rd = rhs.pixels[y,x] + if ld is None and rd is None: + continue + if ld.shape != rd.shape: + raise Exception(f"channel {lhs.name}: deep pixels {i} differ: {ld} {rd}") + with np.errstate(invalid='ignore'): + close = np.isclose(ld, rd, 1e-5, equal_nan=True) + if not np.all(close): + for i in np.argwhere(close==False): + raise Exception(f"channel {lhs.name}: pixels[{y}][{x}] deep sample{i} differs: {ld} {rd}") + else: + + with np.errstate(invalid='ignore'): + close = np.isclose(lhs.pixels, rhs.pixels, 1e-5, equal_nan=True) + + if not np.all(close): + for i in np.argwhere(close==False): + y,x = i + lp = lhs.pixels[y,x] + rp = rhs.pixels[y,x] + if math.isfinite(lp) and math.isfinite(rp): + raise Exception(f"channel {lhs.name}: pixels {i} differ: {lp} {rp}") + +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/MtTamNorth.exr", # channel BY differs. + "Chromaticities/Rec709_YC.exr", # channel BY differs. + "Chromaticities/XYZ_YC.exr", # channel BY differs. +] + +bug_files = [] +exr_files = [ + "v2/Stereo/Trunks.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_file(self, f, print_pixels = False): + + 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.pixels.dtype}") + if print_pixels: + maxy, maxx = c.pixels.shape[0], c.pixels.shape[1] + maxy, maxx = 2, 800 + for y in range(maxy): + for x in range(maxx): + print(f"{n}[{y},{x}]={c.pixels[y,x]}") + + 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 + # + + if "://" not in url: + filename = url + else: + 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: + if P.header["type"] in [OpenEXR.deepscanline, OpenEXR.deeptiled]: + return + + P.header["compression"] = OpenEXR.ZIP_COMPRESSION + P.header["type"] = OpenEXR.tiledimage + if "tiles" not in P.header: + P.header["tiles"] = OpenEXR.TileDescription() + for n,C in P.channels.items(): + C.xSampling = 1 + C.ySampling = 1 + + 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 and t.parts: + if "chunkCount" in P.header: del P.header["chunkCount"] + for P in t.parts: + if "chunkCount" in P.header: del P.header["chunkCount"] + + print(f"Comparing original to tiled...") + compare_files(f, t) + + def do_test_image(self, url): + + verbose = False + verbose_pixels = False + + print(f"testing image {url}...") + + if "://" not in url: + filename = url + else: + 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...") + with OpenEXR.File(filename) as separate_channels: + if verbose: + print("separate_channels:") + self.print_file(separate_channels, verbose_pixels) + self.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...") + with OpenEXR.File("separate_channels.exr") as separate_channels2: + if verbose: + self.print_channel_names(separate_channels2) + + # Confirm that the file that was just written is identical to the original + + print(f"Comparing separate_channels to separate_channels2...") + compare_files(separate_channels, separate_channels2) + print(f"Comparing separate_channels to separate_channels2...done.") + + # Read the original file as RGBA channels + + print(f"Reading {url} as rgba channels...") + with OpenEXR.File(filename, True) as rgba_channels: + if verbose: + self.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 channels3...") + with OpenEXR.File("rgba_channels.exr") as separate_channels3: + if verbose: + print("separate_channels3:") + self.print_file(separate_channels3, verbose_pixels) + self.print_channel_names(separate_channels3) + + # Confirm that it, too, is the same as the original + + print(f"Comparing separate_channels to separate_channels3...") + compare_files(separate_channels, separate_channels3) + + print("ok.") + + 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__': + + + if len(sys.argv) > 2: + exr_files = sys.argv[2:] + bug_files = [] + + 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 0000000000..43560a8560 --- /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 0000000000..12ac0d2e82 --- /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 2bf3da8885..5ddbe1dbd9 100644 --- a/src/wrappers/python/tests/test_readme.py +++ b/src/wrappers/python/tests/test_readme.py @@ -7,25 +7,197 @@ # 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 - - width = 10 - height = 10 - size = width * height +def test_write(): + + # Generate arrays for R, G, and B channels with random values + height, width = (20, 10) + R = np.random.rand(height, width).astype('f') + G = np.random.rand(height, width).astype('f') + B = np.random.rand(height, width).astype('f') + channels = { "R" : R, "G" : G, "B" : B } + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + with OpenEXR.File(header, channels) as outfile: + outfile.write("readme.exr") + + print("ok") + +def test_write_RGB(): + + # Generate a 3D NumPy array for RGB channels with random values + height, width = (20, 10) + RGB = np.random.rand(height, width, 3).astype('f') + + channels = { "RGB" : RGB } + header = { "compression" : OpenEXR.ZIP_COMPRESSION, + "type" : OpenEXR.scanlineimage } + + with OpenEXR.File(header, channels) as outfile: + outfile.write("readme.exr") + + print("ok") + +def test_read_separate_channels(): + + with OpenEXR.File("readme.exr", separate_channels=True) as infile: + + 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 + height, width = R.shape + for y in range(height): + for x in range(width): + pixel = (R[y, x], G[y, x], B[y, x]) + print(f"pixel[{y}][{x}]={pixel}") + + print("ok") + +def test_read_RGB(): + + with OpenEXR.File("readme.exr") as infile: + + print(f"readme.exr: {infile.channels()}") + + RGB = infile.channels()["RGB"].pixels + height, width = RGB.shape[0:2] + for y in range(height): + for x in range(width): + pixel = tuple(RGB[y, x]) + print(f"pixel[{y}][{x}]={pixel}") + + print("ok") + +def test_modify(): + + with OpenEXR.File("readme.exr") as f: + + f.header()["displayWindow"] = ((3,4),(5,6)) + f.header()["screenWindowCenter"] = np.array([1.0,2.0],'float32') + f.header()["comments"] = "test image" + f.header()["longitude"] = -122.5 + f.write("readme_modified.exr") + + with OpenEXR.File("readme_modified.exr") as o: + dw = o.header()["displayWindow"] + assert (tuple(dw[0]), tuple(dw[1])) == ((3,4),(5,6)) + swc = o.header()["screenWindowCenter"] + assert tuple(swc) == (1.0, 2.0) + assert o.header()["comments"] == "test image" + assert o.header()["longitude"] == -122.5 + + print("ok") + +def test_multipart_write(): + + height, width = (20, 10) + Z0 = np.zeros((height, width), dtype='f') + Z1 = np.ones((height, width), dtype='f') + + P0 = OpenEXR.Part({}, {"Z" : Z0 }) + P1 = OpenEXR.Part({}, {"Z" : Z1 }) + + f = OpenEXR.File([P0, P1]) + f.write("readme_2part.exr") + + with OpenEXR.File("readme_2part.exr") as o: + 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, width = (20, 10) + + Z0 = np.zeros((height, width), dtype='f') + P0 = OpenEXR.Part(header={"type" : OpenEXR.scanlineimage }, + channels={"Z" : Z0 }) + + Z1 = np.ones((height, width), dtype='f') + P1 = OpenEXR.Part(header={"type" : OpenEXR.scanlineimage }, + channels={"Z" : Z1 }) + + f = OpenEXR.File(parts=[P0, P1]) + f.write("readme_2part.exr") + + with OpenEXR.File("readme_2part.exr") as o: + 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, width = (20, 10) + + Z = np.zeros((height, width), dtype='f') + P = OpenEXR.Part({"type" : OpenEXR.tiledimage, + "tiles" : OpenEXR.TileDescription() }, + {"Z" : Z }) + + with OpenEXR.File([P]) as f: + f.write("readme_tiled.exr") + + with OpenEXR.File("readme_tiled.exr") as o: + assert o.parts[0].name() == "Part0" + assert o.parts[0].type() == OpenEXR.tiledimage + +def test_write_deep(): - 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() + height, width = (20, 10) + + Z = np.empty((height, width), dtype=object) + for y in range(height): + for x in range(width): + Z[y, x] = np.array([y*width+x], dtype='uint32') + + channels = { "Z" : Z } + header = { "compression" : OpenEXR.ZIPS_COMPRESSION, + "type" : OpenEXR.deepscanline } + with OpenEXR.File(header, channels) as outfile: + outfile.write("readme_test_tiled_deep.exr") + +def test_read_deep(): + + with OpenEXR.File("readme_test_tiled_deep.exr") as infile: + + Z = infile.channels()["Z"].pixels + height, width = Z.shape + for y in range(height): + for x in range(width): + for z in Z[y,x]: + print(f"deep sample at {y},{x}: {z}") + + +if __name__ == '__main__': + + test_multipart_write() + test_write() + test_write_RGB() + test_read_separate_channels() + test_read_RGB() + test_modify() + test_write_tiled() + test_write_deep() + test_read_deep() diff --git a/src/wrappers/python/tests/test_rgba.py b/src/wrappers/python/tests/test_rgba.py new file mode 100644 index 0000000000..0203c2b349 --- /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", separate_channels=True) + + 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") + + 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", separate_channels=True) + + 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") + + 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", separate_channels=True) + + 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") + + 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 96f06d3379..327130a7d0 100644 --- a/src/wrappers/python/tests/test_unittest.py +++ b/src/wrappers/python/tests/test_unittest.py @@ -8,263 +8,559 @@ 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 fractions 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__) -testList = [] +outfilenames = [] +def mktemp_outfilename(): + fd, outfilename = tempfile.mkstemp(".exr") + os.close(fd) + global outfilenames + outfilenames += outfilename + return outfilename -# -# 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("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 cleanup(): + for outfilename in outfilenames: + if os.path.isfile(outfilename): + print(f"deleting {outfilename}") + os.unlink(outfilename) +atexit.register(cleanup) -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)) +def equalWithRelError (x1, x2, e): + return ((x1 - x2) if (x1 > x2) else (x2 - x1)) <= e * (x1 if (x1 > 0) else -x1) -# -# 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 +def compare_files(lhs, rhs): - print("conversion ok") + if len(lhs.parts) != len(rhs.parts): + raise Exception(f"#parts differs: {len(lhs.parts)} {len(rhs.parts)}") -testList.append(("test_conversion", test_conversion)) + for Plhs, Prhs in zip(lhs.parts,rhs.parts): + compare_parts(Plhs, Prhs) -# -# Confirm failure on reading from non-exist location -# +def is_default(name, value): -def test_invalid_input(): - try: - OpenEXR.InputFile("/bad/place") - except: - pass - else: - assert 0 + if name == "screenWindowWidth": + return value == 1.0 -testList.append(("test_invalid_input", test_invalid_input)) + if name == "type": + return value == OpenEXR.scanlineimage -# -# Confirm failure on writing to invalid location -# + return True -def test_invalid_output(): +def compare_parts(lhs, rhs): - try: - hdr = OpenEXR.Header(640, 480) - OpenEXR.OutputFile("/bad/place", hdr) - except: - pass - else: - assert 0 + attributes = set(lhs.header.keys()).union(set(rhs.header.keys())) - print("invalid output ok") - -testList.append(("test_invalid_output", test_invalid_output)) + for a in attributes: + if a in ["channels"]: + continue -def test_one(): - oexr = OpenEXR.InputFile("write.exr") + if a not in lhs.header: + if not is_default(a, rhs.header[a]): + raise Exception(f"attribute {a} not in lhs header") + elif a not in rhs.header: + if not is_default(a, lhs.header[a]): + raise Exception(f"attribute {a} not in rhs header") + else: + compare_attributes(a, lhs.header[a], rhs.header[a]) - header = oexr.header() + if len(lhs.channels) != len(rhs.channels): + raise Exception(f"#channels in {lhs.name} differs: {len(lhs.channels)} {len(rhs.channels)}") - 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))) + for c in lhs.channels.keys(): + compare_channels(lhs.channels[c], rhs.channels[c]) - assert default_size in [ half_size, float_size, uint_size] - assert float_size == uint_size - assert (float_size / 2) == half_size +def compare_attributes(name, lhs, rhs): - 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("out.exr", h) - x.writePixels({'R': data, 'G': data, 'B': data}) - x.close() - - print("one ok") + # convert tuples to array for comparison -testList.append(("test_one", test_one)) + if isinstance(lhs, tuple): + lhs = np.array(lhs) + + if isinstance(rhs, tuple): + rhs = np.array(rhs) + + if isinstance(lhs, np.ndarray) and isinstance(rhs, np.ndarray): + if lhs.shape != rhs.shape: + raise Exception(f"attribute {name}: array shapes differ: {lhs} {rhs}") + close = np.isclose(lhs, rhs, 1e-5) + if not np.all(close): + raise Exception(f"attribute {name}: arrays differ: {lhs} {rhs}") + elif isinstance(lhs, float) and isinstance(rhs, float): + if not equalWithRelError(lhs, rhs, 1e05): + if math.isfinite(lhs) and math.isfinite(rhs): + raise Exception(f"attribute {name}: floats differ: {lhs} {rhs}") + elif lhs != rhs: + raise Exception(f"attribute {name}: values differ: {lhs} {rhs}") + +def compare_channels(lhs, rhs): + + if (lhs.name != rhs.name or + lhs.type() != rhs.type() or + lhs.xSampling != rhs.xSampling or + lhs.ySampling != rhs.ySampling): + raise Exception(f"channel {lhs.name} differs: {lhs.__repr__()} {rhs.__repr__()}") + if lhs.pixels.shape != rhs.pixels.shape: + raise Exception(f"channel {lhs.name}: image size differs: {lhs.pixels.shape} vs. {rhs.pixels.shape}") + + close = np.isclose(lhs.pixels, rhs.pixels, 1e-5) + if not np.all(close): + for i in np.argwhere(close==False): + y,x = i + if math.isfinite(lhs.pixels[y,x]) and math.isfinite(rhs.pixels[y,x]): + raise Exception(f"channel {lhs.name}: pixels {i} differ: {lhs.pixels[y,x]} {rhs.pixels[y,x]}") + + +def print_file(f, print_pixels = False): + + 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) + +def preview_pixels_equal(a, b): + + if a.shape != b.shape: + return False + + 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 + + return True + +class TestUnittest(unittest.TestCase): + + def setUp(self): + # Print the name of the current test method + print(f"Running test {self.id().split('.')[-1]}") + + def test_tuple(self): + + width = 5 + height = 10 + size = width * height + Z = np.array([i for i in range(0,size)], dtype='uint32').reshape((height, width)) + channels = { "Z" : Z } + + header = {} + header["t2i"] = (0,1) + header["t2f"] = (2.3,4.5) + header["t3i"] = (0,1,2) + header["t3f"] = (3.4,5.6,7.8) + + header["a2d"] = np.array((1.2, 3.4), 'float64') + header["a3d"] = np.array((1.2, 3.4, 5.6), 'float64') + + header["a33f"] = np.identity(3, 'float32') + header["a33d"] = np.identity(3, 'float64') + header["a44f"] = np.identity(4, 'float32') + header["a44d"] = np.identity(4, 'float64') + + outfilename = mktemp_outfilename() + with OpenEXR.File(header, channels) as outfile: + + outfile.write(outfilename) + + with OpenEXR.File(outfilename) as infile: + compare_files (infile, outfile) + + with self.assertRaises(Exception): + header["v"] = (0,"x") + with OpenEXR.File(header, channels) as outfile: + outfile.write(outfilename) -# -# Check that the channel method and channels method return the same data -# + # tuple must be either all int or all float + with self.assertRaises(Exception): + header["v"] = (1,2.3) + with OpenEXR.File(header, channels) as outfile: + outfile.write(outfilename) + + def test_read_write(self): + + # + # 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" + with OpenEXR.File(infilename) as infile: + + outfilename = mktemp_outfilename() + infile.write(outfilename) + + with OpenEXR.File(outfilename) as outfile: + compare_files(outfile, infile) + + def test_keycode(self): -def test_channel_channels(): - - aexr = OpenEXR.InputFile("write.exr") - acl = sorted(aexr.header()['channels'].keys()) - a = [aexr.channel(c) for c in acl] - b = aexr.channels(acl) - - assert a == b - - print("channels ok") - -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("out.exr", hdr) - x.writePixels({'L': data}) - x.close() - - 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 - - print("types ok") - -testList.append(("test_types", test_types)) - -def test_invalid_pixeltype(): - oexr = OpenEXR.InputFile("write.exr") - FLOAT = Imath.PixelType.FLOAT - try: - f.channel('R',FLOAT) - except: - pass - else: - assert 0 - - try: - Imath.Channel(FLOAT) - except: - pass - else: - assert 0 - - print("invalid pixeltype ok") - -testList.append(("test_invalid_pixeltype", test_invalid_pixeltype)) + filmMfcCode = 1 + filmType = 2 + prefix = 3 + count = 4 + perfOffset = 5 + perfsPerFrame = 6 + perfsPerCount = 20 + + k = OpenEXR.KeyCode(filmMfcCode, filmType, prefix, count, perfOffset, perfsPerFrame, perfsPerCount) + + 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) -# -# Write arbitrarily named channels. -# + def test_empty_header(self): -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 - - print("mchannels ok") - -testList.append(("test_write_mchannels", test_write_mchannels)) + # Construct a file from scratch and write it. -def load_red(filename): - oexr = OpenEXR.InputFile(filename) - return oexr.channel('R') + 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) } + + header = {} -# -# Write the pixels to two images, first as a single call, -# then as multiple calls. Verify that the images are identical. -# + with OpenEXR.File(header, channels) as outfile: -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() + outfilename = mktemp_outfilename() + outfile.write(outfilename) - hdr = OpenEXR.Header(w,h) - x = OpenEXR.OutputFile("out0.exr", hdr) - x.writePixels({'R': data, 'G': data, 'B': data}) - x.close() + with OpenEXR.File(outfilename) as infile: + compare_files (infile, outfile) + + def test_write_uint(self): - 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() + # Construct a file from scratch and write it. - oexr0 = load_red("out0.exr") - oexr1 = load_red("out1.exr") - assert oexr0 == oexr1 + 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 = {} + + with OpenEXR.File(header, channels) as outfile: + + # 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 + with OpenEXR.File(outfilename, separate_channels=True) as infile: + + compare_files(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) + } - print("chunk ok") - -testList.append(("test_write_chunk", test_write_chunk)) + header = {} + + with OpenEXR.File(header, channels) as outfile: + + outfilename = mktemp_outfilename() + outfile.write(outfilename) + + # Verify reading it back gives the same data + with OpenEXR.File(outfilename, separate_channels=True) as infile: + compare_files (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) + } -for test in testList: - funcName = test[0] - print ("") - print ("Running {}".format (funcName)) - test[1]() + header = { "type" : OpenEXR.tiledimage, + "tiles" : OpenEXR.TileDescription() } -print() -print("all ok") + with OpenEXR.File(header, channels) as outfile: + outfilename = mktemp_outfilename() + outfile.write(outfilename) + # Verify reading it back gives the same data + with OpenEXR.File(outfilename, separate_channels=True) as infile: + compare_files(infile, outfile) + + def test_modify_in_place(self): + + # + # Test modifying header attributes in place + # + + infilename = f"{test_dir}/test.exr" + with OpenEXR.File(infilename, separate_channels=True) as f: + + # 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 + with OpenEXR.File(outfilename, separate_channels=True) as m: + + # 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) + + with OpenEXR.File(header, channels) as outfile: + + outfilename = mktemp_outfilename() + outfile.write(outfilename) + + with OpenEXR.File(outfilename) as infile: + + Q = infile.header()["preview"].pixels + + assert preview_pixels_equal(P, Q) + + compare_files (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"] = (1.0,2.0, 3.0,4.0, 5.0,6.0,7.0,8.0) + header["box2i"] = ((0,1), (2,3)) + header["box2f"] = ((0,1), (2,3)) + header["compression"] = OpenEXR.ZIPS_COMPRESSION + header["double"] = np.array([42000.0], 'float64') + 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"] = np.identity(3, 'float32') + header["m33d"] = np.identity(3, 'float64') + header["m44f"] = np.identity(4, 'float32') + header["m44d"] = np.identity(4, 'float64') + header["rational"] = fractions.Fraction(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"] = (1,2) + header["v2f"] = (1.2,3.4) + header["v2d"] = (1.2,3.4) + header["v3i"] = (1,2,3) + header["v3f"] = (1.2,3.4,5.6) + header["v3d"] = (1.2,3.4,5.6) + + with OpenEXR.File(header, channels) as outfile: + + outfilename = mktemp_outfilename() + outfile.write(outfilename) + print("write done.") + + # Verify reading it back gives the same data + with OpenEXR.File(outfilename, separate_channels=True) as infile: + compare_files (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"] = (1.0,2.0, 3.0,4.0, 5.0,6.0,7.0,8.0) + header["box2i"] = ((0,1),(2,3)) + header["box2f"] = ((0,1),(2,3)) + header["compression"] = OpenEXR.ZIPS_COMPRESSION + header["double"] = np.array([42000.0], 'float64') + 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"] = np.identity(3, 'float32') + header["m33d"] = np.identity(3, 'float64') + header["m44f"] = np.identity(4, 'float32') + header["m44d"] = np.identity(4, 'float64') + header["preview"] = OpenEXR.PreviewImage(P) + header["rational"] = fractions.Fraction(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"] = (1,2) + header["v2f"] = (1.2,3.4) + header["v2d"] = (1.2,3.4) + header["v3i"] = (1,2,3) + header["v3f"] = (1.2,3.4,5.6) + header["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] + with OpenEXR.File(parts) as outfile2: + + outfilename = mktemp_outfilename() + outfile2.write(outfilename) + + # Verify reading it back gives the same data + with OpenEXR.File(outfilename, separate_channels=True) as i: + compare_files (i, outfile2) + +if __name__ == '__main__': + unittest.main()