Categories
ios performance processing-efficiency swift

How to efficiently create a multi-row photo collage from an array of images in Swift

Problem

I am building a collage of photos from an array of images that I am placing onto a tableview. I want to make the images wrap when the number of images reaches the boundary of the tableview cell’s width (this would allow me to display rows of images in the collage). Currently I get a single row. Please feel free to advise if additional information is required. I am most likely not approaching this in the most efficient way since there is a delay as the number of images used in the array begins to increase. (any feedback on this would be very much appreciated).

Nota Bene

I am creating a collage image. It is actually one image. I want to
arrange the collage by creating an efficent matrix of columns and rows
in memory. I then fill these rects with images. Finally I snapshot the resulting image and use it when needed. The algorithm is not
efficient as written and produces only a single row of images. I need
a lightweight alternative to the algorithm used below. I do not
believe UICollectionView will be a useful alternative in this case.

Pseudo Code

  1. Given an array of images and a target rectangle (representing the
    target view)
  2. Get the number of images in the array compared to max number allowed per row
  3. Define a smaller rectangle of appropriate size to hold the image (so
    that each row fills the target rectangle, i.e. – if one image then that should fill the row; if 9 images then that should fill the row completely; if 10 images with a max of 9 images per row then the 10th begins the second row)
  4. Iterate over the collection
  5. Place each rectangle at the correct location from left to right
    until either last image or a max number per row is reached; continue on next row until all images fit within the target rectangle
  6. When reaching a max number of images per row, place the image and
    setup the next rectangle to appear on the successive row

Using: Swift 2.0

class func collageImage (rect:CGRect, images:[UIImage]) -> UIImage {
let maxSide = max(rect.width / CGFloat(images.count), rect.height / CGFloat(images.count))
UIGraphicsBeginImageContextWithOptions(rect.size, false, UIScreen.mainScreen().scale)
var xtransform:CGFloat = 0.0
for img in images {
let smallRect:CGRect = CGRectMake(xtransform, 0.0,maxSide, maxSide)
let rnd = arc4random_uniform(270) + 15
//draw in rect
img.drawInRect(smallRect)
//rotate img using random angle.
UIImage.rotateImage(img, radian: CGFloat(rnd))
xtransform += CGFloat(maxSide * 0.8)
}
let outputImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return outputImage
}
class func rotateImage(src: UIImage, radian:CGFloat) -> UIImage
{
// Calculate the size of the rotated view's containing box for our drawing space
let rotatedViewBox = UIView(frame: CGRectMake(0,0, src.size.width, src.size.height))
let t: CGAffineTransform = CGAffineTransformMakeRotation(radian)
rotatedViewBox.transform = t
let rotatedSize = rotatedViewBox.frame.size
// Create the bitmap context
UIGraphicsBeginImageContext(rotatedSize)
let bitmap:CGContextRef = UIGraphicsGetCurrentContext()
// Move the origin to the middle of the image so we will rotate and scale around the center.
CGContextTranslateCTM(bitmap, rotatedSize.width/2, rotatedSize.height/2);
// Rotate the image context
CGContextRotateCTM(bitmap, radian);
// Now, draw the rotated/scaled image into the context
CGContextScaleCTM(bitmap, 1.0, -1.0);
CGContextDrawImage(bitmap, CGRectMake(-src.size.width / 2, -src.size.height / 2, src.size.width, src.size.height), src.CGImage)
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return newImage
}

Alternative 1

I’ve refined my solution to this a bit. This one does stack the images in columns and rows however, as stated; my interest is in making this as efficient as possible. What’s presented is my attempt at producing the simplest possible thing that works.

Caveat

The image produced using this is skewed rather than evenly distributed across the entire tableview cell. Efficient, even distribution across the tableview cell would be optimal.

skewed-distribution

class func collageImage (rect:CGRect, images:[UIImage]) -> UIImage {
let maxSide = max(rect.width / CGFloat(images.count), rect.height / CGFloat(images.count)) //* 0.80
//let rowHeight = rect.height / CGFloat(images.count) * 0.8
let maxImagesPerRow = 9
var index = 0
var currentRow = 1
var xtransform:CGFloat = 0.0
var ytransform:CGFloat = 0.0
var smallRect:CGRect = CGRectZero
UIGraphicsBeginImageContextWithOptions(rect.size, false, UIScreen.mainScreen().scale)
for img in images {
let x = ++index % maxImagesPerRow //row should change when modulus is 0
//row changes when modulus of counter returns zero @ maxImagesPerRow
if x == 0 {
//last column of current row
//xtransform += CGFloat(maxSide)
smallRect = CGRectMake(xtransform, ytransform, maxSide, maxSide)
//reset for new row
++currentRow
xtransform = 0.0
ytransform = (maxSide * CGFloat(currentRow - 1))
} else {
//not a new row
if xtransform == 0 {
//this is first column
//draw rect at 0,ytransform
smallRect = CGRectMake(xtransform, ytransform, maxSide, maxSide)
xtransform += CGFloat(maxSide)
} else {
//not the first column so translate x, ytransform to be reset for new rows only
smallRect = CGRectMake(xtransform, ytransform, maxSide, maxSide)
xtransform += CGFloat(maxSide)
}
}
//draw in rect
img.drawInRect(smallRect)
}
let outputImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return outputImage
}

Alternative 2

The alternative presented below scales the images so that they always fill the rectangle (in my case the tableview cell). As more images are added they are scaled to fit the width of the rectangle. When the images meet the maximum number of images per row, they wrap. This is the desired behavior, happens in memory, is relatively fast, and is contained in a simple class function that I extend on the UIImage class. I am still interested in any algorithm that can deliver the same functionality only faster.

Nota Bene: I do not believe adding more UI is useful to achieve the
effects as noted above. Therefore a more efficient coding algorithm is
what I am seeking.

class func collageImage (rect:CGRect, images:[UIImage]) -> UIImage {
let maxImagesPerRow = 9
var maxSide : CGFloat = 0.0
if images.count >= maxImagesPerRow {
maxSide = max(rect.width / CGFloat(maxImagesPerRow), rect.height / CGFloat(maxImagesPerRow))
} else {
maxSide = max(rect.width / CGFloat(images.count), rect.height / CGFloat(images.count))
}
var index = 0
var currentRow = 1
var xtransform:CGFloat = 0.0
var ytransform:CGFloat = 0.0
var smallRect:CGRect = CGRectZero
UIGraphicsBeginImageContextWithOptions(rect.size, false, UIScreen.mainScreen().scale)
for img in images {
let x = ++index % maxImagesPerRow //row should change when modulus is 0
//row changes when modulus of counter returns zero @ maxImagesPerRow
if x == 0 {
//last column of current row
smallRect = CGRectMake(xtransform, ytransform, maxSide, maxSide)
//reset for new row
++currentRow
xtransform = 0.0
ytransform = (maxSide * CGFloat(currentRow - 1))
} else {
//not a new row
smallRect = CGRectMake(xtransform, ytransform, maxSide, maxSide)
xtransform += CGFloat(maxSide)
}
//draw in rect
img.drawInRect(smallRect)
}
let outputImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return outputImage
}

Efficiency Testing

Reda Lemeden gives some procedural insight into how to test these CG calls within Instruments on this blog post. He also points out some interesting notes from Andy Matuschak (of the UIKit team) about some of the peculiarities of off-screen rendering. I am probably still not leveraging the CIImage solution properly because initial results show the solution getting slower when attempting to force GPU utilization.