From e099144127ae719f85f3375be8be72fe3b5914c6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Wed, 15 Jul 2026 10:48:38 -0500 Subject: [PATCH] feat: improve background removal feathering - replace hard binary thresholding with smooth linear alpha mapping to reduce halos around foreground edges especially in low resolution images - normalize now uses a quadratic curve instead of hard binarization so that gradient info from the model output is preserved - removebackgroundasync applies a configurable linear interpolation between the set min and max feathering thresholds so you have partial transparency at the edges --- .../Unit/ImageSharpProcessorTests.cs | 36 ++++++++++++++++++- .../Unit/RemovalCommandTests.cs | 4 +-- src/BGR.Console/Removal/ImageProcessor.cs | 2 +- .../Removal/ImageSharp/ImageSharpProcessor.cs | 29 +++++++-------- src/BGR.Console/Removal/RemovalCommand.cs | 10 +++++- 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/BGR.Console.Tests/Unit/ImageSharpProcessorTests.cs b/src/BGR.Console.Tests/Unit/ImageSharpProcessorTests.cs index 7675f08..e55e35a 100644 --- a/src/BGR.Console.Tests/Unit/ImageSharpProcessorTests.cs +++ b/src/BGR.Console.Tests/Unit/ImageSharpProcessorTests.cs @@ -183,7 +183,10 @@ public class ImageSharpProcessorTests : IDisposable await mask.SaveAsPngAsync(maskStream); maskStream.Position = 0; - var result = await _sut.RemoveBackgroundAsync(imageStream, maskStream); + const byte featherMin = 70; + const byte featherMax = 117; + + var result = await _sut.RemoveBackgroundAsync(imageStream, maskStream, featherMin, featherMax); result.ShouldNotBeNull(); result.Length.ShouldBeGreaterThan(0); @@ -203,6 +206,37 @@ public class ImageSharpProcessorTests : IDisposable resultImage[1, 1].A.ShouldBe((byte)255); } + [Fact] + public async Task RemoveBackgroundAsync_WithMidRangeMaskValue_ShouldApplyPartialAlpha() + { + using var imageStream = new MemoryStream(); + using var image = new Image(1, 1); + image[0, 0] = new Rgba32(100, 150, 200, 255); + await image.SaveAsPngAsync(imageStream); + imageStream.Position = 0; + + using var maskStream = new MemoryStream(); + using var mask = new Image(1, 1); + mask[0, 0] = new Rgba32(100, 100, 100, 255); + await mask.SaveAsPngAsync(maskStream); + maskStream.Position = 0; + + const byte featherMin = 70; + const byte featherMax = 117; + + var result = await _sut.RemoveBackgroundAsync(imageStream, maskStream, featherMin, featherMax); + + result.Position = 0; + using var resultImage = await Image.LoadAsync(result); + + resultImage[0, 0].R.ShouldBe((byte)100); + resultImage[0, 0].G.ShouldBe((byte)150); + resultImage[0, 0].B.ShouldBe((byte)200); + + var expectedAlpha = (byte)((100 - 70) / (float)(117 - 70) * 255f); + resultImage[0, 0].A.ShouldBe(expectedAlpha); + } + [Fact] public async Task SaveImageAsync_WhenCalled_ItShouldSaveImageToDiskAtProvidedPath() { diff --git a/src/BGR.Console.Tests/Unit/RemovalCommandTests.cs b/src/BGR.Console.Tests/Unit/RemovalCommandTests.cs index 5c5e732..0f049f6 100644 --- a/src/BGR.Console.Tests/Unit/RemovalCommandTests.cs +++ b/src/BGR.Console.Tests/Unit/RemovalCommandTests.cs @@ -72,7 +72,7 @@ public class RemovalCommandTests : IDisposable .ReturnsAsync(maskStream); _imageProcessorMock - .Setup(p => p.RemoveBackgroundAsync(image.Data, maskStream)) + .Setup(p => p.RemoveBackgroundAsync(image.Data, maskStream, It.IsAny(), It.IsAny())) .ReturnsAsync(outputStream); var commandContext = new CommandContext( @@ -91,7 +91,7 @@ public class RemovalCommandTests : IDisposable _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.RemoveBackgroundAsync(image.Data, maskStream, It.IsAny(), It.IsAny()), Times.Once); _imageProcessorMock.Verify(p => p.SaveImageAsync(outputStream, It.IsAny()), Times.AtLeastOnce); File.Delete(imagePath); diff --git a/src/BGR.Console/Removal/ImageProcessor.cs b/src/BGR.Console/Removal/ImageProcessor.cs index e864234..2ba5a00 100644 --- a/src/BGR.Console/Removal/ImageProcessor.cs +++ b/src/BGR.Console/Removal/ImageProcessor.cs @@ -8,7 +8,7 @@ internal abstract class ImageProcessor public abstract Task GenerateMaskAsync(ITensor maskTensor, int width, int height); - public abstract Task RemoveBackgroundAsync(Stream image, Stream mask); + public abstract Task RemoveBackgroundAsync(Stream image, Stream mask, byte featherMin, byte featherMax); public abstract Task SaveImageAsync(Stream image, string path); diff --git a/src/BGR.Console/Removal/ImageSharp/ImageSharpProcessor.cs b/src/BGR.Console/Removal/ImageSharp/ImageSharpProcessor.cs index dca520e..1a88cf6 100644 --- a/src/BGR.Console/Removal/ImageSharp/ImageSharpProcessor.cs +++ b/src/BGR.Console/Removal/ImageSharp/ImageSharpProcessor.cs @@ -67,7 +67,7 @@ internal class ImageSharpProcessor : ImageProcessor return stream; } - public override async Task RemoveBackgroundAsync(Stream image, Stream mask) + public override async Task RemoveBackgroundAsync(Stream image, Stream mask, byte featherMin, byte featherMax) { image.Position = 0; mask.Position = 0; @@ -76,19 +76,14 @@ internal class ImageSharpProcessor : ImageProcessor var maskImage = await Image.LoadAsync(mask); using var imageWithBgRemoved = new Image(imageWithBg.Width, imageWithBg.Height); - const byte alphaThreshold = 20; - var transparentPixel = new Rgba32(0, 0, 0, 0); - WalkImage(imageWithBg.Height, imageWithBg.Width, (x, y) => { var sourcePixel = imageWithBg[x, y]; var maskPixel = maskImage[x, y]; - var alpha = maskPixel.R; + var alpha = AdjustAlpha(maskPixel.R, featherMin, featherMax); - imageWithBgRemoved[x, y] = alpha > alphaThreshold - ? new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, sourcePixel.A) - : transparentPixel; + imageWithBgRemoved[x, y] = new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, alpha); }); var result = new MemoryStream(); @@ -107,12 +102,7 @@ internal class ImageSharpProcessor : ImageProcessor private static float Normalize(float value) { - const float binarizationThreshold = 0.5f; - const float normalizationFactor = 2f; - - return value > binarizationThreshold - ? (value - binarizationThreshold) * normalizationFactor - : 0f; + return value * value; } private static byte ConvertToGreyscale(float value) @@ -129,4 +119,15 @@ internal class ImageSharpProcessor : ImageProcessor return sigmoidScale / (sigmoidShift + MathF.Exp(sigmoidDivisor * x)); } + + private static byte AdjustAlpha(byte maskValue, byte minVal, byte maxVal) + { + if (maskValue <= minVal) + return 0; + if (maskValue >= maxVal) + return 255; + + float proportion = (maskValue - minVal) / (float)(maxVal - minVal); + return (byte)(proportion * 255f); + } } \ No newline at end of file diff --git a/src/BGR.Console/Removal/RemovalCommand.cs b/src/BGR.Console/Removal/RemovalCommand.cs index 49d21a6..685fce0 100644 --- a/src/BGR.Console/Removal/RemovalCommand.cs +++ b/src/BGR.Console/Removal/RemovalCommand.cs @@ -66,7 +66,7 @@ internal class RemovalCommand( ctx.Status("Removing background..."); var output = await _logger.TimeAndLogActionAsync( "Removing background", - async () => await _imageProcessor.RemoveBackgroundAsync(image.Data, mask) + async () => await _imageProcessor.RemoveBackgroundAsync(image.Data, mask, settings.FeatherMin, settings.FeatherMax) ); if (settings.IncludeMask) @@ -115,6 +115,14 @@ internal class RemovalCommand( [Description("Path to output image without background to. File extension will always be .png")] public string Output { get; init; } = string.Empty; + [CommandOption("--feather-min")] + [Description("Minimum mask value below which pixels become fully transparent (default: 70)")] + public byte FeatherMin { get; init; } = 70; + + [CommandOption("--feather-max")] + [Description("Maximum mask value above which pixels become fully opaque (default: 117)")] + public byte FeatherMax { get; init; } = 117; + public string ResourceName => Models[Model]; public string MaskPath => GetOutputPath("_mask");