Introduction
A week ago, I reviewed websites that offer automatic image tagging as a service. Today, I’m going to put some of that experience to practice by building a simple Rails photo gallery from scratch, which will automatically assign tags to uploaded images and present images by tags. I’ll use Clarifai as the tagging service.
There will be many code snippets provided, so you can experiment as you go along; alternatively, there’s a repository with the app already built and ready to play with.
The goal is to build an app as simple as possible in order to keep this post reasonably short. Specifically, the app will only allow for:
- Submitting a photo and automatically assigned tags to it
- Presenting the list of all tags
- Displaying all photos marked with a given tag
- Displaying a single photo, with all tags assigned to it
To make things simple, I’ll deliberately omit such features as editing or deleting images, manually editing lists of tags, user accounts and logging in/out, or tests. These are left as a—hopefully interesting!—exercise to the reader; you’ll find some of them already present in the repo.
I assume that you have basic experience with Rails, but not necessarily with Paperclip or computer vision. Seasoned Rails developers may wish to skip the initial sections and jump straight ahead to Automatic tagging.
Prerequisites
I’ll be using Rails 5 and Paperclip. These instructions will probably work on any version of Rails that’s compatible with Paperclip (which means at least 4.2 at the time of writing), but I’ve only tested them with Rails 5.0.0.1.
Paperclip is a popular gem by Thoughtbot that allows to easily attach files to your ActiveRecord models. It has especially good support for image attachments, being able to resize or otherwise manipulate images. You’ll need to install ImageMagick (and possibly the file
program if you’re not on Unix); refer to Paperclip’s README for details.
Getting started
You’re now ready to generate our app. Open up a terminal and do: rails new gallery
. Once the directories are generated, edit the Gemfile
and add the gems that you’ll be using:
gem 'paperclip' # for submitting and processing images
gem 'clarifai_ruby' # for auto-detecting image features
gem 'pure-css-rails' # for a nicer UI
group :test do
gem 'rspec-rails' # we like testing Rails apps with RSpec
gem 'capybara' # for integration specs
gem 'launchy' # nifty for debugging
gem 'factory_girl_rails' # for generating test data
end
Models
The model layer of the gallery is very simple. Create three models, Photo
, Tag
with self-explanatory names (each with a textual name or caption), and TaggedPhoto
that will hold the many-to-many relation that binds photos and tags together. From the terminal, do:
rails generate model Photo title:string
rails generate model Tag name:string
rails generate model TaggedPhoto photo:references tag:references
In addition, the Photo
model will hold the images themselves. These are managed by Paperclip, which has a handy way of generating the necessary migrations:
rails generate paperclip Photo image
You’re now ready to initialize your database and create the initial schema:
rake db:create
rake db:migrate
The remaining step is to tell Rails about the relations between your models. The TaggedPhoto
model already has the required belongs_to
annotations, so let’s start with app/models/tag.rb
:
class Tag < ApplicationRecord
has_many :tagged_photos
has_many :photos, through: :tagged_photos
default_scope { order('name ASC') }
end
Specifying the default_scope
is a nifty way of ensuring that collections of tags will always be returned in alphabetical order.
Now turn your attention to the Photo
model in app/models/photo.rb
:
class Photo < ApplicationRecord
has_many :tagged_photos
has_many :tags, through: :tagged_photos
has_attached_file :image, styles: { thumbnail: "320x240>" }
validates_attachment :image, content_type:
{ content_type: ["image/jpeg", "image/gif", "image/png"] }
end
This tells Paperclip that:
- the
image
field in your model (mapping to the four fields in the database:image_file_name
,image_content_size
,image_file_size
, andimage_updated_at
) represents an image attachment, - in addition to storing the original, it should shrink the incoming images to 320x240 if they are larger, preserving the aspect ratio.
The validator ensures that the submitted files are actually of one of the given MIME types and is mandatory: without it, Paperclip will refuse to accept attachments.
Routes
You can now specify routes for your app in config/routes.rb
. Given the use cases outlined before, you can restrict yourself to resourceful routes corresponding to photos and tags:
Rails.application.routes.draw do
resources :photos, only: [:new, :create, :index, :show]
resources :tags, only: [:index, :show]
get "/", to: "photos#index"
end
Note that this yields tag paths of the form /tag/1
rather than the more readable /tag/outdoor
. While it would be nice to have readable URLs, this would come at a cost of slightly increased complexity, so let’s stay with numerical tag IDs.
Submitting photos
At this point, I’ve sketched out the URL scheme for our app. You can now manipulate your photos from the console, but there are no controllers to handle them with. Let’s start with the photos controller. Construct one with rails g controller Photos
and fill in the blanks in app/controllers/photos_controller.rb
:
class PhotosController < ApplicationController
def index
@photos = Photo.all
end
def new
@photo = Photo.new
end
def show
@photo = Photo.find(params[:id])
end
def create
@photo = Photo.new(photo_params)
if @photo.save
redirect_to photo_path(@photo)
else
render :new
end
end
private
def photo_params
params.require(:photo).permit(:image, :title)
end
end
Note that it suffices to supply title
in the permitted params, not the individual Paperclip fields.
Let’s sketch out views rendered by these controller actions. First, the submit form in app/views/photos/new.html.erb
; note the Pure.css classes for some eye candy and that you tell the browser to only accept images:
<%= form_for @photo, html: { class: "pure-form pure-form-aligned" } do |f| %>
<fieldset>
<div class="pure-control-group">
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div class="pure-control-group">
<%= f.label :image %>
<%= f.file_field :image, accept: "image/*" %>
</div>
<div class="pure-controls">
<%= f.submit "Upload", class: "pure-button pure-button-primary" %>
</div>
</fieldset>
<% end %>
And the corresponding views that display a single image (app/views/photos/show.html.erb
):
<figure>
<%= image_tag(@photo.image.url, alt: @photo.title) %>
<figcaption><%= @photo.title %></figcaption>
</figure>
…and all images (app/views/photos/show.html.erb
), rendered as a simple series of thumbnail image tags:
<% @photos.each do |photo| %>
<%= link_to image_tag(photo.image.url(:thumbnail), alt: photo.title),
photo_url(photo) %>
<% end %>
You should now be able to launch the app, navigate to http://localhost:3000/photos/new
, submit a photo and see it displayed both on the list of all images and on its individual page.
Displaying tags
Again, generate a controller and fill in actions in app/controllers/tags_controller.rb
:
class TagsController < ApplicationController
def index
@tags = Tag.all
end
def show
@tag = Tag.find(params[:id])
@photos = @tag.photos
render 'photos/index'
end
end
Because the show
action should render a list of pictures under a given tag, the structure of its output will be the same as the list of all pictures. Thus, we reuse the photos/index
view and there’s no need for a separate view for a tag’s content.
Factor out the tag list to a shared partial so that you can reuse it in individual photo pages. Create app/views/shared/_tags.html.erb
:
<div class="tags">
<ul>
<% tags.each do |tag| %>
<li><%= link_to tag.name, tag_path(tag) %></li>
<% end %>
</ul>
</div>
Now app/views/tags/index.html.erb
can be a simple one-liner:
<%= render "shared/tags", tags: @tags %>
And you can revisit app/views/photos/show.html.erb
to include the tag list of a photo:
<%= render "shared/tags", tags: @photo.tags %>
Automatic tagging
At this point, you have a basic working photo gallery with tags. The only thing left is to actually implement a mechanism that will add tags to a photo upon upload. This is a self-contained operation that interoperates with an external service, so good practice calls for implementing this logic as a service object.
As mentioned, you’ll be using Clarifai as a tagging service provider, but let’s abstract away from it for now. Let’s assume we have a structure defined as:
ExternalTag = Struct.new(:name, :prob)
describing a tag coming from an external service, where name
describes the tag name and prob
denotes probability that the tag befits the given photo. Let’s also assume we have a method, get_tags
, that returns an array of such objects given a local path to the photo.
We need to set some probability cut-off point, say, 0.8. Upon tagging a Photo
object, tags assigned with probability below this threshold will be discarded, and the other tags will be instantiated and associated with the given photo. In terms of code (app/services/tagging_service.rb
):
class TaggingService
CUTOFF = 0.8
def tag(photo)
get_tags(photo.image.path).select { |x| x.prob > CUTOFF }.each do |external_tag|
tag = Tag.find_or_create_by(name: external_tag.name)
tagged_photo = TaggedPhoto.create(photo: photo, tag: tag)
end
end
end
Time to implement get_tags
and get your hands dirty. Register on the Clarifai website and create an application. You’ll get a Client ID and Client Secret, which you’ll keep in secrets.yml
under the clarifai_id
and clarifai_secret
fields, respectively. It is a good idea to hold the production values of these in environment variables and refer to them from secrets.yml
.
As mentioned, I used the clarifai_ruby
gem for interop with Clarifai. To configure it, create an initializer config/initializers/clarifai.rb
with:
ClarifaiRuby.configure do |config|
config.client_id = Rails.application.secrets.clarifai_id
config.client_secret = Rails.application.secrets.clarifai_secret
end
Clarifai’s API documentation suggests that you can either upload the raw image data via POST requests, or submit a publicly available URL of the image via a GET request. Which one to choose? While in principle the image becomes visible to the outside world once processed by Paperclip, and so you could supply @photo.image.url
to Clarifai, this is problematic. It will not work in development where you don’t want to expose our app to the outside world.
Instead, just POST the image data to Clarifai. How to do this with clarifai_ruby
? Unfortunately, the gem’s README doesn’t explain it, and consulting the code corroborates the suspicion that it’s not supported by the gem.
Fortunately, monkey-patching it is easy. You just need to subclass TagRequest
, following the original logic of the get
method but checking whether a local path is supplied instead of an URI, and acting accordingly:
require 'clarifai_ruby'
module ClarifaiRuby
class MyTagRequest < TagRequest
def get(image_url_or_path, opts = {})
if image_url_or_path =~ URI::regexp
super
else
body = {
encoded_data: File.new(image_url_or_path),
model: opts[:model]
}
build_request!(body, opts)
@raw_response = @client.post(TAG_PATH, body).parsed_response
raise RequestError.new @raw_response["status_msg"] if @raw_response["status_code"] != "OK"
TagResponse.new(raw_response)
end
end
end
end
Now your get_tags
becomes simple:
def get_tags(image_path)
response = ClarifaiRuby::MyTagRequest.new.get(image_path)
response.tag_images.first.tags.map { |x| ExternalTag.new(x.word, x.prob) }
end
To complete the picture, you need to invoke TaggingService#tag
from the create
action of our photos controller. Do it after the image is saved and processed by Paperclip:
def create
@photo = Photo.new(photo_params)
if @photo.save
TaggingService.new.tag(@photo)
redirect_to photo_path(@photo)
else
render :new
end
end
And that’s it!
Conclusion
In a few lines of Rails code, we have built a prototype of a smart gallery that knows what is depicted on the photos you submit to it. I hope that you have enjoyed this little journey.