From 84d7d52e17a17e18d138d9d1aa9f4e2de2fcb2d6 Mon Sep 17 00:00:00 2001 From: Cary Phillips Date: Mon, 12 Aug 2024 17:31:42 -0700 Subject: [PATCH] Rewrite OpenEXR python bindings using pybind11 and numpy (#1756) * Rewrite python bindings with pybind11 and numpy This introduces an entirely new python API for reading and writing OpenEXR files that supports all file features (or will soon): scanline, tiled, deep, multi-part, etc. It uses numpy arrays for pixel data, and it supports both separate arrays for each channel and interleaving of RGB data into a single composite channel. It leaves the existing binding API in place for backwards-compatibility; there's no overlap between the two APIs. See src/wrappers/python/README.md for examples of the new API. The API is simple: the ``File`` object holds a py::list of ``Part`` objects, each of which has a py::dict for the header and a py::dict for the channels, and each channels hold a numpy array for the pixel data. There's intentionally no support for selective scanline/time reading; reading a file reads the entire channel data for all parts. There *is*, however, an option to read just the headers and skip the pixel data entirely. A few things don't work yet: - Reading and writing of deep data isn't finished. - ID manfest attributes aren't supported yet. - For mipmaped images, it current only reads the top level. This also does not (yet) properly integrate the real Imath types. It leaves in place for now the internal "Imath.py" module, but defines its own internal set of set of Imath classes. This needs to be resolve once the Imath bindings are distributed via pypi.org The test suite downloads images from openexr-images and runs them through a battery of reading/writing tests. Currently, the download is enabled via the ``OPENEXR_TEST_IMAGE_REPO`` environment variable that is off by default but is on in the python wheel CI workflow. This also adds operator== methods to ``KeyCode`` and ``PreviewImage``, required in order to implement operator== for the ``Part`` and ``File`` objects. Signed-off-by: Cary Phillips * Fix subsampling Signed-off-by: Cary Phillips * - Make README.md examples more pythonic - Add __enter__/__exit__ so "with File(name) as f" works - Allow channel dict to reference pixel array directly - Fix subsampling Signed-off-by: Cary Phillips * Use numpy/tuples in place of Imath types Also: - Use single-element array for DoubleAttribute - Use fractions.Fraction for Rational - Use 8-element tuple for chromaticities - Add __enter__/__exit__ so "with" works with File object - remove operator== entirely, it's way too hard to implement something that's actually useful for testing purposes. The tests do their own validations. Signed-off-by: Cary Phillips pixel comparison Signed-off-by: Cary Phillips * Deep reading/writing works for both scanline and tiles Also works properly for separate channels and when coalescing into RGB/RGBA arrays. Signed-off-by: Cary Phillips * doc strings Signed-off-by: Cary Phillips * Rename rgba param to separate_channels, off by default. Signed-off-by: Cary Phillips * remove numpy as build requirement Signed-off-by: Cary Phillips * Rename rgbaChannel() to channelNameToRGBA; rename is_* to objectTo* Signed-off-by: Cary Phillips * Add "deprecated" to InputFile/OutputFile/Imath doc strings Signed-off-by: Cary Phillips * Explicitly set python versions for wheel workflows, and exclude 3.13 Signed-off-by: Cary Phillips * resolve conflicts Signed-off-by: Cary Phillips * resolve conflict Signed-off-by: Cary Phillips --------- Signed-off-by: Cary Phillips --- .../workflows/python-wheels-publish-test.yml | 4 +- .github/workflows/python-wheels-publish.yml | 5 +- .github/workflows/python-wheels.yml | 10 +- pyproject.toml | 3 +- share/ci/scripts/install_pybind11.sh | 37 + src/lib/OpenEXR/ImfKeyCode.cpp | 12 + src/lib/OpenEXR/ImfKeyCode.h | 2 + src/lib/OpenEXR/ImfPreviewImage.h | 5 + src/wrappers/python/CMakeLists.txt | 5 +- src/wrappers/python/Imath.py | 115 +- src/wrappers/python/PyOpenEXR.cpp | 2755 +++++++++++++++++ src/wrappers/python/PyOpenEXR.h | 324 ++ .../python/{OpenEXR.cpp => PyOpenEXR_old.cpp} | 63 +- src/wrappers/python/README.md | 173 +- src/wrappers/python/tests/test.exr | Bin 0 -> 2790 bytes src/wrappers/python/tests/test_deep.py | 224 ++ src/wrappers/python/tests/test_exceptions.py | 157 + src/wrappers/python/tests/test_images.py | 431 +++ src/wrappers/python/tests/test_import.py | 10 + src/wrappers/python/tests/test_old.py | 241 ++ src/wrappers/python/tests/test_readme.py | 212 +- src/wrappers/python/tests/test_rgba.py | 207 ++ src/wrappers/python/tests/test_unittest.py | 742 +++-- 23 files changed, 5327 insertions(+), 410 deletions(-) create mode 100755 share/ci/scripts/install_pybind11.sh create mode 100644 src/wrappers/python/PyOpenEXR.cpp create mode 100644 src/wrappers/python/PyOpenEXR.h rename src/wrappers/python/{OpenEXR.cpp => PyOpenEXR_old.cpp} (96%) create mode 100644 src/wrappers/python/tests/test.exr create mode 100644 src/wrappers/python/tests/test_deep.py create mode 100644 src/wrappers/python/tests/test_exceptions.py create mode 100755 src/wrappers/python/tests/test_images.py create mode 100644 src/wrappers/python/tests/test_import.py create mode 100644 src/wrappers/python/tests/test_old.py create mode 100644 src/wrappers/python/tests/test_rgba.py diff --git a/.github/workflows/python-wheels-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 0000000000000000000000000000000000000000..cc22acf90866b7dfb8e4c697525239e5a7103483 GIT binary patch literal 2790 zcmai0d0Y}=Xn$*+1eRtU-X%;qOZ~_l4Q$BjJip-870f0 zWNWOGWv*pv)PxaNnq)~Pl5lHge)rD3^T&OEzxSNyyw7`{_x99dLo z^b$#Mbb^&kqSd4ShxP5Ab-F!NrdT8oO8mcha#>W^?3mE77|DN7PZdd2P-K`y5gQ|m zTF|qUERrRJ$sH7n!a`y_f?{RSl5lx+P^?7tH*y&jAu(ZLQNNd)92OPZEf{YVE>Zi9 z{(bm!${bl}Y{dUa#3nBKcg6nQTNEKdQ9Vt`B7+u$N#cL&N+NmxC&^`fB$6?%9yGJ^ zqIIx2UCtN^^@SRAn5WFWIDqeTDxfz(#z%EoVoltA{L?s*(N+iWtn?-PJRIp=Z%&7T zWMRI&8Ykwi#QAemcyFm0E&razr2K{0uet@Pr3M_cDTuB|bZMPFgdUU4*=3e2bob4K zUce~stTD&lh#Syx{FJWd@9|)pf~FsQc_@O(6=FjJlepoPDoIpf*B3wP!iLAWI^c{HtL57-)*=5Yj?}M0Ur$*BZT~5ua zz_u(q?mM*(2W$Q5cld8?Uu#WuA0s#}k3#kU15tK1h$>ed*ktiSOxXDlez#^*maWAp z;UnNQr5GmCfg)hfVT{;(3}tV&pjbN?CADwR8f?r`?TupYm2n7}bpyGJdPAjR1odRS z_)U2VGd>$gUAZ^MolWI_%_)3Rpu*7c=FC#D}IBH}{v+H1EFJ%-h4(2`6c%CeB;R^F0Vf1PW$JTjq zjmK!tjom7qrKK>(rZ>gCCfH0ghJM~S8ZW896VGTmtk-AdVl{TQzC*8RCeoUMYnZmF z3qOns=ISyfRPNj_^;Jrvj#mJUln#i&6ZPC8l{vw=0c|;bQLXI{b?H(X&oe^i z%%jr1#qRWYtqqq>2YNo7kDTki>|0!m+}RV+*5QcSW&Npbq|9qAo``Gum_L;Z?(Hat z%-M)%bKhd)*>#AyX+Vp?-gvvkhb=pDP`S#R7v8)So_=wBS!v3F(>95=9|sG~4m&n? z&ZGW;p=@snW~F)!a=e{rcs~(!8q*Pym=D+7bR7KQjQA{Hm#Qll^W;)xPUzl z-`%5ISu)fI6{7EiD%`crMqyQ(cwI7;A*%yemmVl8+nZ3aQNfkECt)%!oOYqt@%oFg z{7}9OIfr&b!_bMkJ|?UwDZ@8g6JVU|C1NKn;qjY$u;gF?MwfofxpfT`V}Z;u12|63C6Fq zFmJRWtiU zZ?SH+4y}CiF=vf6CdoD+>5M8jjh>GDn~~Jsdlk7pSx8a&h_9|xNcUXz<;p-~#Kq5| zqrZaTF1u-Vg z^=Mici{ba3`L%U42QPKwC%4k*dsK%@BW-y&E)Efmso0c}MyaY3&z<^0v?c^_a{5Eq z`OC%8ggG?Xx*64hWymbK1&5h3c5aDg{lWcW{;Osfw3|xTZ1A9Yr<|)#+rw>=7e_iw zj0RYj%9sY2&-xmSQxStz7`$ucF~~iphx)dJdU%9@51Xz60%lkFlAPo$era(x1t8T zUSY{q=htFLL=)TtP9X1kJ#>c$)3EiJIMS8^hXpA-;28+T4ky;ncW0y0Nxb7+2I`Gw zz#28G%=hLf;l{hcXE5}u+bA<>#=2xHE^uqan!I@4ttxAW8MEZ{H=?Bb ztQzl7=9d%SAUUfLdMA`w+ic4lg+(IrP&2v~Po@3_1;6j-i=t2TkdSl-haPP~!$%XT zezXz$u4>a|QaOf{OPRl`MO0o)X6KMrcno+i&e{dDMMWPypO1#_x7mD%Ki$G-i}J*2 zJlHQ61qO2HJyXZP^>TVD_ebUP1im}30rvtEoY{3%dN_0}$L~nyk82-@cb0x+Z8#HD zm-4~UbTQ?f9aS&g6rKIkksR_0sp)1Y9vCAM&s%aAwfW-3OmX9K1i!mnhfI@TT0eOW z-Sb+~2S==^HgyJvN>Z3L=^zx&ksPM!!4czTQD^8K^f7t{gOAMU?T~{Z4k^5(l?WH} zwNPEMl!?{Hr7te((P@-5z0KZYWSujd+8)BRzd9Yy53)}x*)9C9mJ6GGCOD#QjYrn2 z&{bQGv7;<;NOKz|oCs#)&yBd+s?OHxuc7wTfYZCi;ca^zESvRsJuXnHztEH(CoYL) zD|JM@$v$cI$xeIa)Dsw6X%D@j*0`dr#^&u0@uvPZa@zF4}kNVME;>ggZ!@}5B zjeEa-gx>C>Xt}*qOzE=VJYh@y;(EwA0C@>>+3!GyIHq|Lvx>ryv7``YB{F6zb>Y6D z5)KxV*?}3t#@~nK%JI;gqRXgXsCWDxEj||!|ML}uX7u9O H 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()