Add ImageSaveOptions and optimize pixel format for saving

This commit is contained in:
Ben Olden-Cooligan 2023-02-05 13:54:21 -08:00
parent bc95170215
commit b333d6c89b
14 changed files with 218 additions and 57 deletions

View File

@ -57,39 +57,43 @@ public class GdiImage : IMemoryImage
public ImagePixelFormat LogicalPixelFormat { get; set; }
public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1)
public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, ImageSaveOptions? options = null)
{
if (imageFormat == ImageFileFormat.Unspecified)
{
imageFormat = ImageContext.GetFileFormatFromExtension(path);
}
ImageContext.CheckSupportsFormat(imageFormat);
if (imageFormat == ImageFileFormat.Jpeg && quality != -1)
options ??= new ImageSaveOptions();
using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint);
if (imageFormat == ImageFileFormat.Jpeg && options.Quality != -1)
{
var (encoder, encoderParams) = GetJpegSaveArgs(quality);
Bitmap.Save(path, encoder, encoderParams);
var (encoder, encoderParams) = GetJpegSaveArgs(options.Quality);
helper.Image.Bitmap.Save(path, encoder, encoderParams);
}
else
{
Bitmap.Save(path, imageFormat.AsImageFormat());
helper.Image.Bitmap.Save(path, imageFormat.AsImageFormat());
}
}
public void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1)
public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
{
if (imageFormat == ImageFileFormat.Unspecified)
{
throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
}
ImageContext.CheckSupportsFormat(imageFormat);
if (imageFormat == ImageFileFormat.Jpeg && quality != -1)
options ??= new ImageSaveOptions();
using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint);
if (imageFormat == ImageFileFormat.Jpeg && options.Quality != -1)
{
var (encoder, encoderParams) = GetJpegSaveArgs(quality);
Bitmap.Save(stream, encoder, encoderParams);
var (encoder, encoderParams) = GetJpegSaveArgs(options.Quality);
helper.Image.Bitmap.Save(stream, encoder, encoderParams);
}
else
{
Bitmap.Save(stream, imageFormat.AsImageFormat());
helper.Image.Bitmap.Save(stream, imageFormat.AsImageFormat());
}
}

View File

@ -69,7 +69,8 @@ public class GtkImage : IMemoryImage
public ImagePixelFormat LogicalPixelFormat { get; set; }
public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1)
public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified,
ImageSaveOptions? options = null)
{
if (imageFormat == ImageFileFormat.Unspecified)
{
@ -81,12 +82,14 @@ public class GtkImage : IMemoryImage
return;
}
ImageContext.CheckSupportsFormat(imageFormat);
options ??= new ImageSaveOptions();
var type = GetType(imageFormat);
var (keys, values) = GetSaveOptions(imageFormat, quality);
Pixbuf.Savev(path, type, keys, values);
var (keys, values) = GetSaveOptions(imageFormat, options.Quality);
using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.RGB24);
helper.Image.Pixbuf.Savev(path, type, keys, values);
}
public void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1)
public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
{
if (imageFormat == ImageFileFormat.Unspecified)
{
@ -98,10 +101,12 @@ public class GtkImage : IMemoryImage
return;
}
ImageContext.CheckSupportsFormat(imageFormat);
options ??= new ImageSaveOptions();
var type = GetType(imageFormat);
var (keys, values) = GetSaveOptions(imageFormat, quality);
var (keys, values) = GetSaveOptions(imageFormat, options.Quality);
// TODO: Map to OutputStream directly?
stream.Write(Pixbuf.SaveToBuffer(type, keys, values));
using var helper = PixelFormatHelper.Create(this, options.PixelFormatHint, minFormat: ImagePixelFormat.RGB24);
stream.Write(helper.Image.Pixbuf.SaveToBuffer(type, keys, values));
}
private string GetType(ImageFileFormat fileFormat) => fileFormat switch

View File

