feat: refactor to proper app + write tests
This commit is contained in:
@@ -11,9 +11,14 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Spectre.Console.Testing" Version="0.49.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -25,14 +30,14 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<CollectCoverage>true</CollectCoverage>
|
||||
<CoverletOutput>./TestResults/coverage/</CoverletOutput>
|
||||
<CoverletOutput>./TestResults/Coverage/</CoverletOutput>
|
||||
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
|
||||
<Include>[BGR.Console]*</Include>
|
||||
<ExcludeByFile>**/Program.cs</ExcludeByFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="GenerateHtmlCoverageReport" AfterTargets="GenerateCoverageResultAfterTest">
|
||||
<Exec Command="reportgenerator -reports:./TestResults/coverage/*.xml -targetdir:./TestResults/coverage/report/ -reporttypes:Html_Dark" />
|
||||
<Exec Command="reportgenerator -reports:./TestResults/Coverage/*.xml -targetdir:./TestResults/Coverage/Report/ -reporttypes:Html_Dark" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace BGR.Console.Tests.Integration;
|
||||
|
||||
internal static class AppFactory
|
||||
{
|
||||
public static CommandApp<RemovalCommand> Create()
|
||||
{
|
||||
return Host.CreateDefaultBuilder()
|
||||
.ConfigureLogging(static logging => logging.ClearProviders())
|
||||
.ConfigureServices(static (_, services) =>
|
||||
{
|
||||
services.AddSingleton<IAnsiConsole>(new TestConsole());
|
||||
services.AddSingleton<IResourceManager, ResourceManager>();
|
||||
services.AddSingleton<ImageProcessor, ImageSharpProcessor>();
|
||||
services.AddSingleton<IInferenceRunner, OnnxInferenceRunner>();
|
||||
services.AddSingleton<IModelFactory, ModelFactory>();
|
||||
})
|
||||
.BuildApp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace BGR.Console.Tests.Integration;
|
||||
|
||||
public class RemovalCommandTests
|
||||
{
|
||||
private readonly CommandApp<RemovalCommand> _app = AppFactory.Create();
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenCalled_ItShouldRemoveImageBackground()
|
||||
{
|
||||
var imagePath = $"{Guid.NewGuid()}.png";
|
||||
var outputPath = $"{Guid.NewGuid()}.png";
|
||||
|
||||
using var testImage = new Image<Rgba32>(100, 100);
|
||||
await testImage.SaveAsPngAsync(imagePath);
|
||||
|
||||
var result = await _app.RunAsync([imagePath, "--model", "u2net", "--output", outputPath]);
|
||||
|
||||
result.ShouldBe(0);
|
||||
File.Exists(outputPath).ShouldBeTrue();
|
||||
|
||||
File.Delete(imagePath);
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class ImageSharpProcessorTests : IDisposable
|
||||
{
|
||||
private const string TestImagePath = "test.jpg";
|
||||
private bool _isDisposed;
|
||||
private readonly ImageSharpProcessor _sut = new();
|
||||
private readonly Mock<Model> _modelMock = new();
|
||||
private readonly Stream _testImageStream;
|
||||
|
||||
public ImageSharpProcessorTests()
|
||||
{
|
||||
if (File.Exists(TestImagePath) is false)
|
||||
{
|
||||
using var testImage = new Image<Rgba32>(100, 100);
|
||||
testImage.SaveAsJpeg(TestImagePath);
|
||||
}
|
||||
|
||||
_modelMock.Setup(static x => x.InputWidth).Returns(320);
|
||||
_modelMock.Setup(static x => x.InputHeight).Returns(320);
|
||||
|
||||
var stream = new MemoryStream();
|
||||
using var image = new Image<Rgba32>(100, 100);
|
||||
|
||||
for (var y = 0; y < image.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < image.Width; x++)
|
||||
{
|
||||
image[x, y] = new Rgba32((byte)x, (byte)y, 128, 255);
|
||||
}
|
||||
}
|
||||
|
||||
image.SaveAsPng(stream);
|
||||
stream.Position = 0;
|
||||
_testImageStream = stream;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadImageAsync_WhenCalledWithValidPath_ItShouldReturnImage()
|
||||
{
|
||||
var result = await _sut.LoadImageAsync(TestImagePath);
|
||||
|
||||
result.ShouldBeOfType<SharpImage>();
|
||||
result.ShouldNotBeNull();
|
||||
result.Width.ShouldBe(100);
|
||||
result.Height.ShouldBe(100);
|
||||
result.Data.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadImageAsync_WhenCalledWithValidPath_ItShouldReturnReusableStream()
|
||||
{
|
||||
var result = await _sut.LoadImageAsync(TestImagePath);
|
||||
|
||||
result.Data.Position.ShouldBe(0);
|
||||
result.Data.CanRead.ShouldBeTrue();
|
||||
|
||||
var buffer = new byte[100];
|
||||
await result.Data.ReadExactlyAsync(buffer);
|
||||
|
||||
result.Data.Position = 0;
|
||||
await result.Data.ReadExactlyAsync(buffer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTensorInputAsync_WhenCalled_ItShouldResizeImageToModelDimensions()
|
||||
{
|
||||
const int modelWidth = 64;
|
||||
const int modelHeight = 48;
|
||||
|
||||
_modelMock.Setup(static x => x.InputWidth).Returns(modelWidth);
|
||||
_modelMock.Setup(static x => x.InputHeight).Returns(modelHeight);
|
||||
|
||||
var result = await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object);
|
||||
|
||||
result.Width.ShouldBe(modelWidth);
|
||||
result.Height.ShouldBe(modelHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTensorInputAsync_WhenCalled_ItShouldCreateTensorWithCorrectDimensions()
|
||||
{
|
||||
var result = await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object);
|
||||
|
||||
result.ShouldBeOfType<OnnxTensor>();
|
||||
Should.NotThrow(() => result.GetValue(0, 2, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTensorInputAsync_WhenCalled_ItShouldNormalizePixelValues()
|
||||
{
|
||||
var normalizedValue = 0.5f;
|
||||
_modelMock.Setup(static x => x.NormalizeRed(It.IsAny<float>())).Returns(normalizedValue);
|
||||
_modelMock.Setup(static x => x.NormalizeGreen(It.IsAny<float>())).Returns(normalizedValue);
|
||||
_modelMock.Setup(static x => x.NormalizeBlue(It.IsAny<float>())).Returns(normalizedValue);
|
||||
|
||||
var result = await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object);
|
||||
|
||||
for (var y = 0; y < result.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < result.Width; x++)
|
||||
{
|
||||
result.GetValue(0, 0, y, x).ShouldBe(normalizedValue); // Red
|
||||
result.GetValue(0, 1, y, x).ShouldBe(normalizedValue); // Green
|
||||
result.GetValue(0, 2, y, x).ShouldBe(normalizedValue); // Blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTensorInputAsync_WhenCalled_ItShouldCallNormalizeForEachChannel()
|
||||
{
|
||||
await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object);
|
||||
|
||||
_modelMock.Verify(static x => x.NormalizeRed(It.IsAny<float>()), Times.AtLeast(1));
|
||||
_modelMock.Verify(static x => x.NormalizeGreen(It.IsAny<float>()), Times.AtLeast(1));
|
||||
_modelMock.Verify(static x => x.NormalizeBlue(It.IsAny<float>()), Times.AtLeast(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateMaskAsync_WhenCalled_ItShouldCreateMaskWithCorrectDimensions()
|
||||
{
|
||||
const int width = 64;
|
||||
const int height = 48;
|
||||
|
||||
var tensor = new OnnxTensor(1, 1, height, width);
|
||||
var stream = await _sut.GenerateMaskAsync(tensor, width, height);
|
||||
|
||||
using var mask = await Image.LoadAsync<Rgba32>(stream);
|
||||
|
||||
mask.Width.ShouldBe(width);
|
||||
mask.Height.ShouldBe(height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateMaskAsync_WhenCalled_ItShouldCreateGreyscaleMask()
|
||||
{
|
||||
var tensor = new OnnxTensor(1, 1, 100, 100);
|
||||
var stream = await _sut.GenerateMaskAsync(tensor, 100, 100);
|
||||
|
||||
using var mask = await Image.LoadAsync<Rgba32>(stream);
|
||||
|
||||
for (var y = 0; y < mask.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < mask.Width; x++)
|
||||
{
|
||||
// we expect the mask to be greyscale
|
||||
// so R, G, B should be equal
|
||||
mask[x, y].R.ShouldBe(mask[x, y].G);
|
||||
mask[x, y].G.ShouldBe(mask[x, y].B);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveBackgroundAsync_WithValidImageAndMask_ShouldReturnProcessedStream()
|
||||
{
|
||||
var width = 2;
|
||||
var height = 2;
|
||||
|
||||
using var imageStream = new MemoryStream();
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
image[x, y] = new Rgba32(255, 0, 0, 255); // Red pixels
|
||||
}
|
||||
}
|
||||
|
||||
await image.SaveAsPngAsync(imageStream);
|
||||
imageStream.Position = 0;
|
||||
|
||||
using var maskStream = new MemoryStream();
|
||||
using var mask = new Image<Rgba32>(width, height);
|
||||
|
||||
mask[0, 0] = new Rgba32(0, 0, 0, 255);
|
||||
mask[0, 1] = new Rgba32(255, 255, 255, 255);
|
||||
mask[1, 0] = new Rgba32(255, 255, 255, 255);
|
||||
mask[1, 1] = new Rgba32(255, 255, 255, 255);
|
||||
|
||||
await mask.SaveAsPngAsync(maskStream);
|
||||
maskStream.Position = 0;
|
||||
|
||||
var result = await _sut.RemoveBackgroundAsync(imageStream, maskStream);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
result.Position = 0;
|
||||
using var resultImage = await Image.LoadAsync<Rgba32>(result);
|
||||
resultImage.Width.ShouldBe(width);
|
||||
resultImage.Height.ShouldBe(height);
|
||||
|
||||
resultImage[0, 0].A.ShouldBe((byte)0);
|
||||
|
||||
resultImage[0, 1].R.ShouldBe((byte)255);
|
||||
resultImage[0, 1].A.ShouldBe((byte)255);
|
||||
resultImage[1, 0].R.ShouldBe((byte)255);
|
||||
resultImage[1, 0].A.ShouldBe((byte)255);
|
||||
resultImage[1, 1].R.ShouldBe((byte)255);
|
||||
resultImage[1, 1].A.ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveImageAsync_WhenCalled_ItShouldSaveImageToDiskAtProvidedPath()
|
||||
{
|
||||
using var image = new Image<Rgba32>(100, 100);
|
||||
var stream = new MemoryStream();
|
||||
await image.SaveAsPngAsync(stream);
|
||||
|
||||
var path = $"{Guid.NewGuid()}.png";
|
||||
await _sut.SaveImageAsync(stream, path);
|
||||
|
||||
File.Exists(path).ShouldBeTrue();
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
File.Delete(TestImagePath);
|
||||
_testImageStream.Dispose();
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class ModNetModelTests
|
||||
{
|
||||
private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03];
|
||||
private readonly ModNetModel _sut;
|
||||
|
||||
public ModNetModelTests()
|
||||
{
|
||||
_sut = new ModNetModel(_sampleModelBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Id_WhenCalled_ItShouldReturnModNetId()
|
||||
{
|
||||
ModNetModel.Id.ShouldBe("modnet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputWidth_WhenCalled_ItShouldReturn512()
|
||||
{
|
||||
_sut.InputWidth.ShouldBe(512);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputHeight_WhenCalled_ItShouldReturn512()
|
||||
{
|
||||
_sut.InputHeight.ShouldBe(512);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedNormalizationMean_WhenCalled_ItShouldReturnCorrectValue()
|
||||
{
|
||||
_sut.RedNormalizationMean.ShouldBe(0.485f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreenNormalizationMean_WhenCalled_ItShouldReturnCorrectValue()
|
||||
{
|
||||
_sut.GreenNormalizationMean.ShouldBe(0.456f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlueNormalizationMean_WhenCalled_ItShouldReturnCorrectValue()
|
||||
{
|
||||
_sut.BlueNormalizationMean.ShouldBe(0.406f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedNormalizationStd_WhenCalled_ItShouldReturnCorrectValue()
|
||||
{
|
||||
_sut.RedNormalizationStd.ShouldBe(0.229f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreenNormalizationStd_WhenCalled_ItShouldReturnCorrectValue()
|
||||
{
|
||||
_sut.GreenNormalizationStd.ShouldBe(0.224f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlueNormalizationStd_WhenCalled_ItShouldReturnCorrectValue()
|
||||
{
|
||||
_sut.BlueNormalizationStd.ShouldBe(0.225f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class ModelFactoryTests
|
||||
{
|
||||
private readonly Mock<IResourceManager> _resourceManagerMock;
|
||||
private readonly ModelFactory _sut;
|
||||
private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03];
|
||||
|
||||
public ModelFactoryTests()
|
||||
{
|
||||
_resourceManagerMock = new Mock<IResourceManager>();
|
||||
_sut = new ModelFactory(_resourceManagerMock.Object);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("u2net.onnx", typeof(U2NetModel))]
|
||||
[InlineData("rmbg.onnx", typeof(RmbgModel))]
|
||||
[InlineData("modnet.onnx", typeof(ModNetModel))]
|
||||
public void Create_WhenCalledWithValidModelName_ItShouldReturnCorrectModelType(string resourceName, Type expectedType)
|
||||
{
|
||||
SetupResourceManagerMock(resourceName);
|
||||
|
||||
var result = _sut.Create(resourceName);
|
||||
|
||||
result.ShouldBeOfType(expectedType);
|
||||
VerifyResourceManagerCalled(resourceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WhenCalledWithUnknownModel_ItShouldThrowArgumentException()
|
||||
{
|
||||
var resourceName = "unknown.onnx";
|
||||
SetupResourceManagerMock(resourceName);
|
||||
|
||||
var exception = Should.Throw<ArgumentException>(() => _sut.Create(resourceName));
|
||||
|
||||
exception.Message.ShouldBe($"Unknown model name: {resourceName}");
|
||||
VerifyResourceManagerCalled(resourceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WhenCalledWithU2NetModel_ItShouldReadTheResourceStreamToTheEnd()
|
||||
{
|
||||
var resourceName = $"{U2NetModel.Id}.onnx";
|
||||
var memoryStream = new MemoryStream(_sampleModelBytes);
|
||||
|
||||
_resourceManagerMock.Setup(x => x.GetResource(resourceName))
|
||||
.Returns(memoryStream);
|
||||
|
||||
_sut.Create(resourceName);
|
||||
|
||||
memoryStream.Position.ShouldBe(memoryStream.Length);
|
||||
}
|
||||
|
||||
private void SetupResourceManagerMock(string resourceName)
|
||||
{
|
||||
var memoryStream = new MemoryStream(_sampleModelBytes);
|
||||
|
||||
_resourceManagerMock.Setup(x => x.GetResource(resourceName))
|
||||
.Returns(memoryStream);
|
||||
}
|
||||
|
||||
private void VerifyResourceManagerCalled(string resourceName)
|
||||
{
|
||||
_resourceManagerMock.Verify(x => x.GetResource(resourceName), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class ModelTests
|
||||
{
|
||||
internal sealed class TestModel(byte[] modelBytes) : Model(modelBytes)
|
||||
{
|
||||
public override int InputWidth => 100;
|
||||
public override int InputHeight => 100;
|
||||
public override float RedNormalizationMean => 0.5f;
|
||||
public override float GreenNormalizationMean => 0.5f;
|
||||
public override float BlueNormalizationMean => 0.5f;
|
||||
public override float RedNormalizationStd => 0.25f;
|
||||
public override float GreenNormalizationStd => 0.25f;
|
||||
public override float BlueNormalizationStd => 0.25f;
|
||||
}
|
||||
|
||||
private readonly byte[] _sampleBytes = [0x01, 0x02, 0x03];
|
||||
private readonly TestModel _sut;
|
||||
private const float Delta = 0.00001f;
|
||||
|
||||
public ModelTests()
|
||||
{
|
||||
_sut = new TestModel(_sampleBytes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f)]
|
||||
[InlineData(127.5f)]
|
||||
[InlineData(255f)]
|
||||
public void NormalizeRed_WhenCalled_ItShouldNormalizeCorrectly(float value)
|
||||
{
|
||||
var result = _sut.NormalizeRed(value);
|
||||
|
||||
var expected = ((value / 255f) - _sut.RedNormalizationMean) / _sut.RedNormalizationStd;
|
||||
result.ShouldBe(expected, Delta);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f)]
|
||||
[InlineData(127.5f)]
|
||||
[InlineData(255f)]
|
||||
public void NormalizeGreen_WhenCalled_ItShouldNormalizeCorrectly(float value)
|
||||
{
|
||||
var result = _sut.NormalizeGreen(value);
|
||||
|
||||
var expected = ((value / 255f) - _sut.GreenNormalizationMean) / _sut.GreenNormalizationStd;
|
||||
result.ShouldBe(expected, Delta);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f)]
|
||||
[InlineData(127.5f)]
|
||||
[InlineData(255f)]
|
||||
public void NormalizeBlue_WhenCalled_ItShouldNormalizeCorrectly(float value)
|
||||
{
|
||||
var result = _sut.NormalizeBlue(value);
|
||||
|
||||
var expected = ((value / 255f) - _sut.BlueNormalizationMean) / _sut.BlueNormalizationStd;
|
||||
result.ShouldBe(expected, Delta);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f)]
|
||||
[InlineData(256f)]
|
||||
public void NormalizeRed_WhenCalledWithOutOfRangeValues_ItShouldStillNormalize(float value)
|
||||
{
|
||||
var result = _sut.NormalizeRed(value);
|
||||
|
||||
var expected = ((value / 255f) - _sut.RedNormalizationMean) / _sut.RedNormalizationStd;
|
||||
result.ShouldBe(expected, Delta);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f)]
|
||||
[InlineData(256f)]
|
||||
public void NormalizeGreen_WhenCalledWithOutOfRangeValues_ItShouldStillNormalize(float value)
|
||||
{
|
||||
var result = _sut.NormalizeGreen(value);
|
||||
|
||||
var expected = ((value / 255f) - _sut.GreenNormalizationMean) / _sut.GreenNormalizationStd;
|
||||
result.ShouldBe(expected, Delta);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f)]
|
||||
[InlineData(256f)]
|
||||
public void NormalizeBlue_WhenCalledWithOutOfRangeValues_ItShouldStillNormalize(float value)
|
||||
{
|
||||
var result = _sut.NormalizeBlue(value);
|
||||
|
||||
var expected = ((value / 255f) - _sut.BlueNormalizationMean) / _sut.BlueNormalizationStd;
|
||||
result.ShouldBe(expected, Delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class OnnxInferenceRunnerTests
|
||||
{
|
||||
private const string TestModel = "u2net.onnx";
|
||||
private readonly ResourceManager _resourceManager = new();
|
||||
private readonly OnnxInferenceRunner _sut = new();
|
||||
private readonly byte[] _sampleModelBytes;
|
||||
private readonly Mock<ITensor<float>> _mockInputTensor = new();
|
||||
|
||||
public OnnxInferenceRunnerTests()
|
||||
{
|
||||
var modelStream = _resourceManager.GetResource(TestModel);
|
||||
var modelBytes = new byte[modelStream.Length];
|
||||
modelStream.ReadExactly(modelBytes);
|
||||
_sampleModelBytes = modelBytes;
|
||||
|
||||
_mockInputTensor
|
||||
.Setup(static x => x.ToTensor())
|
||||
.Returns(new DenseTensor<float>([1, 3, 320, 320]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_WhenCalledWithValidInput_ItShouldReturnOutput()
|
||||
{
|
||||
var result = _sut.Run(_sampleModelBytes, _mockInputTensor.Object);
|
||||
|
||||
result.ShouldBeOfType<OnnxTensor>();
|
||||
result.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using BGR.Console.Removal.Onnx;
|
||||
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class OnnxTensorTests
|
||||
{
|
||||
private const int DefaultBatchSize = 1;
|
||||
private const int DefaultChannels = 3;
|
||||
private const int DefaultHeight = 4;
|
||||
private const int DefaultWidth = 5;
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithDimensions_ItShouldCreateTensorWithCorrectDimensions()
|
||||
{
|
||||
var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth);
|
||||
|
||||
tensor.Height.ShouldBe(DefaultHeight);
|
||||
tensor.Width.ShouldBe(DefaultWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithExistingTensor_ItShouldCreateTensorWithSameDimensions()
|
||||
{
|
||||
var existingTensor = new DenseTensor<float>([DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth]);
|
||||
|
||||
var tensor = new OnnxTensor(existingTensor);
|
||||
|
||||
tensor.Height.ShouldBe(DefaultHeight);
|
||||
tensor.Width.ShouldBe(DefaultWidth);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0, 0, 1.0f)]
|
||||
[InlineData(0, 1, 2, 3, 2.5f)]
|
||||
[InlineData(0, 2, 3, 4, -1.0f)]
|
||||
public void SetValue_WhenCalledWithValidValues_ItShouldSetCorrectValueAtPosition(int batch, int channel, int y, int x, float expectedValue)
|
||||
{
|
||||
var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth);
|
||||
|
||||
tensor.SetValue(batch, channel, y, x, expectedValue);
|
||||
var actualValue = tensor.GetValue(batch, channel, y, x);
|
||||
|
||||
actualValue.ShouldBe(expectedValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0, 0, 1.0f)]
|
||||
[InlineData(0, 1, 2, 3, 2.5f)]
|
||||
[InlineData(0, 2, 3, 4, -1.0f)]
|
||||
public void GetValue_WhenCalledWithValidValues_ItShouldReturnCorrectValue(int batch, int channel, int y, int x, float value)
|
||||
{
|
||||
var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth);
|
||||
tensor.SetValue(batch, channel, y, x, value);
|
||||
|
||||
var result = tensor.GetValue(batch, channel, y, x);
|
||||
|
||||
result.ShouldBe(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToTensor_WhenCalled_ItShouldReturnUnderlyingTensor()
|
||||
{
|
||||
var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth);
|
||||
const float testValue = 42.0f;
|
||||
tensor.SetValue(0, 0, 0, 0, testValue);
|
||||
|
||||
var result = tensor.ToTensor();
|
||||
|
||||
result.ShouldBeOfType<DenseTensor<float>>();
|
||||
result[0, 0, 0, 0].ShouldBe(testValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1, 0, 0, 0)]
|
||||
[InlineData(1, 3, 0, 0)]
|
||||
[InlineData(0, 0, 4, 0)]
|
||||
[InlineData(0, 0, 0, 5)]
|
||||
public void SetValue_WhenCalledWithInvalidIndices_ItShouldThrowIndexOutOfRangeException(int batch, int channel, int y, int x)
|
||||
{
|
||||
var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth);
|
||||
|
||||
try
|
||||
{
|
||||
tensor.SetValue(batch, channel, y, x, 0);
|
||||
}
|
||||
catch (IndexOutOfRangeException ex)
|
||||
{
|
||||
ex.Message.ShouldBe("Index was outside the bounds of the array.");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1, 0, 0, 0)]
|
||||
[InlineData(1, 3, 0, 0)]
|
||||
[InlineData(0, 0, 4, 0)]
|
||||
[InlineData(0, 0, 0, 5)]
|
||||
public void GetValue_WhenCalledWithInvalidIndices_ItShouldThrowIndexOutOfRangeException(int batch, int channel, int y, int x)
|
||||
{
|
||||
var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth);
|
||||
|
||||
try
|
||||
{
|
||||
tensor.GetValue(batch, channel, y, x);
|
||||
}
|
||||
catch (IndexOutOfRangeException ex)
|
||||
{
|
||||
ex.Message.ShouldBe("Index was outside the bounds of the array.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithZeroDimension_ItShouldCreateEmptyTensor()
|
||||
{
|
||||
var tensor = new OnnxTensor(0, 0, 0, 0);
|
||||
|
||||
tensor.Height.ShouldBe(0);
|
||||
tensor.Width.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Spectre.Console.Cli;
|
||||
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class RemovalCommandTests : IDisposable
|
||||
{
|
||||
private bool _isDisposed;
|
||||
private readonly Mock<IModelFactory> _modelFactoryMock = new();
|
||||
private readonly Mock<ImageProcessor> _imageProcessorMock = new();
|
||||
private readonly Mock<IInferenceRunner> _inferenceRunnerMock = new();
|
||||
private readonly TestConsole _console = new();
|
||||
private readonly Mock<ILogger<RemovalCommand>> _loggerMock = new();
|
||||
private readonly RemovalCommand _sut;
|
||||
|
||||
public RemovalCommandTests()
|
||||
{
|
||||
_sut = new RemovalCommand(
|
||||
_modelFactoryMock.Object,
|
||||
_imageProcessorMock.Object,
|
||||
_inferenceRunnerMock.Object,
|
||||
_console,
|
||||
_loggerMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("output.png", false)]
|
||||
[InlineData("", true)]
|
||||
public async Task ExecuteAsync_WhenCalled_ItShouldProcessImage(string outputPath, bool includeMask)
|
||||
{
|
||||
var imagePath = $"{Guid.NewGuid()}.png";
|
||||
|
||||
using var testImage = new Image<Rgba32>(100, 100);
|
||||
await testImage.SaveAsPngAsync(imagePath);
|
||||
|
||||
var resourceName = "u2net.onnx";
|
||||
|
||||
var settings = new RemovalCommand.Settings()
|
||||
{
|
||||
Image = imagePath,
|
||||
Model = "u2net",
|
||||
IncludeMask = includeMask,
|
||||
Output = outputPath,
|
||||
};
|
||||
|
||||
var model = new U2NetModel([1, 2, 3]);
|
||||
var image = new SharpImage(100, 100, new MemoryStream());
|
||||
var inputTensor = new OnnxTensor(1, 3, 320, 320);
|
||||
var outputTensor = new OnnxTensor(1, 1, 320, 320);
|
||||
var maskStream = new MemoryStream();
|
||||
var outputStream = new MemoryStream();
|
||||
|
||||
_modelFactoryMock
|
||||
.Setup(m => m.Create(resourceName))
|
||||
.Returns(model);
|
||||
|
||||
_imageProcessorMock
|
||||
.Setup(p => p.LoadImageAsync(imagePath))
|
||||
.ReturnsAsync(image);
|
||||
|
||||
_imageProcessorMock
|
||||
.Setup(p => p.CreateTensorInputAsync(image.Data, model))
|
||||
.ReturnsAsync(inputTensor);
|
||||
|
||||
_inferenceRunnerMock
|
||||
.Setup(r => r.Run(model.Bytes, inputTensor))
|
||||
.Returns(outputTensor);
|
||||
|
||||
_imageProcessorMock
|
||||
.Setup(p => p.GenerateMaskAsync(outputTensor, image.Width, image.Height))
|
||||
.ReturnsAsync(maskStream);
|
||||
|
||||
_imageProcessorMock
|
||||
.Setup(p => p.RemoveBackgroundAsync(image.Data, maskStream))
|
||||
.ReturnsAsync(outputStream);
|
||||
|
||||
var commandContext = new CommandContext(
|
||||
[],
|
||||
new Mock<IRemainingArguments>().Object,
|
||||
"test",
|
||||
new object()
|
||||
);
|
||||
|
||||
var result = await _sut.ExecuteAsync(commandContext, settings);
|
||||
|
||||
result.ShouldBe(0);
|
||||
|
||||
_modelFactoryMock.Verify(m => m.Create(resourceName), Times.Once);
|
||||
_imageProcessorMock.Verify(p => p.LoadImageAsync(imagePath), Times.Once);
|
||||
_imageProcessorMock.Verify(p => p.CreateTensorInputAsync(image.Data, model), Times.Once);
|
||||
_inferenceRunnerMock.Verify(r => r.Run(model.Bytes, inputTensor), Times.Once);
|
||||
_imageProcessorMock.Verify(p => p.GenerateMaskAsync(outputTensor, image.Width, image.Height), Times.Once);
|
||||
_imageProcessorMock.Verify(p => p.RemoveBackgroundAsync(image.Data, maskStream), Times.Once);
|
||||
_imageProcessorMock.Verify(p => p.SaveImageAsync(outputStream, It.IsAny<string>()), Times.AtLeastOnce);
|
||||
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenCalledAndFileDoesNotExist_ItShouldReturnError()
|
||||
{
|
||||
var settings = new RemovalCommand.Settings()
|
||||
{
|
||||
Image = "invalid.png",
|
||||
Model = "u2net",
|
||||
IncludeMask = false,
|
||||
Output = "output.png",
|
||||
};
|
||||
|
||||
var result = settings.Validate();
|
||||
|
||||
result.Successful.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenCalledAndModelIsInvalid_ItShouldReturnError()
|
||||
{
|
||||
var imagePath = $"{Guid.NewGuid()}.png";
|
||||
|
||||
using var testImage = new Image<Rgba32>(100, 100);
|
||||
testImage.SaveAsPng(imagePath);
|
||||
|
||||
var settings = new RemovalCommand.Settings()
|
||||
{
|
||||
Image = imagePath,
|
||||
Model = "invalid",
|
||||
IncludeMask = false,
|
||||
Output = "output.png",
|
||||
};
|
||||
|
||||
var result = settings.Validate();
|
||||
|
||||
result.Successful.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenCalledAndSettingsValid_ItShouldReturnSuccess()
|
||||
{
|
||||
var imagePath = $"{Guid.NewGuid()}.png";
|
||||
|
||||
using var testImage = new Image<Rgba32>(100, 100);
|
||||
testImage.SaveAsPng(imagePath);
|
||||
|
||||
var settings = new RemovalCommand.Settings()
|
||||
{
|
||||
Image = imagePath,
|
||||
Model = "u2net",
|
||||
IncludeMask = false,
|
||||
Output = "output.png",
|
||||
};
|
||||
|
||||
var result = settings.Validate();
|
||||
|
||||
result.Successful.ShouldBeTrue();
|
||||
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_console.Dispose();
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class RmbgModelTests
|
||||
{
|
||||
private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03];
|
||||
private readonly RmbgModel _sut;
|
||||
|
||||
public RmbgModelTests()
|
||||
{
|
||||
_sut = new RmbgModel(_sampleModelBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Id_WhenCalled_ItShouldReturnRmbgId()
|
||||
{
|
||||
RmbgModel.Id.ShouldBe("rmbg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputWidth_WhenCalled_ItShouldReturn1024()
|
||||
{
|
||||
_sut.InputWidth.ShouldBe(1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputHeight_WhenCalled_ItShouldReturn1024()
|
||||
{
|
||||
_sut.InputHeight.ShouldBe(1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedNormalizationMean_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.RedNormalizationMean.ShouldBe(0.485f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreenNormalizationMean_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.GreenNormalizationMean.ShouldBe(0.456f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlueNormalizationMean_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.BlueNormalizationMean.ShouldBe(0.406f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedNormalizationStd_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.RedNormalizationStd.ShouldBe(0.229f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreenNormalizationStd_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.GreenNormalizationStd.ShouldBe(0.224f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlueNormalizationStd_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.BlueNormalizationStd.ShouldBe(0.225f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class SharpImageTests : IDisposable
|
||||
{
|
||||
private bool _isDisposed;
|
||||
private const int DefaultWidth = 100;
|
||||
private const int DefaultHeight = 200;
|
||||
private readonly MemoryStream _sampleStream;
|
||||
|
||||
public SharpImageTests()
|
||||
{
|
||||
_sampleStream = new MemoryStream([0x01, 0x02, 0x03]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalled_ItShouldSetProperties()
|
||||
{
|
||||
var image = new SharpImage(DefaultWidth, DefaultHeight, _sampleStream);
|
||||
|
||||
image.Width.ShouldBe(DefaultWidth);
|
||||
image.Height.ShouldBe(DefaultHeight);
|
||||
image.Data.ShouldBe(_sampleStream);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(1920, 1080)]
|
||||
[InlineData(int.MaxValue, int.MaxValue)]
|
||||
public void Constructor_WhenCalledWithDifferentDimensions_ItShouldSetCorrectValues(int width, int height)
|
||||
{
|
||||
var image = new SharpImage(width, height, _sampleStream);
|
||||
|
||||
image.Width.ShouldBe(width);
|
||||
image.Height.ShouldBe(height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithNullStream_ItShouldThrowException()
|
||||
{
|
||||
var action = static () => new SharpImage(DefaultWidth, DefaultHeight, null!);
|
||||
|
||||
action.ShouldThrow<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(-1, -1)]
|
||||
[InlineData(1, -1)]
|
||||
[InlineData(-1, 1)]
|
||||
public void Constructor_WhenCalledWithInvalidDimensions_ItShouldThrowException(int width, int height)
|
||||
{
|
||||
var action = () => new SharpImage(width, height, _sampleStream);
|
||||
|
||||
action.ShouldThrow<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_WhenCalled_ItShouldReturnSameStreamInstance()
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
var image = new SharpImage(DefaultWidth, DefaultHeight, stream);
|
||||
|
||||
image.Data.ShouldBeSameAs(stream);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_sampleStream.Dispose();
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace BGR.Console.Tests.Unit;
|
||||
|
||||
public class U2NetModelTests
|
||||
{
|
||||
private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03];
|
||||
private readonly U2NetModel _sut;
|
||||
|
||||
public U2NetModelTests()
|
||||
{
|
||||
_sut = new U2NetModel(_sampleModelBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Id_WhenCalled_ItShouldReturnU2NetId()
|
||||
{
|
||||
U2NetModel.Id.ShouldBe("u2net");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputWidth_WhenCalled_ItShouldReturn320()
|
||||
{
|
||||
_sut.InputWidth.ShouldBe(320);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputHeight_WhenCalled_ItShouldReturn320()
|
||||
{
|
||||
_sut.InputHeight.ShouldBe(320);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedNormalizationMean_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.RedNormalizationMean.ShouldBe(0.485f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreenNormalizationMean_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.GreenNormalizationMean.ShouldBe(0.456f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlueNormalizationMean_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.BlueNormalizationMean.ShouldBe(0.406f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedNormalizationStd_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.RedNormalizationStd.ShouldBe(0.229f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreenNormalizationStd_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.GreenNormalizationStd.ShouldBe(0.224f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlueNormalizationStd_WhenCalled_ItShouldHaveCorrectValue()
|
||||
{
|
||||
_sut.BlueNormalizationStd.ShouldBe(0.225f);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
global using BGR.Console.Common;
|
||||
global using BGR.Console.Removal;
|
||||
global using BGR.Console.Removal.ImageSharp;
|
||||
global using BGR.Console.Removal.Models;
|
||||
global using BGR.Console.Removal.Onnx;
|
||||
global using BGR.Console.Resources;
|
||||
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Hosting;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
|
||||
global using Moq;
|
||||
|
||||
global using SixLabors.ImageSharp;
|
||||
global using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
global using Spectre.Console;
|
||||
global using Spectre.Console.Cli;
|
||||
global using Spectre.Console.Testing;
|
||||
|
||||
Reference in New Issue
Block a user