SixLabors / ImageSharp.Drawing

:pen: Extensions to ImageSharp containing a cross-platform 2D polygon manipulation API and drawing operations.
https://sixlabors.com/products/imagesharp-drawing/
Other
282 stars 38 forks source link

Off-by-one error on Polygon (InternalPath).Bounds.Width and Length #238

Closed remcoros closed 2 years ago

remcoros commented 2 years ago

Prerequisites

Description

I believe there is an off-by-one error on 'InternalPath.Bounds'

Steps to Reproduce

This fails:

            var poly = new Polygon(
                new LinearLineSegment(
                    new PointF(0, 0),
                    new PointF(9, 0),
                    new PointF(9, 9),
                    new PointF(0, 9))
            );

            Assert.AreEqual(10, poly.Bounds.Width);
            Assert.AreEqual(10, poly.Bounds.Height);

Using '10,10' for the end points is incorrect. If you try to draw a '10,10' polygon on a 10x10 image, the right and bottom part of the outline falls outside the image (since the range of pixels is 0..9 not 0..10)

Either, the current implementation is wrong and width/length should be corrected. Or it could be correct and we should use PointF(10, 10) if we want a 10x10 polygon. But then there is something wrong with drawing this polygon on an Image(10, 10), since when I try to draw a 10x10 polygon (using PointF(10, 10)), the right and bottom outline are not in the image.

System Configuration

        [TestMethod]
        public void Image()
        {
            var poly = new Polygon(
                new LinearLineSegment(
                    new PointF(0, 0),
                    new PointF(9, 0),
                    new PointF(9, 9),
                    new PointF(0, 9))
            );

            var image = new Image<Rgba32>(10, 10);
            image.Mutate(x =>
            {
                x.SetGraphicsOptions(new GraphicsOptions
                {
                    Antialias = false
                });

                x.Draw(Pens.Solid(Color.Red, 1), poly);
                x.BackgroundColor(Color.White);
            });

            // var tmpFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".bmp");
            // image.SaveAsBmp(tmpFile);
            // Process.Start(tmpFile);

            Assert.AreEqual(10, poly.Bounds.Width);
            Assert.AreEqual(10, poly.Bounds.Height);
        }
JimBobSquarePants commented 2 years ago

@remcoros Can you try the latest MyGet build 1.0.0-beta14.16

remcoros commented 2 years ago

@JimBobSquarePants I will as soon as I get home.

But I don't think that changes anything. As the issue is in this code: https://github.com/SixLabors/ImageSharp.Drawing/blob/main/src/ImageSharp.Drawing/Shapes/InternalPath.cs#L88

which didn't change recently.

"Max(x) - Min(x)" has a off-by-one error if you start at '0'.

Some background: In a geometrical coordinate system, going from 0mm, 0mm - 10mm, 10mm, actually means you have a line of 10mm. So doing Max(x) - Min(x) gives you the correct length.

But when dealing with a screen/image coordinate system, having a 'line' from 0,0 to 10,10 is actually a line of 11 units (pixels).

This caused me a lot of confusion when translating incoming geometrical shapes (as a list of points) to ImageSharp polygons.

tocsoft commented 2 years ago

I don't believe this is a bug and from how I interpret the issue is working as intended... this is due to our drawing code being subpixel aware, basically we are not drawing pixel we are drawing an area around your chosen line, and drawing is done from the pixel center not the pixel intersection.

these images should help

the effect you are after here is filling in the entire top row 9 pixel at the subpixel