@ -117,37 +117,39 @@ public class MacImage : IMemoryImage
public ImagePixelFormat LogicalPixelFormat { get; set; }
public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1)
public void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified,
ImageSaveOptions? options = null)
{
if (imageFormat == ImageFileFormat.Unspecified)
{
imageFormat = ImageContext.GetFileFormatFromExtension(path);
}
ImageContext.CheckSupportsFormat(imageFormat);
var rep = GetRepForSaving(imageFormat, quality);
var rep = GetRepForSaving(imageFormat, options);
if (!rep.Save(path, false, out var error))
{
throw new IOException(error!.Description);
}
}
public void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1)
public void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null)
{
if (imageFormat == ImageFileFormat.Unspecified)
{
throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
}
ImageContext.CheckSupportsFormat(imageFormat);
var rep = GetRepForSaving(imageFormat, quality);
var rep = GetRepForSaving(imageFormat, options);
rep.AsStream().CopyTo(stream);
}
private NSData GetRepForSaving(ImageFileFormat imageFormat, int quality)
private NSData GetRepForSaving(ImageFileFormat imageFormat, ImageSaveOptions? options)
{
options ??= new ImageSaveOptions();
lock (MacImageContext.ConstructorLock)
{
var props = quality != -1 && imageFormat is ImageFileFormat.Jpeg or ImageFileFormat.Jpeg2000
? NSDictionary.FromObjectAndKey(NSNumber.FromDouble(quality / 100.0),
var props = options.Quality != -1 && imageFormat is ImageFileFormat.Jpeg or ImageFileFormat.Jpeg2000
? NSDictionary.FromObjectAndKey(NSNumber.FromDouble(options.Quality / 100.0),
NSBitmapImageRep.CompressionFactor)
: null;
var fileType = imageFormat switch
@ -159,18 +161,15 @@ public class MacImage : IMemoryImage
ImageFileFormat.Jpeg2000 => NSBitmapImageFileType.Jpeg2000,
_ => throw new InvalidOperationException("Unsupported image format")
};
var targetFormat = PixelFormat;
if (imageFormat == ImageFileFormat.Bmp && targetFormat == ImagePixelFormat.Gray8)
var targetFormat = options.PixelFormatHint;
if (imageFormat == ImageFileFormat.Bmp && targetFormat == ImagePixelFormat.Unsupported &&
PixelFormat == ImagePixelFormat.Gray8)
{
// Workaround for issue in some macOS versions with 8bit BMPs
targetFormat = ImagePixelFormat.RGB24;
}
if (targetFormat != PixelFormat)
{
using var copy = (MacImage) this.CopyWithPixelFormat(targetFormat);
return copy.Rep.RepresentationUsingTypeProperties(fileType, props);
}
return Rep.RepresentationUsingTypeProperties(fileType, props);
using var helper = PixelFormatHelper.Create(this, targetFormat);
return helper.Image.Rep.RepresentationUsingTypeProperties(fileType, props);
}
}

View File

@ -77,16 +77,16 @@ public interface IMemoryImage : IImageStorage
/// </summary>
/// <param name="path">The path to save the image file to.</param>
/// <param name="imageFormat">The file format to use.</param>
/// <param name="quality">The quality parameter for JPEG compression, if applicable. -1 for default.</param>
void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, int quality = -1);
/// <param name="options">Options for saving, e.g. JPEG quality.</param>
void Save(string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, ImageSaveOptions? options = null);
/// <summary>
/// Saves the image to the given stream. The file format must be specified.
/// </summary>
/// <param name="stream">The stream to save the image to.</param>
/// <param name="imageFormat">The file format to use.</param>
/// <param name="quality">The quality parameter for JPEG compression, if applicable. -1 for default.</param>
void Save(Stream stream, ImageFileFormat imageFormat, int quality = -1);
/// <param name="options">Options for saving, e.g. JPEG quality.</param>
void Save(Stream stream, ImageFileFormat imageFormat, ImageSaveOptions? options = null);
/// <summary>
/// Creates a copy of the image so that one can be edited or disposed without affecting the other.

