Skip to content

Commit

Permalink
Escl: Quality fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
cyanfish committed Dec 14, 2023
1 parent b5b0274 commit 0ba5861
Show file tree
Hide file tree
Showing 17 changed files with 113 additions and 50 deletions.
7 changes: 6 additions & 1 deletion NAPS2.Escl.Server/EsclApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ public async Task GetScannerCapabilities()
new XElement(ScanNs + "PlatenInputCaps", GetCommonInputCaps())),
new XElement(ScanNs + "Adf",
new XElement(ScanNs + "AdfSimplexInputCaps", GetCommonInputCaps()),
new XElement(ScanNs + "AdfDuplexInputCaps", GetCommonInputCaps()))));
new XElement(ScanNs + "AdfDuplexInputCaps", GetCommonInputCaps())),
new XElement(ScanNs + "CompressionFactorSupport",
new XElement(ScanNs + "Min", 0),
new XElement(ScanNs + "Max", 100),
new XElement(ScanNs + "Normal", 75),
new XElement(ScanNs + "Step", 1))));
Response.ContentType = "text/xml";
using var writer = new StreamWriter(HttpContext.OpenResponseStream());
await writer.WriteAsync(doc);
Expand Down
1 change: 1 addition & 0 deletions NAPS2.Escl.Server/SettingsParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public static EsclScanSettings Parse(XDocument doc)
Height = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "Height")) ?? 0,
XOffset = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "XOffset")) ?? 0,
YOffset = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "YOffset")) ?? 0,
CompressionFactor = ParseHelper.MaybeParseInt(root.Element(ScanNs + "CompressionFactor"))
};
}
}
18 changes: 10 additions & 8 deletions NAPS2.Escl/Client/CapabilitiesParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public static EsclCapabilities Parse(XDocument doc)
Naps2Extensions = root.Element(ScanNs + "Naps2Extensions")?.Value,
PlatenCaps = ParseInputCaps(platenCapsEl, settingProfiles),
AdfSimplexCaps = ParseInputCaps(adfSimplexCapsEl, settingProfiles),
AdfDuplexCaps = ParseInputCaps(adfDuplexCapsEl, settingProfiles)
AdfDuplexCaps = ParseInputCaps(adfDuplexCapsEl, settingProfiles),
CompressionFactorSupport = ParseRange(root.Element(ScanNs + "CompressionFactorSupport"))
};
}

Expand All @@ -60,9 +61,9 @@ private static EsclSettingProfile ParseSettingProfile(XElement element,
.Select(x => x.Value).ToList() ?? new List<string>(),
DiscreteResolutions = ParseDiscreteResolutions(element.Element(ScanNs + "SupportedResolutions")
?.Element(ScanNs + "DiscreteResolutions")?.Elements(ScanNs + "DiscreteResolution")),
XResolutionRange = ParseResolutionRange(element.Element(ScanNs + "SupportedResolutions")
XResolutionRange = ParseRange(element.Element(ScanNs + "SupportedResolutions")
?.Element(ScanNs + "XResolutionRange")),
YResolutionRange = ParseResolutionRange(element.Element(ScanNs + "SupportedResolutions")
YResolutionRange = ParseRange(element.Element(ScanNs + "SupportedResolutions")
?.Element(ScanNs + "YResolutionRange")),
};
if (profile.Name != null)
Expand All @@ -72,16 +73,17 @@ private static EsclSettingProfile ParseSettingProfile(XElement element,
return profile;
}

private static ResolutionRange? ParseResolutionRange(XElement? element)
private static EsclRange? ParseRange(XElement? element)
{
if (element == null) return null;

var min = element.Element(ScanNs + "Min");
var max = element.Element(ScanNs + "Max");
var normal = element.Element(ScanNs + "Max");
var min = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Min"));
var max = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Max"));
var normal = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Normal"));
var step = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Step"));
if (min != null && max != null && normal != null)
{
return new ResolutionRange(int.Parse(min.Value), int.Parse(max.Value), int.Parse(normal.Value));
return new EsclRange(min.Value, max.Value, normal.Value, step ?? 1);
}
return null;
}
Expand Down
9 changes: 8 additions & 1 deletion NAPS2.Escl/Client/EsclClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public async Task<EsclJob> CreateScanJob(EsclScanSettings settings)
// new XElement(ScanNs + "Brightness", settings.Brightness),
// new XElement(ScanNs + "Contrast", settings.Contrast),
// new XElement(ScanNs + "Threshold", settings.Threshold),
OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor),
new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat)));
var content = new StringContent(doc, Encoding.UTF8, "text/xml");
var url = GetUrl($"/{_service.RootUrl}/ScanJobs");
Expand All @@ -100,6 +101,12 @@ public async Task<EsclJob> CreateScanJob(EsclScanSettings settings)
};
}