this is the issue with drawing at the intersection, you only get half of your line in each target pixel, which (with anti-aliasing will result the wrong color 10 pixel at the intersection

remcoros commented 2 years ago

@tocsoft So what's the correct approach/code to get an exact 10x10 square (outline) on a 10x10 image. Pixel perfect, no anti-aliasing?

I'm basically trying to visualize a 2D shape (defined as a list of integer points). My idea was to make a polygon of it, and use an image width/height exactly the same as the shapes height and width, so each point maps to a pixel, avoiding any artifacts due to aliassing

JimBobSquarePants commented 2 years ago

After updating to 1.0.0-beta15 (which fixes 1px vertical line rendering #236) and running a comparison with both SkiaSharp and System.Drawing it looks to me like we do exactly the same thing they do.

// SkiaSharp
using var skImage = new SKBitmap(100, 100);
using var skCanvas = new SKCanvas(skImage);

var skRectangle = SKRect.Create(0, 0, 100, 100);
using var paint = new SKPaint
{
    Style = SKPaintStyle.Fill,
    Color = SKColors.White
};

skCanvas.DrawRect(skRectangle, paint);

paint.Style = SKPaintStyle.Stroke;
paint.Color = SKColors.Red;
skCanvas.DrawRect(skRectangle, paint);

using var skStream = File.Create(@"C:\Users\james\Documents\MiscDevelopment\skImage.png");
skImage.Encode(skStream, SKEncodedImageFormat.Png, 100);

// System.Drawing
using var sdImage = new Bitmap(100, 100);
using var sdGraphics = Graphics.FromImage(sdImage);
using var sdBrush = new System.Drawing.SolidBrush(System.Drawing.Color.White);

var sdRectangle = new System.Drawing.Rectangle(0, 0, 100, 100);
sdGraphics.FillRectangle(sdBrush, sdRectangle);

using var pen = new System.Drawing.Pen(System.Drawing.Color.Red);
sdGraphics.DrawRectangle(pen, sdRectangle);

using var sdStream = File.Create(@"C:\Users\james\Documents\MiscDevelopment\sdImage.png");
sdImage.Save(sdStream, ImageFormat.Png);

// ImageSharp
using var slImage = new Image<Rgba32>(Configuration.Default, 100, 100, SixLabors.ImageSharp.Color.White);
slImage.Mutate(x =>
{
    x.SetGraphicsOptions(new GraphicsOptions
    {
        Antialias = false
    });
    x.Draw(SixLabors.ImageSharp.Drawing.Processing.Pens.Solid(SixLabors.ImageSharp.Color.Red, 1), new RectangularPolygon(0, 0, 100, 100).AsClosedPath());
});
slImage.SaveAsPng(@"C:\Users\james\Documents\MiscDevelopment\slImage.png");

SkiaSharp skImage

System.Drawing sdImage

ImageSharp slImage

SkiaSharp (Left) - ImageSharp (Right) comparison

remcoros commented 2 years ago

Thanks for the extensive explanations! Considering anti-aliasing, what @tocsoft explains makes sense.

Also looking at @JimBobSquarePants example above. Is it correct to assume that, if I want to draw a rectangle of 100x100 on a canvas of 100x100. I would need a (Rectangular)Polygon of 0,0,99,99 ?

So for my case (where I get a list of points from a 2D CAD application), I would need to subtract 1 if x/y > 0 like so:

var imagePoints = sourcePoints.Select(src=> new PointF(src.X == 0 ? 0 : src.X - 1, src.Y == 0 ? 0 : src.Y - 1))

Is that a correct assumption?

edit: the source points are the shape of a 2D object defined in mm. So a 10 mm line, goes from 0,0 - 0,10

edit2: this all started because I saw weird artifacts on some generated images. But that actually also might be because of the mentioned bug. I'll update to that beta and do some more testing.

JimBobSquarePants commented 2 years ago

Is it correct to assume that, if I want to draw a rectangle of 100x100 on a canvas of 100x100. I would need a (Rectangular)Polygon of 0,0,99,99 ?

Yep, 0-99. It's all zero based.

image

That latest releases fixes a few things so yes, definitely give it a go.

remcoros commented 2 years ago

Awesome! For the test geometry I had which had some really weird artifacts (even without anti-aliassing), they are all gone!

Thanks a lot for the explanations, it's a bit weird for me having worked a lot with geometrical data structures (A 'Line' entity with a starting point of 0,0 and an endpoint of 0,10 had a length of 10, and we map these one-to-one to lines/polygons within imagesharp). I understand it's not that simple, considering the subpixel drawing. Thanks a lot, i'll have some fixing to do on our side :)