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:
@@ -183,7 +183,10 @@ public class ImageSharpProcessorTests : IDisposable
|
|||||||
await mask.SaveAsPngAsync(maskStream);
|
await mask.SaveAsPngAsync(maskStream);
|
||||||
maskStream.Position = 0;
|
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.ShouldNotBeNull();
|
||||||
result.Length.ShouldBeGreaterThan(0);
|
result.Length.ShouldBeGreaterThan(0);
|
||||||
@@ -203,6 +206,37 @@ public class ImageSharpProcessorTests : IDisposable
|
|||||||
resultImage[1, 1].A.ShouldBe((byte)255);
|
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]
|
[Fact]
|
||||||
public async Task SaveImageAsync_WhenCalled_ItShouldSaveImageToDiskAtProvidedPath()
|
public async Task SaveImageAsync_WhenCalled_ItShouldSaveImageToDiskAtProvidedPath()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class RemovalCommandTests : IDisposable
|
|||||||
.ReturnsAsync(maskStream);
|
.ReturnsAsync(maskStream);
|
||||||
|
|
||||||
_imageProcessorMock
|
_imageProcessorMock
|
||||||
.Setup(p => p.RemoveBackgroundAsync(image.Data, maskStream))
|
.Setup(p => p.RemoveBackgroundAsync(image.Data, maskStream, It.IsAny<byte>(), It.IsAny<byte>()))
|
||||||
.ReturnsAsync(outputStream);
|
.ReturnsAsync(outputStream);
|
||||||
|
|
||||||
var commandContext = new CommandContext(
|
var commandContext = new CommandContext(
|
||||||
@@ -91,7 +91,7 @@ public class RemovalCommandTests : IDisposable
|
|||||||
_imageProcessorMock.Verify(p => p.CreateTensorInputAsync(image.Data, model), Times.Once);
|
_imageProcessorMock.Verify(p => p.CreateTensorInputAsync(image.Data, model), Times.Once);
|
||||||
_inferenceRunnerMock.Verify(r => r.Run(model.Bytes, inputTensor), 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.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);
|
_imageProcessorMock.Verify(p => p.SaveImageAsync(outputStream, It.IsAny<string>()), Times.AtLeastOnce);
|
||||||
|
|
||||||
File.Delete(imagePath);
|
File.Delete(imagePath);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ internal abstract class ImageProcessor
|
|||||||
|
|
||||||
public abstract Task<Stream> GenerateMaskAsync(ITensor<float> 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<Stream> RemoveBackgroundAsync(Stream image, Stream mask, byte featherMin, byte featherMax);
|
||||||
|
|
||||||
public abstract Task SaveImageAsync(Stream image, string path);
|
public abstract Task SaveImageAsync(Stream image, string path);
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ internal class ImageSharpProcessor : ImageProcessor
|
|||||||
return stream;
|
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;
|
image.Position = 0;
|
||||||
mask.Position = 0;
|
mask.Position = 0;
|
||||||
@@ -76,19 +76,14 @@ internal class ImageSharpProcessor : ImageProcessor
|
|||||||
var maskImage = await Image.LoadAsync<Rgba32>(mask);
|
var maskImage = await Image.LoadAsync<Rgba32>(mask);
|
||||||
using var imageWithBgRemoved = new Image<Rgba32>(imageWithBg.Width, imageWithBg.Height);
|
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) =>
|
WalkImage(imageWithBg.Height, imageWithBg.Width, (x, y) =>
|
||||||
{
|
{
|
||||||
var sourcePixel = imageWithBg[x, y];
|
var sourcePixel = imageWithBg[x, y];
|
||||||
var maskPixel = maskImage[x, y];
|
var maskPixel = maskImage[x, y];
|
||||||
|
|
||||||
var alpha = maskPixel.R;
|
var alpha = AdjustAlpha(maskPixel.R, featherMin, featherMax);
|
||||||
|
|
||||||
imageWithBgRemoved[x, y] = alpha > alphaThreshold
|
imageWithBgRemoved[x, y] = new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, alpha);
|
||||||
? new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, sourcePixel.A)
|
|
||||||
: transparentPixel;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var result = new MemoryStream();
|
var result = new MemoryStream();
|
||||||
@@ -107,12 +102,7 @@ internal class ImageSharpProcessor : ImageProcessor
|
|||||||
|
|
||||||
private static float Normalize(float value)
|
private static float Normalize(float value)
|
||||||
{
|
{
|
||||||
const float binarizationThreshold = 0.5f;
|
return value * value;
|
||||||
const float normalizationFactor = 2f;
|
|
||||||
|
|
||||||
return value > binarizationThreshold
|
|
||||||
? (value - binarizationThreshold) * normalizationFactor
|
|
||||||
: 0f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte ConvertToGreyscale(float value)
|
private static byte ConvertToGreyscale(float value)
|
||||||
@@ -129,4 +119,15 @@ internal class ImageSharpProcessor : ImageProcessor
|
|||||||
|
|
||||||
return sigmoidScale / (sigmoidShift + MathF.Exp(sigmoidDivisor * x));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ internal class RemovalCommand(
|
|||||||
ctx.Status("Removing background...");
|
ctx.Status("Removing background...");
|
||||||
var output = await _logger.TimeAndLogActionAsync(
|
var output = await _logger.TimeAndLogActionAsync(
|
||||||
"Removing background",
|
"Removing background",
|
||||||
async () => await _imageProcessor.RemoveBackgroundAsync(image.Data, mask)
|
async () => await _imageProcessor.RemoveBackgroundAsync(image.Data, mask, settings.FeatherMin, settings.FeatherMax)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (settings.IncludeMask)
|
if (settings.IncludeMask)
|
||||||
@@ -115,6 +115,14 @@ internal class RemovalCommand(
|
|||||||
[Description("Path to output image without background to. File extension will always be .png")]
|
[Description("Path to output image without background to. File extension will always be .png")]
|
||||||
public string Output { get; init; } = string.Empty;
|
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 ResourceName => Models[Model];
|
||||||
|
|
||||||
public string MaskPath => GetOutputPath("_mask");
|
public string MaskPath => GetOutputPath("_mask");
|
||||||
|
|||||||
Reference in New Issue
Block a user