Title: Make a Pinterest-style diagonal picture montage in C#
This example was inspired by a picture montage generated by Pinterest. Several months ago, my girlfriend [update: now fiancee] and I started a bakery (see the website here), and a cool image appeared on our Pinterest page (which is here). Pinterest had taken random pictures from our page and arranged them in diagonal slices similar to those shown at the top of this post.
I thought that looked pretty interesting, so I decided to write this example to do something similar. I think it's a pretty interesting example because it requires you to do several moderately (but not extremely) difficult things. The explanation is fairly long, but you should be able to handle it if you take it slowly in pieces. It's also worth the effort because it will explain how to:
- Rotate graphics
- Size and crop an image to completely fill an area
- Map points clicked by the user back to non-rotated coordinates
- Determine whether a point lies within a rotated area
It's also useful if you need to create a rotated picture montage.
The following section explains how to use the example program. The sections after that one explain how the program works.
Using the Program
Run the program and enter the following parameters:
- Image width and height give the desired size for the picture whole montage.
- Cell width and height indicate how wide and tall the rectangular picture cells should be.
- Angle tells how many degrees the pictures should be rotated clockwise.
After you fill in those values, click Create to make a grid of empty cells similar to those shown in the following picture.
If you want to change the grid parameters, you have to start over and will lose any pictures that you have already assigned. You can, however, change the width or color of the dividers between the cells without losing any picture assignments.
After you create the grid, click in a cell to open a file selection dialog. If you select an image file and click Open, the program displays that picture in the cell that you clicked. The program makes the picture as large as necessary to fill the width and height of the cell and then trims the picture to fit. Note that you can click on a cell again to change its picture. (Although I didn't give the program the ability to clear a cell.)
After you have filled the cells the way you want to, use the File menu's Save As command to save the picture montage. If you try to close the program or create a new grid and you have unsaved changes, the program asks if you want to save the changes.
Overview
This section provides a brief overview of the basic approach that the program uses to make a picture montage.
To make a rotated image, you first apply a rotation transformation to the Graphics object that you're using to draw. You then draw the image non-rotated and the transformation rotates it. The following picture illustrates the idea.
The program draws the cells on the left so they are oriented normally. The rotation transformation automatically tilts the cells to make the diagonal picture montage shown on the right.
The red dashed rectangle on the right shows the picture montage's area. The dashed rectangle on the left shows the parts of the original image that were rotated to make the montage. The program doesn't actually draw the red rectangle.
Notice that the grid cells on the left that become part of the picture montage are not nicely lined up in rows and columns. For example, they are not simply the cells in rows 1 through 3 and columns 2 through 7. Instead they are cells in different numbered rows for columns -1, 0, 1, 2, 3, 4, and 5.
One of the more interesting challenges for this program is figuring out which cells must be drawn to fill the dashed red rectangle. You could just draw a whole bunch of cells centered around the origin, but that would waste time.
We're also going to need to be able to map mouse clicks to cells so you can click on a cell to assign its picture. To do that, we need to be able to map points in the picture on the right back to locations in the picture on the left. We can use that same method to figure out which cells we need to draw to fill the red dashed rectangle.
When the program draws a cell, it must decide how to scale the image to fill the cell. You could use any of the following approaches.
- Draw the image in the cell's upper left corner at full scale and truncate to fit.
- Center the image at full scale and truncate to fit.
- Stretch the image to fill the cell even if it distorts the image
- Uniformly scale the image so it is as large as possible while still fitting in the cell, possibly leaving some blank space above/below or left/right of the image
- Uniformly scale the image so it completely fills the cell, possibly truncating some of it on the top/bottom or left/right
This example uses the last approach. The following picture shows how a square picture would be scaled to fill a cell that was taller than it was wide.
Here the original image was enlarged until it filled the height of the cell. The image was centered in the cell and its left and right sides were truncated to make it fit.
The last thing we need to do is draw the rectangles with rounded corners around the cells as shown in the picture at the top of the post. We can use techniques from an earlier post to do that.
Those are the basic tasks we need to handle to draw the picture montage. The following sections explain how the program accomplishes those tasks.
The Cell Class
The program uses a Cell class to store information about the cells. The following code shows the class's main pieces of code.
class Cell
{
public RectangleF Bounds;
public Bitmap Picture = null;
public Cell(RectangleF bounds)
{
Bounds = bounds;
}
// Draw the cell.
public void Draw(Graphics gr, Pen pen,
float cell_width, float cell_height)
{
// Draw the cell's picture.
if (Picture != null)
{
// Find the part of the picture that we will draw.
float pic_wid = Picture.Width;
float pic_hgt = Picture.Height;
float cx = pic_wid / 2f;
float cy = pic_hgt / 2f;
if (pic_wid / pic_hgt > Bounds.Width / Bounds.Height)
{
// The picture is too short and wide. Make it narrower.
pic_wid = Bounds.Width / Bounds.Height * pic_hgt;
}
else
{
// The picture is too tall and thin. Make it shorter.
pic_hgt = pic_wid / (Bounds.Width / Bounds.Height);
}
RectangleF src_rect = new RectangleF(
cx - pic_wid / 2f, cy - pic_hgt / 2f, pic_wid, pic_hgt);
// Draw the picture.
PointF[] dest_points =
{
new PointF(Bounds.Left, Bounds.Top),
new PointF(Bounds.Right, Bounds.Top),
new PointF(Bounds.Left, Bounds.Bottom),
};
gr.DrawImage(Picture, dest_points, src_rect,
GraphicsUnit.Pixel);
}
// Outline the cell.
GraphicsPath path = MakeRoundedRect(Bounds,
2 * pen.Width, 2 * pen.Width, true, true, true, true);
gr.DrawPath(pen, path);
}
// Return true if the cell contains the point.
public bool ContainsPoint(PointF point)
{
return Bounds.Contains(point);
}
// Draw a rectangle in the indicated Rectangle
// rounding the indicated corners.
private GraphicsPath MakeRoundedRect(...)
{
...
}
}
The class's Bounds field indicates the rectangle where the cell's picture should be drawn in the images on the left in the earlier pictures. The Picture field will hold the cell's picture. Those are the only two things that a Cell object needs to know to draw itself.
The class's constructor simply saves the cell's bounds. It does not save a picture for the cell because this program assigns pictures later when you click on the cells.
The Draw method draws the cell. If the Picture field is not null, the code compares the picture's aspect ratio (the width/height ratio) to the cell's aspect ratio. If the picture is relatively short and wide compared to the cell's bounds, the program makes the picture narrower. Similarly if the picture is relatively tall and thin compared to the cell's bounds, the program makes the picture shorter.
After it has adjusted the picture's width and height so it has the same aspect ratio as the cell, the program makes a rectangle of that size centered over the picture. that is the area that it will draw on the cell.
The code also makes an array of points holding the cell's upper left, upper right, and lower left corners. That defines the area where we will draw the image. (Don't blame me. A rectangle and three points is the goofy way that Microsoft decided you should specify where an image should be drawn.)
Finally, the program uses the rectangle and points to draw the cell. (The main program will apply a rotation transformation to the Graphics object before it calls this method so the cell will be rotated.)
After drawing the cell's picture (if the Picture field isn't null), the Draw method outlines the cell. It calls the MakeRoundedRect method described in my earlier post Draw rounded rectangles in C# to make a GraphicsPath that defines a rounded rectangle around the cell. See that post for details about how that method works. The code then draws the path with the Pen object that was passed into the method.
The Cell class's ContainsPoint method returns true if the cell contains a particular point in non-rotated coordinates. To do that, it simply calls the Bounds rectangle's Contains method passing it the point.
The Cell class stores a cell's bounds and picture. The following section explains how the program builds the Cell objects that it needs.
Creating the Grid
The program uses the following variables to keep track of the grid's geometry.
private int ImgWidth, ImgHeight;
private float CellWidth, CellHeight, Angle, DividerWidth;
private Color DividerColor;
private Matrix Transform = null, InverseTransform = null;
private List<Cell> Cells = null;
private bool DocumentModified = false;
The ImgWidth and ImgHeight values hold the dimensions of the whole picture montage. The CellWidth and CellHeight fields hold the size of the non-rotated cells. The value Angle indicates the angle by which the cells should be rotated.
The DividerWidth value holds the desired thickness of the dividers between the cells. As you can probably guess, DividerColor holds the desired divider color.
The Transform field is a Matrix that represents the desired rotation. The InverseTransform value represents the inverse of the transformation.
The first transformation maps the normal coordinate system to the rotated system as shown in the earlier picture that contained red dashed rectangles. The inverse transformation maps back from the rotated coordinate system to the original coordinate system. The first is useful for drawing the cells; the second is useful for figuring out where the user clicked on the rotated result.
The Cells field is a list that holds one Cell object for each of the cells that intersect the red dashed rectangle shown earlier.
Finally, the DocumentModified value indicates whether you have made changes to the picture montage since the last time you saved or created it. The program uses that value to decide whether it is safe to exit or to create a new picture montage. This is useful but it's a bit off topic so I won't cover it in detail. Download the example to see the details.
When you enter the grid parameters and click Create, the following code executes.
private void btnCreate_Click(object sender, EventArgs e)
{
if (!DocumentIsSafe()) return;
try
{
// Save the parameters.
ImgWidth = int.Parse(txtWidth.Text);
ImgHeight = int.Parse(txtHeight.Text);
CellWidth = int.Parse(txtCellWidth.Text);
CellHeight = int.Parse(txtCellHeight.Text);
Angle = float.Parse(txtAngle.Text);
DividerWidth = float.Parse(txtDividerWidth.Text);
DividerColor = lblColor.BackColor;
Transform = new Matrix();
Transform.Rotate(Angle);
InverseTransform = new Matrix();
InverseTransform.Rotate(-Angle);
// Make the cells.
MakeCells();
// Show the result.
picCanvas.ClientSize = new Size(ImgWidth, ImgHeight);
int margin = picCanvas.Left;
int client_right = margin + Math.Max(
picCanvas.Right, btnCreate.Right);
int client_bottom = margin + picCanvas.Bottom;
this.ClientSize = new Size(client_right, client_bottom);
picCanvas.Visible = true;
mnuFileSaveAs.Enabled = true;
picCanvas.Refresh();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
This code first parses the values that you entered. It then creates a new Transform matrix and uses its Rotate method to make that matrix represent rotation through the desired angle. It similarly creates the InverseTransform matrix and makes it represent a rotation through the negative of the desired angle. (Alternatively you could copy the Transform matrix and then call its Invert method to invert the matrix. The inverse of a rotation is a rotation by the negative of the original angle, however, so this program just creates it directly.)
Next, the program calls the MakeCells method described in the following section to create the necessary Cell objects. It then sizes its picCanvas PictureBox to hold the picture montage and arranges the form to show the result. This code finishes by refreshing the picCanvas control to show the picture montage. That control's Paint event handler is described in a later section.
The most interesting part of this code is its call to the MakeCells method described next.
Making Cells
The following code shows the MakeCells method, which creates the cells that intersect the red dashed rectangle shown in the earlier picture.
// Make the cells.
private void MakeCells()
{
// Rotate the image's corners by -Angle degrees.
PointF[] points =
{
new PointF(0, 0),
new PointF(0, ImgHeight),
new PointF(ImgWidth, ImgHeight),
new PointF(ImgWidth, 0),
};
InverseTransform.TransformPoints(points);
// Get the rotated image's bounds.
float xmin = points[0].X;
float ymin = points[0].Y;
float xmax = xmin;
float ymax = ymin;
for (int i = 1; i < points.Length; i++)
{
if (xmin > points[i].X) xmin = points[i].X;
if (xmax < points[i].X) xmax = points[i].X;
if (ymin > points[i].Y) ymin = points[i].Y;
if (ymax < points[i].Y) ymax = points[i].Y;
}
// Calculate the minimum and maximum rows
// and columns that might be needed.
int min_row = (int)(ymin / CellHeight) - 1;
int max_row = (int)(ymax / CellHeight) + 1;
int min_col = (int)(xmin / CellWidth) - 1;
int max_col = (int)(xmax / CellWidth) + 1;
// Make a GraphicsPath representing the rotated image bounds.
GraphicsPath image_path = new GraphicsPath();
image_path.AddPolygon(points);
// Make a Graphics Object for use in IsEmpty.
Graphics gr = CreateGraphics();
// Loop over the possible rows and columns
// and see which are actually needed.
Cells = new List<Cell>();
for (int row = min_row; row <= max_row; row++)
{
for (int col = min_col; col <= max_col; col++)
{
// See if this cell's rectangle intersects
// the image's rotated bounds.
Region rgn = new Region(image_path);
float x = col * CellWidth;
float y = row * CellHeight;
if (Math.Abs(col % 2) == 1) y += CellHeight / 2f;
RectangleF cell_rect = new RectangleF(
x, y, CellWidth, CellHeight);
rgn.Intersect(cell_rect);
if (!rgn.IsEmpty(gr))
{
// Save this cell.
Cells.Add(new Cell(cell_rect));
}
}
}
Console.WriteLine("# Cells: " + Cells.Count.ToString());
}
This method creates an array holding the points at the corners of the picture montage. The red dashed rectangle on the right side of the following picture shows the area that defines the picture montage.
The code then calls the IntervseTransform object's TransformPoints method to apply its transformation to those corner points. This maps the points from the rotated coordinate system on the right back to the non-rotated system on the left.
Next, the code loops through the transformed points to find their minimum and maximum X and Y coordinates. That gives a bounding area for the non-rotated points. The green dashed box on the left side of the preceding picture shows that bounding area.
The program also uses the minimum and maximum X and Y coordinates to calculate minimum and maximum row and column numbers for cells that might overlap the green dashed bounding area. It adds one to the maximums and subtracts one from the minimums so we are sure to get all of the cells that might overlap that area.
After all of this setup, the program is almost ready to start creating cells, but it still needs some additional values to help determine whether a cell actually intersects the red dashed rectangle. To detect those intersections, the program It makes a GraphicsPath representing the red dashed bounding rectangle's transformed points (on the left). The program also makes a Graphics object for use with the IsEmpty method. (You'll see how that works shortly.)
Now the program loops over the rows and columns that might intersect the green dashed area. It adds half of the cell height to the Y position of the cells in odd-numbered columns so they are offset vertically as shown in the picture on the left. (You could change this. For example, you could offset columns by a third, fourth, or some other fraction of the cell's height to make other brick-like arrangements.)
For each row and column, the code determines whether the corresponding cell intersects the red dashed area. To do that, it creates a Region object that holds the GraphicsPath representing that area. (Remember the GraphicsPath we created earlier?) It also makes a rectangle holding the area occupied by the cell. It then calls the region's Intersect method to make the region hold the intersection of its original contents (the red dashed rectangle) and the cell's rectangle.
Finally, the program uses the region's IsEmpty method to determine whether the result is empty. (Here's where we use that Graphics object that we created earlier.) If the region is not empty, then the cell intersects the red dashed rectangle. In that case, the program creates a new Cell object to hold the cell's bounds and adds it to the Cells list.
The method finishes by displaying the number of Cell objects that it created in the Console window so you can verify that it makes sense.
After the method finishes, the Cells list contains Cell objects representing cells that intersect the red dashed box in the preceding picture. After all of that work, it's relatively easy to paint the picture montage and to let the user click on a cell to set its picture. The code that handles those tasks is described in the following sections.
Painting the Picture Montage
The picCanvas PictureBox control displays the picture montage. When it needs to refresh, the control's Paint event handler simply calls the following DrawCells method.
private void DrawCells(Graphics gr)
{
gr.SmoothingMode = SmoothingMode.AntiAlias;
gr.InterpolationMode = InterpolationMode.High;
gr.Clear(picCanvas.BackColor);
gr.Transform = Transform;
using (Pen pen = new Pen(lblColor.BackColor, DividerWidth))
{
foreach (Cell cell in Cells)
cell.Draw(gr, pen, CellWidth, CellHeight);
}
}
This method sets the Graphics object's SmoothingMode property to produce smooth lines, sets InterpolationMode to resize images smoothly, and clears the drawing.
The program then sets the Graphics object's Transform property to the Transform matrix that we created earlier. After this, any shapes that the program draws on the Graphics object are automatically rotated appropriately.
Next, the method creates a pen with the desired divider thickness and color. It then loops through the Cell objects in the Cells list and calls their Draw methods. The objects draw themselves and Robert's your mother's brother.
Handling Clicks
When the user clicks on the picture montage, the following event handler executes.
// Place a picture in this cell.
private void picCanvas_MouseClick(object sender, MouseEventArgs e)
{
if (ofdCellPicture.ShowDialog() == DialogResult.OK)
{
try
{
// Find the clicked cell.
// Inverse transform the clicked point.
PointF[] points = { e.Location };
InverseTransform.TransformPoints(points);
// See which cell contains the inverted point.
foreach (Cell cell in Cells)
{
if (cell.ContainsPoint(points[0]))
{
cell.Picture = new Bitmap(ofdCellPicture.FileName);
DocumentModified = true;
break;
}
}
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
picCanvas.Refresh();
}
}
This code displays the file selection dialog named ofdCellPicture to let user pick an image file. If the user selects a file and clicks Open, the program creates an array named points that contains the point that the user clicked. It then calls the InverseTransform object's TransformPoints method to map that point from the normal, rotated coordinates that the user sees to the pre-rotation coordinates. If you look at the following picture, the user clicks on the image on the right and the TransformPoints method maps that point to the corresponding location in the image on the left.
The program then loops through the Cell objects in the Cells collection and calls each object's ContainsPoint method to see if the point lies within that cell. If the point is inside the cell, the code loads the image file that the user selected and sets the cell's Picture value to the resulting bitmap. The code then sets the DocumentModified value to true and breaks out of the loop.
The code finishes by refreshing the picCanvas control so the user can see the modified cell.
Summary
Hopefully you found the example interesting. It showed how you can do all of the following.
- Rotate graphics (create a Matrix and apply it to the Graphics object, although note that this isn't the only way you can do this)
- Size and crop an image to completely fill an area (set the width or height to get the correct aspect ratio and then use DrawImage)
- Map points clicked by the user back to non-rotated coordinates (use an inverse transformation Matrix to transform the clicked point)
- Determine whether a point lies within a rotated rectangle (map the point back to non-rotated coordinates and then use the non-rotated rectangle's Contains method)
The example program performs several other important tasks such as saving the picture montage into a file, ensuring that you don't exit without saving your changes, letting the user click on the color sample to change the divider color, and drawing rounded rectangles. Download the example to see those details.
Feel free to experiment with the example. For example, try different angles of rotation or change the offsets between cells in adjacent columns. You could also let the user select multiple image files and then make the program place the images in random cells.
Download the example to experiment with it and to see additional details.
|