diff --git a/ReFuel.StbImage.csproj b/ReFuel.StbImage.csproj index 2099a2a..b7fe89b 100644 --- a/ReFuel.StbImage.csproj +++ b/ReFuel.StbImage.csproj @@ -1,9 +1,9 @@ - + net6.0 - disable - 7.3 + enable + latest True linux-arm;linux-arm64;linux-x64;win-x86;win-x64;osx-arm64;osx-x64 ReFuel.Stb diff --git a/StbImage.cs b/StbImage.cs index b1bdc82..ff9f496 100644 --- a/StbImage.cs +++ b/StbImage.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.InteropServices; @@ -50,6 +51,71 @@ namespace ReFuel.Stb Dispose(false); } + /// + /// Get a safe span to the image pointer. + /// + /// The pixel type. + /// A span to the image data. + /// The image uses an unexpected image format. + public ReadOnlySpan AsSpan() where T : unmanaged + { + int sz = Format switch + { + StbiImageFormat.Grey => 1, + StbiImageFormat.GreyAlpha => 2, + StbiImageFormat.Rgb => 3, + StbiImageFormat.Rgba => 4, + _ => throw new Exception("unkown image format") + } * (IsFloat ? sizeof(float) : sizeof(byte)); + + return new ReadOnlySpan((T*)ImagePointer, Width * Height * sz / sizeof(T)); + } + + /// + /// Write image to a PNG file. + /// + /// The destination stream. + /// + /// Incurs a conversion cost if the image format is not a byte format. Affected by non-thread safe global options. + /// + public void WritePng(Stream dest) => WritePng(AsSpan(), Width, Height, Format, dest, isFloat: IsFloat); + + /// + /// Write image to a BMP file. + /// + /// The destination stream. + /// + /// Incurs a conversion cost if the image format is not a byte format. Affected by non-thread safe global options. + /// + public void WriteBmp(Stream dest) => WriteBmp(AsSpan(), Width, Height, Format, dest, isFloat: IsFloat); + + /// + /// Write image to a TGA file. + /// + /// The destination stream. + /// + /// Incurs a conversion cost if the image format is not a byte format. Ignores alpha channel. Affected by non-thread safe global options. + /// + public void WriteTga(Stream dest) => WriteTga(AsSpan(), Width, Height, Format, dest, isFloat: IsFloat); + + /// + /// Write image to a PNG file. + /// + /// The destination stream. + /// + /// Incurs a conversion cost if the image format is not a float format. Affected by non-thread safe global options. + /// + public void WriteHdr(Stream dest) => WriteHdr(AsSpan(), Width, Height, Format, dest, isFloat: IsFloat); + + /// + /// Write image to a PNG file. + /// + /// The destination stream. + /// + /// Incurs a conversion cost if the image format is not a byte format. Ignores alpha channel. Affected by non-thread safe global options. + /// + public void WriteJpg(Stream dest, int quality = 90) => WriteJpg(AsSpan(), Width, Height, Format, dest, quality: quality, isFloat: IsFloat); + public void Dispose() => Dispose(true); private void Dispose(bool disposing) @@ -68,7 +134,12 @@ namespace ReFuel.Stb /// /// Set to flip the y-axis of loaded images on load. /// - public static bool FlipVerticallyOnLoad { set => Stbi.set_flip_vertically_on_load(1); } + public static bool FlipVerticallyOnLoad { set => Stbi.set_flip_vertically_on_load(value ? 1 : 0); } + + /// + /// Set to flip the y-axis of saved images. + /// + public static bool FlipVerticallyOnSave { set => Stbi.flip_vertically_on_write(value ? 1 : 0); } /// /// Set to unpremultiply images on load. @@ -79,6 +150,50 @@ namespace ReFuel.Stb /// public static bool UnpremultiplyOnLoad { set => Stbi.set_unpremultiply_on_load(1); } + /// + /// Force a filter on PNG filter when saving. + /// + /// + /// -1 for auto, 0 through 5 to pick a filter. Higher is more. Not thread safe. + /// + public int WriteForcePngFilter + { + get => Stbi.write_force_png_filter; + set + { + if (value < -1 || value > 5) + { + throw new ArgumentOutOfRangeException(nameof(value), "The PNG filter must be in the range 0 to 5, or -1 for auto."); + } + + Stbi.write_force_png_filter = value; + } + } + + /// + /// Change the PNG compression level on save. + /// + /// + /// Higher is more. Defaults to 8. Not thread safe. + /// + public int WritePngCompressionLevel + { + get => Stbi.write_png_compression_level; + set => Stbi.write_png_compression_level = value; + } + + /// + /// Enable run length encoding on TGA images on save. + /// + /// + /// Not thread safe. + /// + public bool WriteTgaEnableRLE + { + get => Stbi.write_tga_with_rle != 0; + set => Stbi.write_tga_with_rle = value ? 1 : 0; + } + /// /// Try loading an image, without raising exceptions. /// @@ -86,7 +201,7 @@ namespace ReFuel.Stb /// Source stream. /// The desired image format. /// True on success. - public static bool TryLoad(out StbImage image, Stream stream, StbiImageFormat format = StbiImageFormat.Default, bool isFloat = false) + public static bool TryLoad([NotNullWhen(true)] out StbImage? image, Stream stream, StbiImageFormat format = StbiImageFormat.Default, bool asFloat = false) { int x, y, iFormat; StbiStreamWrapper wrapper = new StbiStreamWrapper(stream, true); @@ -94,7 +209,7 @@ namespace ReFuel.Stb stream.Position = 0; IntPtr imagePtr; - if (isFloat) + if (asFloat) { imagePtr = (IntPtr)Stbi.loadf_from_callbacks(&cb, null, &x, &y, &iFormat, (int)format); } @@ -105,7 +220,7 @@ namespace ReFuel.Stb if (imagePtr != IntPtr.Zero) { - image = new StbImage(imagePtr, x, y, (StbiImageFormat)iFormat, isFloat); + image = new StbImage(imagePtr, x, y, (StbiImageFormat)iFormat, asFloat); return true; } else @@ -122,25 +237,24 @@ namespace ReFuel.Stb /// Source memory span. /// The desired image format. /// True on success. - public static bool TryLoad(out StbImage image, ReadOnlySpan span, StbiImageFormat format = StbiImageFormat.Default, bool isFloat = false) - where T : unmanaged + public static bool TryLoad([NotNullWhen(true)] out StbImage? image, ReadOnlySpan span, StbiImageFormat format = StbiImageFormat.Default, bool asFloat = false) { IntPtr imagePtr = IntPtr.Zero; int x, y, iFormat; fixed (byte *ptr = MemoryMarshal.AsBytes(span)) { - if (isFloat) + if (asFloat) { - imagePtr = (IntPtr)Stbi.loadf_from_memory(ptr, span.Length * sizeof(T), &x, &y, &iFormat, (int)format); + imagePtr = (IntPtr)Stbi.loadf_from_memory(ptr, span.Length, &x, &y, &iFormat, (int)format); } else { - imagePtr = (IntPtr)Stbi.load_from_memory(ptr, span.Length * sizeof(T), &x, &y, &iFormat, (int)format); + imagePtr = (IntPtr)Stbi.load_from_memory(ptr, span.Length, &x, &y, &iFormat, (int)format); } if (imagePtr != IntPtr.Zero) { - image = new StbImage(imagePtr, x, y, (StbiImageFormat)iFormat, isFloat); + image = new StbImage(imagePtr, x, y, (StbiImageFormat)iFormat, asFloat); return true; } else @@ -157,14 +271,14 @@ namespace ReFuel.Stb /// The stream to load from. /// The desired image format. /// The image object. - public static StbImage Load(Stream stream, StbiImageFormat format = StbiImageFormat.Default, bool isFloat = false) + public static StbImage Load(Stream stream, StbiImageFormat format = StbiImageFormat.Default, bool asFloat = false) { - if (TryLoad(out StbImage image, stream, format, isFloat)) + if (TryLoad(out StbImage? image, stream, format, asFloat)) { return image; } - string reason = Marshal.PtrToStringUTF8((IntPtr)Stbi.failure_reason()); + string reason = Marshal.PtrToStringUTF8((IntPtr)Stbi.failure_reason())!; throw new Exception($"Failed to load image: {reason}"); } @@ -174,15 +288,14 @@ namespace ReFuel.Stb /// The span of memory to load from. /// The desired image format. /// The image object. - public static StbImage Load(ReadOnlySpan span, StbiImageFormat format = StbiImageFormat.Default, bool isFloat = false) - where T : unmanaged + public static StbImage Load(ReadOnlySpan span, StbiImageFormat format = StbiImageFormat.Default, bool asFloat = false) { - if (TryLoad(out StbImage image, span, format, isFloat)) + if (TryLoad(out StbImage? image, span, format, asFloat)) { return image; } - string reason = Marshal.PtrToStringUTF8((IntPtr)Stbi.failure_reason()); + string reason = Marshal.PtrToStringUTF8((IntPtr)Stbi.failure_reason())!; throw new Exception($"Failed to load image: {reason}"); } @@ -230,5 +343,222 @@ namespace ReFuel.Stb return result != 0; } } + + private static int Components(StbiImageFormat format) => format switch + { + StbiImageFormat.Grey => 1, + StbiImageFormat.GreyAlpha => 2, + StbiImageFormat.Rgb => 3, + StbiImageFormat.Rgba => 4, + _ => throw new ArgumentException("Expected a fully qualified format.") + }; + + private static byte[] ConvertFloatToByte(ReadOnlySpan source, int width, int height, int components) + where T : unmanaged + { + byte[] conversion = new byte[width * height * components]; + ReadOnlySpan dataAsFloat = MemoryMarshal.Cast(source); + + for (int i = 0; i(ReadOnlySpan source, int width, int height, int components) + where T : unmanaged + { + float[] conversion = new float[width * height * components]; + ReadOnlySpan dataAsByte = MemoryMarshal.Cast(source); + + for (int i = 0; i < conversion.Length; i++) + { + conversion[i] = Math.Clamp(dataAsByte[i]/255f, 0f, 1f); + } + + return conversion; + } + + /// + /// Write any image to a PNG file. + /// + /// Any packed byte or float array structure. + /// Span of pixel data. + /// Width of the pixel data in pixels. + /// Height of the pixel data in pixels. + /// Color format of the pixel data. Must not be . + /// The destination stream. + /// True if the pixel format in data is a floating point format. + /// + /// This will incur a conversion cost if the pixel format is not a byte format. Affected by global non-thread safe options. + /// + public static void WritePng(ReadOnlySpan data, int width, int height, StbiImageFormat format, Stream destination, bool isFloat = false) + where T : unmanaged + { + int components = Components(format); + ReadOnlySpan source; + byte[]? conversion; + + if (isFloat) + { + conversion = ConvertFloatToByte(data, width, height, components); + source = conversion; + } + else + { + source = MemoryMarshal.AsBytes(data); + } + + StbiWriteStreamWrapper wrapper = new StbiWriteStreamWrapper(destination); + + fixed (byte *ptr = source) + Stbi.write_png_to_func(wrapper, null, width, height, components, ptr, width * components); + } + + /// + /// Write any image to a BMP file. + /// + /// Any packed byte or float array structure. + /// Span of pixel data. + /// Width of the pixel data in pixels. + /// Height of the pixel data in pixels. + /// Color format of the pixel data. Must not be . + /// The destination stream. + /// True if the pixel format in data is a floating point format. + /// + /// This will incur a conversion cost if the pixel format is not a byte format. Ignores the alpha channel. Affected by global non-thread safe options. + /// + public static void WriteBmp(ReadOnlySpan data, int width, int height, StbiImageFormat format, Stream destination, bool isFloat = false) + where T : unmanaged + { + int components = Components(format); + ReadOnlySpan source; + byte[]? conversion; + + if (isFloat) + { + conversion = ConvertFloatToByte(data, width, height, components); + source = conversion; + } + else + { + source = MemoryMarshal.AsBytes(data); + } + + StbiWriteStreamWrapper wrapper = new StbiWriteStreamWrapper(destination); + + fixed (byte* ptr = source) + Stbi.write_bmp_to_func(wrapper, null, width, height, components, ptr); + } + + /// + /// Write any image to a TGA file. + /// + /// Any packed byte or float array structure. + /// Span of pixel data. + /// Width of the pixel data in pixels. + /// Height of the pixel data in pixels. + /// Color format of the pixel data. Must not be . + /// The destination stream. + /// True if the pixel format in data is a floating point format. + /// + /// This will incur a conversion cost if the pixel format is not a byte format. Affected by global non-thread safe options. + /// + public static void WriteTga(ReadOnlySpan data, int width, int height, StbiImageFormat format, Stream destination, bool isFloat = false) + where T : unmanaged + { + int components = Components(format); + ReadOnlySpan source; + byte[]? conversion; + + if (isFloat) + { + conversion = ConvertFloatToByte(data, width, height, components); + source = conversion; + } + else + { + source = MemoryMarshal.AsBytes(data); + } + + StbiWriteStreamWrapper wrapper = new StbiWriteStreamWrapper(destination); + + fixed (byte* ptr = source) + Stbi.write_tga_to_func(wrapper, null, width, height, components, ptr); + } + + /// + /// Write any image to a PNG file. + /// + /// Any packed byte or float array structure. + /// Span of pixel data. + /// Width of the pixel data in pixels. + /// Height of the pixel data in pixels. + /// Color format of the pixel data. Must not be . + /// The destination stream. + /// True if the pixel format in data is a floating point format. + /// + /// This will incur a conversion cost if the pixel format is not a float format. Affected by global non-thread safe options. + /// + public static void WriteHdr(ReadOnlySpan data, int width, int height, StbiImageFormat format, Stream destination, bool isFloat = false) + where T : unmanaged + { + int components = Components(format); + ReadOnlySpan source; + float[]? conversion; + + if (!isFloat) + { + conversion = ConvertByteToFloat(data, width, height, components); + source = conversion; + } + else + { + source = MemoryMarshal.Cast(data); + } + + StbiWriteStreamWrapper wrapper = new StbiWriteStreamWrapper(destination); + + fixed (float* ptr = source) + Stbi.write_hdr_to_func(wrapper, null, width, height, components, ptr); + } + + /// + /// Write any image to a PNG file. + /// + /// Any packed byte or float array structure. + /// Span of pixel data. + /// Width of the pixel data in pixels. + /// Height of the pixel data in pixels. + /// Color format of the pixel data. Must not be . + /// The destination stream. + /// True if the pixel format in data is a floating point format. + /// + /// This will incur a conversion cost if the pixel format is not a byte format. Ignores the alpha channel. Affected by global non-thread safe options. + /// + public static void WriteJpg(ReadOnlySpan data, int width, int height, StbiImageFormat format, Stream destination, int quality = 90, bool isFloat = false) + where T : unmanaged + { + int components = Components(format); + ReadOnlySpan source; + byte[]? conversion; + + if (isFloat) + { + conversion = ConvertFloatToByte(data, width, height, components); + source = conversion; + } + else + { + source = MemoryMarshal.AsBytes(data); + } + + StbiWriteStreamWrapper wrapper = new StbiWriteStreamWrapper(destination); + + fixed (byte* ptr = source) + Stbi.write_jpg_to_func(wrapper, null, width, height, components, ptr, quality); + } } } \ No newline at end of file diff --git a/StbiStreamWrapper.cs b/StbiStreamWrapper.cs index e417916..ecec932 100644 --- a/StbiStreamWrapper.cs +++ b/StbiStreamWrapper.cs @@ -10,14 +10,17 @@ namespace ReFuel.Stb public unsafe class StbiStreamWrapper : IDisposable { - private Stream _stream; - private bool _keepOpen; + private readonly stbi_io_callbacks _callbacks; + private readonly Stream _stream; + private readonly bool _keepOpen; private bool _isDisposed; private StbiReadProc _readCb; private StbiSkipProc _skipCb; private StbiEofProc _eofCb; + public ref readonly stbi_io_callbacks Callbacks => ref _callbacks; + public StbiStreamWrapper(Stream stream, bool keepOpen = false) { if (stream == null) throw new ArgumentNullException(nameof(stream)); @@ -28,6 +31,11 @@ namespace ReFuel.Stb _readCb = ReadCb; _skipCb = SkipCb; _eofCb = EofCb; + + _callbacks = default; + _callbacks.read = Marshal.GetFunctionPointerForDelegate(_readCb); + _callbacks.skip = Marshal.GetFunctionPointerForDelegate(_skipCb); + _callbacks.eof = Marshal.GetFunctionPointerForDelegate(_eofCb); } public void CreateCallbacks(out stbi_io_callbacks cb) @@ -65,4 +73,28 @@ namespace ReFuel.Stb _isDisposed = true; } } + + internal struct StbiWriteStreamWrapper + { + private readonly Stream _stream; + private readonly StbiWriteProc _cb; + + public IntPtr Callback => Marshal.GetFunctionPointerForDelegate(_cb); + + public StbiWriteStreamWrapper(Stream stream) + { + _stream = stream; + unsafe + { + _cb = WriteCb; + } + } + + private unsafe void WriteCb(void *context, void *data, int size) + { + _stream.Write(new ReadOnlySpan((byte*)data, size)); + } + + public static implicit operator IntPtr(in StbiWriteStreamWrapper wrapper) => wrapper.Callback; + } } \ No newline at end of file