#!/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 fractions

import OpenEXR

test_dir = os.path.dirname(__file__)

outfilenames = []
def mktemp_outfilename():
    fd, outfilename = tempfile.mkstemp(".exr")
    os.close(fd)
    global outfilenames
    outfilenames += outfilename
    return outfilename

def cleanup():
    for outfilename in outfilenames:
        if os.path.isfile(outfilename):
            print(f"deleting {outfilename}")
            os.unlink(outfilename)
atexit.register(cleanup)


def equalWithRelError (x1, x2, e):
    return ((x1 - x2) if (x1 > x2) else (x2 - x1)) <= e * (x1 if (x1 > 0) else -x1)

def 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"]:
            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)
        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["a2f"] = np.array((1.2, 3.4), 'float32')
        header["a2d"] = np.array((1.2, 3.4), 'float64')
        header["a3f"] = np.array((1.2, 3.4, 5.6), 'float32')
        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)

        # 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)

        os.remove(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):

        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)

    def test_empty_header(self):

        # Construct a file from scratch and write it.

        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 = {}

        with OpenEXR.File(header, channels) as outfile:

            outfilename = mktemp_outfilename()
            outfile.write(outfilename)

            with OpenEXR.File(outfilename) as infile:
                compare_files (infile, outfile)

        os.remove(outfilename)
                
    def test_write_uint(self):

        # Construct a file from scratch and write it.

        width = 5
        height = 10
        size = width * height
        R = np.array([i for i in range(0,size)], dtype='uint32').reshape((height, width))
        G = np.array([i*2 for i in range(0,size)], dtype='uint32').reshape((height, width))
        B = np.array([i*3 for i in range(0,size)], dtype='uint32').reshape((height, width))
        A = np.array([i*5 for i in range(0,size)], dtype='uint32').reshape((height, width))
        channels = {
            "R" : OpenEXR.Channel(R, 1, 1),
            "G" : OpenEXR.Channel(G, 1, 1),
            "B" : OpenEXR.Channel(B, 1, 1),
            "A" : OpenEXR.Channel(A, 1, 1), 
        }

        header = {}

        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)
        
        os.remove(outfilename)

    def test_write_half(self):

        # Construct a file from scratch and write it.

        width = 10
        height = 20
        size = width * height
        R = np.array([i for i in range(0,size)], dtype='e').reshape((height, width))
        G = np.array([i*10 for i in range(0,size)], dtype='e').reshape((height, width))
        B = np.array([i*100 for i in range(0,size)], dtype='e').reshape((height, width))
        A = np.array([i/size for i in range(0,size)], dtype='e').reshape((height, width))
        channels = {
            "A" : OpenEXR.Channel("A", A, 1, 1), 
            "B" : OpenEXR.Channel("B", B, 1, 1),
            "G" : OpenEXR.Channel("G", G, 1, 1),
            "R" : OpenEXR.Channel("R", R, 1, 1)
        }

        header = {}

        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)
                if "name" in infile.header():
                   raise Exception(f"name attribute was added to single part half file")

        os.remove(outfilename)

    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)
        }

        header = { "type" : OpenEXR.tiledimage,
                   "tiles" : OpenEXR.TileDescription() }

        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)

        os.remove(outfilename)

    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_header_only(self):

        #
        # Test modifying header when reading just the header.
        #

        multipartfilename = f"{test_dir}/multipart.exr"

        height, width = (20, 10)
        Z = np.zeros((height, width), dtype='f')
        P0 = OpenEXR.Part({"compression" : OpenEXR.ZIP_COMPRESSION,
                           "type" : OpenEXR.scanlineimage,
                           "foo" : "foo0",
                           "n" : 9,
                           "d" : 1.0 },
                          {"Z" : Z })

        R = np.random.rand(height, width).astype('f')
        G = np.random.rand(height, width).astype('f')
        B = np.random.rand(height, width).astype('f')
        P1 = OpenEXR.Part({"compression" : OpenEXR.ZIP_COMPRESSION,
                           "type" : OpenEXR.scanlineimage,
                           "foo" : "foo1",
                           "m" : 9,
                           "D" : 1.0 },
                          { "R" : R, "G" : G, "B" : B })

        f = OpenEXR.File([P0, P1])
        f.write(multipartfilename)

        # validate that the file has the proper values
        with OpenEXR.File(multipartfilename) as o:
            assert o.parts[0].name() == "Part0"
            assert o.parts[0].width() == 10
            assert o.parts[0].height() == 20
            assert o.parts[0].header["foo"] == "foo0"
            assert o.parts[0].header["n"] == 9
            assert o.parts[1].name() == "Part1"
            assert o.parts[1].width() == 10
            assert o.parts[1].height() == 20
            assert o.parts[1].header["foo"] == "foo1"
            assert o.parts[1].header["m"] == 9

        with OpenEXR.File(multipartfilename, separate_channels=True, header_only=True) as f:

            # change the value of an existing attribute
            f.parts[0].header["foo"] = "foo0!"
            f.parts[0].header["n"] = 99
            f.parts[1].header["foo"] = "foo1!"
            f.parts[1].header["m"] = 42

            # add a new attribute
            f.parts[0].header["bar"] = "bar0"
            f.parts[1].header["bar"] = "bar1"
            
            del f.parts[0].header["d"]
            del f.parts[1].header["D"]
            
            # write to a new file
            outfilename = mktemp_outfilename()
            f.write(outfilename)

        with OpenEXR.File(multipartfilename, separate_channels=True) as f:

            # read the new file
            with OpenEXR.File(outfilename, separate_channels=True) as m:

                # f has the original values
                # m has the modified values
                # Modify f again manually and validate that they match
            
                f.parts[0].header["foo"] = "foo0!"
                f.parts[0].header["n"] = 99
                f.parts[1].header["foo"] = "foo1!"
                f.parts[1].header["m"] = 42
                f.parts[0].header["bar"] = "bar0"
                f.parts[1].header["bar"] = "bar1"
                del f.parts[0].header["d"]
                del f.parts[1].header["D"]

                compare_files(f, m)

    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)

        os.remove(outfilename)

    def test_write_float_vector_ndarray(self):

        channels = {
            "Z" : np.zeros((1, 1), dtype='uint32')
        }
        header = {
            "floatvector32" : np.array([0.0, 1.0, 2.0, 3.0], dtype='float32'),
            "singlevalue32" : np.array([0.5], dtype='float32')
        }

        with OpenEXR.File(header, channels) as outfile:

            outfilename = mktemp_outfilename()
            outfile.write(outfilename)

            with OpenEXR.File(outfilename) as infile:
                self.assertEqual(infile.header()["floatvector32"], [0.0, 1.0, 2.0, 3.0])
                self.assertEqual(infile.header()["singlevalue32"], [0.5])

        os.remove(outfilename)

    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.0,1.0), (2.0,3.0))
        header["bytes"] = OpenEXR.Bytes(b"\x76\x2f\x31\x01", "guess to win a prize")
        header["bytes0"] = OpenEXR.Bytes(b'')
        header["opaque"] = OpenEXR.OpaqueAttribute(b'',"Hello")
        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)
                  
            # Verify reading it back gives the same data
            with OpenEXR.File(outfilename, separate_channels=True) as infile:
                compare_files (infile, outfile)

        os.remove(outfilename)

    def test_unfinished_multipart_load(self):

        #
        # Multipart files may list more part headers than have valid
        # pixel data (interrupted write). Loading should return only
        # parts whose pixels read successfully, not fail the entire
        # file open.
        #
        # The test file unfinished_multipart.exr has 4 parts, 2 valid
        # and 2 invalid. The default read should return 2 parts; the
        # header_only read should return all 4 parts.
        #

        path = f"{test_dir}/unfinished_multipart.exr"
        num_parts = 4
        num_good = 2

        with OpenEXR.File(path, separate_channels=True) as f:
            self.assertEqual(len(f.parts), num_good)
            for i in range(num_good):
                self.assertIn('Z', f.parts[i].channels)
                self.assertEqual(f.parts[i].channels['Z'].pixels.shape, (8, 8))
            self.assertEqual(f.parts[0].name(), 'Part0')
            self.assertEqual(f.parts[1].name(), 'Part1')

        with OpenEXR.File(path, separate_channels=True, header_only=True) as f:
            self.assertEqual(len(f.parts), num_parts)

    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.0,1.0),(2.0,3.0))
            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)

    def test_gil_released_during_io(self):

        #
        # Verify that the GIL is released during I/O operations by checking
        # that a background thread can make progress while OpenEXR performs
        # file operations.
        #

        import threading
        import time

        counter = [0]
        stop_flag = [False]

        def background_worker():
            while not stop_flag[0]:
                counter[0] += 1
                time.sleep(0.001)

        # Create a moderately large image to ensure I/O takes measurable time
        width = 1000
        height = 1000
        size = width * height
        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": OpenEXR.Channel("R", R),
            "G": OpenEXR.Channel("G", G),
            "B": OpenEXR.Channel("B", B),
        }

        # Start background thread
        thread = threading.Thread(target=background_worker)
        thread.start()

        # Let thread run briefly to establish baseline
        time.sleep(0.05)
        count_before = counter[0]

        # Perform I/O - if GIL is released, background thread will increment counter
        outfilename = mktemp_outfilename()
        with OpenEXR.File({}, channels) as outfile:
            outfile.write(outfilename)

        count_after_write = counter[0]

        # Now test reading
        with OpenEXR.File(outfilename) as infile:
            _ = infile.channels()

        count_after_read = counter[0]

        # Stop background thread
        stop_flag[0] = True
        thread.join()

        # If GIL was released, background thread should have made progress during I/O
        write_progress = count_after_write - count_before
        read_progress = count_after_read - count_after_write

        self.assertGreater(write_progress, 0,
            f"Background thread made no progress during write (GIL not released?)")
        self.assertGreater(read_progress, 0,
            f"Background thread made no progress during read (GIL not released?)")

        os.remove(outfilename)

    def test_box2i_numpy_scalars(self):

        # Verifying that numpy scalar values are also accepted for
        # Box2i type

        mn = np.array((0, 0), dtype=np.int32)
        mx = np.array((15, 15), dtype=np.int32)

        test_cases = [
            # python int
            ((0, 0), (15, 15)),  
            # numpy array
            (mn, mx),        
            # numpy scalar values                
            ((mn[0], mn[1]), (mx[0], mx[1])),    
        ]

        for data_window in test_cases:

            w = data_window[1][0] - data_window[0][0] + 1
            h = data_window[1][1] - data_window[0][1] + 1

            header = {
                "type": OpenEXR.scanlineimage,
                "dataWindow": data_window,
            }

            channels = {"RGB": np.zeros((h, w, 3), dtype=np.float16)}

            part = OpenEXR.Part(header, channels)
            f = OpenEXR.File([part])

            outfilename = mktemp_outfilename()
            f.write(outfilename)

            # Read back and verify the file structure & attributes
            with OpenEXR.File(outfilename, header_only=True) as infile:
                actual_window = infile.parts[0].header["dataWindow"]
                # Since getAttributeObject returns tuples of numpy arrays,
                # we need to convert them to standard python tuples before comparison
                actual_tuple = (
                    (int(actual_window[0][0]), int(actual_window[0][1])),
                    (int(actual_window[1][0]), int(actual_window[1][1]))
                )
                self.assertEqual(actual_tuple, ((0, 0), (15, 15)))

            if os.path.isfile(outfilename):
                os.remove(outfilename)

    def test_v2f_numpy_scalars(self):
        # Verifying that numpy scalar values are also accepted for
        # V2f (float vector) types. 

        # Setup numpy values for testing
        center_np_float32 = np.array((0.5, 0.75), dtype=np.float32)
        
        test_cases = [
            # python float 
            (0.0, 0.0),
            # numpy array
            center_np_float32,
            # numpy float scalars 
            (center_np_float32[0], center_np_float32[1]),
            # numpy float64 scalars 
            (np.float64(0.5), np.float64(0.5)),
        ]

        # Use a fixed data window for the file structure
        data_window = ((0, 0), (15, 15))
        w, h = 16, 16

        for center_value in test_cases:
            header = {
                "type": OpenEXR.scanlineimage,
                "dataWindow": data_window,
                "displayWindow": data_window,
                "screenWindowCenter": center_value,  
            }
            
            channels = {"RGB": np.zeros((h, w, 3), dtype=np.float16)}

            part = OpenEXR.Part(header, channels)
            f = OpenEXR.File([part])
            
            outfilename = mktemp_outfilename()
            f.write(outfilename)

            # Read back and verify the file structure & attributes
            with OpenEXR.File(outfilename, header_only=True) as inf:
                header_to_verify = inf.parts[0].header

                # Since getAttributeObject returns tuples of numpy arrays,
                # we need to convert them to standard python tuples before comparison
                act_dw = header_to_verify["dataWindow"]
                dw_tuple = (
                    (int(act_dw[0][0]), int(act_dw[0][1])),
                    (int(act_dw[1][0]), int(act_dw[1][1]))
                )
                self.assertEqual(dw_tuple, ((0, 0), (15, 15)))
                
                act_disp = header_to_verify["displayWindow"]
                disp_tuple = (
                    (int(act_disp[0][0]), int(act_disp[0][1])),
                    (int(act_disp[1][0]), int(act_disp[1][1]))
                )
                self.assertEqual(disp_tuple, ((0, 0), (15, 15)))
                
                actual_center = header_to_verify["screenWindowCenter"]
                center_tuple = (float(actual_center[0]), float(actual_center[1]))
                
                expected_center = (float(center_value[0]), float(center_value[1]))
                
                # Uses assertAlmostEqual on individual items to compare float values 
                self.assertAlmostEqual(center_tuple[0], expected_center[0], places=5)
                self.assertAlmostEqual(center_tuple[1], expected_center[1], places=5)
            
            if os.path.isfile(outfilename):
                os.remove(outfilename)

    def test_box2i_float_rejection(self):
        # dataWindow requires an integer-based bounding box (Box2i).
        # We test Python floats, NumPy float32, and NumPy float64.
        invalid_floats = [0.0, np.float32(0.0), np.float64(0.0)]
        
        for f in invalid_floats:
            invalid_float_box = ((f, f), (f + 15.0, f + 15.0))
            
            header = {
                "type": OpenEXR.scanlineimage,
                "dataWindow": invalid_float_box, 
            }
            channels = {"RGB": np.zeros((16, 16, 3), dtype=np.float16)}

            part = OpenEXR.Part(header, channels)

            outfilename = mktemp_outfilename()
            with self.assertRaises(Exception):
                with OpenEXR.File([part]) as exr_file:
                    exr_file.write(outfilename)

            if os.path.isfile(outfilename):
                os.remove(outfilename)

    def test_v2f_int_rejection(self):
        # screenWindowCenter requires floating-point coordinates (V2f).
        # We test Python ints, NumPy int32, and NumPy int64.
        invalid_ints = [1, np.int32(1), np.int64(1)]
        
        data_window = ((0, 0), (15, 15))
        
        for i in invalid_ints:
            invalid_int_center = (i, i + 1) 
            
            header = {
                "type": OpenEXR.scanlineimage,
                "dataWindow": data_window,
                "screenWindowCenter": invalid_int_center, 
            }
            channels = {"RGB": np.zeros((16, 16, 3), dtype=np.float16)}

            part = OpenEXR.Part(header, channels)

            outfilename = mktemp_outfilename()
            with self.assertRaises(Exception):
                with OpenEXR.File([part]) as exr_file:
                    exr_file.write(outfilename)

            if os.path.isfile(outfilename):
                os.remove(outfilename)
    
    def test_mixed_standard_and_numpy_scalars(self):

        # Allow mixed types seamlessly to match our convenient API design
        mixed_data_window = ((0, np.int32(0)), (15, np.int64(15)))

        header = {
            "type": OpenEXR.scanlineimage,
            "dataWindow": mixed_data_window,
        }

        channels = {"RGB": np.zeros((16, 16, 3), dtype=np.float16)}

        part = OpenEXR.Part(header, channels)
        f = OpenEXR.File([part])

        outfilename = mktemp_outfilename()

        f.write(outfilename)

        with OpenEXR.File(outfilename, header_only=True) as inf:
            act_dw = inf.parts[0].header["dataWindow"]
            # Since getAttributeObject returns tuples of numpy arrays,
            # we need to convert them to standard python tuples before comparison
            dw_tuple = (
                (int(act_dw[0][0]), int(act_dw[0][1])),
                (int(act_dw[1][0]), int(act_dw[1][1]))
            )
            self.assertEqual(dw_tuple, ((0, 0), (15, 15)))

        if os.path.isfile(outfilename):
            os.remove(outfilename)


if __name__ == '__main__':
    unittest.main()