View File

@ -92,10 +92,10 @@ public static class ImageExtensions
}
public static MemoryStream SaveToMemoryStream(this IMemoryImage image, ImageFileFormat imageFormat,
int quality = -1)
ImageSaveOptions? options = null)
{
var stream = new MemoryStream();
image.Save(stream, imageFormat, quality);
image.Save(stream, imageFormat, options);
stream.Seek(0, SeekOrigin.Begin);
return stream;
}

View File

@ -5,6 +5,6 @@ public enum ImagePixelFormat
Unsupported,
BW1,
Gray8,
RGB24, // This is actually BGR in the binary representation
RGB24,
ARGB32
}

View File

@ -0,0 +1,18 @@
namespace NAPS2.Images;
public record ImageSaveOptions
{
/// <summary>
/// The quality parameter for JPEG compression, if applicable. -1 for default.
/// </summary>
public int Quality { get; init; } = -1;
/// <summary>
/// The preferred pixel format that should be used for saving. If not specified, the image's LogicalPixelFormat
/// will be preferred to minimize disk space used.
/// <para/>
/// This will not result in a loss of information, e.g. if you set this to BW1 but your image has color in it, it
/// will have no effect. If you want to change the color information, use CopyWithPixelFormat before saving.
/// </summary>
public ImagePixelFormat PixelFormatHint { get; init; }
}

View File

@ -31,6 +31,15 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>NAPS2.Lib.WinForms</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>NAPS2.Images.Gdi</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>NAPS2.Images.Gtk</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>NAPS2.Images.Mac</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<Import Project="..\NAPS2.Setup\targets\CommonTargets.targets" />

View File

@ -0,0 +1,59 @@
namespace NAPS2.Images;
// TODO: Use this for TIFF saving too, maybe
internal static class PixelFormatHelper
{
public static PixelFormatHelper<T> Create<T>(T image,
ImagePixelFormat targetFormat = ImagePixelFormat.Unsupported,
ImagePixelFormat minFormat = ImagePixelFormat.Unsupported) where T : IMemoryImage
{
return new PixelFormatHelper<T>(image, targetFormat, minFormat);
}
}
internal class PixelFormatHelper<T> : IDisposable where T : IMemoryImage
{
public PixelFormatHelper(T image, ImagePixelFormat targetFormat, ImagePixelFormat minFormat)
{
// TODO: Maybe we can be aware of the target filetype, e.g. JPEG doesn't have 1bpp. Although the specifics
// are going to be platform-dependent.
if (targetFormat == ImagePixelFormat.Unsupported)
{
// If targetFormat is not specified, we'll use the logical format to minimize on-disk size.
targetFormat = image.LogicalPixelFormat;
}
if (targetFormat < image.LogicalPixelFormat)
{
// We never want to lose color information.
targetFormat = image.LogicalPixelFormat;
}
if (targetFormat < minFormat)
{
// GTK only supports RGB24/ARGB32 so it's pointless to target BW1/Gray8 as it will end up as RGB24 anyway.
targetFormat = minFormat;
}
if (targetFormat != image.PixelFormat)
{
Image = (T) image.CopyWithPixelFormat(targetFormat);
IsCopy = true;
}
else
{
Image = image;
IsCopy = false;
}
}
public T Image { get; }
public bool IsCopy { get; }
public void Dispose()
{
if (IsCopy)
{
Image.Dispose();
}
}
}

View File

