Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 71 additions & 32 deletions src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -662,8 +662,24 @@ public override void DrawImage(
return;
}

Image<TPixel> convertedImage = image.CloneAs<TPixel>();
this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true);
// Only the pixels inside the clipped source region are ever sampled by the draw operation.
// When that region covers just part of the image, crop it in the source pixel format first so
// the per-pixel format conversion runs over the required region instead of the whole image.
if (!TryGetDrawImageClip(sourceRect, destinationRect, image.Bounds, out Rectangle clippedSourceRect, out RectangleF clippedDestinationRect))
{
return;
}

if (clippedSourceRect == image.Bounds)
{
Image<TPixel> convertedImage = image.CloneAs<TPixel>();
this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true);
return;
}

using Image croppedSource = image.Clone(ctx => ctx.Crop(clippedSourceRect));
Image<TPixel> convertedRegion = croppedSource.CloneAs<TPixel>();
this.DrawImageCore(convertedRegion, convertedRegion.Bounds, clippedDestinationRect, sampler, ownsSourceImage: true);
}

/// <inheritdoc cref="DrawingCanvas.DrawImage(Image, Rectangle, RectangleF, IResampler?)" />
Expand Down Expand Up @@ -722,41 +738,26 @@ private void DrawImageCore(
DrawingOptions commandOptions = effectiveOptions;
IReadOnlyList<IPath> commandClipPaths = state.ClipPaths;

if (sourceRect.Width <= 0 ||
sourceRect.Height <= 0 ||
destinationRect.Width <= 0 ||
destinationRect.Height <= 0)
{
return;
}

Rectangle clippedSourceRect = Rectangle.Intersect(sourceRect, image.Bounds);
if (clippedSourceRect.Width <= 0 || clippedSourceRect.Height <= 0)
{
return;
}

RectangleF clippedDestinationRect = MapSourceClipToDestination(sourceRect, destinationRect, clippedSourceRect);
if (clippedDestinationRect.Width <= 0 || clippedDestinationRect.Height <= 0)
Image<TPixel>? ownedImage = null;
try
{
return;
}
if (!TryGetDrawImageClip(sourceRect, destinationRect, image.Bounds, out Rectangle clippedSourceRect, out RectangleF clippedDestinationRect))
{
return;
}

Size scaledSize = new(
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Width)),
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Height)));
Size scaledSize = new(
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Width)),
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Height)));

bool requiresScaling =
clippedSourceRect.Width != scaledSize.Width ||
clippedSourceRect.Height != scaledSize.Height;
bool requiresScaling =
clippedSourceRect.Width != scaledSize.Width ||
clippedSourceRect.Height != scaledSize.Height;

Image<TPixel> brushImage = image;
RectangleF brushImageRegion = clippedSourceRect;
RectangleF renderDestinationRect = clippedDestinationRect;
Image<TPixel>? ownedImage = null;
Image<TPixel> brushImage = image;
RectangleF brushImageRegion = clippedSourceRect;
RectangleF renderDestinationRect = clippedDestinationRect;

try
{
// Phase 1: Prepare source pixels (crop/scale) in image-local space.
if (requiresScaling)
{
Expand Down Expand Up @@ -1532,6 +1533,44 @@ private static Image<TPixel> CreateTransformedDrawImage(
sampler ?? KnownResamplers.Bicubic));
}

/// <summary>
/// Computes the source and destination rectangles that a draw-image operation will actually
/// touch, clipping the requested source rectangle to the image bounds.
/// </summary>
/// <param name="sourceRect">Requested source rectangle.</param>
/// <param name="destinationRect">Requested destination rectangle.</param>
/// <param name="imageBounds">Bounds of the source image.</param>
/// <param name="clippedSourceRect">Receives the source rectangle clipped to <paramref name="imageBounds"/>.</param>
/// <param name="clippedDestinationRect">Receives the destination rectangle matching <paramref name="clippedSourceRect"/>.</param>
/// <returns><see langword="true"/> when the operation covers a non-empty region; otherwise <see langword="false"/>.</returns>
private static bool TryGetDrawImageClip(
Rectangle sourceRect,
RectangleF destinationRect,
Rectangle imageBounds,
out Rectangle clippedSourceRect,
out RectangleF clippedDestinationRect)
{
clippedSourceRect = default;
clippedDestinationRect = default;

// A zero-area source cannot be sampled and would divide by zero when mapping to the destination.
if (sourceRect.Width <= 0 || sourceRect.Height <= 0)
{
return false;
}

clippedSourceRect = Rectangle.Intersect(sourceRect, imageBounds);
if (clippedSourceRect.Width <= 0 || clippedSourceRect.Height <= 0)
{
return false;
}

clippedDestinationRect = MapSourceClipToDestination(sourceRect, destinationRect, clippedSourceRect);

// A degenerate (empty or inverted) destination maps to nothing to draw.
return clippedDestinationRect.Width > 0 && clippedDestinationRect.Height > 0;
}

