Skip to content

Add support for OpenEXR image format#3096

Merged
JimBobSquarePants merged 103 commits intomainfrom
bp/openExr
Apr 16, 2026
Merged

Add support for OpenEXR image format#3096
JimBobSquarePants merged 103 commits intomainfrom
bp/openExr

Conversation

@brianpopow
Copy link
Copy Markdown
Collaborator

@brianpopow brianpopow commented Mar 29, 2026

Prerequisites

  • I have written a descriptive pull-request title
  • I have verified that there are no overlapping pull-requests open
  • I have verified that I am following the existing coding patterns and practice as demonstrated in the repository. These follow strict Stylecop rules 👮.
  • I have provided test coverage for my change (where applicable)

Description

This PR adds support for decoding and encoding of images in the OpenEXR format. OpenEXR is a HDR image format, therefore I have added two new pixel formats Rgb96 and Rgba128 which store each color channel as UInt32.

The specification can be found here: https://openexr.com/en/latest/index.html#openexr

A reference implementation can be found here: https://github.com/AcademySoftwareFoundation/openexr.git

# Conflicts:
#	src/ImageSharp/Configuration.cs
# Conflicts:
#	src/ImageSharp/Formats/Bmp/BmpConstants.cs
#	src/ImageSharp/Formats/Bmp/BmpDecoder.cs
#	src/ImageSharp/Formats/Bmp/BmpFormat.cs
# Conflicts:
#	src/ImageSharp/Configuration.cs
#	src/ImageSharp/Formats/Bmp/BmpArrayFileHeader.cs
#	src/ImageSharp/Formats/Bmp/BmpConstants.cs
#	src/ImageSharp/Formats/Bmp/BmpFormat.cs
#	src/ImageSharp/Formats/Bmp/BmpImageFormatDetector.cs
#	src/ImageSharp/Formats/ImageExtensions.Save.cs
#	src/ImageSharp/Formats/Pbm/BufferedReadStreamExtensions.cs
#	tests/ImageSharp.Tests/ConfigurationTests.cs
#	tests/ImageSharp.Tests/Formats/GeneralFormatTests.cs
# Conflicts:
#	src/ImageSharp/Configuration.cs
#	src/ImageSharp/Formats/Bmp/BmpArrayFileHeader.cs
#	src/ImageSharp/Formats/Bmp/BmpConstants.cs
#	src/ImageSharp/Formats/Bmp/BmpDecoder.cs
#	src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs
#	src/ImageSharp/Formats/Bmp/BmpFileHeader.cs
#	src/ImageSharp/Formats/Bmp/BmpFormat.cs
#	src/ImageSharp/Formats/Bmp/BmpImageFormatDetector.cs
#	src/ImageSharp/Formats/Bmp/BmpInfoHeader.cs
#	src/ImageSharp/Formats/ImageDecoderUtilities.cs
#	src/ImageSharp/Formats/ImageExtensions.Save.cs
#	src/ImageSharp/Formats/Pbm/BufferedReadStreamExtensions.cs
#	src/ImageSharp/Image.Decode.cs
#	tests/ImageSharp.Tests/ConfigurationTests.cs
#	tests/ImageSharp.Tests/Formats/GeneralFormatTests.cs
# Conflicts:
#	src/ImageSharp/Formats/Bmp/BmpFormat.cs
#	src/ImageSharp/Formats/ImageDecoderUtilities.cs
#	src/ImageSharp/Formats/ImageExtensions.Save.cs
#	src/ImageSharp/Formats/OpenExr/ExrDecoderCore.cs
# Conflicts:
#	src/ImageSharp/Configuration.cs
#	src/ImageSharp/Formats/ImageDecoderUtilities.cs
#	tests/ImageSharp.Tests/TestUtilities/TestEnvironment.Formats.cs
Comment thread tests/ImageSharp.Tests/PixelFormats/Rgba128Tests.cs Dismissed
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This output looks bugged to me. Has the Magick comparison been hiding an issue?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image looks like this:
pal4rlecut

The image is from the bmp suite where alot of the edge case image in the tests come from.

From the description of the image:

An RLE-compressed image that uses “delta” codes, and early EOL & EOBMP markers, to skip over some pixels. It’s okay if the viewer’s image doesn’t exactly match any of the reference images.