private XElement? OptionalElement(XName elementName, int? value)
{
if (value == null) return null;
return new XElement(elementName, value);
}

public async Task<RawDocument?> NextDocument(EsclJob job, Action<double>? pageProgress = null)
{
var progressCts = new CancellationTokenSource();
Expand Down Expand Up @@ -155,7 +162,7 @@ public async Task<EsclJob> CreateScanJob(EsclScanSettings settings)

public async Task<string> ErrorDetails(EsclJob job)
{
var url = GetUrl($"{job.UriPath}/ErrorDetails");;
var url = GetUrl($"{job.UriPath}/ErrorDetails");
Logger.LogDebug("ESCL GET {Url}", url);
var response = await HttpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
Expand Down
1 change: 1 addition & 0 deletions NAPS2.Escl/EsclCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ public class EsclCapabilities
public EsclInputCaps? PlatenCaps { get; init; }
public EsclInputCaps? AdfSimplexCaps { get; init; }
public EsclInputCaps? AdfDuplexCaps { get; init; }
public EsclRange? CompressionFactorSupport { get; init; }
}
3 changes: 3 additions & 0 deletions NAPS2.Escl/EsclRange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace NAPS2.Escl;

public record EsclRange(int Min, int Max, int Normal, int Step);
2 changes: 2 additions & 0 deletions NAPS2.Escl/EsclScanSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ public class EsclScanSettings
public int Contrast { get; init; }

public int Threshold { get; init; }

public int? CompressionFactor { get; set; }
}
4 changes: 2 additions & 2 deletions NAPS2.Escl/EsclSettingProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ public class EsclSettingProfile
public List<string> DocumentFormats { get; init; } = new();
public List<string> DocumentFormatsExt { get; init; } = new();
public List<DiscreteResolution> DiscreteResolutions { get; init; } = new();
public ResolutionRange? XResolutionRange { get; init; }
public ResolutionRange? YResolutionRange { get; init; }
public EsclRange? XResolutionRange { get; init; }
public EsclRange? YResolutionRange { get; init; }
}
3 changes: 0 additions & 3 deletions NAPS2.Escl/ResolutionRange.cs

This file was deleted.

40 changes: 39 additions & 1 deletion NAPS2.Images/ImageExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using NAPS2.Images.Bitwise;

namespace NAPS2.Images;
Expand All @@ -9,6 +10,25 @@ public static IMemoryImage Render(this IRenderableImage image)
return image.ImageContext.Render(image);
}

/// <summary>
/// Checks if we can copy the source JPEG directly rather than re-encoding and suffering JPEG degradation.
/// </summary>
/// <param name="image"></param>
/// <param name="jpegPath"></param>
/// <returns></returns>
internal static bool IsUntransformedJpegFile(this IRenderableImage image,
[MaybeNullWhen(false)] out string jpegPath)
{
if (image is { Storage: ImageFileStorage fileStorage, TransformState.IsEmpty: true } &&
ImageContext.GetFileFormatFromExtension(fileStorage.FullPath) == ImageFileFormat.Jpeg)
{
jpegPath = fileStorage.FullPath;
return true;
}
jpegPath = null;
return false;
}

/// <summary>
/// Saves the image to the given file path. If the file format is unspecified, it will be inferred from the
/// file extension if possible.
Expand All @@ -20,7 +40,15 @@ public static IMemoryImage Render(this IRenderableImage image)
public static void Save(this IRenderableImage image, string path,
ImageFileFormat imageFormat = ImageFileFormat.Unspecified, ImageSaveOptions? options = null)
{
// TODO: Optimized JPEG saving for file storage with no transforms etc?
if (imageFormat == ImageFileFormat.Unspecified)
{
imageFormat = ImageContext.GetFileFormatFromExtension(path);
}
if (imageFormat == ImageFileFormat.Jpeg && image.IsUntransformedJpegFile(out var jpegPath))
{
File.Copy(jpegPath, path);
return;
}
using var renderedImage = image.Render();
renderedImage.Save(path, imageFormat, options);
}
Expand All @@ -35,6 +63,16 @@ public static void Save(this IRenderableImage image, string path,
public static void Save(this IRenderableImage image, Stream stream,
ImageFileFormat imageFormat = ImageFileFormat.Unspecified, ImageSaveOptions? options = null)
{
if (imageFormat == ImageFileFormat.Unspecified)
{
throw new ArgumentException("Format required to save to a stream", nameof(imageFormat));
}
if (imageFormat == ImageFileFormat.Jpeg && image.IsUntransformedJpegFile(out var jpegPath))
{
using var fileStream = File.OpenRead(jpegPath);
fileStream.CopyTo(stream);
return;
}
using var renderedImage = image.Render();
renderedImage.Save(stream, imageFormat, options);
}
Expand Down
1 change: 1 addition & 0 deletions NAPS2.Images/NAPS2.Images.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="Nullable" Version="1.3.1" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="ZXing.Net" Version="0.16.9" />
Expand Down
12 changes: 10 additions & 2 deletions NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ public async Task ScanWithCorrectOptions()
Assert.Equal(PaperSource.Flatbed, opts.PaperSource);
Assert.Equal(PageSize.Letter, opts.PageSize);
Assert.Equal(HorizontalAlign.Right, opts.PageAlign);
Assert.Equal(75, opts.Quality);
Assert.False(opts.MaxQuality);
Assert.Single(images);
ImageAsserts.Similar(ImageResources.dog, images[0]);

Expand All @@ -118,7 +120,9 @@ public async Task ScanWithCorrectOptions()
Dpi = 300,
PaperSource = PaperSource.Feeder,
PageSize = PageSize.Legal,
PageAlign = HorizontalAlign.Center
PageAlign = HorizontalAlign.Center,
Quality = 0,
MaxQuality = true
}).ToListAsync();

opts = _bridge.LastOptions;
Expand All @@ -127,6 +131,8 @@ public async Task ScanWithCorrectOptions()
Assert.Equal(PaperSource.Feeder, opts.PaperSource);
Assert.Equal(PageSize.Legal, opts.PageSize);
Assert.Equal(HorizontalAlign.Center, opts.PageAlign);
Assert.Equal(0, opts.Quality);
Assert.True(opts.MaxQuality);
Assert.Single(images);
ImageAsserts.Similar(ImageResources.dog_gray, images[0]);

Expand All @@ -138,7 +144,8 @@ public async Task ScanWithCorrectOptions()
Dpi = 4800,
PaperSource = PaperSource.Duplex,
PageSize = PageSize.A3,
PageAlign = HorizontalAlign.Left
PageAlign = HorizontalAlign.Left,
Quality = 100
}).ToListAsync();

opts = _bridge.LastOptions;
Expand All @@ -148,6 +155,7 @@ public async Task ScanWithCorrectOptions()
Assert.Equal(PageSize.A3.WidthInMm, opts.PageSize!.WidthInMm, 1);
Assert.Equal(PageSize.A3.HeightInMm, opts.PageSize!.HeightInMm, 1);
Assert.Equal(HorizontalAlign.Left, opts.PageAlign);
Assert.Equal(100, opts.Quality);
Assert.Single(images);
ImageAsserts.Similar(ImageResources.dog_bw, images[0]);
}
Expand Down
7 changes: 3 additions & 4 deletions NAPS2.Sdk/Pdf/PdfExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,16 +258,15 @@ private PageExportState RenderStep(PageExportState state)

private IEmbedder GetRenderedImageOrDirectJpegEmbedder(PageExportState state)
{
if (state.Image is { Storage: ImageFileStorage fileStorage, TransformState.IsEmpty: true } &&
ImageContext.GetFileFormatFromExtension(fileStorage.FullPath) == ImageFileFormat.Jpeg)
if (state.Image.IsUntransformedJpegFile(out var jpegPath))
{
// Special case if we have an un-transformed JPEG - just use the original file instead of re-encoding
using var fileStream = new FileStream(fileStorage.FullPath, FileMode.Open, FileAccess.Read);
using var fileStream = new FileStream(jpegPath, FileMode.Open, FileAccess.Read);
var jpegHeader = JpegFormatHelper.ReadHeader(fileStream);
// Ensure it's not a grayscale image as those are known to not be embeddable
if (jpegHeader is { NumComponents: > 1 })
{
return new DirectJpegEmbedder(jpegHeader, fileStorage.FullPath);
return new DirectJpegEmbedder(jpegHeader, jpegPath);
}
}
return new RenderedImageEmbedder(state.Image.Render());
Expand Down
18 changes: 11 additions & 7 deletions NAPS2.Sdk/Remoting/Server/ScanJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ public ScanJob(ScanningContext scanningContext, ScanController controller, ScanD
_statusCallback?.Invoke(StatusTransition.ScanComplete);
_completedTcs.TrySetResult(!args.HasError);
};

var requestedFormat = settings.DocumentFormat;
ContentType = requestedFormat switch
{
ContentTypes.PNG or ContentTypes.PDF => requestedFormat,
_ => ContentTypes.JPEG
};
var options = new ScanOptions
{
Device = device,
Expand All @@ -63,14 +70,11 @@ public ScanJob(ScanningContext scanningContext, ScanController controller, ScanD
PageSize = settings.Width > 0 && settings.Height > 0
? new PageSize(settings.Width / 300m, settings.Height / 300m, PageSizeUnit.Inch)
: PageSize.Letter,
PageAlign = SnapToAlignment(settings.XOffset, settings.Width, EsclInputCaps.DEFAULT_MAX_WIDTH)
};
var requestedFormat = settings.DocumentFormat;
ContentType = requestedFormat switch
{
ContentTypes.PNG or ContentTypes.PDF => requestedFormat,
_ => ContentTypes.JPEG
PageAlign = SnapToAlignment(settings.XOffset, settings.Width, EsclInputCaps.DEFAULT_MAX_WIDTH),
Quality = settings.CompressionFactor ?? ScanOptions.DEFAULT_QUALITY,
MaxQuality = ContentType == ContentTypes.PNG
};

try
{
_enumerable = controller.Scan(options, _cts.Token).GetAsyncEnumerator();
Expand Down
16 changes: 1 addition & 15 deletions NAPS2.Sdk/Remoting/Server/ScanServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,7 @@ private EsclDeviceConfig MakeEsclDeviceConfig(SharedDevice device)
MakeAndModel = device.Name,
Uuid = device.GetUuid(InstanceId),
IconPng = _defaultIconPng,
// TODO: Ideally we want to get the actual device capabilities
PlatenCaps = new EsclInputCaps
{
SettingProfiles =
{
new EsclSettingProfile
{
ColorModes =
{ EsclColorMode.RGB24, EsclColorMode.Grayscale8, EsclColorMode.BlackAndWhite1 },
XResolutionRange = new ResolutionRange(100, 4800, 300),
YResolutionRange = new ResolutionRange(100, 4800, 300),
DocumentFormats = { ContentTypes.PDF, ContentTypes.JPEG, ContentTypes.PNG }
}
}
}
// TODO: Ideally we want to get the actual device capabilities (flatbed/feeder, resolution etc.)
},
CreateJob = settings => new ScanJob(_scanningContext, ScanController, device.Device, settings)
};
Expand Down
17 changes: 12 additions & 5 deletions NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,14 @@ private EsclScanSettings GetScanSettings(ScanOptions options, EsclCapabilities c
height = Math.Min(height, inputCaps.MaxHeight.Value);
}

var contentType = ContentTypes.JPEG;
if (options.BitDepth == BitDepth.BlackAndWhite || options.MaxQuality)
{
bool supportsPng = settingProfile != null && settingProfile.DocumentFormats
.Concat(settingProfile.DocumentFormatsExt).Contains(ContentTypes.PNG);
contentType = supportsPng ? ContentTypes.PNG : ContentTypes.PDF;
}

return new EsclScanSettings
{
Width = width,
Expand All @@ -323,16 +331,15 @@ private EsclScanSettings GetScanSettings(ScanOptions options, EsclCapabilities c
ColorMode = colorMode,
InputSource = inputSource,
Duplex = duplex,
DocumentFormat = options.BitDepth == BitDepth.BlackAndWhite || options.MaxQuality
? ContentTypes.PDF // TODO: Use PNG if available?
: ContentTypes.JPEG,
DocumentFormat = contentType,
XOffset = options.PageAlign switch
{
HorizontalAlign.Left => inputCaps.MaxWidth is > 0 ? inputCaps.MaxWidth.Value - width : 0,
HorizontalAlign.Center => inputCaps.MaxWidth is > 0 ? (inputCaps.MaxWidth.Value - width) / 2 : 0,
_ => 0
}
// TODO: Brightness/contrast, quality, etc.
},
CompressionFactor = caps.CompressionFactorSupport is { Min: 0, Max: 100, Step: 1 } ? options.Quality : null
// TODO: Brightness/contrast, etc.
};
}

Expand Down
4 changes: 3 additions & 1 deletion NAPS2.Sdk/Scan/ScanOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ namespace NAPS2.Scan;
/// </summary>
public class ScanOptions
{
public const int DEFAULT_QUALITY = 75;

/// <summary>
/// The driver type used for scanning. Supported drivers depend on the platform (Windows/Mac/Linux). This usually
/// doesn't need to be set as it can be determined from the Device property.
Expand Down Expand Up @@ -115,7 +117,7 @@ public class ScanOptions
/// <summary>
/// The JPEG compression quality used for storing images. Ignored if MaxQuality is true.
/// </summary>
public int Quality { get; set; }
public int Quality { get; set; } = DEFAULT_QUALITY;

/// <summary>
/// If non-null, generates thumbnails of the specified size and provides them in the PostProcessingData of each
Expand Down

0 comments on commit 0ba5861

Please sign in to comment.