When docs aren't enough - how to turn off sharpening for ActiveStorage variants

I recently had the pleasure of helping someone I don’t know fix a problem they had with generating image variants using ActiveStorage::Variant. They were using the libvips processor (which I believe is the default image processor now in Rails 7 because it is faster and uses less memory), and wanted to turn off the sharpening that vips automatically applies when resizing images.

They understood how to use ImageProcessing (the high level “wrapper” that ActiveStorage::Variant uses to communicate to libvips) to turn off sharpening, but were stuck when it came to disabling sharpening through ActiveStorage.

“I must be doing something wrong here, but I can’t tell how to properly do this from the docs. Help would be greatly appreciated.”

Unfortunately, the documentation available in the Rails Guides and api.rubyonrails.org was not detailed enough, and left out some key information. This person tried out a couple of things in an attempt to get things to work and when they weren’t successful, decided to reach out on a public forum.

The problem

Let’s put ourselves in this person’s shoes and see if we can retrace the steps they took. Say we have a model User, which has an avatar image. With ActiveRecord and ActiveStorage, we set it up like this:

class User < ApplicationRecord
  has_one_attached :avatar

Now, in our app view, we want to display a resized version of this avatar. There is where ActiveStorage::Variant comes in. In our view, we do:

= image_tag user.avatar.variant(resize_to_fit: [100, 100])

We notice that the resized images are sharpened, which we don’t want. After reading some documentation, we realize that this is a default setting and can be turned off. We dig a bit deeper and find:

From the ImageProcessing docs, it is clear that we can disable image sharpening by doing:

  .resize_to_limit!(100, 100, sharpen: false)

However, the docs for ActiveStorage::Variant make no mention of this and it is unclear how we can turn off sharpening from the “transformation” that we pass into #variant. So, we try a couple of things:

= image_tag user.avatar.variant(resize_to_fit: [width, height], sharpen: false)

Nope, that doesn’t work. How about:

= image_tag user.avatar.variant(resize_to_fit: [width, height], options: { sharpen: false })

This, unfortunately, throws an error.

Continue experimenting and guess the solution

Finally, though it looks weird, we try this:

= image_tag user.avatar.variant(resize_to_fit: [width, height, sharpen: false])

And that works.

Try to understand why the solution works

Why does this work? To find out, we’ll have to take a dive into the Rails codebase. The call to #variant, via user.avatar.variant(...), ends up in a class called ImageProcessingTransformer. This class is ultimately responsible for passing through the transformations that we send in to #variant, to the ImageProcessing::Vips class.

ImageProcessingTransformer takes advantage of a method that the ImageProcessing gem exposes called apply. apply takes in a hash of key-value pairs, where the key corresponds to a given transformation (e.g. resize_to_limit) and the value corresponds to an array of arguments that the transformation method takes (e.g. width, height), destructures them to name/argument list pairs, and then uses Ruby’s public_send method to call the transformation method with the given argument list. If I was to write a couple of lines of code trying to capture the gist of what apply does, it would look something like:

# a hash that looks like this:
transformations = { resize_to_fit: [width, height, sharpen: false], rotate: [90, background: [0, 0, 0]] }

# is destructured into a name/argument list pair
transformations.inject(ImageProcessing::Vips.source(image)) do |chainable, transformation_method, arguments|
  # which is then passed into `public_send`
  # because `ImageProcessing` supports chaining methods together, we
  # can apply all the transformations specified in the transformations hash,
  # one by one
  chainable.public_send(transformation_method, *arguments)

apply is flexible enough to be able to destructure a hash or an array (e.g. [[:resize_to_limit, [400, 400]], [:strip, true]), and also just pass in any object in as an argument (this is why we can do things like user.avatar.variant(rotate: 90)).

Are there any bigger lessons to learn here?

Aside from the fact that we now know how to turn off sharpening or apply a different-than-default sharpening mask to a resize operation with ActiveStorage#variant, it seems worth asking if there are any other takeaways from this journey.

For one, we know when calling #variant that keys in the transformation hash refer to actual methods that the underlying image processor exposes (things like resize_to_fit, crop and so on), and the values in the transformation hash correspond directly to arguments that are sent in to the underlying image processor. We learned that a value in the transformation hash can either be an array of arguments, or a non-array object, like a number or boolean.

The other, arguably more important, lesson is that sometimes docs are not enough. When you need to make progress or get some work done and you’re stuck because the docs are deficient in some way, you’ll need to take steps to get yourself unstuck. We ran through a few in this article, like experimentation, reading the source, or like the person who asked the question about turning off sharpening, reaching out to humans (friends or strangers, IRL or online) for help. Then, when you figure it out, especially when you’re experimenting, try to dig deeper and understand why something worked (or didn’t work) the way it did.

If you got value out of this article and want to hear more from me, consider subscribing to my email newsletter to get notified whenever I publish a new article.

Want to be notified when I publish a new article?