diff --git a/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs b/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs new file mode 100644 index 000000000..fc84e4a1f --- /dev/null +++ b/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs @@ -0,0 +1,286 @@ +// Copyright (c) Six Labors and contributors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.Primitives; +using SixLabors.Shapes; + +namespace SixLabors.ImageSharp.Processing +{ + /// + /// Provides an implementation of a brush for painting gradients between multiple color positions in 2D coordinates. + /// It works similarly with the class in System.Drawing.Drawing2D of the same name. + /// + public sealed class PathGradientBrush : IBrush + { + private readonly Polygon path; + + private readonly IList edges; + + private readonly Color centerColor; + + /// + /// Initializes a new instance of the class. + /// + /// Line segments of a polygon that represents the gradient area. + /// Array of colors that correspond to each point in the polygon. + /// Color at the center of the gradient area to which the other colors converge. + public PathGradientBrush(ILineSegment[] lines, Color[] colors, Color centerColor) + { + if (lines == null) + { + throw new ArgumentNullException(nameof(lines)); + } + + if (lines.Length < 3) + { + throw new ArgumentOutOfRangeException( + nameof(lines), + "There must be at least 3 lines to construct a path gradient brush."); + } + + if (colors == null) + { + throw new ArgumentNullException(nameof(colors)); + } + + if (!colors.Any()) + { + throw new ArgumentOutOfRangeException( + nameof(colors), + "One or more color is needed to construct a path gradient brush."); + } + + this.path = new Polygon(lines); + this.centerColor = centerColor; + + Color ColorAt(int index) => colors[index % colors.Length]; + + this.edges = this.path.LineSegments.Select(s => new Path(s)) + .Select((path, i) => new Edge(path, ColorAt(i), ColorAt(i + 1))).ToList(); + } + + /// + /// Initializes a new instance of the class. + /// + /// Line segments of a polygon that represents the gradient area. + /// Array of colors that correspond to each point in the polygon. + public PathGradientBrush(ILineSegment[] lines, Color[] colors) + : this(lines, colors, CalculateCenterColor(colors)) + { + } + + /// + public BrushApplicator CreateApplicator( + ImageFrame source, + RectangleF region, + GraphicsOptions options) + where TPixel : struct, IPixel + { + return new PathGradientBrushApplicator(source, this.path, this.edges, this.centerColor, options); + } + + private static Color CalculateCenterColor(Color[] colors) + { + if (colors == null) + { + throw new ArgumentNullException(nameof(colors)); + } + + if (!colors.Any()) + { + throw new ArgumentOutOfRangeException( + nameof(colors), + "One or more color is needed to construct a path gradient brush."); + } + + return new Color(colors.Select(c => c.ToVector4()).Aggregate((p1, p2) => p1 + p2) / colors.Length); + } + + private static float DistanceBetween(PointF p1, PointF p2) => ((Vector2)(p2 - p1)).Length(); + + private struct Intersection + { + public Intersection(PointF point, float distance) + { + this.Point = point; + this.Distance = distance; + } + + public PointF Point { get; } + + public float Distance { get; } + } + + /// + /// An edge of the polygon that represents the gradient area. + /// + private class Edge + { + private readonly Path path; + + private readonly float length; + + private readonly PointF[] buffer; + + public Edge(Path path, Color startColor, Color endColor) + { + this.path = path; + + Vector2[] points = path.LineSegments.SelectMany(s => s.Flatten()).Select(p => (Vector2)p).ToArray(); + + this.Start = points.First(); + this.StartColor = startColor.ToVector4(); + + this.End = points.Last(); + this.EndColor = endColor.ToVector4(); + + this.length = DistanceBetween(this.End, this.Start); + this.buffer = new PointF[this.path.MaxIntersections]; + } + + public PointF Start { get; } + + public Vector4 StartColor { get; } + + public PointF End { get; } + + public Vector4 EndColor { get; } + + public Intersection? FindIntersection(PointF start, PointF end) + { + int intersections = this.path.FindIntersections(start, end, this.buffer); + + if (intersections == 0) + { + return null; + } + + return this.buffer.Take(intersections) + .Select(p => new Intersection(point: p, distance: ((Vector2)(p - start)).LengthSquared())) + .Aggregate((min, current) => min.Distance > current.Distance ? current : min); + } + + public Vector4 ColorAt(float distance) + { + float ratio = this.length > 0 ? distance / this.length : 0; + + return Vector4.Lerp(this.StartColor, this.EndColor, ratio); + } + + public Vector4 ColorAt(PointF point) => this.ColorAt(DistanceBetween(point, this.Start)); + } + + /// + /// The path gradient brush applicator. + /// + private class PathGradientBrushApplicator : BrushApplicator + where TPixel : struct, IPixel + { + private readonly Path path; + + private readonly PointF center; + + private readonly Vector4 centerColor; + + private readonly float maxDistance; + + private readonly IList edges; + + /// + /// Initializes a new instance of the class. + /// + /// The source image. + /// A polygon that represents the gradient area. + /// Edges of the polygon. + /// Color at the center of the gradient area to which the other colors converge. + /// The options. + public PathGradientBrushApplicator( + ImageFrame source, + Path path, + IList edges, + Color centerColor, + GraphicsOptions options) + : base(source, options) + { + this.path = path; + this.edges = edges; + + PointF[] points = path.LineSegments.Select(s => s.EndPoint).ToArray(); + + this.center = points.Aggregate((p1, p2) => p1 + p2) / points.Length; + this.centerColor = centerColor.ToVector4(); + + this.maxDistance = points.Select(p => (Vector2)(p - this.center)).Select(d => d.Length()).Max(); + } + + /// + internal override TPixel this[int x, int y] + { + get + { + var point = new PointF(x, y); + + if (point == this.center) + { + return new Color(this.centerColor).ToPixel(); + } + + if (!this.path.Contains(point)) + { + return Color.Transparent.ToPixel(); + } + + Vector2 direction = Vector2.Normalize(point - this.center); + + PointF end = point + (PointF)(direction * this.maxDistance); + + (Edge edge, Intersection? info) = this.FindIntersection(point, end); + + PointF intersection = info.Value.Point; + + Vector4 edgeColor = edge.ColorAt(intersection); + + float length = DistanceBetween(intersection, this.center); + float ratio = length > 0 ? DistanceBetween(intersection, point) / length : 0; + + Vector4 color = Vector4.Lerp(edgeColor, this.centerColor, ratio); + + return new Color(color).ToPixel(); + } + } + + private (Edge edge, Intersection? info) FindIntersection(PointF start, PointF end) + { + (Edge edge, Intersection? info) closest = default; + + foreach (Edge edge in this.edges) + { + Intersection? intersection = edge.FindIntersection(start, end); + + if (!intersection.HasValue) + { + continue; + } + + if (closest.info == null || closest.info.Value.Distance > intersection.Value.Distance) + { + closest = (edge, intersection); + } + } + + return closest; + } + + /// + public override void Dispose() + { + } + } + } +} diff --git a/src/ImageSharp/Processing/Processors/Normalization/AdaptiveHistogramEqualizationProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Normalization/AdaptiveHistogramEqualizationProcessor{TPixel}.cs index 08222d4ca..4cda4030f 100644 --- a/src/ImageSharp/Processing/Processors/Normalization/AdaptiveHistogramEqualizationProcessor{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Normalization/AdaptiveHistogramEqualizationProcessor{TPixel}.cs @@ -73,10 +73,12 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization var tileYStartPositions = new List<(int y, int cdfY)>(); int cdfY = 0; - for (int y = halfTileHeight; y < sourceHeight - halfTileHeight; y += tileHeight) + int yStart = halfTileHeight; + for (int tile = 0; tile < tileCount - 1; tile++) { - tileYStartPositions.Add((y, cdfY)); + tileYStartPositions.Add((yStart, cdfY)); cdfY++; + yStart += tileHeight; } Parallel.For( @@ -92,7 +94,8 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization ref TPixel sourceBase = ref source.GetPixelReference(0, 0); int cdfX = 0; - for (int x = halfTileWidth; x < sourceWidth - halfTileWidth; x += tileWidth) + int x = halfTileWidth; + for (int tile = 0; tile < tileCount - 1; tile++) { int tileY = 0; int yEnd = Math.Min(y + tileHeight, sourceHeight); @@ -125,24 +128,25 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization } cdfX++; + x += tileWidth; } }); ref TPixel pixelsBase = ref source.GetPixelReference(0, 0); // Fix left column - ProcessBorderColumn(ref pixelsBase, cdfData, 0, sourceWidth, sourceHeight, tileWidth, tileHeight, xStart: 0, xEnd: halfTileWidth, luminanceLevels); + ProcessBorderColumn(ref pixelsBase, cdfData, 0, sourceWidth, sourceHeight, this.Tiles, tileHeight, xStart: 0, xEnd: halfTileWidth, luminanceLevels); // Fix right column int rightBorderStartX = ((this.Tiles - 1) * tileWidth) + halfTileWidth; - ProcessBorderColumn(ref pixelsBase, cdfData, this.Tiles - 1, sourceWidth, sourceHeight, tileWidth, tileHeight, xStart: rightBorderStartX, xEnd: sourceWidth, luminanceLevels); + ProcessBorderColumn(ref pixelsBase, cdfData, this.Tiles - 1, sourceWidth, sourceHeight, this.Tiles, tileHeight, xStart: rightBorderStartX, xEnd: sourceWidth, luminanceLevels); // Fix top row - ProcessBorderRow(ref pixelsBase, cdfData, 0, sourceWidth, tileWidth, yStart: 0, yEnd: halfTileHeight, luminanceLevels); + ProcessBorderRow(ref pixelsBase, cdfData, 0, sourceWidth, this.Tiles, tileWidth, yStart: 0, yEnd: halfTileHeight, luminanceLevels); // Fix bottom row int bottomBorderStartY = ((this.Tiles - 1) * tileHeight) + halfTileHeight; - ProcessBorderRow(ref pixelsBase, cdfData, this.Tiles - 1, sourceWidth, tileWidth, yStart: bottomBorderStartY, yEnd: sourceHeight, luminanceLevels); + ProcessBorderRow(ref pixelsBase, cdfData, this.Tiles - 1, sourceWidth, this.Tiles, tileWidth, yStart: bottomBorderStartY, yEnd: sourceHeight, luminanceLevels); // Left top corner ProcessCornerTile(ref pixelsBase, cdfData, sourceWidth, 0, 0, xStart: 0, xEnd: halfTileWidth, yStart: 0, yEnd: halfTileHeight, luminanceLevels); @@ -206,7 +210,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization /// The X index of the lookup table to use. /// The source image width. /// The source image height. - /// The width of a tile. + /// The number of vertical tiles. /// The height of a tile. /// X start position in the image. /// X end position of the image. @@ -220,7 +224,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization int cdfX, int sourceWidth, int sourceHeight, - int tileWidth, + int tileCount, int tileHeight, int xStart, int xEnd, @@ -229,7 +233,8 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization int halfTileHeight = tileHeight / 2; int cdfY = 0; - for (int y = halfTileHeight; y < sourceHeight - halfTileHeight; y += tileHeight) + int y = halfTileHeight; + for (int tile = 0; tile < tileCount - 1; tile++) { int yLimit = Math.Min(y + tileHeight, sourceHeight - 1); int tileY = 0; @@ -247,6 +252,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization } cdfY++; + y += tileHeight; } } @@ -257,6 +263,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization /// The pre-computed lookup tables to remap the grey values for each tiles. /// The Y index of the lookup table to use. /// The source image width. + /// The number of horizontal tiles. /// The width of a tile. /// Y start position in the image. /// Y end position of the image. @@ -269,6 +276,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization CdfTileData cdfData, int cdfY, int sourceWidth, + int tileCount, int tileWidth, int yStart, int yEnd, @@ -277,7 +285,8 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization int halfTileWidth = tileWidth / 2; int cdfX = 0; - for (int x = halfTileWidth; x < sourceWidth - halfTileWidth; x += tileWidth) + int x = halfTileWidth; + for (int tile = 0; tile < tileCount - 1; tile++) { for (int dy = yStart; dy < yEnd; dy++) { @@ -294,6 +303,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization } cdfX++; + x += tileWidth; } } diff --git a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs index 1d9d5c986..8ddb4834d 100644 --- a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs +++ b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Six Labors and contributors. +// Copyright (c) Six Labors and contributors. // Licensed under the Apache License, Version 2.0. namespace SixLabors.ImageSharp.Processing.Processors.Normalization @@ -25,7 +25,9 @@ namespace SixLabors.ImageSharp.Processing.Processors.Normalization public int LuminanceLevels { get; set; } = 256; /// - /// Gets or sets a value indicating whether to clip the histogram bins at a specific value. Defaults to false. + /// Gets or sets a value indicating whether to clip the histogram bins at a specific value. + /// It is recommended to use clipping when the AdaptiveTileInterpolation method is used, to suppress artifacts which can occur on the borders of the tiles. + /// Defaults to false. /// public bool ClipHistogram { get; set; } = false; diff --git a/tests/ImageSharp.Tests/Drawing/FillPathGradientBrushTests.cs b/tests/ImageSharp.Tests/Drawing/FillPathGradientBrushTests.cs new file mode 100644 index 000000000..d76893108 --- /dev/null +++ b/tests/ImageSharp.Tests/Drawing/FillPathGradientBrushTests.cs @@ -0,0 +1,209 @@ +// Copyright (c) Six Labors and contributors. +// Licensed under the Apache License, Version 2.0. + +using System; + +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; +using SixLabors.Primitives; +using SixLabors.Shapes; + +using Xunit; + +namespace SixLabors.ImageSharp.Tests.Drawing +{ + [GroupOutput("Drawing/GradientBrushes")] + public class FillPathGradientBrushTests + { + public static ImageComparer TolerantComparer = ImageComparer.TolerantPercentage(0.01f); + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillRectangleWithDifferentColors(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillTriangleWithDifferentColors(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(5, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(5, 0)) + }; + + Color[] colors = { Color.Red, Color.Green, Color.Blue }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillRectangleWithSingleColor(TestImageProvider provider) + where TPixel : struct, IPixel + { + using (Image image = provider.GetImage()) + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Red }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + + image.ComparePixelBufferTo(Color.Red); + } + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void ShouldRotateTheColorsWhenThereAreMorePoints(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Red, Color.Yellow }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillWithCustomCenterColor(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + var brush = new PathGradientBrush(path, colors, Color.White); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Fact] + public void ShouldThrowArgumentNullExceptionWhenLinesAreNull() + { + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + PathGradientBrush Create() => new PathGradientBrush(null, colors, Color.White); + + Assert.Throws(Create); + } + + [Fact] + public void ShouldThrowArgumentOutOfRangeExceptionWhenLessThan3LinesAreGiven() + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)) + }; + + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + PathGradientBrush Create() => new PathGradientBrush(path, colors, Color.White); + + Assert.Throws(Create); + } + + [Fact] + public void ShouldThrowArgumentNullExceptionWhenColorsAreNull() + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + PathGradientBrush Create() => new PathGradientBrush(path, null, Color.White); + + Assert.Throws(Create); + } + + [Fact] + public void ShouldThrowArgumentOutOfRangeExceptionWhenEmptyColorArrayIsGiven() + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + var colors = new Color[0]; + + PathGradientBrush Create() => new PathGradientBrush(path, colors, Color.White); + + Assert.Throws(Create); + } + } +} diff --git a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs index 5d8a155f0..c71232524 100644 --- a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs +++ b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs @@ -9,6 +9,7 @@ using Xunit; namespace SixLabors.ImageSharp.Tests.Processing.Normalization { + // ReSharper disable InconsistentNaming public class HistogramEqualizationTests { private static readonly ImageComparer ValidatorComparer = ImageComparer.TolerantPercentage(0.0456F); @@ -113,5 +114,30 @@ namespace SixLabors.ImageSharp.Tests.Processing.Normalization image.CompareToReferenceOutput(ValidatorComparer, provider); } } + + /// + /// This is regression test for a bug with the calculation of the y-start positions, + /// where it could happen that one too much start position was calculated in some cases. + /// See: https://github.com/SixLabors/ImageSharp/pull/984 + /// + [Theory] + [WithTestPatternImages(110, 110, PixelTypes.Rgba32)] + [WithTestPatternImages(170, 170, PixelTypes.Rgba32)] + public void Issue984(TestImageProvider provider) + where TPixel : struct, IPixel + { + using (Image image = provider.GetImage()) + { + var options = new HistogramEqualizationOptions() + { + Method = HistogramEqualizationMethod.AdaptiveTileInterpolation, + LuminanceLevels = 256, + ClipHistogram = true, + NumberOfTiles = 10 + }; + image.Mutate(x => x.HistogramEqualization(options)); + image.DebugSave(provider); + } + } } } diff --git a/tests/Images/External b/tests/Images/External index 36f39bc62..99a2bc523 160000 --- a/tests/Images/External +++ b/tests/Images/External @@ -1 +1 @@ -Subproject commit 36f39bc624f8a49caf512077bf70cab30c2e5fb4 +Subproject commit 99a2bc523cd4eb00e37af20d1b2088fa11564c57