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
+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");