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
+1
View File
@@ -6,6 +6,7 @@
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
@@ -2,10 +2,22 @@ namespace BGR.Console.Common;
internal static class HostBuilderExtensions
{
public static CommandApp BuildApp(this IHostBuilder builder)
public static CommandApp<RemovalCommand> BuildApp(this IHostBuilder builder)
{
var registrar = new TypeRegistrar(builder);
var app = new CommandApp(registrar);
var app = new CommandApp<RemovalCommand>(registrar);
app.Configure(static c =>
c.SetExceptionHandler(static (ex, resolver) =>
{
var logger = resolver?.Resolve(typeof(ILogger<RemovalCommand>)) as ILogger<RemovalCommand>;
logger?.RemovalCommandFailed(ex);
var console = resolver?.Resolve(typeof(IAnsiConsole)) as IAnsiConsole;
console?.WriteLine($"[red]An error occurred while executing the command:[/]");
console?.WriteException(ex, ExceptionFormats.ShortenEverything);
})
);
return app;
}
@@ -0,0 +1,63 @@
using System.Diagnostics;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace BGR.Console.Logging;
internal static class LoggerExtensions
{
private static readonly Action<ILogger, Exception> RemovalCommandFailedMsg = LoggerMessage.Define(
LogLevel.Error,
new EventId(0, nameof(RemovalCommandFailed)),
"An error occurred while executing the command."
);
private static readonly Action<ILogger, string, long, Exception> TimeAndLogActionMsg = LoggerMessage.Define<string, long>(
LogLevel.Information,
new EventId(0, nameof(TimeAndLogAction)),
"{Message} in {ElapsedMilliseconds}ms"
);
public static void RemovalCommandFailed(this ILogger logger, Exception ex)
{
RemovalCommandFailedMsg(logger, ex);
}
public static async Task TimeAndLogActionAsync(this ILogger logger, string message, Func<Task> action)
{
var sw = new Stopwatch();
sw.Start();
await action();
sw.Stop();
TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!);
}
public static async Task<T> TimeAndLogActionAsync<T>(this ILogger logger, string message, Func<Task<T>> action)
{
var sw = new Stopwatch();
sw.Start();
var result = await action();
sw.Stop();
TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!);
return result;
}
public static void TimeAndLogAction(this ILogger logger, string message, Action action)
{
var sw = new Stopwatch();
sw.Start();
action();
sw.Stop();
TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!);
}
public static T TimeAndLogAction<T>(this ILogger logger, string message, Func<T> action)
{
var sw = new Stopwatch();
sw.Start();
var result = action();
sw.Stop();
TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!);
return result;
}
}
+4 -197
View File
@@ -19,7 +19,11 @@ try
.ConfigureServices(static (_, services) =>
{
services.AddSerilog();
services.AddSingleton(AnsiConsole.Console);
services.AddSingleton<IResourceManager, ResourceManager>();
services.AddSingleton<ImageProcessor, ImageSharpProcessor>();
services.AddSingleton<IInferenceRunner, OnnxInferenceRunner>();
services.AddSingleton<IModelFactory, ModelFactory>();
})
.BuildApp()
.RunAsync(args);
@@ -35,200 +39,3 @@ finally
{
await Log.CloseAndFlushAsync();
}
if (args.Length < 1)
{
Console.WriteLine("Usage: BackgroundRemover <input_image_path>");
return;
}
var inputImagePath = args[0];
var maskImagePath = Path.ChangeExtension(inputImagePath, null) + "_mask.png";
var outputImagePath = Path.ChangeExtension(inputImagePath, null) + "_no_bg.png";
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "BGR.Console.Resources.Files.rmbg.onnx";
using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new FileNotFoundException("Model not found in embedded resources.");
var modelBytes = new byte[stream.Length];
stream.ReadExactly(modelBytes);
using var image = await Image.LoadAsync<Rgba32>(inputImagePath);
var inputTensor = CreateTensorInput(image);
using var options = new SessionOptions() { LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR };
using InferenceSession session = new(modelBytes, options);
var inputs = new List<NamedOnnxValue>()
{
NamedOnnxValue.CreateFromTensor(session.InputNames[0], inputTensor),
};
using var results = session.Run(inputs);
var outputTensor = results[0].AsTensor<float>();
using var mask = GenerateMask(outputTensor, image.Width, image.Height);
using var bgRemoved = GetImageWithBackgroundRemoved(image, mask);
var encoder = new PngEncoder { CompressionLevel = PngCompressionLevel.BestCompression };
await mask.SaveAsync(maskImagePath, encoder);
await bgRemoved.SaveAsync(outputImagePath, encoder);
Console.WriteLine($"Background removed and saved to {outputImagePath}");
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
throw;
}
static Tensor<float> CreateTensorInput(Image<Rgba32> image)
{
// U2Net expects input images to be 320x320. This is dependent on the model.
const int targetWidth = 1024;
const int targetHeight = 1024;
// ImageNet normalization parameters
// source:
// - https://www.image-net.org/
// - https://pytorch.org calculated these values from the ImageNet dataset
// and they are commonly used for models trained on ImageNet so we use them here
// to normalize the input image to better match the distribution of the data the model was trained on
// NOTE: These values are not universal and may vary for different models
const float rMean = 0.485f; // Mean value for Red channel
const float gMean = 0.456f; // Mean value for Green channel
const float bMean = 0.406f; // Mean value for Blue channel
const float rStd = 0.229f; // Standard deviation for Red channel
const float gStd = 0.224f; // Standard deviation for Green channel
const float bStd = 0.225f; // Standard deviation for Blue channel
const float pixelMax = 255f; // Maximum pixel intensity for normalization
// Create a temporary image for preprocessing
using var resized = image.Clone();
resized.Mutate(x => x.Resize(targetWidth, targetHeight));
// Create tensor of shape (1, 3, 320, 320)
// 1 for batch size, 3 for RGB channels, 320x320 for image dimensions
DenseTensor<float> tensor = new([1, 3, targetHeight, targetWidth]);
// Normalize pixel values and copy to tensor
WalkImage(resized.Height, resized.Width, (x, y) =>
{
var pixel = resized[x, y];
// u2net expects expect input images to be normalized using ImageNet mean and std
// to better match the distribution of the data the model was trained on
// Normalize to range [0, 1] and standardize using ImageNet mean/std
// The tensor is filled with normalized pixel values
tensor[0, 0, y, x] = ((pixel.R / pixelMax) - rMean) / rStd; // Red channel
tensor[0, 1, y, x] = ((pixel.G / pixelMax) - gMean) / gStd; // Green channel
tensor[0, 2, y, x] = ((pixel.B / pixelMax) - bMean) / bStd; // Blue channel
});
return tensor;
}
static Image<Rgba32> GenerateMask(Tensor<float> maskTensor, int width, int height)
{
var mask = new Image<Rgba32>(width, height);
var sourceHeight = maskTensor.Dimensions[2]; // Height of the original tensor mask
var sourceWidth = maskTensor.Dimensions[3]; // Width of the original tensor mask
using Image<Rgba32> tempMask = new(sourceWidth, sourceHeight);
// Sigmoid function parameters
const float sigmoidScale = 1f; // Scaling factor for sigmoid activation
const float sigmoidShift = 1f; // Shift factor in the denominator of the sigmoid function
const float sigmoidDivisor = -1f; // Multiplier for the exponent in the sigmoid function
static float CalculateSigmoid(float x)
{
return sigmoidScale / (sigmoidShift + MathF.Exp(sigmoidDivisor * x));
}
const float binarizationThreshold = 0.5f; // Threshold to determine foreground vs. background
const float normalizationFactor = 2f; // Scales the thresholded value to enhance contrast
// Pixel intensity values
const byte maxIntensity = 255; // Maximum grayscale intensity
const byte opaqueAlpha = 255; // Fully opaque alpha value
WalkImage(sourceHeight, sourceWidth, (x, y) =>
{
// a sigmoid function is a function that produces an S-shaped curve
// it is often used in machine learning and statistics to model probabilities
// the sigmoid function is defined as:
// f(x) = 1 / (1 + e^(-x))
// where e is the base of the natural logarithm and x is the input value
// the raw tensor values for our mask are going to be real unbounded numbers
// i.e. -1.5, 0.5, 2.0, etc.
// the sigmoid function will map these values to a range between 0 and 1
// this allows us to say that value closer to 0 is background and value
// closer to 1 is foreground
var sigmoidValue = CalculateSigmoid(maskTensor[0, 0, y, x]);
// now we want to threshold the sigmoid value to determine if it is foreground or background
// we are arbitrarily choosing 0.5 as the threshold. so if the sigmoid value is greater than
// 0.5 we will consider it foreground and if it is less than 0.5 we will consider it background
// when a sigmoid value is greater than 0.5 we will subtract the threshold from it
// and multiply it by 2 this way the intensity value will be larger for values closer to 1
// and create more contrast in the mask
var normalizedValue = sigmoidValue > binarizationThreshold
? (sigmoidValue - binarizationThreshold) * normalizationFactor
: 0f;
// Convert to an 8-bit grayscale intensity
var intensity = (byte)(normalizedValue * maxIntensity);
// Store the pixel with full opacity
tempMask[x, y] = new Rgba32(intensity, intensity, intensity, opaqueAlpha);
});
// Resize the mask to match the target dimensions
tempMask.Mutate(x => x.Resize(width, height));
// Copy the resized mask to the final output image
WalkImage(height, width, (x, y) => mask[x, y] = tempMask[x, y]);
return mask;
}
static Image<Rgba32> GetImageWithBackgroundRemoved(Image<Rgba32> image, Image<Rgba32> mask)
{
Image<Rgba32> result = new(image.Width, image.Height);
const byte alphaThreshold = 20;
Rgba32 transparentPixel = new(0, 0, 0, 0);
WalkImage(image.Height, image.Width, (x, y) =>
{
var sourcePixel = image[x, y];
var maskPixel = mask[x, y];
var alpha = maskPixel.R;
result[x, y] = alpha > alphaThreshold
? new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, sourcePixel.A)
: transparentPixel;
});
return result;
}
static void WalkImage(int height, int width, Action<int, int> action)
{
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
action(x, y);
}
}
}
+1 -2
View File
@@ -4,6 +4,5 @@ internal interface IImage
{
int Width { get; }
int Height { get; }
void Resize(int width, int height);
IPixel GetPixel(int x, int y);
Stream Data { get; }
}
@@ -0,0 +1,6 @@
namespace BGR.Console.Removal;
internal interface IInferenceRunner
{
ITensor<float> Run(byte[] model, ITensor<float> inputTensor);
}
-8
View File
@@ -1,8 +0,0 @@
namespace BGR.Console.Removal;
internal interface IPixel
{
float R { get; }
float G { get; }
float B { get; }
}
+2 -1
View File
@@ -6,4 +6,5 @@ internal interface ITensor<T>
int Width { get; }
void SetValue(int batch, int channel, int y, int x, T value);
float GetValue(int batch, int channel, int y, int x);
}
Tensor<T> ToTensor();
}
+5 -1
View File
@@ -2,12 +2,16 @@ namespace BGR.Console.Removal;
internal abstract class ImageProcessor
{
public abstract Task<IImage> LoadImageAsync(string path);
public abstract Task<ITensor<float>> CreateTensorInputAsync(Stream image, Model model);
public abstract Task<Stream> GenerateMaskAsync(OnnxTensor maskTensor, int width, int height);
public abstract Task<Stream> GenerateMaskAsync(ITensor<float> maskTensor, int width, int height);
public abstract Task<Stream> RemoveBackgroundAsync(Stream image, Stream mask);
public abstract Task SaveImageAsync(Stream image, string path);
protected static void WalkImage(int height, int width, Action<int, int> action)
{
for (var y = 0; y < height; y++)
@@ -2,9 +2,26 @@ namespace BGR.Console.Removal.ImageSharp;
internal class ImageSharpProcessor : ImageProcessor
{
public override async Task<IImage> LoadImageAsync(string path)
{
var image = await Image.LoadAsync<Rgba32>(path);
if (image.Metadata.DecodedImageFormat is null)
{
throw new InvalidOperationException("Image format is not supported.");
}
var stream = new MemoryStream();
await image.SaveAsync(stream, image.Metadata.DecodedImageFormat);
stream.Position = 0;
return new SharpImage(image.Width, image.Height, stream);
}
public override async Task<ITensor<float>> CreateTensorInputAsync(Stream image, Model model)
{
using var resized = await Image.LoadAsync<Rgba32>(image);
resized.Mutate(x => x.Resize(model.InputWidth, model.InputHeight));
const int batchSize = 1;
@@ -22,7 +39,7 @@ internal class ImageSharpProcessor : ImageProcessor
return tensor;
}
public override async Task<Stream> GenerateMaskAsync(OnnxTensor maskTensor, int width, int height)
public override async Task<Stream> GenerateMaskAsync(ITensor<float> maskTensor, int width, int height)
{
using var mask = new Image<Rgba32>(width, height);
@@ -45,12 +62,16 @@ internal class ImageSharpProcessor : ImageProcessor
var stream = new MemoryStream();
await mask.SaveAsync(stream, new PngEncoder());
stream.Position = 0;
return stream;
}
public override async Task<Stream> RemoveBackgroundAsync(Stream image, Stream mask)
{
image.Position = 0;
mask.Position = 0;
var imageWithBg = await Image.LoadAsync<Rgba32>(image);
var maskImage = await Image.LoadAsync<Rgba32>(mask);
using var imageWithBgRemoved = new Image<Rgba32>(imageWithBg.Width, imageWithBg.Height);
@@ -72,9 +93,18 @@ internal class ImageSharpProcessor : ImageProcessor
var result = new MemoryStream();
await imageWithBgRemoved.SaveAsync(result, new PngEncoder());
result.Position = 0;
return result;
}
public override async Task SaveImageAsync(Stream image, string path)
{
image.Position = 0;
var img = await Image.LoadAsync<Rgba32>(image);
await img.SaveAsync(path, new PngEncoder());
}
private static float Normalize(float value)
{
const float binarizationThreshold = 0.5f;
@@ -1,19 +1,25 @@
namespace BGR.Console.Removal.ImageSharp;
internal class SharpImage(Image<Rgba32> image) : IImage
internal class SharpImage : IImage
{
private readonly Image<Rgba32> _image = image;
public int Width { get; }
public int Height { get; }
public Stream Data { get; }
public int Width => _image.Width;
public int Height => _image.Height;
public void Resize(int width, int height)
public SharpImage(int width, int height, Stream data)
{
_image.Mutate(x => x.Resize(width, height));
}
if (width <= 0)
{
throw new ArgumentOutOfRangeException(nameof(width), "must be greater than 0");
}
public IPixel GetPixel(int x, int y)
{
return new SharpPixel(_image[x, y]);
if (height <= 0)
{
throw new ArgumentOutOfRangeException(nameof(height), "must be greater than 0");
}
Width = width;
Height = height;
Data = data ?? throw new ArgumentNullException(nameof(data));
}
}
@@ -1,10 +0,0 @@
namespace BGR.Console.Removal.ImageSharp;
internal class SharpPixel(Rgba32 pixel) : IPixel
{
private readonly Rgba32 _pixel = pixel;
public float R => _pixel.R;
public float G => _pixel.G;
public float B => _pixel.B;
}
@@ -0,0 +1,6 @@
namespace BGR.Console.Removal.Models;
internal interface IModelFactory
{
Model Create(string resourceName);
}
@@ -1,7 +1,8 @@
namespace BGR.Console.Removal.Models;
internal class ModNetModel : Model
internal class ModNetModel(byte[] modelBytes) : Model(modelBytes)
{
public const string Id = "modnet";
public override int InputWidth => 512;
public override int InputHeight => 512;
public override float RedNormalizationMean => 0.485f;
+13 -3
View File
@@ -11,18 +11,28 @@ internal abstract class Model
public abstract float RedNormalizationStd { get; }
public abstract float GreenNormalizationStd { get; }
public abstract float BlueNormalizationStd { get; }
public byte[] Bytes { get; } = [];
public float NormalizeRed(float value)
internal Model()
{
}
protected Model(byte[] modelBytes)
{
Bytes = modelBytes;
}
public virtual float NormalizeRed(float value)
{
return Normalize(value, RedNormalizationMean, RedNormalizationStd);
}
public float NormalizeGreen(float value)
public virtual float NormalizeGreen(float value)
{
return Normalize(value, GreenNormalizationMean, GreenNormalizationStd);
}
public float NormalizeBlue(float value)
public virtual float NormalizeBlue(float value)
{
return Normalize(value, BlueNormalizationMean, BlueNormalizationStd);
}
@@ -0,0 +1,21 @@
namespace BGR.Console.Removal.Models;
internal class ModelFactory(IResourceManager resourceManager) : IModelFactory
{
private readonly IResourceManager _resourceManager = resourceManager;
public Model Create(string resourceName)
{
var resource = _resourceManager.GetResource(resourceName);
var model = new byte[resource.Length];
resource.ReadExactly(model);
return resourceName switch
{
$"{U2NetModel.Id}.onnx" => new U2NetModel(model),
$"{RmbgModel.Id}.onnx" => new RmbgModel(model),
$"{ModNetModel.Id}.onnx" => new ModNetModel(model),
_ => throw new ArgumentException($"Unknown model name: {resourceName}")
};
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
namespace BGR.Console.Removal.Models;
internal class RmbgModel : Model
internal class RmbgModel(byte[] modelBytes) : Model(modelBytes)
{
public const string Id = "rmbg";
public override int InputWidth => 1024;
public override int InputHeight => 1024;
public override float RedNormalizationMean => 0.485f;
+3 -2
View File
@@ -1,7 +1,8 @@
namespace BGR.Console.Removal.Models;
internal class U2NetModel : Model
internal class U2NetModel(byte[] modelBytes) : Model(modelBytes)
{
public const string Id = "u2net";
public override int InputWidth => 320;
public override int InputHeight => 320;
public override float RedNormalizationMean => 0.485f;
@@ -10,4 +11,4 @@ internal class U2NetModel : Model
public override float RedNormalizationStd => 0.229f;
public override float GreenNormalizationStd => 0.224f;
public override float BlueNormalizationStd => 0.225f;
}
}
@@ -0,0 +1,18 @@
namespace BGR.Console.Removal.Onnx;
internal class OnnxInferenceRunner : IInferenceRunner
{
public ITensor<float> Run(byte[] model, ITensor<float> inputTensor)
{
using var options = new SessionOptions() { LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR };
using var session = new InferenceSession(model, options);
var inputs = new List<NamedOnnxValue>()
{
NamedOnnxValue.CreateFromTensor(session.InputNames[0], inputTensor.ToTensor()),
};
var results = session.Run(inputs);
var outputTensor = results[0].AsTensor<float>();
return new OnnxTensor(outputTensor);
}
}
+22 -8
View File
@@ -1,19 +1,28 @@
namespace BGR.Console.Removal.Onnx;
public class OnnxTensor(
int batchSize,
int channels,
int height,
int width
) : ITensor<float>
public class OnnxTensor : ITensor<float>
{
private readonly DenseTensor<float> _tensor =
new([batchSize, channels, height, width]);
private readonly Tensor<float> _tensor;
public int Height => _tensor.Dimensions[2];
public int Width => _tensor.Dimensions[3];
public OnnxTensor(
int batchSize,
int channels,
int height,
int width
)
{
_tensor = new DenseTensor<float>([batchSize, channels, height, width]);
}
public OnnxTensor(Tensor<float> tensor)
{
_tensor = tensor;
}
public void SetValue(
int batch,
int channel,
@@ -34,4 +43,9 @@ public class OnnxTensor(
{
return _tensor[batch, channel, y, x];
}
public Tensor<float> ToTensor()
{
return _tensor;
}
}
+139
View File
@@ -0,0 +1,139 @@
using System.Diagnostics;
namespace BGR.Console.Removal;
internal class RemovalCommand(
IModelFactory modelFactory,
ImageProcessor imageProcessor,
IInferenceRunner inferenceRunner,
IAnsiConsole console,
ILogger<RemovalCommand> logger
) : AsyncCommand<RemovalCommand.Settings>
{
private readonly IModelFactory _modelFactory = modelFactory;
private readonly ImageProcessor _imageProcessor = imageProcessor;
private readonly IInferenceRunner _inferenceRunner = inferenceRunner;
private readonly IAnsiConsole _console = console;
private readonly ILogger<RemovalCommand> _logger = logger;
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
await _console.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync("Removing background...", async ctx =>
{
ctx.Status("Loading model...");
var model = _logger.TimeAndLogAction(
"Loading model",
() => _modelFactory.Create(settings.ResourceName)
);
ctx.Status("Loading image...");
var image = await _logger.TimeAndLogActionAsync(
"Loading image",
async () => await _imageProcessor.LoadImageAsync(settings.Image)
);
ctx.Status("Creating tensor input...");
var inputTensor = await _logger.TimeAndLogActionAsync(
"Creating tensor input",
async () => await _imageProcessor.CreateTensorInputAsync(image.Data, model)
);
ctx.Status("Running inference...");
var outputTensor = _logger.TimeAndLogAction(
"Running inference",
() => _inferenceRunner.Run(model.Bytes, inputTensor)
);
ctx.Status("Generating mask...");
var mask = await _logger.TimeAndLogActionAsync(
"Generating mask",
async () => await _imageProcessor.GenerateMaskAsync(outputTensor, image.Width, image.Height)
);
ctx.Status("Removing background...");
var output = await _logger.TimeAndLogActionAsync(
"Removing background",
async () => await _imageProcessor.RemoveBackgroundAsync(image.Data, mask)
);
if (settings.IncludeMask)
{
await _logger.TimeAndLogActionAsync(
"Saving mask",
async () => await _imageProcessor.SaveImageAsync(mask, settings.MaskPath)
);
_console.MarkupLine($"[bold]Mask saved to:[/] [blue]{settings.OutputPath}[/]");
}
await _logger.TimeAndLogActionAsync(
"Saving output",
async () => await _imageProcessor.SaveImageAsync(output, settings.OutputPath)
);
_console.MarkupLine($"[bold]Output saved to:[/] [green]{settings.OutputPath}[/]");
});
return 0;
}
internal class Settings : CommandSettings
{
private static readonly Dictionary<string, string> Models = new()
{
{ RmbgModel.Id, "rmbg.onnx" },
{ ModNetModel.Id, "modnet.onnx" },
{ U2NetModel.Id, "u2net.onnx" },
};
[CommandArgument(0, "<image>")]
[Description("Path to the image file whose background you want to remove")]
public string Image { get; init; } = string.Empty;
[CommandOption("--model|-m")]
[Description("The model to use for background removal")]
public string Model { get; init; } = "rmbg";
[CommandOption("--include-mask|-i")]
[Description("Generate and output the mask used for background removal")]
public bool IncludeMask { get; init; } = false;
[CommandOption("--output|-o")]
[Description("Path to output image without background to. File extension will always be .png")]
public string Output { get; init; } = string.Empty;
public string ResourceName => Models[Model];
public string MaskPath => GetOutputPath("_mask");
public string OutputPath => GetOutputPath("_no_bg");
public override ValidationResult Validate()
{
if (File.Exists(Image) is false)
{
return ValidationResult.Error($"The image file '{Image}' does not exist.");
}
if (Models.ContainsKey(Model) is false)
{
return ValidationResult.Error($"The model '{Model}' is not supported.");
}
return ValidationResult.Success();
}
private string GetOutputPath(string modifier)
{
if (string.IsNullOrWhiteSpace(Output))
{
return Path.ChangeExtension(Image, null) + modifier + ".png";
}
return Path.ChangeExtension(Output, ".png");
}
}
}
@@ -1,4 +1,3 @@
namespace BGR.Console.Resources;
internal sealed class ResourceManager : IResourceManager
+6 -1
View File
@@ -1,9 +1,13 @@
global using System.ComponentModel;
global using System.Reflection;
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 BGR.Console.Logging;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
@@ -20,4 +24,5 @@ global using SixLabors.ImageSharp.Formats.Png;
global using SixLabors.ImageSharp.PixelFormats;
global using SixLabors.ImageSharp.Processing;
global using Spectre.Console.Cli;
global using Spectre.Console;
global using Spectre.Console.Cli;