Here's a rudimentary answer using MATLAB that provides a good alternative to the convex hull as Emre has mentioned. The basic algorithm is the following:
- Binarize the image and invert the text so white is text while black is background
- Find the minimum spanning bounding boxes for each of the characters
- Create a new output image that takes the co-ordinates of each of the bounding boxes and fills them in with white.
- Do a morphological closing with a relatively large structuring square element to mash all of the text into a single object
- Find the perimeter of this object - This is your boundary of your text
A small note: I had to play around with the size of my structuring element until the results looked right. You should choose the size of it based on how big the tallest letter is, and a few more pixels around the structuring element so that when you close, you are able to join neighbouring bounding boxes together. Also, as noted by Selim Arikan in the comment below, it is recommended to add a few pixels of dilation to the perimeter depending on the typeface size. I haven't done that in this implementation, but it's fairly simple to do.
Without further ado, here's my code, along with the example image that I used:
Input Image

Code
% Clear all varibles and close all windows
clear all;
close all;
% Read in image
imorig = imread('https://i.stack.imgur.com/uOyDn.png');
% Threshold and invert
im = ~im2bw(imorig);
% Find bounding boxes of all characters
s = regionprops(im, 'BoundingBox');
bbox = round(reshape([s.BoundingBox], 4, []).');
% Create a blank image and for each box, fill in with white
outImg = false(size(im));
for idx = 1 : size(bbox,1)
outImg(bbox(idx,2):bbox(idx,2)+bbox(idx,4), ...
bbox(idx,1):bbox(idx,1)+bbox(idx,3)) = true;
end
% Close with a very large structuring element
se = strel('square', 20);
outImg2 = imclose(outImg, se);
% Find the perimeter of this object, then take the original image and
% the pixels that belong to this boundary. Colour all of these pixels red
% for illustration
redChan = imorig(:,:,1);
greenChan = imorig(:,:,2);
blueChan = imorig(:,:,3);
perim = bwperim(outImg2,8);
redChan(perim) = 255;
greenChan(perim) = 0;
blueChan(perim) = 0;
out = cat(3,redChan,greenChan,blueChan);
figure;
subplot(1,2,1);
imshow(imorig);
title('Original image');
subplot(1,2,2);
imshow(out);
title('Image with perimeter overlaid');
Output Image

Edit: July 20th, 2014
Here is an OpenCV / Python implementation of what I did above. The main differences are:
- The
regionprops
method in MATLAB does not exist in Python / OpenCV. However, there is a findContours
method which provides a list of points that traces over every boundary of each of the letters in the image. This is essentially a list of a list of points... woah! Basically, this returns a list. For each element in this list, this contains a list of 2D points that trace around a particular object.
- For each object, I find the minimum and maximum of the rows and columns and this denotes the minimum spanning bounding box for each object. I then fill in this box with white.
- It's also a good idea where if an image serves as input into the function, you provide a copy of the image as the input to avoid shadowing and accidental overwriting. You use the
.copy()
method for each of these images.
- I padded the boundaries of the image by 20 pixels to allow for a tighter boundary as per your request.
You will need the numpy
, scipy
and opencv
modules before you run this method. If you need help in installing these modules, let me know in a comment.
Without further ado, here's the code.
import cv2 # For OpenCV modules (Morphology and Contour Finding)
import numpy as np # For general purpose array manipulation
# Load in image
img = cv2.imread('uOyDn.png', 0)
# Create a new image that pads each side by 20 pixels
# This will allow the outlining of the text to be tighter
# Create an image of all white
imgPad = 255*np.ones((img.shape[0] + 40, img.shape[1] + 40),
dtype='uint8')
# Place the original image in the middle
imgPad[20:imgPad.shape[0]-20, 20:imgPad.shape[1]-20] = img
# Invert image
# White becomes black and black becomes white
imgBW = imgPad < 128
imgBW = 255*imgBW.astype('uint8')
# Find all of the contours in the image
contours,hierarchy = cv2.findContours(imgBW.copy(), cv2.RETR_LIST,
cv2.CHAIN_APPROX_NONE)
# New image that places square blocks over all letters
imgBlocks = np.zeros(imgBW.shape, dtype='uint8')
# For each contour...
for idx in range(len(contours)):
# Reshape each contour into a 2D array
# First co-ordinate is the column, second is the row
cnt = np.reshape(contours[idx], (contours[idx].shape[0],
contours[idx].shape[2]))
# Transpose to allow for max and min calls
cnt = cnt.T
# Find the max and min of each contour
maxCol = np.max(cnt[0])
minCol = np.min(cnt[0])
maxRow = np.max(cnt[1])
minRow = np.min(cnt[1])
# Use the previous to fill in a minimum spanning bounding
# box around each contour
for row in np.arange(minRow, maxRow+1):
for col in np.arange(minCol, maxCol+1):
imgBlocks[row,col] = 255
# Morphological closing on the image with a 20 x 20 structuring element
structuringElement = cv2.getStructuringElement(cv2.MORPH_RECT, (20,20))
imgClose = cv2.morphologyEx(imgBlocks.copy(), cv2.MORPH_CLOSE, structuringElement)
# Find the contour of this structure
contoursFinal,hierarchyFinal = cv2.findContours(imgClose.copy(), cv2.RETR_LIST,
cv2.CHAIN_APPROX_NONE)
# Take the padded image, and draw a red boundary around the font
# First, create a colour image
imgPadColour = np.dstack((imgPad, imgPad, imgPad))
# Reshape the contour points like we did before
cnt = np.reshape(contoursFinal[0], (contoursFinal[0].shape[0],
contoursFinal[0].shape[2]))
# Careful - pixels are packed in BGR format
# As such, for each point in the outer shape, set to red
for (col,row) in cnt:
imgPadColour[row][col][0] = 0
imgPadColour[row][col][1] = 0
imgPadColour[row][col][2] = 255
# Crop out image for final one
imgFinal = imgPadColour[20:imgPad.shape[0]-20, 20:imgPad.shape[1]-20]
# Show both images
cv2.imshow('Original Image', img)
cv2.imshow('Image with red outline over text', imgFinal)
cv2.waitKey(0)
cv2.destroyAllWindows()
The output will show you two windows. One of the original image, and the other is the output. You'll need to move one of the windows out of the way, as they will appear one on top of the other. Note that you need to download the images to your computer before you run this method.