@ -156,9 +156,9 @@ public class SaveImagesOperation : OperationBase
private void DoSaveImage(ProcessedImage image, string path, ImageFileFormat format, ImageSettings imageSettings)
{
FileSystemHelper.EnsureParentDirExists(path);
using var renderedImage = image.Render();
if (format == ImageFileFormat.Tiff)
{
using var renderedImage = image.Render();
_imageContext.TiffWriter.SaveTiff(new[] { renderedImage }, path,
imageSettings.TiffCompression.ToTiffCompressionType(), CancelToken);
}
@ -167,8 +167,7 @@ public class SaveImagesOperation : OperationBase
// Quality will be ignored when not needed
// TODO: Scale quality differently for jpeg2000?
var quality = imageSettings.JpegQuality.Clamp(0, 100);
using var bitmap = image.Render();
bitmap.Save(path, format, quality);
renderedImage.Save(path, format, new ImageSaveOptions { Quality = quality });
}
}
}

View File

@ -169,6 +169,82 @@ public class LoadSaveTests : ContextualTests
ImageAsserts.Similar(ImageResources.dog, images[0]);
}
[Fact]
public void SavePngOptimizesBitDepth()
{
var image32Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.ARGB32);
var image24Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.RGB24);
var image8Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.Gray8);
var image1Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.BW1);
var optimized32Bpp = GetSavedSize(image24Bpp, ImageFileFormat.Png);
var optimized24Bpp = GetSavedSize(image24Bpp, ImageFileFormat.Png);
var optimized8Bpp = GetSavedSize(image8Bpp, ImageFileFormat.Png);
var optimized1Bpp = GetSavedSize(image1Bpp, ImageFileFormat.Png);
// All should be equal as since the logical pixel format is BW1, all should be converted to BW1 for saving.
Assert.Equal(optimized24Bpp, optimized32Bpp);
Assert.Equal(optimized24Bpp, optimized8Bpp);
Assert.Equal(optimized24Bpp, optimized1Bpp);
}
// Gtk does not support saving with 1bpp/8bpp
[PlatformFact(exclude: PlatformFlags.Linux)]
public void SavePngWithUnoptimizedBitDepth()
{
var image32Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.ARGB32);
var image24Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.RGB24);
var image8Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.Gray8);
var image1Bpp = LoadImage(ImageResources.dog_bw).CopyWithPixelFormat(ImagePixelFormat.BW1);
// Specifying a PixelFormatHint equal to the real pixel format prevents optimized saving.
var optimized32Bpp = GetSavedSize(image32Bpp, ImageFileFormat.Png);
var unoptimized32Bpp = GetSavedSize(image32Bpp, ImageFileFormat.Png,
new ImageSaveOptions { PixelFormatHint = ImagePixelFormat.ARGB32 });
var optimized24Bpp = GetSavedSize(image24Bpp, ImageFileFormat.Png);
var unoptimized24Bpp = GetSavedSize(image24Bpp, ImageFileFormat.Png,
new ImageSaveOptions { PixelFormatHint = ImagePixelFormat.RGB24 });
var optimized8Bpp = GetSavedSize(image8Bpp, ImageFileFormat.Png);
var unoptimized8Bpp = GetSavedSize(image8Bpp, ImageFileFormat.Png,
new ImageSaveOptions { PixelFormatHint = ImagePixelFormat.Gray8 });
var optimized1Bpp = GetSavedSize(image1Bpp, ImageFileFormat.Png);
var unoptimized1Bpp = GetSavedSize(image1Bpp, ImageFileFormat.Png,
new ImageSaveOptions { PixelFormatHint = ImagePixelFormat.BW1 });
// All optimized values should be less than their unoptimized counterparts.
Assert.True(optimized32Bpp < unoptimized32Bpp);
Assert.True(optimized24Bpp < unoptimized24Bpp);
Assert.True(optimized8Bpp < unoptimized8Bpp);
Assert.Equal(optimized1Bpp, unoptimized1Bpp);
// Verify that 1bpp < 8bpp < 24bpp. 32bpp and 24bpp should be close but may vary so it isn't worth testing.
Assert.True(unoptimized1Bpp < unoptimized8Bpp);
Assert.True(unoptimized8Bpp < unoptimized24Bpp);
}
[Fact]
public void PixelFormatHintDoesntLoseColor()
{
var original = LoadImage(ImageResources.dog);
var stream = new MemoryStream();
original.Save(stream, ImageFileFormat.Png, new ImageSaveOptions { PixelFormatHint = ImagePixelFormat.BW1 });
var copy = ImageContext.Load(stream);
ImageAsserts.Similar(ImageResources.dog, copy);
}
private int GetSavedSize(IMemoryImage image, ImageFileFormat fileFormat, ImageSaveOptions options = null)
{
var stream = new MemoryStream();
image.Save(stream, fileFormat, options);
return (int) stream.Length;
}
private static byte[] GetResource(string resource) =>
(byte[]) ImageResources.ResourceManager.GetObject(resource, CultureInfo.InvariantCulture);