/// <summary>
/// Maps a clipped source rectangle back to the corresponding destination rectangle.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,186 @@ public void DrawImage_WithClipPathAndTransform_MatchesReference<TPixel>(TestImag
target.DebugSave(provider, appendSourceFileOrDescription: false);
target.CompareToReferenceOutput(provider, appendSourceFileOrDescription: false);
}

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithForeignPixelFormat_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertForeignPixelFormatMatchesFullConversion(
provider,
new Rectangle(64, 48, 180, 150),
new RectangleF(40, 30, 200, 170),
new Matrix4x4(Matrix3x2.CreateRotation(0.28F, new Vector2(160, 120))));

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithForeignPixelFormat_PartialRegionNoTransform_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertForeignPixelFormatMatchesFullConversion(
provider,
new Rectangle(64, 48, 180, 150),
new RectangleF(40, 30, 200, 170),
Matrix4x4.Identity);

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithForeignPixelFormat_SourceOutsideTopLeft_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertForeignPixelFormatMatchesFullConversion(
provider,
new Rectangle(-48, -32, 220, 190),
new RectangleF(30, 24, 210, 180),
new Matrix4x4(Matrix3x2.CreateRotation(0.21F, new Vector2(160, 120))));

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithForeignPixelFormat_SourceOutsideBottomRight_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertForeignPixelFormatMatchesFullConversion(
provider,
new Rectangle(200, 150, 260, 220),
new RectangleF(48, 40, 200, 168),
Matrix4x4.Identity);

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithForeignPixelFormat_ProjectiveTransform_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
// A quad/projective transform (non-affine Matrix4x4 with perspective terms) combined
// with a rotation, exercising the transform path over the clipped region.
Matrix4x4 projective = new Matrix4x4(Matrix3x2.CreateRotation(0.18F, new Vector2(160, 120)))
{
M14 = 0.0006F,
M24 = 0.0004F
};

AssertForeignPixelFormatMatchesFullConversion(
provider,
new Rectangle(56, 40, 190, 160),
new RectangleF(44, 34, 200, 168),
projective);
}

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithForeignPixelFormat_WholeImage_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertForeignPixelFormatMatchesFullConversion(
provider,
new Rectangle(0, 0, 320, 240),
new RectangleF(24, 20, 260, 200),
new Matrix4x4(Matrix3x2.CreateRotation(0.15F, new Vector2(160, 120))));

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithEmptySourceRect_IsNoOp<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertDrawImageIsNoOp(
provider,
new Rectangle(40, 30, 0, 120),
new RectangleF(20, 20, 200, 160));

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithEmptyDestinationRect_IsNoOp<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertDrawImageIsNoOp(
provider,
new Rectangle(40, 30, 180, 150),
new RectangleF(20, 20, 200, 0));

[Theory]
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
public void DrawImage_WithSourceRectFullyOutsideImage_IsNoOp<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
=> AssertDrawImageIsNoOp(
provider,
new Rectangle(400, 300, 120, 100),
new RectangleF(20, 20, 200, 160));

/// <summary>
/// A draw whose clipped source/destination region is empty must be a no-op for both the
/// typed <see cref="Image{TPixel}"/> overload and the foreign-pixel-format <see cref="Image"/>
/// overload, leaving the cleared background untouched.
/// </summary>
private static void AssertDrawImageIsNoOp<TPixel>(
TestImageProvider<TPixel> provider,
Rectangle sourceRect,
RectangleF destinationRect)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> source = provider.GetImage();

// A source image whose pixel format differs from the canvas, forcing the foreign-format path.
using Image<Rgb24> foreignSource = source.CloneAs<Rgb24>();

// The reference is the cleared background: a degenerate draw must not change any pixel.
using Image<TPixel> expected = new(source.Width, source.Height);
using (DrawingCanvas<TPixel> reference = CreateCanvas(provider, expected, new DrawingOptions()))
{
reference.Clear(Brushes.Solid(Color.White));
}

void AssertNoOp(Action<DrawingCanvas<TPixel>> draw)
{
using Image<TPixel> actual = new(source.Width, source.Height);
using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, actual, new DrawingOptions()))
{
canvas.Clear(Brushes.Solid(Color.White));
draw(canvas);
}

ImageComparer.Exact.VerifySimilarity(expected, actual);
}

// Typed overload -> DrawImageCore empty-region early-return.
AssertNoOp(canvas => canvas.DrawImage(source, sourceRect, destinationRect, KnownResamplers.Bicubic));

// Foreign-format overload -> DrawImage empty-region early-return before any conversion.
AssertNoOp(canvas => canvas.DrawImage((Image)foreignSource, sourceRect, destinationRect, KnownResamplers.Bicubic));
}

/// <summary>
/// Drawing a foreign-pixel-format image (which converts only the clipped source region) must
/// produce pixels identical to first converting the whole image to the canvas format and drawing that.
/// </summary>
private static void AssertForeignPixelFormatMatchesFullConversion<TPixel>(
TestImageProvider<TPixel> provider,
Rectangle sourceRect,
RectangleF destinationRect,
Matrix4x4 transform)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> source = provider.GetImage();

// A source image whose pixel format differs from the canvas, forcing a per-pixel conversion.
using Image<Rgb24> foreignSource = source.CloneAs<Rgb24>();

// Reference source: the whole foreign image converted up-front to the canvas format.
using Image<TPixel> convertedSource = foreignSource.CloneAs<TPixel>();

DrawingOptions options = new()
{
Transform = transform
};

using Image<TPixel> actual = new(source.Width, source.Height);
using Image<TPixel> expected = new(source.Width, source.Height);

using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, actual, options))
{
canvas.Clear(Brushes.Solid(Color.White));
canvas.DrawImage((Image)foreignSource, sourceRect, destinationRect, KnownResamplers.Bicubic);
}

using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, expected, options))
{
canvas.Clear(Brushes.Solid(Color.White));
canvas.DrawImage(convertedSource, sourceRect, destinationRect, KnownResamplers.Bicubic);
}

// Converting only the clipped region must produce pixels identical to converting the whole image.
ImageComparer.Exact.VerifySimilarity(expected, actual);
}
}