Adam McNeilly

Cropping Bitmaps With Custom Glide Transformations

One of the most crucial features of any dating app is the ability to share pictures. I spend a decent amount of time combing through the (few) good pictures of myself, and cropping out the best part.

Alas, other users may go to view my profile, but what they see is not what I cropped out.

What happened? Well, like many developers, we struggled with how best to make an image fit inside an ImageView. In this example our image has a scale type of CENTER_CROP, which appears okay for this photo, but there's a risk that something important would be left out if we had a really tall or really wide picture.

Despite some very helpful guides on the various scale types, none of them work here. We want to display a specific portion of the photo, based on what the user cropped, and there isn't a built in scale type to help with this.

Glide Custom Transformations

At OkCupid we use Glide by Bumptech for image loading. It is a library that allows us to effortlessly load an image from a URL into an ImageView. Glide does support default transformations for the default scale types such as FIT_CENTER and CENTER_CROP if you need them. Thankfully, for those of us who need more than that, we can write custom transformations.

Note: For any number of reasons, you may choose to work with Square's Picasso instead of Glide for image loading. Picasso has support for custom transformations as well, and some of the following code will also apply to that library.

As per the Glide Wiki, the easiest way to write one of these is by extending BitmapTransformation. Before we get into the bulk of how to focus on this thumbnail, let's look at the base we'll be starting with:

class CropTransformation(context: Context) : BitmapTransformation(context) {

    override fun getId(): String {
        return CropTransformation::class.java.name
    }

    override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
        return toTransform
    }
}

There are two methods we are required to override. getId() determines a unique ID for the transformation. Glide caches these, so you may want to give it an ID specific to the image you are working with - we'll see this later. transform(...), as the name implies, is used to transform a bitmap. The toTransform parameter is the original bitmap that we'll be cropping from.

To call this transform, we will add it to our Glide request:

Glide.with(view.context)
        .load(uri)
        .listener(listener)
        .bitmapTransform(CropTransformation(view.context))
        .into(view)

Thumbnail Input

Now we have a skeleton class for our transformation, and we know the original bitmap to crop. How do we determine the thumbnail coordinates for our photo?

When we make a call to the server for all of the profile photos, our response may look like this (simplified):

{
	original: {
		width: 404,
		height: 720
	},
	thumbnail: {
		x: 0,
		y: 6,
		width: 404,
		height: 404
	},
	caption: "Some Caption",
	url: "..."
}

Let's walk through each of these values:

  • original.width: The width, in pixels, of the original photo.
  • original.height: The height, in pixels, of the original photo.
  • thumbnail.x: The starting x coordinate of the thumbnail, with respect to the original photo.
  • thumbnail.y: The starting y coordinate of the thumbnail, with respect to the original photo.
  • thumbnail.width: The width of the thumbnail, with respect to the original photo.
  • thumbnail.height: The height of the thumbnail, with respect to the original photo.

Given all this info, let's try to explain how this particular user cropped their photo.

  1. We know that the thumbnail is a square, because it is 404x404.
  2. We know that the thumbnail is just as wide as the original, because the original is 404x720.
  3. We know that the thumbnail is aligned with the left side of the original photo because thumbnail.x is equal to 0.
  4. We know that the thumbnail was shifted down 6 pixels from the top of the photo, because thumbnail.y is equal to 6.

If you're still having trouble visualizing that, it would look something like this:

Your mileage may vary. You may get your thumbnail input in some other JSON format, but the following rules should still apply if you start with those six values.

Croppable Interface

As far as our custom transformation is concerned, this is all the info we'll need. We don't care about image URLs or any other aspects - so we created an interface to reference in our custom transformation. This interface is also helpful if you have multiple photo models, as long as they all still have the necessary six values:

interface Croppable {
	val thumbnailX: Int
	val thumbnailY: Int
	val thumbnailWidth: Int
	val thumbnailHeight: Int
	val originalWidth: Int
	val originalHeight: Int
}

Now we can update our transformation to take an instance of this interface. We can use this to make the value returned by the getId() method unique. We'll also create new variables for our Croppable fields as floats to avoid any integer division.

class CropTransformation(context: Context, private val photo: Croppable) : BitmapTransformation(context) {

    /**
     * Returns a unique identifier for the transformation. Glide actually caches these, so we supply
     * all dimensions to make sure it's unique to this photo.
     */
    override fun getId(): String {
        return CropTransformation::class.java.name +
                "width=${photo.thumbnailWidth}" +
                "height=${photo.thumbnailHeight}" +
                "x=${photo.thumbnailX}" +
                "y=${photo.thumbnailY}" +
                "originalWidth=${photo.originalWidth}" +
                "originalHeight=${photo.originalHeight}"
    }

    override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
        // If there's nothing to crop, or we don't know the original size, just return
        // what we were given.
        if (photo.thumbnailWidth == 0 || photo.thumbnailHeight == 0 || photo.originalHeight == 0 || photo.originalWidth == 0) {
            return toTransform
        }

        // Get all the thumbnail and original values as floats to avoid integer division.
        val thumbX = photo.thumbnailX.toFloat()
        val thumbY = photo.thumbnailYtoFloat()
        val thumbWidth = photo.thumbnailWidth.toFloat()
        val thumbHeight = photo.thumbnailHeight.toFloat()
        val originalWidth = photo.originalWidth.toFloat()
        val originalHeight = photo.originalHeight.toFloat()

