diff --git a/Pinta.Effects/CoreEffectsExtension.cs b/Pinta.Effects/CoreEffectsExtension.cs index 71457f161..19626798c 100644 --- a/Pinta.Effects/CoreEffectsExtension.cs +++ b/Pinta.Effects/CoreEffectsExtension.cs @@ -69,13 +69,14 @@ public void Initialize () PintaCore.Effects.RegisterEffect (new GaussianBlurEffect (services)); PintaCore.Effects.RegisterEffect (new GlowEffect (services)); PintaCore.Effects.RegisterEffect (new FeatherEffect (services)); + PintaCore.Effects.RegisterEffect (new OutlineObjectEffect (services)); PintaCore.Effects.RegisterEffect (new InkSketchEffect (services)); PintaCore.Effects.RegisterEffect (new JuliaFractalEffect (services)); PintaCore.Effects.RegisterEffect (new MandelbrotFractalEffect (services)); PintaCore.Effects.RegisterEffect (new MedianEffect (services)); PintaCore.Effects.RegisterEffect (new MotionBlurEffect (services)); PintaCore.Effects.RegisterEffect (new OilPaintingEffect (services)); - PintaCore.Effects.RegisterEffect (new OutlineEffect (services)); + PintaCore.Effects.RegisterEffect (new OutlineEdgeEffect (services)); PintaCore.Effects.RegisterEffect (new PencilSketchEffect (services)); PintaCore.Effects.RegisterEffect (new PixelateEffect (services)); PintaCore.Effects.RegisterEffect (new PolarInversionEffect (services)); @@ -120,13 +121,14 @@ public void Uninitialize () PintaCore.Effects.UnregisterInstanceOfEffect (typeof (GaussianBlurEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (GlowEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (FeatherEffect)); + PintaCore.Effects.UnregisterInstanceOfEffect (typeof (OutlineObjectEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (InkSketchEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (JuliaFractalEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (MandelbrotFractalEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (MedianEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (MotionBlurEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (OilPaintingEffect)); - PintaCore.Effects.UnregisterInstanceOfEffect (typeof (OutlineEffect)); + PintaCore.Effects.UnregisterInstanceOfEffect (typeof (OutlineEdgeEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (PencilSketchEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (PixelateEffect)); PintaCore.Effects.UnregisterInstanceOfEffect (typeof (PolarInversionEffect)); diff --git a/Pinta.Effects/Effects/FeatherEffect.cs b/Pinta.Effects/Effects/FeatherEffect.cs index 056a8e7f8..0e0111b4a 100644 --- a/Pinta.Effects/Effects/FeatherEffect.cs +++ b/Pinta.Effects/Effects/FeatherEffect.cs @@ -16,7 +16,7 @@ public sealed class FeatherEffect : BaseEffect // Takes two passes, so must be multithreaded internally public sealed override bool IsTileable => false; - public override string Name => Translations.GetString ("Feather"); + public override string Name => Translations.GetString ("Feather Object"); public override bool IsConfigurable => true; diff --git a/Pinta.Effects/Effects/OutlineEffect.cs b/Pinta.Effects/Effects/OutlineEdgeEffect.cs similarity index 91% rename from Pinta.Effects/Effects/OutlineEffect.cs rename to Pinta.Effects/Effects/OutlineEdgeEffect.cs index 20020a833..e585ef9e4 100644 --- a/Pinta.Effects/Effects/OutlineEffect.cs +++ b/Pinta.Effects/Effects/OutlineEdgeEffect.cs @@ -14,7 +14,7 @@ namespace Pinta.Effects; -public sealed class OutlineEffect : LocalHistogramEffect +public sealed class OutlineEdgeEffect : LocalHistogramEffect { private int thickness; private int intensity; @@ -23,20 +23,20 @@ public sealed class OutlineEffect : LocalHistogramEffect public sealed override bool IsTileable => true; - public override string Name => Translations.GetString ("Outline"); + public override string Name => Translations.GetString ("Outline Edge"); public override bool IsConfigurable => true; public override string EffectMenuCategory => Translations.GetString ("Stylize"); - public OutlineData Data => (OutlineData) EffectData!; // NRT - Set in constructor + public OutlineEdgeData Data => (OutlineEdgeData) EffectData!; // NRT - Set in constructor private readonly IChromeService chrome; - public OutlineEffect (IServiceProvider services) + public OutlineEdgeEffect (IServiceProvider services) { chrome = services.GetService (); - EffectData = new OutlineData (); + EffectData = new OutlineEdgeData (); } public override void LaunchConfiguration () @@ -131,7 +131,7 @@ public override void Render (ImageSurface src, ImageSurface dest, ReadOnlySpan Pinta.Resources.Icons.EffectsStylizeOutline; + + // Takes two passes, so must be multithreaded internally + public sealed override bool IsTileable => false; + + public override string Name => Translations.GetString ("Outline Object"); + + public override bool IsConfigurable => true; + + public override string EffectMenuCategory => Translations.GetString ("Object"); + + public OutlineObjectData Data => (OutlineObjectData) EffectData!; // NRT - Set in constructor + + private readonly IChromeService chrome; + private readonly ISystemService system; + private readonly IPaletteService palette; + + public OutlineObjectEffect (IServiceProvider services) + { + chrome = services.GetService (); + system = services.GetService (); + palette = services.GetService (); + EffectData = new OutlineObjectData (); + } + + public override void LaunchConfiguration () + => chrome.LaunchSimpleEffectDialog (this); + + protected override void Render (ImageSurface src, ImageSurface dest, RectangleI roi) + { + int top = roi.Top; + int bottom = roi.Bottom; + int left = roi.Left; + int right = roi.Right; + int srcHeight = src.Height; + int srcWidth = src.Width; + int radius = Data.Radius; + int threads = system.RenderThreads; + + ColorBgra primaryColor = palette.PrimaryColor.ToColorBgra (); + ColorBgra secondaryColor = palette.SecondaryColor.ToColorBgra (); + ConcurrentBag borderPixels = new ConcurrentBag (); + + // First pass + // Clean up dest, then collect all border pixels + Parallel.For (top, bottom + 1, new ParallelOptions { MaxDegreeOfParallelism = threads }, y => { + var srcData = src.GetReadOnlyPixelData (); + + // reset dest to src + // Removing this causes preview to not update to lower radius levels + var srcRow = srcData.Slice (y * srcWidth, srcWidth); + var dstRow = dest.GetPixelData ().Slice (y * srcWidth, srcWidth); + srcRow.CopyTo (dstRow); + + // Produces different behaviour at radius == 0 and radius == 1 + // When radius == 0, only consider direct border pixels + // When radius == 1, consider border pixels on diagonal + Span pixels = stackalloc PointI[8]; + // Collect a list of pixels that surround the object (border pixels) + for (int x = left; x <= right; x++) { + PointI potentialBorderPixel = new (x, y); + if (Data.OutlineBorder && (x == 0 || x == srcWidth - 1 || y == 0 || y == srcHeight - 1)) { + borderPixels.Add (potentialBorderPixel); + } else if (src.GetColorBgra (srcData, srcWidth, potentialBorderPixel).A <= Data.Tolerance) { + // Test pixel above, below, left, & right + pixels[0] = new (x - 1, y); + pixels[1] = new (x + 1, y); + pixels[2] = new (x, y - 1); + pixels[3] = new (x, y + 1); + int pixelCount = 4; + if (radius == 1) { + // if radius == 1, also test pixels on diagonals + pixels[4] = new (x - 1, y - 1); + pixels[5] = new (x - 1, y + 1); + pixels[6] = new (x + 1, y - 1); + pixels[7] = new (x + 1, y + 1); + pixelCount = 8; + } + + for (int i = 0; i < pixelCount; i++) { + var px = pixels[i].X; + var py = pixels[i].Y; + if (px < 0 || px >= srcWidth || py < 0 || py >= srcHeight) + continue; + if (src.GetColorBgra (srcData, srcWidth, new PointI (px, py)).A > Data.Tolerance) { + borderPixels.Add (potentialBorderPixel); + // Remove comments below to draw border pixels + // You will also have to comment out the 2nd pass because it will overwrite this + //int pos = srcWidth * y + x; + //borderData[pos].Bgra = 0; + //borderData[pos].A = 255; + + break; + } + } + + + } + } + }); + + + // Second pass + // Generate outline and blend to dest + Parallel.For (top, bottom + 1, new ParallelOptions { MaxDegreeOfParallelism = threads }, y => { + // otherwise produces nothing at radius == 0 + if (radius == 0) + radius = 1; + var relevantBorderPixels = borderPixels.Where (borderPixel => borderPixel.Y > y - radius && borderPixel.Y < y + radius).ToArray (); + var destRow = dest.GetPixelData ().Slice (y * srcWidth, srcWidth); + Span outlineRow = stackalloc ColorBgra[destRow.Length]; + + for (int x = left; x <= right; x++) { + byte highestAlpha = 0; + + // optimization: no change if destination has max alpha already + if (destRow[x].A == 255) + continue; + + if (Data.FillObjectBackground && destRow[x].A >= Data.Tolerance) + highestAlpha = 255; + + // Grab nearest border pixel, and calculate outline alpha based off it + foreach (var borderPixel in relevantBorderPixels) { + if (borderPixel.X == x && borderPixel.Y == y) + highestAlpha = 255; + + if (highestAlpha == 255) + break; + + if (borderPixel.X > x - radius && borderPixel.X < x + radius) { + var dx = borderPixel.X - x; + var dy = borderPixel.Y - y; + float distance = MathF.Sqrt (dx * dx + dy * dy); + if (distance <= radius) { + float mult = 1 - distance / radius; + if (mult <= 0) + continue; + byte alpha = (byte) (255 * mult); + if (alpha > highestAlpha) + highestAlpha = alpha; + } + } + } + + // Handle color gradient / no alpha gradient option + var color = primaryColor; + if (Data.ColorGradient) + color = ColorBgra.Blend (secondaryColor, primaryColor, highestAlpha); + if (!Data.AlphaGradient && highestAlpha != 0) + highestAlpha = 255; + + outlineRow[x] = color.NewAlpha (highestAlpha).ToPremultipliedAlpha (); + } + // Performs alpha blending + new UserBlendOps.NormalBlendOp ().Apply (outlineRow, destRow); + outlineRow.CopyTo (destRow); + }); + } + + public sealed class OutlineObjectData : EffectData + { + [Caption ("Radius"), MinimumValue (0), MaximumValue (100)] + public int Radius { get; set; } = 6; + + [Caption ("Tolerance"), MinimumValue (0), MaximumValue (255)] + public int Tolerance { get; set; } = 20; + + [Caption ("Alpha Gradient")] + public bool AlphaGradient { get; set; } = true; + + [Caption ("Color Gradient")] + public bool ColorGradient { get; set; } = true; + + [Caption ("Outline Border")] + public bool OutlineBorder { get; set; } = false; + + [Caption ("Fill Object Background")] + public bool FillObjectBackground { get; set; } = true; + } +} diff --git a/tests/Pinta.Effects.Tests/Assets/outlineobject1.png b/tests/Pinta.Effects.Tests/Assets/outlineobject1.png new file mode 100644 index 000000000..adfc66171 Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/outlineobject1.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/outlineobject2.png b/tests/Pinta.Effects.Tests/Assets/outlineobject2.png new file mode 100644 index 000000000..953e91bb2 Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/outlineobject2.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/outlineobject3.png b/tests/Pinta.Effects.Tests/Assets/outlineobject3.png new file mode 100644 index 000000000..4fff09100 Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/outlineobject3.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/outlineobject4.png b/tests/Pinta.Effects.Tests/Assets/outlineobject4.png new file mode 100644 index 000000000..47583ed3b Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/outlineobject4.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/outlineobject5.png b/tests/Pinta.Effects.Tests/Assets/outlineobject5.png new file mode 100644 index 000000000..08270fd75 Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/outlineobject5.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/outlineobjectinput.png b/tests/Pinta.Effects.Tests/Assets/outlineobjectinput.png new file mode 100644 index 000000000..1952c0bfc Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/outlineobjectinput.png differ diff --git a/tests/Pinta.Effects.Tests/EffectsTest.cs b/tests/Pinta.Effects.Tests/EffectsTest.cs index 9bbaee4df..58e68ff33 100644 --- a/tests/Pinta.Effects.Tests/EffectsTest.cs +++ b/tests/Pinta.Effects.Tests/EffectsTest.cs @@ -340,14 +340,14 @@ public void OilPainting2 () [Test] public void Outline1 () { - OutlineEffect effect = new (Utilities.CreateMockServices ()); + OutlineEdgeEffect effect = new (Utilities.CreateMockServices ()); Utilities.TestEffect (effect, "outline1.png"); } [Test] public void Outline2 () { - OutlineEffect effect = new (Utilities.CreateMockServices ()); + OutlineEdgeEffect effect = new (Utilities.CreateMockServices ()); effect.Data.Thickness = 25; effect.Data.Intensity = 20; Utilities.TestEffect (effect, "outline2.png"); @@ -634,10 +634,72 @@ public void AlignObject2 () effect.Data.Position = AlignPosition.Center; Utilities.TestEffect (effect, "alignobject2.png", source_image_name: "alignobjectinput.png"); } + [Test] public void AlignObject3 () { AlignObjectEffect effect = new (Utilities.CreateMockServices ()); effect.Data.Position = AlignPosition.BottomRight; Utilities.TestEffect (effect, "alignobject3.png", source_image_name: "alignobjectinput.png"); } + + [Test] + public void OutlineObject1 () + { + OutlineObjectEffect effect = new (Utilities.CreateMockServices ()); + effect.Data.Radius = 10; + effect.Data.Tolerance = 130; + effect.Data.AlphaGradient = true; + effect.Data.ColorGradient = false; + effect.Data.OutlineBorder = false; + effect.Data.FillObjectBackground = false; + Utilities.TestEffect (effect, "outlineobject1.png", source_image_name: "outlineobjectinput.png"); + } + [Test] + public void OutlineObject2 () + { + OutlineObjectEffect effect = new (Utilities.CreateMockServices ()); + effect.Data.Radius = 10; + effect.Data.Tolerance = 20; + effect.Data.AlphaGradient = false; + effect.Data.ColorGradient = true; + effect.Data.OutlineBorder = true; + effect.Data.FillObjectBackground = false; + Utilities.TestEffect (effect, "outlineobject2.png", source_image_name: "outlineobjectinput.png"); + } + [Test] + public void OutlineObject3 () + { + OutlineObjectEffect effect = new (Utilities.CreateMockServices ()); + effect.Data.Radius = 10; + effect.Data.Tolerance = 20; + effect.Data.AlphaGradient = true; + effect.Data.ColorGradient = true; + effect.Data.OutlineBorder = false; + effect.Data.FillObjectBackground = true; + Utilities.TestEffect (effect, "outlineobject3.png", source_image_name: "outlineobjectinput.png"); + } + [Test] + public void OutlineObject4 () + { + OutlineObjectEffect effect = new (Utilities.CreateMockServices ()); + effect.Data.Radius = 1; + effect.Data.Tolerance = 20; + effect.Data.AlphaGradient = false; + effect.Data.ColorGradient = false; + effect.Data.OutlineBorder = false; + effect.Data.FillObjectBackground = false; + Utilities.TestEffect (effect, "outlineobject4.png", source_image_name: "outlineobjectinput.png"); + } + [Test] + public void OutlineObject5 () + { + OutlineObjectEffect effect = new (Utilities.CreateMockServices ()); + effect.Data.Radius = 0; + effect.Data.Tolerance = 20; + effect.Data.AlphaGradient = false; + effect.Data.ColorGradient = false; + effect.Data.OutlineBorder = false; + effect.Data.FillObjectBackground = false; + Utilities.TestEffect (effect, "outlineobject5.png", source_image_name: "outlineobjectinput.png"); + } } diff --git a/tests/PintaBenchmarks/EffectsBenchmarks.cs b/tests/PintaBenchmarks/EffectsBenchmarks.cs index d9adb8144..360a088a7 100644 --- a/tests/PintaBenchmarks/EffectsBenchmarks.cs +++ b/tests/PintaBenchmarks/EffectsBenchmarks.cs @@ -128,7 +128,7 @@ public void OilPaintingEffect () [Benchmark] public void OutlineEffect () { - var effect = new OutlineEffect (Utilities.CreateMockServices ()); + var effect = new OutlineEdgeEffect (Utilities.CreateMockServices ()); effect.Render (surface, dest_surface, bounds); }