feat: refactor to proper app + write tests

This commit is contained in:
Stevan Freeborn
2025-02-13 17:24:38 -06:00
parent baf4035616
commit 42435ad915
38 changed files with 1462 additions and 253 deletions
@@ -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);
}
}
+94
View File
@@ -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);
}
}
+12
View File
@@ -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;