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
This commit is contained in:
Stevan Freeborn
2026-07-15 10:52:35 -05:00
parent 51abe75e07
commit e099144127
5 changed files with 62 additions and 19 deletions
@@ -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<Rgba32>(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<Rgba32>(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<Rgba32>(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()
{
@@ -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<byte>(), It.IsAny<byte>()))
.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<byte>(), It.IsAny<byte>()), Times.Once);
_imageProcessorMock.Verify(p => p.SaveImageAsync(outputStream, It.IsAny<string>()), Times.AtLeastOnce);
File.Delete(imagePath);
+1 -1
View File
@@ -8,7 +8,7 @@ internal abstract class ImageProcessor
public abstract Task<Stream> GenerateMaskAsync(ITensor<float> maskTensor, int width, int height);
public abstract Task<Stream> RemoveBackgroundAsync(Stream image, Stream mask);
public abstract Task<Stream> RemoveBackgroundAsync(Stream image, Stream mask, byte featherMin, byte featherMax);
public abstract Task SaveImageAsync(Stream image, string path);
@@ -67,7 +67,7 @@ internal class ImageSharpProcessor : ImageProcessor
return stream;
}
public override async Task<Stream> RemoveBackgroundAsync(Stream image, Stream mask)
public override async Task<Stream> 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<Rgba32>(mask);
using var imageWithBgRemoved = new Image<Rgba32>(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);
}
}
+9 -1
View File
@@ -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");