It looks like the third image in the "correct display" list: the undefined pixels are black.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking.

Comment thread src/ImageSharp/Formats/Exr/ExrDecoderCore.cs Fixed
/// <param name="component">The 32 bit component value.</param>
/// <returns>The <see cref="byte"/> value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte From32BitTo8Bit(uint component) => (byte)(component >> 24);
Copy link
Copy Markdown
Member

@JimBobSquarePants JimBobSquarePants Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually truncation, not scaling, and introduces bias across the range. Here's a test demonstrating that vs a safer scaling operation like the one we have for From16BitTo8Bit.

[Fact]
public void From32BitTo8Bit_CurrentImplementation_IsTruncation_NotScaling()
{
	// Use 8421505 as the input because it is the first integer that should map to
	// 1 under correct 32->8 bit scaling.
	//
	// The scaled value is:
	//
	//     round(component * 255 / uint.MaxValue)
	//
	// To find the smallest input that rounds to 1, solve for the point where the
	// scaled value reaches 0.5:
	//
	//     component * 255 / uint.MaxValue >= 0.5
	//
	// Rearranging:
	//
	//     component >= uint.MaxValue / (2 * 255)
	//     component >= 4294967295 / 510
	//     component >= 8421504.5
	//
	// Since component is an integer, the smallest valid value is:
	//
	//     8421505
	//
	// At this point correct scaling yields 1, while (component >> 24) still yields
	// 0, making this the smallest counterexample.

	uint component = 8421505u;

	byte truncated = CurrentFrom32BitTo8Bit(component);
	byte scaled = CorrectFrom32BitTo8Bit(component);

    // Current implementation: floor(component / 2^24)
    // Correct scaling: round(component * 255 / uint.MaxValue)
	Assert.Equal(0, truncated);
	Assert.Equal(1, scaled);
	Assert.NotEqual(truncated, scaled);

	static byte CurrentFrom32BitTo8Bit(uint value) => (byte)(value >> 24);

	static byte CorrectFrom32BitTo8Bit(uint value) =>
		(byte)((((ulong)component) + 8421504UL) / 16843009UL)
}

The correct method would be.

/// <summary>
/// Scales a value from a 32 bit <see cref="uint"/> to an
/// 8 bit <see cref="byte"/> equivalent.
/// </summary>
/// <param name="component">The 32 bit component value.</param>
/// <returns>The <see cref="byte"/> value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte From32BitTo8Bit(uint component) =>

	// To scale to 8 bits from a 32-bit value V the required value is:
	//
	//    (V * 255) / 4294967295
	//
	// Since:
	//
	//    4294967295 = 255 * 16843009
	//
	// this reduces exactly to:
	//
	//    V / 16843009
	//
	// To round to nearest using integer arithmetic we add half the divisor
	// before dividing:
	//
	//    (V + 16843009 / 2) / 16843009
	//
	// where:
	//
	//    16843009 / 2 = 8421504.5
	//
	// Using 8421504 ensures correct round-to-nearest behaviour:
	//
	//    8421504 -> 0
	//    8421505 -> 1
	//
	// The addition must be performed in 64-bit to avoid overflow for large
	// input values (for example uint.MaxValue).
	//
	// Final exact integer implementation:
	//
	//    ((ulong)V + 8421504) / 16843009
	(byte)((((ulong)component) + 8421504UL) / 16843009UL);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing this, I already had a feeling that there might be an issue with this

Copy link
Copy Markdown
Member

@JimBobSquarePants JimBobSquarePants left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brianpopow I fixed the remaining issues I could find. I can't see anything else unless you have more to add.

Otherwise, happy to merge.

@brianpopow
Copy link
Copy Markdown
Collaborator Author

@brianpopow I fixed the remaining issues I could find. I can't see anything else unless you have more to add.

Otherwise, happy to merge.

@JimBobSquarePants : No, I dont have more to add. I think it is now good to go in now. Thanks for helping with the last issues with it 👍

@JimBobSquarePants JimBobSquarePants merged commit b3f3793 into main Apr 16, 2026
19 of 22 checks passed
@JimBobSquarePants JimBobSquarePants deleted the bp/openExr branch April 16, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

formats:exr Any PR or issue related to OpenExr file format

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants