Title: Crop scaled images to a desired aspect ratio in C#
I often need to crop images and sometimes I want a specific aspect ratio. (In case you haven't heard this term before, the aspect ratio is the ratio of the image's width to height. As an equation, it's width / height.) For example, depending on the project, I might want a square 1:1 aspect ratio, the 4:3 aspect ratio that phones produce, or the 3:4 aspect ratio for a vertically oriented image. (Most of these pictures are for the web site and Facebook feed for my bakery The Enchanted Oven, although I have not cropped all of those pictures to specific aspect ratios.)
I've written several cropping examples in the past and you can use them to do this, but they're cumbersome. For example, you can figure out how tall the cropped area should be and then calculate how wide it must be to give the desired aspect ratio.
The example in this post is the one I really wanted all along, but I knew it was going to be hard so I kept putting it off. As I expected it would be, it is fairly complicated so it requires a fairly long explanation.
This example is too long for me to post all of its code so I'm just going to cover some of its most interesting parts. Download the example to experiment with it and to see additional details. And there are a lot of additional details!
Features
The example is fairly easy to use, but it provides a lot of features so it takes a while to describe.
To use the program, open the File menu and select Open to pick an image file. Use the Scale menu to scale the image.
This example displays three text boxes where you can adjust the selection area. Enter the aspect ratio that you want in the first text box. If you change the aspect ratio, the program must update the rectangle's width or height so it matches the new ratio. To do this, it enlarges one of those values, keeping the rectangle centered over its original position.
If you enter a new width in the second text box, the program updates the height in the third text box to keep the same aspect ratio. Conversely if you enter a new height in the third text box, the program updates the width in the second text box to maintain the aspect ratio. When you use the width and height text boxes to change the rectangle's dimensions, the program centers the rectangle over its previous center.
In addition to using the width and height text boxes, you can modify the selection area by clicking and dragging.
If you click the body of the selection area, you can move the selection rectangle to a new location.
If you click one of the selection area's corners, you can move that corner while leaving the opposite corner unmoved. The program adjusts the width and height to maintain the desired aspect ratio.
If you click and drag one of the selection area's sides, you can change the area's width or height, again maintaining the desired aspect ratio. The program leaves the opposite side unmoved. It centers the other dimension over the selection area's current location.
For example, suppose you drag the selection area's bottom edge downward. That increases the area's height, so the program must also increase its width to keep the aspect ratio unchanged. The program adjusts the enlarged selection area so it has the same top edge and its left and right edges are centered over its original horizontal center.
After you have selected the area that you want, use the File menu's Save As command to save the selected area into a new file.
The example's final feature is the Rectangle menu's Reset command. Sometimes you may accidentally drag the selection rectangle completely off of the form. For example, suppose you move the rectangle so it hangs off of the form's left edge. If you then drag the area's right side to the left so it is also off of the form, you won't be able to get the rectangle back. You can probably recover the rectangle by using the width and height text boxes to make it big enough to overlap the image and then drag it into a better position, but that could be a lot of work. If you select the Rectangle menu's Reset command, the program moves the rectangle so it's upper left corner is at position (10, 10). You can then drag it to a better position or resize it as needed.
Managing Text Boxes
The program's three text boxes provide an interesting problem. If you change the value in one of them, the others may need to update to show new values.
One way you could handle this is to use TextChanged event handlers to look for changes. Unfortunately when you update one of the other text boxes, it will fire its TextChanged event. That may make the program update other text boxes and that causes yet another TextChanged event. In the worst case, you could enter an infinite series of events.
Most controls are smart enough to not fire their changed events if the value is changing from a value to that same value, so eventually the event chain should stop, possibly after a round of unnecessary changes. In general, however, this kind of sequence may be risky if you are using other controls that always fire their changed events or if rounding errors make the values change every time. In any case, it's a waste of time.
To avoid these sorts of changes, the program uses a variable named IgnoreTextChanged. Before it updates any of the text boxes, the program sets this value to true. The TextChanged event handlers then check this value and, if it is true, they return without doing anything.
For example the following code shows the program's SetWidth method, which, as you can probably guess, sets the selection area's width.
private bool IgnoreTextChanged = false;
private void SetWidth(float width)
{
RectHeight = (float)(width / AspectRatio);
IgnoreTextChanged = true;
txtHeight.Text = RectHeight.ToString();
IgnoreTextChanged = false;
SetSelectionRectangle();
}
This method calculates the height that the selection area must have to preserve the current aspect ratio and then sets IgnoreTextChanged to true. Next the code displays the new height in the txtHeight text box. It resets IgnoreTextChanged to false and calls the SetSelectionRectangle method to position the resized selection rectangle so it is centered over its previous position.
The following code shows the txtHeight control's TextChanged event handler.
private void txtHeight_TextChanged(object sender, EventArgs e)
{
if (IgnoreTextChanged) return;
if (!float.TryParse(txtHeight.Text, out RectHeight)) return;
SetHeight(RectHeight);
}
This event handler checks the value of IgnoreTextChanged and returns if it is true.
This is a fairly common technique for avoiding update event loops. Use a variable to indicate when the program is updating controls such as text boxes. Those controls' update event handlers should check this value and do nothing if the value is true.
The Selection Rectangle
One complicating factor that runs throughout the example is that this program lets you scale the image while you work on it. That means values such as the selection rectangle must sometimes be scaled along with the image.
The variable SelectionRectangle defined by the following statement keeps track of the current selection rectangle in image coordinates.
private RectangleF SelectionRectangle;
For example, if the selection rectangle is 100 pixels wide, then the area that it represents on the scaled image is 100 pixels wide.
Often the program needs to work with the scaled selection rectangle. For example, if you are working on an image at 50% scale, then the program must draw the selection rectangle at 50% scale. To make that a little easier, the following ScaledSelectionRectangle method returns the rectangle scaled.
// Scale the selection rectangle.
private RectangleF ScaledSelectionRectangle()
{
float x = ImageScale * SelectionRectangle.X;
float y = ImageScale * SelectionRectangle.Y;
float wid = ImageScale * SelectionRectangle.Width;
float hgt = ImageScale * SelectionRectangle.Height;
return new RectangleF(x, y, wid, hgt);
}
This method simply scales the rectangle's left, top, width, and height values. It uses those values to create the scaled rectangle and returns the result. The program can then draw the scaled rectangle, see if the mouse is over the scaled rectangle, or otherwise work with the scaled rectangle.
Mouse Movements
The example tracks mouse movement much as other programs do. When you are not dragging anything, the MouseMove event handler changes the cursor to indicate the part of the selection rectangle below the mouse. If you click over the selection rectangle's corners or edges, the program lets you resize the rectangle. If you click on the selection rectangle's body, the program lets you move the rectangle.
The FindHitType helper method returns a value indicating the part of the selection rectangle that is at a specified position. The following code shows the method and the HitTypes enumeration that it returns.
private enum HitTypes
{
None,
Body,
LeftEdge,
RightEdge,
TopEdge,
BottomEdge,
ULCorner,
URCorner,
LLCorner,
LRCorner,
};
private const int HandleRadius = 4;
private HitTypes FindHitType(Point point)
{
RectangleF scaled_rect = ScaledSelectionRectangle();
bool hit_left, hit_right, hit_top, hit_bottom;
hit_left =
((point.X >= scaled_rect.Left - HandleRadius) &&
(point.X <= scaled_rect.Left + HandleRadius));
hit_right =
((point.X >= scaled_rect.Right - HandleRadius) &&
(point.X <= scaled_rect.Right + HandleRadius));
hit_top =
((point.Y >= scaled_rect.Top - HandleRadius) &&
(point.Y <= scaled_rect.Top + HandleRadius));
hit_bottom =
((point.Y >= scaled_rect.Bottom - HandleRadius) &&
(point.Y <= scaled_rect.Bottom + HandleRadius));
if (hit_left && hit_top) return HitTypes.ULCorner;
if (hit_right && hit_top) return HitTypes.URCorner;
if (hit_left && hit_bottom) return HitTypes.LLCorner;
if (hit_right && hit_bottom) return HitTypes.LRCorner;
if (hit_left) return HitTypes.LeftEdge;
if (hit_right) return HitTypes.RightEdge;
if (hit_top) return HitTypes.TopEdge;
if (hit_bottom) return HitTypes.BottomEdge;
if ((point.X >= scaled_rect.Left) && (point.X <= scaled_rect.Right) &&
(point.Y >= scaled_rect.Top) && (point.Y <= scaled_rect.Bottom))
return HitTypes.Body;
return HitTypes.None;
}
The method first gets the scaled selection rectangle so it has the rectangle defined in pixels. The point passed into the method is the mouse position, which is in pixels seen on the screen, so the method must compare that position to the rectangle when it is scaled.
The method then simply compares the specified point's coordinates to the rectangle's coordinates to see whether the point is over the rectangle's corners, edges, or body and returns the appropriate result.
The program uses the hit type in several ways. The most complicated of those ways occurs when you click and drag the mouse over the selection rectangle's parts.
The CurrentHitType and Dragging variables are set in the MouseDown event handler. That method is relatively straightforward so I won't show it here. It simply calls FindHitType to get the hit type appropriate for the mouse's position and then sets Dragging to true.
The following code executes when you move the mouse.
private void picImage_MouseMove(object sender, MouseEventArgs e)
{
if (!Dragging) MouseMoveNotDragging(e.Location);
else MouseMoveDragging(e.Location);
}
If the Dragging variable is false, the code calls the MouseMoveNotDragging to display an appropriate cursor for the current mouse position. That method is also relatively straightforward so I won't show it here. The only non-obvious part to that method that I want to describe is the OppositeCorner variable. The method sets that variable equal to the selection rectangle's corner that is opposite to the point under the mouse. This doesn't really matter if the mouse is not over a selection rectangle corner.
If the Dragging variable is true, then the mouse is down and you are dragging. In that case the code calls the MouseMoveDragging method. That method is fairly long, so I'm going to describe it in pieces. Here's the first piece.
private void MouseMoveDragging(Point point)
{
// Find the new size for corner drags.
float corner_wid = Math.Abs(OppositeCorner.X - point.X / ImageScale);
float corner_hgt = Math.Abs(OppositeCorner.Y - point.Y / ImageScale);
SizeF corner_size = GetReducedSize(corner_wid, corner_hgt);
...
This code subtracts the mouse's coordinates from the OppositeCorner to see how wide and tall the selection rectangle should be. Notice that the code scales the mouse's coordinates by dividing them by the current image scale to convert from screen coordinates to the image's scaled coordinates. That puts all of the coordinates in the selection rectangle's coordinate system.
The code then calls the GetReducedSize method to adjust the width and height so they satisfy the desired aspect ratio. The following code shows the GetReducedSize method.
private SizeF GetReducedSize(float new_width, float new_height)
{
if (new_width < 10) new_width = 10;
if (new_height < 10) new_height = 10;
if (new_width / new_height > AspectRatio)
{
// Too short and wide. Decrease the width.
new_width = new_height * AspectRatio;
}
else
{
// Too tall and thin. Decrease the height.
new_height = new_width / AspectRatio;
}
return new SizeF(new_width, new_height);
}
This method first ensures that the new width and height are at least 10. It then compares the desired width / height ratio to the current aspect ratio. If the new ratio is too short and wide, then the code decreases the width to match the desired aspect ratio. Conversely if the new ratio is too tall and thin, then the code decreases the height to match the desired aspect ratio.
Notice that in either case, the program reduces the width or height to match the desired aspect ratio. If you run the program and drag one of the selection rectangle's corners, you'll see how the rectangle shrinks if necessary. That seems like the natural action.
Here's the next piece of the MouseMoveDragging method.
...
// Find the new size for edge drags.
SizeF edge_size = new SizeF();
if ((CurrentHitType == HitTypes.TopEdge) ||
(CurrentHitType == HitTypes.BottomEdge))
edge_size = GetEnlargedSize(0, corner_hgt);
else if ((CurrentHitType == HitTypes.LeftEdge) ||
(CurrentHitType == HitTypes.RightEdge))
edge_size = GetEnlargedSize(corner_wid, 0);
...
This code is similar to the earlier code except it works for edge drags instead of corner drags. If this is an edge drag, the code calls the GetEnlargedSize method, passing it the appropriate width or height for the new rectangle. (Depending on the edge that you are dragging.)
The following code shows the GetEnlargedSize method.
pivate SizeF GetEnlargedSize(float new_width, float new_height)
{
if (new_width < 10) new_width = 10;
if (new_height < 10) new_height = 10;
if (new_width / new_height > AspectRatio)
{
// Too short and wide. Increase the height.
new_height = new_width / AspectRatio;
}
else
{
// Too tall and thin. Increase the width.
new_width = new_height * AspectRatio;
}
return new SizeF(new_width, new_height);
}
This method is similar to the GetReducedSize method except it enlarges the rectangle if necessary instead of shrinking it. If you run the program and drag one of the selection rectangle's edges, you'll see how the rectangle enlarges if necessary. That seems like the natural action.
Now back to the MouseMoveDragging method. Here's the next piece.
...
// Find the center of the selection rectangle for edge drags.
float cx = SelectionRectangle.X + SelectionRectangle.Width / 2f;
float cy = SelectionRectangle.Y + SelectionRectangle.Height / 2f;
...
This code simply finds the center of the current selection rectangle.
The following code shows most of the rest of the MouseMoveDragging method.
...
switch (CurrentHitType)
{
// Corners.
case HitTypes.ULCorner:
SelectionRectangle = new RectangleF(
SelectionRectangle.Right - corner_size.Width,
SelectionRectangle.Bottom - corner_size.Height,
corner_size.Width, corner_size.Height);
break;
case HitTypes.URCorner:
SelectionRectangle = new RectangleF(
SelectionRectangle.Left,
SelectionRectangle.Bottom - corner_size.Height,
corner_size.Width, corner_size.Height);
break;
case HitTypes.LRCorner:
SelectionRectangle = new RectangleF(
SelectionRectangle.X,
SelectionRectangle.Y,
corner_size.Width, corner_size.Height);
break;
case HitTypes.LLCorner:
SelectionRectangle = new RectangleF(
SelectionRectangle.Right - corner_size.Width,
SelectionRectangle.Top,
corner_size.Width, corner_size.Height);
break;
// Edges.
case HitTypes.TopEdge:
SelectionRectangle = new RectangleF(
cx - edge_size.Width / 2f,
SelectionRectangle.Bottom - edge_size.Height,
edge_size.Width, edge_size.Height);
break;
case HitTypes.RightEdge:
SelectionRectangle = new RectangleF(
SelectionRectangle.Left,
cy - edge_size.Height / 2f,
edge_size.Width, edge_size.Height);
break;
case HitTypes.BottomEdge:
SelectionRectangle = new RectangleF(
cx - edge_size.Width / 2f,
SelectionRectangle.Top,
edge_size.Width, edge_size.Height);
break;
case HitTypes.LeftEdge:
SelectionRectangle = new RectangleF(
SelectionRectangle.Right - edge_size.Width,
cy - edge_size.Height / 2f,
edge_size.Width, edge_size.Height);
break;
// Body.
case HitTypes.Body:
int dx = (int)((point.X - LastPoint.X) / ImageScale);
int dy = (int)((point.Y - LastPoint.Y) / ImageScale);
SelectionRectangle.X += dx;
SelectionRectangle.Y += dy;
break;
}
...
This code creates a new selection rectangle that is appropriate to the kind of drag you are performing. For example, if you are dragging the rectangle's left edge (the second-to-last case), the code leaves the rectangle's right edge unchanged and positions the left edge the correct distances away. It sets the rectangle's top coordinate so the rectangle is centered vertically at Y position cy.
If you look through each of the cases, you can see that the code adjusts the rectangle to leave the corner or edge opposite the mouse's position unchanged. You may want to run the program and experiment to see how each of the cases works. As you do, also notice how natural it is that the rectangle shrinks
The following code shows the last part of the MouseMoveDragging method.
...
LastPoint = point;
picImage.Refresh();
ShowWidthAndHeight();
}
This code saves the mouse's position in the variable LastPoint so it can see how far the mouse has moved if you are dragging the selection rectangle's body. (See the last case block in the switch statement shown earlier.) It refreshes the displayed image and calls ShowWidthAndHeight.
The ShowWidthAndHeight method displays the selection rectangle's dimensions in the text boxes. That method is fairly straightforward (given the earlier discussion of the IgnoreTextChanged variable described earlier), so I won't show it here. Download the example to see how it works.
Selecting a Scale
Compared to the MouseMoveDragging method, this is simple. When you select one of the Scale menu's commands, the following code executes.
private void mnuScale_Click(object sender, EventArgs e)
{
// Get the scale factor.
ToolStripMenuItem menu_item =
sender as ToolStripMenuItem;
string scale_text = menu_item.Text.Replace("&", "").Replace("%", "");
ImageScale = float.Parse(scale_text) / 100f;
ShowScaledImage();
// Display the new scale.
mnuScale.Text = "Scale (" + menu_item.Text.Replace("&", "") + ")";
// Check the selected menu item.
foreach (ToolStripMenuItem item in mnuScale.DropDownItems)
{
item.Checked = (item == menu_item);
}
}
The code converts the event's sender into the menu item that raised the event. It uses the menu item's caption to get the desired scale.
The code then sets the ImageScale value and calls the ShowScaledImage method described shortly to display the scaled image. It then updates the caption of the top-level menu so it displays text such as "Scale (75%)" and finishes by unchecking the other scale menu items.
The following code shows the ShowScaledImage method.
private void ShowScaledImage()
{
if (OriginalImage == null) return;
int scaled_width = (int)(OriginalImage.Width * ImageScale);
int scaled_height = (int)(OriginalImage.Height * ImageScale);
ScaledImage = new Bitmap(scaled_width, scaled_height);
using (Graphics gr = Graphics.FromImage(ScaledImage))
{
Point[] dest_points =
{
new Point(0, 0),
new Point(scaled_width - 1, 0),
new Point(0, scaled_height - 1),
};
Rectangle src_rect = new Rectangle(
0, 0,
OriginalImage.Width - 1,
OriginalImage.Height - 1);
gr.DrawImage(OriginalImage, dest_points,
src_rect, GraphicsUnit.Pixel);
}
picImage.Image = ScaledImage;
picImage.Visible = true;
picImage.Refresh();
}
This method calculates the scaled image width and height, and uses those to create a Bitmap with the scaled size. It creates an associated Graphics object and uses it to draw the original image onto the scaled Bitmap. The method finishes by displaying the scaled image in the picImage control and making that control visible.
Drawing the Selection Rectangle
The following code shows how the program draws the selection rectangle.
// Draw the selection rectangle.
private const int HandleRadius = 4;
private void picImage_Paint(object sender, PaintEventArgs e)
{
try
{
// Draw the selection rectangle.
RectangleF scaled_rect = ScaledSelectionRectangle();
using (Pen pen = new Pen(Color.Red, 2))
{
e.Graphics.DrawRectangle(pen, scaled_rect);
pen.Color = Color.Yellow;
pen.DashPattern = new float[] { 5, 5 };
e.Graphics.DrawRectangle(pen, scaled_rect);
}
PointF[] corners =
{
new PointF(scaled_rect.Left, scaled_rect.Top),
new PointF(scaled_rect.Right, scaled_rect.Top),
new PointF(scaled_rect.Left, scaled_rect.Bottom),
new PointF(scaled_rect.Right, scaled_rect.Bottom),
};
foreach (PointF point in corners)
{
e.Graphics.DrawBox(Brushes.White, Pens.Black,
point, HandleRadius);
}
}
catch
{
}
}
This code first uses the following ScaledSelectionRectangle method to get a scaled version of the selection rectangle.
// Scale the selection rectangle.
private RectangleF ScaledSelectionRectangle()
{
float x = ImageScale * SelectionRectangle.X;
float y = ImageScale * SelectionRectangle.Y;
float wid = ImageScale * SelectionRectangle.Width;
float hgt = ImageScale * SelectionRectangle.Height;
return new RectangleF(x, y, wid, hgt);
}
This helper method simply scales the selection rectangle's X and Y coordinates, and its width and height. It uses the scaled values to create a scaled rectangle and returns it.
After it has the scaled selection rectangle, the Paint event handler creates a two-pixel-wide red pen and uses it to draw the rectangle. It then changes the pen's color to yellow, gives it a dash pattern, and draws the rectangle again. The result is a red and yellow dashed rectangle.
Next the code creates an array holding the rectangle's corners and loops through them calling the following DrawBox extension method to draw them.
public static void DrawBox(this Graphics gr,
Brush brush, Pen pen, PointF center, float radius)
{
RectangleF rect = new RectangleF(
center.X - radius,
center.Y - radius,
2 * radius, 2 * radius);
gr.FillRectangle(brush, rect);
gr.DrawRectangle(pen, rect);
}
This method creates a Rectangle with the desired width, height, and center. It then fills and outlines the rectangle with the desired brush and pen.
Note that the Paint event handler does not draw the image. The ShowScaledImage method sets the picImage control's Image property to the scaled image. After that, the control automatically redisplays the image when necessary. All the Paint event handler needs to do is draw the selection rectangle on top of it.
Also note that the Paint event handler does not call e.Graphics.Clear to clear the drawing area. If it did that, it would erase the image.
Saving the Result
When you select the File menu's Save As command, the following code executes.
// Save the selected area.
private void mnuFileSaveAs_Click(object sender, EventArgs e)
{
if (sfdImage.ShowDialog() == DialogResult.OK)
{
try
{
// Copy the selected area into a new Bitmap.
Bitmap bm = new Bitmap(
(int)SelectionRectangle.Width,
(int)SelectionRectangle.Height);
using (Graphics gr = Graphics.FromImage(bm))
{
gr.DrawImage(OriginalImage, 0, 0,
SelectionRectangle,
GraphicsUnit.Pixel);
}
// Save the new Bitmap.
SaveImage(bm, sfdImage.FileName);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
}
This code displays the sfdImage SaveFileDialog so you can indicate where you want to save the file. If you pick a file and click Save, then the program creates a Bitmap with the selection rectangle's dimensions. It copies the piece of the original image that lies below the selection rectangle onto this bitmap. Finally it calls the SaveImage method to save the image. For information on that method, see Save images with an appropriate format depending on the file name's extension in C#.
Conclusion
I know this is a long post, but it still doesn't cover a lot of details. Hopefully the pieces that it covers will help you figure out the rest of it.
You should at least download the program and experiment with it. I've found it a remarkably intuitive and useful tool for cropping images.
Download the example to experiment with it and to see additional details.
|