        // Get the width and height of the bitmap we'll be cropping from.
        val bitmapWidth = toTransform.width.toFloat()
        val bitmapHeight = toTransform.height.toFloat()

        return toTransform
    }
}

Find Thumbnail From Bitmap

Previously, we called out the toTransform parameter in our custom transformation as the bitmap we'll be manipulating. We can get the dimensions of this bitmap by calling toTransform.width and toTransform.height. However, these dimensions may not be the same as our original photo (though it should maintain the aspect ratio).

In order to get a thumbnail from our bitmap, we'll have to do some math. Knowing that our bitmap and original photo have the same aspect ratio, we can use that to our advantage.

Let's start by calculating the width/height of what we need to crop. What are the width and height of the thumbnail, as a percentage of the original photo?

val thumbWidthPercentage = (thumbWidth / originalWidth) // 1
val thumbHeightPercentage = (thumbHeight / originalHeight) // 0.5611

Now that we know this, we can calculate the width/height of the thumbnail with relation to the original bitmap. We just multiply that percentage by the width/height of the bitmap, respectively.

// Will be the same width as the bitmap.
val newWidth = thumbWidthPercentage * bitmapWidth
// Will be a little more than half of the bitmap's height.
val newHeight = thumbHeightPercentage * bitmapHeight

We can use this same idea to calculate the new starting x/y coordinates - get their distance as a percentage of the original photo, and use that percentage to find their corresponding points in the bitmap:

// Get the percent distance of the starting points from the edges for the original thumbnail
val startingLeftPercentage = thumbX / originalWidth
val startingTopPercentage = thumbY / originalHeight

// Use the starting percentages to get the starting coordinates with respect to the
// bitmap we're cropping from
val startingLeft = startingLeftPercentage * bitmapWidth
val startingTop = startingTopPercentage * bitmapHeight

Create New Bitmap

Now that we have those values, we can use the createBitmap method to crop it out.

Our full transformation looks like this now:

class CropTransformation(context: Context, private val photo: Croppable) : BitmapTransformation(context) {

    /**
     * Returns a unique identifier for the transformation. Glide actually caches these, so we supply
     * all dimensions to make sure it's unique to this photo.
     */
    override fun getId(): String {
        return CropTransformation::class.java.name +
                "width=${photo.thumbnailWidth}" +
                "height=${photo.thumbnailHeight}" +
                "x=${photo.thumbnailX}" +
                "y=${photo.thumbnailY}" +
                "originalWidth=${photo.originalWidth}" +
                "originalHeight=${photo.originalHeight}"
    }

    override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
        // If there's nothing to crop, or we don't know the original size, just return
        // what we were given.
        if (photo.thumbnailWidth == 0 || photo.thumbnailHeight == 0 || photo.originalHeight == 0 || photo.originalWidth == 0) {
            return toTransform
        }

        // Get all the thumbnail and original values as floats to avoid integer division.
        val thumbX = photo.thumbnailX.toFloat()
        val thumbY = photo.thumbnailYtoFloat()
        val thumbWidth = photo.thumbnailWidth.toFloat()
        val thumbHeight = photo.thumbnailHeight.toFloat()
        val originalWidth = photo.originalWidth.toFloat()
        val originalHeight = photo.originalHeight.toFloat()

        // Get the width and height of the bitmap we'll be cropping from.
        val bitmapWidth = toTransform.width.toFloat()
        val bitmapHeight = toTransform.height.toFloat()

        // Get the percent distance of the starting points from the edges for the original thumbnail
        val startingLeftPercentage = thumbX / originalWidth
        val startingTopPercentage = thumbY / originalHeight

        // Use the starting percentages to get the starting coordinates with respect to the
        // bitmap we're cropping from
        val startingLeft = startingLeftPercentage * bitmapWidth
        val startingTop = startingTopPercentage * bitmapHeight

        // Figure out the percentage width/height of the thumbnail with respect to the original image
        val thumbWidthPercentage = thumbWidth / originalWidth
        val thumbHeightPercentage = thumbHeight / originalHeight

        // Use that percentage to calculate the width/height to crop from the given bitmap
        val newWidth = thumbWidthPercentage * bitmapWidth
        val newHeight = thumbHeightPercentage * bitmapHeight
        
        // Create a bitmap by cropping out our new width/height begin at our starting coordinates.
        return Bitmap.createBitmap(
                toTransform,
                startingLeft.toInt(),
                startingTop.toInt(),
                newWidth.toInt(),
                newHeight.toInt())
    }
}

Our usage is now like this:

Glide.with(view.context)
        .load(uri)
        .listener(listener)
        .bitmapTransform(CropTransformation(view.context, photo))
        .into(view)

Additional Notes

This approach has worked very well for us to narrow down on the important part of the image. After this we just use the center crop scaletype to make the thumbnail fit our ImageView.

Why doesn't it fit perfectly? Well, the aspect ratio of the bitmap we return will be the aspect ratio of the thumbnail. In this case it's 404/404, or 1. The ImageView that we used had an aspect ratio of ~1.6.

The output aspect ratio can be calculated by using the outWidth and outHeight params of the transform() method. This ratio would be outWidth / outHeight. You can use this information to try and scale your image even more to make it fit the output aspect ratio, but that's some math for another blog post.

These custom transformations can be used for much more than just cropping. You can find some cool libraries with custom transformations on GitHub.

Have any questions, or want to share some cool transformations of your own? Reach out to me on Twitter @AdamMc331. Want to join me at OkCupid? We're hiring!