Skip to content

Commit ec167af

Browse files
Merge pull request #407 from mediaclip/perf/drawimage-region-conversion
Perf: Convert only the required source region in DrawImage
2 parents 034d0ef + 844c332 commit ec167af

2 files changed

Lines changed: 253 additions & 32 deletions

File tree

src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -662,8 +662,24 @@ public override void DrawImage(
662662
return;
663663
}
664664

665-
Image<TPixel> convertedImage = image.CloneAs<TPixel>();
666-
this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true);
665+
// Only the pixels inside the clipped source region are ever sampled by the draw operation.
666+
// When that region covers just part of the image, crop it in the source pixel format first so
667+
// the per-pixel format conversion runs over the required region instead of the whole image.
668+
if (!TryGetDrawImageClip(sourceRect, destinationRect, image.Bounds, out Rectangle clippedSourceRect, out RectangleF clippedDestinationRect))
669+
{
670+
return;
671+
}
672+
673+
if (clippedSourceRect == image.Bounds)
674+
{
675+
Image<TPixel> convertedImage = image.CloneAs<TPixel>();
676+
this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true);
677+
return;
678+
}
679+
680+
using Image croppedSource = image.Clone(ctx => ctx.Crop(clippedSourceRect));
681+
Image<TPixel> convertedRegion = croppedSource.CloneAs<TPixel>();
682+
this.DrawImageCore(convertedRegion, convertedRegion.Bounds, clippedDestinationRect, sampler, ownsSourceImage: true);
667683
}
668684

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

725-
if (sourceRect.Width <= 0 ||
726-
sourceRect.Height <= 0 ||
727-
destinationRect.Width <= 0 ||
728-
destinationRect.Height <= 0)
729-
{
730-
return;
731-
}
732-
733-
Rectangle clippedSourceRect = Rectangle.Intersect(sourceRect, image.Bounds);
734-
if (clippedSourceRect.Width <= 0 || clippedSourceRect.Height <= 0)
735-
{
736-
return;
737-
}
738-
739-
RectangleF clippedDestinationRect = MapSourceClipToDestination(sourceRect, destinationRect, clippedSourceRect);
740-
if (clippedDestinationRect.Width <= 0 || clippedDestinationRect.Height <= 0)
741+
Image<TPixel>? ownedImage = null;
742+
try
741743
{
742-
return;
743-
}
744+
if (!TryGetDrawImageClip(sourceRect, destinationRect, image.Bounds, out Rectangle clippedSourceRect, out RectangleF clippedDestinationRect))
745+
{
746+
return;
747+
}
744748

745-
Size scaledSize = new(
746-
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Width)),
747-
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Height)));
749+
Size scaledSize = new(
750+
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Width)),
751+
Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Height)));
748752

749-
bool requiresScaling =
750-
clippedSourceRect.Width != scaledSize.Width ||
751-
clippedSourceRect.Height != scaledSize.Height;
753+
bool requiresScaling =
754+
clippedSourceRect.Width != scaledSize.Width ||
755+
clippedSourceRect.Height != scaledSize.Height;
752756

753-
Image<TPixel> brushImage = image;
754-
RectangleF brushImageRegion = clippedSourceRect;
755-
RectangleF renderDestinationRect = clippedDestinationRect;
756-
Image<TPixel>? ownedImage = null;
757+
Image<TPixel> brushImage = image;
758+
RectangleF brushImageRegion = clippedSourceRect;
759+
RectangleF renderDestinationRect = clippedDestinationRect;
757760

758-
try
759-
{
760761
// Phase 1: Prepare source pixels (crop/scale) in image-local space.
761762
if (requiresScaling)
762763
{
@@ -1532,6 +1533,44 @@ private static Image<TPixel> CreateTransformedDrawImage(
15321533
sampler ?? KnownResamplers.Bicubic));
15331534
}
15341535

1536+
/// <summary>
1537+
/// Computes the source and destination rectangles that a draw-image operation will actually
1538+
/// touch, clipping the requested source rectangle to the image bounds.
1539+
/// </summary>
1540+
/// <param name="sourceRect">Requested source rectangle.</param>
1541+
/// <param name="destinationRect">Requested destination rectangle.</param>
1542+
/// <param name="imageBounds">Bounds of the source image.</param>
1543+
/// <param name="clippedSourceRect">Receives the source rectangle clipped to <paramref name="imageBounds"/>.</param>
1544+
/// <param name="clippedDestinationRect">Receives the destination rectangle matching <paramref name="clippedSourceRect"/>.</param>
1545+
/// <returns><see langword="true"/> when the operation covers a non-empty region; otherwise <see langword="false"/>.</returns>
1546+
private static bool TryGetDrawImageClip(
1547+
Rectangle sourceRect,
1548+
RectangleF destinationRect,
1549+
Rectangle imageBounds,
1550+
out Rectangle clippedSourceRect,
1551+
out RectangleF clippedDestinationRect)
1552+
{
1553+
clippedSourceRect = default;
1554+
clippedDestinationRect = default;
1555+
1556+
// A zero-area source cannot be sampled and would divide by zero when mapping to the destination.
1557+
if (sourceRect.Width <= 0 || sourceRect.Height <= 0)
1558+
{
1559+
return false;
1560+
}
1561+
1562+
clippedSourceRect = Rectangle.Intersect(sourceRect, imageBounds);
1563+
if (clippedSourceRect.Width <= 0 || clippedSourceRect.Height <= 0)
1564+
{
1565+
return false;
1566+
}
1567+
1568+
clippedDestinationRect = MapSourceClipToDestination(sourceRect, destinationRect, clippedSourceRect);
1569+
1570+
// A degenerate (empty or inverted) destination maps to nothing to draw.
1571+
return clippedDestinationRect.Width > 0 && clippedDestinationRect.Height > 0;
1572+
}
1573+
15351574
/// <summary>
15361575
/// Maps a clipped source rectangle back to the corresponding destination rectangle.
15371576
/// </summary>

tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.DrawImage.cs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,186 @@ public void DrawImage_WithClipPathAndTransform_MatchesReference<TPixel>(TestImag
107107
target.DebugSave(provider, appendSourceFileOrDescription: false);
108108
target.CompareToReferenceOutput(provider, appendSourceFileOrDescription: false);
109109
}
110+
111+
[Theory]
112+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
113+
public void DrawImage_WithForeignPixelFormat_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
114+
where TPixel : unmanaged, IPixel<TPixel>
115+
=> AssertForeignPixelFormatMatchesFullConversion(
116+
provider,
117+
new Rectangle(64, 48, 180, 150),
118+
new RectangleF(40, 30, 200, 170),
119+
new Matrix4x4(Matrix3x2.CreateRotation(0.28F, new Vector2(160, 120))));
120+
121+
[Theory]
122+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
123+
public void DrawImage_WithForeignPixelFormat_PartialRegionNoTransform_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
124+
where TPixel : unmanaged, IPixel<TPixel>
125+
=> AssertForeignPixelFormatMatchesFullConversion(
126+
provider,
127+
new Rectangle(64, 48, 180, 150),
128+
new RectangleF(40, 30, 200, 170),
129+
Matrix4x4.Identity);
130+
131+
[Theory]
132+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
133+
public void DrawImage_WithForeignPixelFormat_SourceOutsideTopLeft_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
134+
where TPixel : unmanaged, IPixel<TPixel>
135+
=> AssertForeignPixelFormatMatchesFullConversion(
136+
provider,
137+
new Rectangle(-48, -32, 220, 190),
138+
new RectangleF(30, 24, 210, 180),
139+
new Matrix4x4(Matrix3x2.CreateRotation(0.21F, new Vector2(160, 120))));
140+
141+
[Theory]
142+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
143+
public void DrawImage_WithForeignPixelFormat_SourceOutsideBottomRight_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
144+
where TPixel : unmanaged, IPixel<TPixel>
145+
=> AssertForeignPixelFormatMatchesFullConversion(
146+
provider,
147+
new Rectangle(200, 150, 260, 220),
148+
new RectangleF(48, 40, 200, 168),
149+
Matrix4x4.Identity);
150+
151+
[Theory]
152+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
153+
public void DrawImage_WithForeignPixelFormat_ProjectiveTransform_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
154+
where TPixel : unmanaged, IPixel<TPixel>
155+
{
156+
// A quad/projective transform (non-affine Matrix4x4 with perspective terms) combined
157+
// with a rotation, exercising the transform path over the clipped region.
158+
Matrix4x4 projective = new Matrix4x4(Matrix3x2.CreateRotation(0.18F, new Vector2(160, 120)))
159+
{
160+
M14 = 0.0006F,
161+
M24 = 0.0004F
162+
};
163+
164+
AssertForeignPixelFormatMatchesFullConversion(
165+
provider,
166+
new Rectangle(56, 40, 190, 160),
167+
new RectangleF(44, 34, 200, 168),
168+
projective);
169+
}
170+
171+
[Theory]
172+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
173+
public void DrawImage_WithForeignPixelFormat_WholeImage_MatchesFullConversion<TPixel>(TestImageProvider<TPixel> provider)
174+
where TPixel : unmanaged, IPixel<TPixel>
175+
=> AssertForeignPixelFormatMatchesFullConversion(
176+
provider,
177+
new Rectangle(0, 0, 320, 240),
178+
new RectangleF(24, 20, 260, 200),
179+
new Matrix4x4(Matrix3x2.CreateRotation(0.15F, new Vector2(160, 120))));
180+
181+
[Theory]
182+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
183+
public void DrawImage_WithEmptySourceRect_IsNoOp<TPixel>(TestImageProvider<TPixel> provider)
184+
where TPixel : unmanaged, IPixel<TPixel>
185+
=> AssertDrawImageIsNoOp(
186+
provider,
187+
new Rectangle(40, 30, 0, 120),
188+
new RectangleF(20, 20, 200, 160));
189+
190+
[Theory]
191+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
192+
public void DrawImage_WithEmptyDestinationRect_IsNoOp<TPixel>(TestImageProvider<TPixel> provider)
193+
where TPixel : unmanaged, IPixel<TPixel>
194+
=> AssertDrawImageIsNoOp(
195+
provider,
196+
new Rectangle(40, 30, 180, 150),
197+
new RectangleF(20, 20, 200, 0));
198+
199+
[Theory]
200+
[WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)]
201+
public void DrawImage_WithSourceRectFullyOutsideImage_IsNoOp<TPixel>(TestImageProvider<TPixel> provider)
202+
where TPixel : unmanaged, IPixel<TPixel>
203+
=> AssertDrawImageIsNoOp(
204+
provider,
205+
new Rectangle(400, 300, 120, 100),
206+
new RectangleF(20, 20, 200, 160));
207+
208+
/// <summary>
209+
/// A draw whose clipped source/destination region is empty must be a no-op for both the
210+
/// typed <see cref="Image{TPixel}"/> overload and the foreign-pixel-format <see cref="Image"/>
211+
/// overload, leaving the cleared background untouched.
212+
/// </summary>
213+
private static void AssertDrawImageIsNoOp<TPixel>(
214+
TestImageProvider<TPixel> provider,
215+
Rectangle sourceRect,
216+
RectangleF destinationRect)
217+
where TPixel : unmanaged, IPixel<TPixel>
218+
{
219+
using Image<TPixel> source = provider.GetImage();
220+
221+
// A source image whose pixel format differs from the canvas, forcing the foreign-format path.
222+
using Image<Rgb24> foreignSource = source.CloneAs<Rgb24>();
223+
224+
// The reference is the cleared background: a degenerate draw must not change any pixel.
225+
using Image<TPixel> expected = new(source.Width, source.Height);
226+
using (DrawingCanvas<TPixel> reference = CreateCanvas(provider, expected, new DrawingOptions()))
227+
{
228+
reference.Clear(Brushes.Solid(Color.White));
229+
}
230+
231+
void AssertNoOp(Action<DrawingCanvas<TPixel>> draw)
232+
{
233+
using Image<TPixel> actual = new(source.Width, source.Height);
234+
using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, actual, new DrawingOptions()))
235+
{
236+
canvas.Clear(Brushes.Solid(Color.White));
237+
draw(canvas);
238+
}
239+
240+
ImageComparer.Exact.VerifySimilarity(expected, actual);
241+
}
242+
243+
// Typed overload -> DrawImageCore empty-region early-return.
244+
AssertNoOp(canvas => canvas.DrawImage(source, sourceRect, destinationRect, KnownResamplers.Bicubic));
245+
246+
// Foreign-format overload -> DrawImage empty-region early-return before any conversion.
247+
AssertNoOp(canvas => canvas.DrawImage((Image)foreignSource, sourceRect, destinationRect, KnownResamplers.Bicubic));
248+
}
249+
250+
/// <summary>
251+
/// Drawing a foreign-pixel-format image (which converts only the clipped source region) must
252+
/// produce pixels identical to first converting the whole image to the canvas format and drawing that.
253+
/// </summary>
254+
private static void AssertForeignPixelFormatMatchesFullConversion<TPixel>(
255+
TestImageProvider<TPixel> provider,
256+
Rectangle sourceRect,
257+
RectangleF destinationRect,
258+
Matrix4x4 transform)
259+
where TPixel : unmanaged, IPixel<TPixel>
260+
{
261+
using Image<TPixel> source = provider.GetImage();
262+
263+
// A source image whose pixel format differs from the canvas, forcing a per-pixel conversion.
264+
using Image<Rgb24> foreignSource = source.CloneAs<Rgb24>();
265+
266+
// Reference source: the whole foreign image converted up-front to the canvas format.
267+
using Image<TPixel> convertedSource = foreignSource.CloneAs<TPixel>();
268+
269+
DrawingOptions options = new()
270+
{
271+
Transform = transform
272+
};
273+
274+
using Image<TPixel> actual = new(source.Width, source.Height);
275+
using Image<TPixel> expected = new(source.Width, source.Height);
276+
277+
using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, actual, options))
278+
{
279+
canvas.Clear(Brushes.Solid(Color.White));
280+
canvas.DrawImage((Image)foreignSource, sourceRect, destinationRect, KnownResamplers.Bicubic);
281+
}
282+
283+
using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, expected, options))
284+
{
285+
canvas.Clear(Brushes.Solid(Color.White));
286+
canvas.DrawImage(convertedSource, sourceRect, destinationRect, KnownResamplers.Bicubic);
287+
}
288+
289+
// Converting only the clipped region must produce pixels identical to converting the whole image.
290+
ImageComparer.Exact.VerifySimilarity(expected, actual);
291+
}
110292
}

0 commit comments

Comments
 (0)