Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Outline Object Effect #971

Merged
merged 15 commits into from
Sep 16, 2024
6 changes: 4 additions & 2 deletions Pinta.Effects/CoreEffectsExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion Pinta.Effects/Effects/FeatherEffect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

namespace Pinta.Effects;

public sealed class OutlineEffect : LocalHistogramEffect
public sealed class OutlineEdgeEffect : LocalHistogramEffect
{
private int thickness;
private int intensity;
Expand All @@ -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<IChromeService> ();
EffectData = new OutlineData ();
EffectData = new OutlineEdgeData ();
}

public override void LaunchConfiguration ()
Expand Down Expand Up @@ -131,7 +131,7 @@ public override void Render (ImageSurface src, ImageSurface dest, ReadOnlySpan<R

#endregion

public sealed class OutlineData : EffectData
public sealed class OutlineEdgeData : EffectData
{
[Caption ("Thickness"), MinimumValue (1), MaximumValue (200)]
public int Thickness { get; set; } = 3;
Expand Down
194 changes: 194 additions & 0 deletions Pinta.Effects/Effects/OutlineObjectEffect.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
using Cairo;
using Pinta.Core;
using Pinta.Gui.Widgets;

namespace Pinta.Effects;

public sealed class OutlineObjectEffect : BaseEffect
{

public override string Icon => 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<IChromeService> ();
system = services.GetService<ISystemService> ();
palette = services.GetService<IPaletteService> ();
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<PointI> borderPixels = new ConcurrentBag<PointI> ();

// 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<PointI> 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<ColorBgra> 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure if I'm understanding the use case for the Fill Object Background toggle, but it might be more clear to label it as making the object opaque since it's not filling with a new colour?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To show, 1st is pic before, 2nd is without, 3rd is with fill object background

image
image
image

I'm also not too sure what to name this, but I decided "fill object background" was most fitting.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, yeah that seems like a reasonable label then 👍

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;
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 64 additions & 2 deletions tests/Pinta.Effects.Tests/EffectsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
}
}
2 changes: 1 addition & 1 deletion tests/PintaBenchmarks/EffectsBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading