Gtk: Tiff saving

This commit is contained in:
Ben Olden-Cooligan 2022-09-17 10:46:31 -07:00
parent e628127547
commit 76a8778752
8 changed files with 212 additions and 48 deletions

View File

@ -1,5 +1,6 @@
using System.Threading;
using Gdk;
using NAPS2.Images.Bitwise;
namespace NAPS2.Images.Gtk;
@ -63,42 +64,138 @@ public class GtkImageContext : ImageContext
return new[] { Load(path) };
}
public override bool SaveTiff(IList<IMemoryImage> images, string path, TiffCompressionType compression = TiffCompressionType.Auto,
public override bool SaveTiff(IList<IMemoryImage> images, string path,
TiffCompressionType compression = TiffCompressionType.Auto,
Action<int, int>? progressCallback = null, CancellationToken cancelToken = default)
{
throw new NotImplementedException();
var tiff = LibTiff.TIFFOpen(path, "w");
return WriteTiff(tiff, null, images, compression, progressCallback, cancelToken);
}
public override bool SaveTiff(IList<IMemoryImage> images, Stream stream, TiffCompressionType compression = TiffCompressionType.Auto,
public override bool SaveTiff(IList<IMemoryImage> images, Stream stream,
TiffCompressionType compression = TiffCompressionType.Auto,
Action<int, int>? progressCallback = null, CancellationToken cancelToken = default)
{
throw new NotImplementedException();
var client = new LibTiffStreamClient(stream);
var tiff = client.TIFFClientOpen("w");
return WriteTiff(tiff, client, images, compression, progressCallback, cancelToken);
}
private bool WriteTiff(IntPtr tiff, LibTiffStreamClient client, IList<IMemoryImage> images,
TiffCompressionType compression, Action<int, int>? progressCallback, CancellationToken cancelToken)
{
try
{
int i = 0;
progressCallback?.Invoke(0, images.Count);
foreach (var image in images)
{
if (cancelToken.IsCancellationRequested) return false;
var pixelFormat =
image.LogicalPixelFormat == ImagePixelFormat.BW1 || compression == TiffCompressionType.Ccitt4
? ImagePixelFormat.BW1
: image.LogicalPixelFormat;
WriteTiffMetadata(tiff, pixelFormat, compression, image);
WriteTiffImageData(tiff, pixelFormat, image);
if (images.Count > 1)
{
LibTiff.TIFFWriteDirectory(tiff);
}
progressCallback?.Invoke(++i, images.Count);
}
return true;
}
finally
{
LibTiff.TIFFClose(tiff);
}
}
private unsafe void WriteTiffImageData(IntPtr tiff, ImagePixelFormat pixelFormat, IMemoryImage image)
{
var bufferInfo = new PixelInfo(image.Width, image.Height, pixelFormat switch
{
ImagePixelFormat.ARGB32 => SubPixelType.Rgba,
ImagePixelFormat.RGB24 => SubPixelType.Rgb,
ImagePixelFormat.Gray8 => SubPixelType.Gray,
ImagePixelFormat.BW1 => SubPixelType.Bit
});
var buffer = new byte[bufferInfo.Length];
new CopyBitwiseImageOp().Perform(image, buffer, bufferInfo);
fixed (byte* buf = buffer)
{
for (int i = 0; i < image.Height; i++)
{
LibTiff.TIFFWriteScanline(tiff, (IntPtr) (buf + bufferInfo.Stride * i), i, 0);
}
}
}
private static void WriteTiffMetadata(IntPtr tiff, ImagePixelFormat pixelFormat,
TiffCompressionType compression, IMemoryImage image)
{
LibTiff.TIFFSetField(tiff, TiffTag.ImageWidth, image.Width);
LibTiff.TIFFSetField(tiff, TiffTag.ImageHeight, image.Height);
LibTiff.TIFFSetField(tiff, TiffTag.PlanarConfig, 1);
// TODO: Test setting g4 compression when it's not a BW image
LibTiff.TIFFSetField(tiff, TiffTag.Compression, (int) (compression switch
{
TiffCompressionType.Auto => pixelFormat == ImagePixelFormat.BW1
? TiffCompression.G4
: TiffCompression.Lzw,
TiffCompressionType.Ccitt4 => TiffCompression.G4,
TiffCompressionType.Lzw => TiffCompression.Lzw,
TiffCompressionType.None => TiffCompression.None
}));
LibTiff.TIFFSetField(tiff, TiffTag.Orientation, 1);
LibTiff.TIFFSetField(tiff, TiffTag.BitsPerSample, pixelFormat == ImagePixelFormat.BW1 ? 1 : 8);
LibTiff.TIFFSetField(tiff, TiffTag.SamplesPerPixel, pixelFormat switch
{
ImagePixelFormat.RGB24 => 3,
ImagePixelFormat.ARGB32 => 4,
_ => 1
});
LibTiff.TIFFSetField(tiff, TiffTag.Photometric, (int) (pixelFormat switch
{
ImagePixelFormat.RGB24 or ImagePixelFormat.ARGB32 => TiffPhotometric.Rgb,
_ => TiffPhotometric.MinIsBlack
}));
if (pixelFormat == ImagePixelFormat.ARGB32)
{
LibTiff.TIFFSetField(tiff, TiffTag.ExtraSamples, 1);
}
if (image.HorizontalResolution != 0 && image.VerticalResolution != 0)
{
LibTiff.TIFFSetField(tiff, TiffTag.ResolutionUnit, 2);
LibTiff.TIFFSetField(tiff, TiffTag.XResolution, (int) image.HorizontalResolution);
LibTiff.TIFFSetField(tiff, TiffTag.YResolution, (int) image.VerticalResolution);
}
}
private IEnumerable<IMemoryImage> LoadTiff(Stream stream, out int count)
{
var c = new LibTiffStreamClient(stream);
var tiff = c.TIFFClientOpen("r");
var client = new LibTiffStreamClient(stream);
var tiff = client.TIFFClientOpen("r");
count = LibTiff.TIFFNumberOfDirectories(tiff);
return EnumerateTiffFrames(tiff, c);
return EnumerateTiffFrames(tiff, client);
}
private IEnumerable<IMemoryImage> LoadTiff(string path, out int count)
{
var tiff = LibTiff.TIFFOpen(path, "r");
count = LibTiff.TIFFNumberOfDirectories(tiff);
return EnumerateTiffFrames(tiff, null);
return EnumerateTiffFrames(tiff);
}
private IEnumerable<IMemoryImage> EnumerateTiffFrames(IntPtr tiff, LibTiffStreamClient? client)
private IEnumerable<IMemoryImage> EnumerateTiffFrames(IntPtr tiff, LibTiffStreamClient? client = null)
{
// We keep a reference to the client to avoid garbage collection
try
{
do
{
LibTiff.TIFFGetField(tiff, 256, out var w);
LibTiff.TIFFGetField(tiff, 257, out var h);
LibTiff.TIFFGetField(tiff, TiffTag.ImageWidth, out var w);
LibTiff.TIFFGetField(tiff, TiffTag.ImageHeight, out var h);
var img = Create(w, h, ImagePixelFormat.ARGB32);
img.OriginalFileFormat = ImageFileFormat.Tiff;
using var imageLock = img.Lock(LockMode.WriteOnly, out var data);

View File

@ -6,7 +6,7 @@ using tdata_t = System.IntPtr;
namespace NAPS2.Images.Gtk;
public static class LibTiff
internal static class LibTiff
{
// TODO: String marshalling?
[DllImport("libtiff.so.5")]
@ -54,7 +54,17 @@ public static class LibTiff
public static extern int TIFFReadDirectory(IntPtr tiff);
[DllImport("libtiff.so.5")]
public static extern int TIFFGetField(IntPtr tiff, int tag, out int field);
public static extern int TIFFWriteDirectory(IntPtr tiff);
[DllImport("libtiff.so.5")]
public static extern int TIFFGetField(IntPtr tiff, TiffTag tag, out int field);
[DllImport("libtiff.so.5")]
public static extern int TIFFSetField(IntPtr tiff, TiffTag tag, int field);
[DllImport("libtiff.so.5")]
public static extern int TIFFWriteScanline(
IntPtr tiff, tdata_t buf, int row, short sample);
[DllImport("libtiff.so.5")]
public static extern int TIFFReadRGBAImage(

View File

@ -6,7 +6,7 @@ using tdata_t = System.IntPtr;
namespace NAPS2.Images.Gtk;
public class LibTiffStreamClient
internal class LibTiffStreamClient
{
private readonly Stream _stream;
private readonly LibTiff.TIFFErrorHandler _error;

View File

@ -0,0 +1,11 @@
namespace NAPS2.Images.Gtk;
internal enum TiffCompression
{
None = 1,
G4 = 4,
Lzw = 5,
Jpeg = 7,
Deflate = 32946,
Lzma = 34925
}

View File

@ -0,0 +1,7 @@
namespace NAPS2.Images.Gtk;
internal enum TiffPhotometric
{
MinIsBlack = 1,
Rgb = 2
}

View File

@ -0,0 +1,22 @@
namespace NAPS2.Images.Gtk;
internal enum TiffTag
{
SubFileType = 254,
ImageWidth = 256,
ImageHeight = 257,
BitsPerSample = 258,
Compression = 259,
Photometric = 262,
DocumentName = 269,
ImageDescription = 270,
Make = 271,
Model = 272,
Orientation = 274,
SamplesPerPixel = 277,
XResolution = 282,
YResolution = 283,
PlanarConfig = 284,
ResolutionUnit = 296,
ExtraSamples = 338
}

View File

@ -162,6 +162,7 @@ public abstract class ImageContext
/// <returns></returns>
public abstract IEnumerable<IMemoryImage> LoadFrames(string path, out int count);
// TODO: Instead of having these methods directly here, instead have a TiffWriter property
public abstract bool SaveTiff(IList<IMemoryImage> images, string path,
TiffCompressionType compression = TiffCompressionType.Auto, Action<int, int>? progressCallback = null,
CancellationToken cancelToken = default);

View File

@ -6,7 +6,8 @@ namespace NAPS2.Sdk.Tests.Images;
public class LoadSaveTests : ContextualTests
{
// TODO: Add tests for error/edge cases (e.g. invalid files, mismatched extensions/format (?), tiff progress)
// TODO: Add tests for error/edge cases (e.g. invalid files, mismatched extensions/format (?), tiff progress, saving 0/null images, unicode file names)
// TODO: Verify rough expected file sizes to ensure compression is as expected (especially for tiff saving)
[Theory]
[MemberData(nameof(TestCases))]
@ -89,12 +90,7 @@ public class LoadSaveTests : ContextualTests
var original = LoadImage(ImageResources.color_image);
ImageContext.SaveTiff(new[] { original }, path);
var actual = ImageContext.LoadFrames(path, out var count).ToArray();
Assert.Equal(1, count);
Assert.Single(actual);
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
AssertTiff(path, ImageResources.color_image);
}
[Fact]
@ -104,21 +100,12 @@ public class LoadSaveTests : ContextualTests
var original = new[]
{
LoadImage(ImageResources.color_image),
LoadImage(ImageResources.color_image_h_p300),
LoadImage(ImageResources.color_image_bw).PerformTransform(new BlackWhiteTransform()),
LoadImage(ImageResources.stock_cat)
};
ImageContext.SaveTiff(original, path);
var actual = ImageContext.LoadFrames(path, out var count).ToArray();
Assert.Equal(3, count);
Assert.Equal(3, original.Length);
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
Assert.Equal(ImageFileFormat.Tiff, actual[1].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.color_image_h_p300, actual[1]);
Assert.Equal(ImageFileFormat.Tiff, actual[2].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.stock_cat, actual[2]);
AssertTiff(path, ImageResources.color_image, ImageResources.color_image_bw, ImageResources.stock_cat);
}
[Fact]
@ -128,13 +115,7 @@ public class LoadSaveTests : ContextualTests
var original = LoadImage(ImageResources.color_image);
ImageContext.SaveTiff(new[] { original }, stream);
stream.Seek(0, SeekOrigin.Begin);
var actual = ImageContext.LoadFrames(stream, out var count).ToArray();
Assert.Equal(1, count);
Assert.Single(actual);
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
AssertTiff(stream, ImageResources.color_image);
}
[Fact]
@ -144,22 +125,57 @@ public class LoadSaveTests : ContextualTests
var original = new[]
{
LoadImage(ImageResources.color_image),
LoadImage(ImageResources.color_image_h_p300),
LoadImage(ImageResources.color_image_bw).PerformTransform(new BlackWhiteTransform()),
LoadImage(ImageResources.stock_cat)
};
ImageContext.SaveTiff(original, stream);
AssertTiff(stream, ImageResources.color_image, ImageResources.color_image_bw, ImageResources.stock_cat);
}
[Fact]
public void SaveBlackAndWhiteTiff()
{
var path = Path.Combine(FolderPath, "image.tiff");
var original = LoadImage(ImageResources.color_image_bw);
original = original.PerformTransform(new BlackWhiteTransform());
ImageContext.SaveTiff(new[] { original }, path);
AssertTiff(path, ImageResources.color_image_bw);
}
[Fact]
public void SaveColorTiffWithG4()
{
var path = Path.Combine(FolderPath, "image.tiff");
var original = LoadImage(ImageResources.color_image);
ImageContext.SaveTiff(new[] { original }, path, TiffCompressionType.Ccitt4);
AssertTiff(path, ImageResources.color_image_bw);
}
private void AssertTiff(string path, params byte[][] expectedImages)
{
var actual = ImageContext.LoadFrames(path, out var count).ToArray();
DoAssertTiff(actual, count, expectedImages);
}
private void AssertTiff(Stream stream, params byte[][] expectedImages)
{
stream.Seek(0, SeekOrigin.Begin);
var actual = ImageContext.LoadFrames(stream, out var count).ToArray();
DoAssertTiff(actual, count, expectedImages);
}
Assert.Equal(3, count);
Assert.Equal(3, original.Length);
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
Assert.Equal(ImageFileFormat.Tiff, actual[1].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.color_image_h_p300, actual[1]);
Assert.Equal(ImageFileFormat.Tiff, actual[2].OriginalFileFormat);
ImageAsserts.Similar(ImageResources.stock_cat, actual[2]);
private static void DoAssertTiff(IMemoryImage[] actual, int count, byte[][] expectedImages)
{
Assert.Equal(expectedImages.Length, count);
Assert.Equal(expectedImages.Length, actual.Length);
for (int i = 0; i < expectedImages.Length; i++)
{
Assert.Equal(ImageFileFormat.Tiff, actual[i].OriginalFileFormat);
ImageAsserts.Similar(expectedImages[i], actual[i]);
}
}
private static byte[] GetResource(string resource) =>