View File

@ -33,8 +33,8 @@ public class MemoryImageTests : ContextualTests
var highQualityPath = Path.Combine(FolderPath, "highq.jpg");
var lowQualityPath = Path.Combine(FolderPath, "lowq.jpg");
image.Save(highQualityPath, ImageFileFormat.Jpeg, 75);
image.Save(lowQualityPath, ImageFileFormat.Jpeg, 25);
image.Save(highQualityPath, ImageFileFormat.Jpeg, new ImageSaveOptions { Quality = 75 });
image.Save(lowQualityPath, ImageFileFormat.Jpeg, new ImageSaveOptions { Quality = 25 });
var highQuality = TestImageContextFactory.Get().Load(highQualityPath);
var lowQuality = TestImageContextFactory.Get().Load(lowQualityPath);
@ -66,8 +66,8 @@ public class MemoryImageTests : ContextualTests
var highQualityStream = new MemoryStream();
var lowQualityStream = new MemoryStream();
image.Save(highQualityStream, ImageFileFormat.Jpeg, 75);
image.Save(lowQualityStream, ImageFileFormat.Jpeg, 25);
image.Save(highQualityStream, ImageFileFormat.Jpeg, new ImageSaveOptions { Quality = 75 });
image.Save(lowQualityStream, ImageFileFormat.Jpeg, new ImageSaveOptions { Quality = 25 });
var highQuality = TestImageContextFactory.Get().Load(highQualityStream);
var lowQuality = TestImageContextFactory.Get().Load(lowQualityStream);

View File

@ -31,11 +31,11 @@ public class ImageExportHelper
if (exportFormat.FileFormat == ImageFileFormat.Jpeg)
{
imageFileFormat = ImageFileFormat.Jpeg;
return image.SaveToMemoryStream(ImageFileFormat.Jpeg, quality);
return image.SaveToMemoryStream(ImageFileFormat.Jpeg, new ImageSaveOptions { Quality = quality });
}
// Save as PNG/JPEG depending on which is smaller
var pngEncoded = image.SaveToMemoryStream(ImageFileFormat.Png);
var jpegEncoded = image.SaveToMemoryStream(ImageFileFormat.Jpeg, quality);
var jpegEncoded = image.SaveToMemoryStream(ImageFileFormat.Jpeg, new ImageSaveOptions { Quality = quality });
if (pngEncoded.Length <= jpegEncoded.Length)
{
// Probably a black and white image (e.g. from native WIA, where bitDepth is unknown), which PNG compresses well vs. JPEG

View File

@ -511,16 +511,8 @@ public class PdfExporter : IPdfExporter
public void SaveAsJpeg(MemoryStream ms)
{
if (_image.PixelFormat == ImagePixelFormat.Gray8)
{
// PDFs require RGB channels so we need to make sure we're exporting that
using var copy = _image.CopyWithPixelFormat(ImagePixelFormat.RGB24);
copy.Save(ms, ImageFileFormat.Jpeg);
}
else
{
_image.Save(ms, ImageFileFormat.Jpeg);
}
// PDFs require RGB channels so we need to make sure we're exporting that.
_image.Save(ms, ImageFileFormat.Jpeg, new ImageSaveOptions { PixelFormatHint = ImagePixelFormat.RGB24 });
}
public void SaveAsPdfBitmap(MemoryStream ms)