Saturday, June 09, 2007

Scaling images with Java

As a newly minted manager, I am finding myself doing things over the past couple of weeks that I would not normally do otherwise. One such thing was to cut images out of a PDF document, paste it into the GIMP and make scaled images suitable for use on web pages. Why would I do such a thing, you ask? Well, the project was running late, and even though I hadn't been part of the project up until now, I was now responsible for its delivery. One of the things that needed to get done was this, and someone had to do it, so I did.

I am usually a great believer in writing tools, since the time spent writing tools usually are recovered many times over in terms of productivity and morale gains. However, I mistook the scope of the work, thinking that there may just be a few images which needed to be handled this way. Also, since I don't do too much image-processing at work or at home, I did not know how quickly I could build the tool. Anyway, at the time, it did not seem like a good idea to spend time building the tool. In retrospect, I realize I was wrong.

The GIMP has multiple scriptable interfaces where you can write scripts to automate its behavior. There is the Scheme based script-fu, the Perl based perl-fu, and the Python based python-fu. I did find script-fu and python-fu based solutions on the Internet which I could have adapted. However, the person who was doing the image work was using Adobe Photoshop on Windows, and he was unfamiliar with the GIMP. So a GIMP based solution would not have been optimal in my case. I ultimately settled on building a Java based solution, since the rest of our content generation pipeline is Java-based. The code I came up with is loosely based on the code I found in the Real's Howto site.

We needed code that would take a directory full of JPEG files, scale it into three sizes - thumbnail, medium and large, and dump it an output location. The thumbnail will be used as a clickable image in sidebars, and will measure 60 pixels on its long side. The medium image would be used for inline display on the web page, and will measure 250 pixels on its long side. The large image will be in its own image page when the thumbnail is clicked, and will measure 450 pixels on its long side. We will specify the source directory where the input JPEG files are located, and an output directory. The code will create three subdirectories - thumbnails, medium and large and put the scaled images in the correct location.

Here is the code to do this. As you can see, there are two generate() methods. The parameter-less generator() method can be used to batch process a directory full of JPEG files, while the one with the file name can be process a single file. The one-argument generate() method is also the place where all the image processing code is happening. There are two setters which can be set using IoC from a Spring container (my choice), but which can also be set explicitly within code, as shown in the calling code example below.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
/**
 * Takes an input image JPEG file, and produces 3 scaled versions of this
 * image: thumbnal, medium and large.
 * @author Sujit Pal
 */
public class ScaledImageGenerator {

  private static final Logger LOGGER = Logger.getLogger(ScaledImageGenerator.class);
  
  private String sourceDir;
  private String targetDir;
  
  private enum ImageSize {
    THUMBNAIL(60), 
    MEDIUM(250), 
    LARGE(450);
    
    int longSide;
    
    ImageSize(int longSide) {
      this.longSide = longSide;
    }
  };
  
  public ScaledImageGenerator() {
    super();
  }
  
  public void setSourceDir(String sourceDir) {
    this.sourceDir = sourceDir;
  }
  
  public void setTargetDir(String targetDir) {
    this.targetDir = targetDir;
  }

  /**
   * Process all JPEG files in the directory specified by sourceDir and
   * drop the different versions of the target images in the target directory.
   * This is a wrapper over the single file generate call.
   * @throws IOException if one is thrown.
   */
  public void generate() throws IOException {
    setUpDirectories();
    File sourceDirectory = new File(sourceDir);
    String[] jpegInputFiles = sourceDirectory.list(new FilenameFilter() {
      public boolean accept(File dir, String name) {
        return name.endsWith(".jpg");
      }
    });
    int totalFiles = jpegInputFiles.length;
    int i = 0;
    for (String jpegInputFile : jpegInputFiles) {
      i++;
      LOGGER.info("Processing file(" + i + "/" + totalFiles + "):" + jpegInputFile);
      generate(jpegInputFile);
    }
  }
  
  /**
   * Generates 3 images for a single input JPEG image.
   * @param imageFileName the name of the source JPEG image file.
   * @throws Exception if one is thrown.
   */
  public void generate(String imageFileName) throws IOException {
    BufferedImage sourceImage = ImageIO.read(
      new File(FilenameUtils.concat(sourceDir, imageFileName)));
    int srcWidth = sourceImage.getWidth();
    int srcHeight = sourceImage.getHeight();
    for (ImageSize imageSize : ImageSize.values()) {
      double longSideForSource = (double) Math.max(srcWidth, srcHeight);
      double longSideForDest = (double) imageSize.longSide;
      double multiplier = longSideForDest / longSideForSource;
      int destWidth = (int) (srcWidth * multiplier);
      int destHeight = (int) (srcHeight * multiplier);
      BufferedImage destImage = new BufferedImage(destWidth, destHeight, 
        BufferedImage.TYPE_INT_RGB); 
      Graphics2D graphics = destImage.createGraphics();
      AffineTransform affineTransform = 
        AffineTransform.getScaleInstance(multiplier, multiplier);
      graphics.drawRenderedImage(sourceImage, affineTransform);
      ImageIO.write(destImage, "JPG", new File(FilenameUtils.concat(
        getImageTargetDir(imageSize), imageFileName)));
    }
  }

  /**
   * Clean up target directories from previous run, if any, and create fresh
   * subdirectories under the target directory for the current run.
   * @throws Exception if one is thrown.
   */
  private void setUpDirectories() throws IOException {
    for (ImageSize imageSize : ImageSize.values()) {
      String imageTargetDir = getImageTargetDir(imageSize);
      FileUtils.deleteDirectory(new File(imageTargetDir));
      FileUtils.forceMkdir(new File(imageTargetDir));
    }
  }
  
  /**
   * Returns the target directory for the scaled image, given the ImageSize attribute.
   * Uses the targetDir setting that is injected in via the container.
   * @param imageSize the ImageSize.
   * @return the name of the target directory.
   */
  private String getImageTargetDir(ImageSize imageSize) {
    String imageTargetDir = null;
    switch (imageSize) {
    case THUMBNAIL:
      imageTargetDir = FilenameUtils.concat(targetDir, "thumbnails");
      break;
    case MEDIUM:
      imageTargetDir = FilenameUtils.concat(targetDir, "medium");
      break;
    case LARGE:
      imageTargetDir = FilenameUtils.concat(targetDir, "large");
      break;
    }
    return imageTargetDir;
  }
}

The example below illustrates calling code that works against a single file. This is pulled from my JUnit test. You will need to manually create the target directory, and set up three subdirectories - thumbnails, medium and large within it.

1
2
3
4
    ScaledImageGenerator generator = new ScaledImageGenerator();
    generator.setSourceDir("/path/to/source/directory");
    generator.setTargetDir("/path/to/target/directory");
    generator.generate("s_seagull.jpg");

To test this code, I used a royalty-free image from FreeDigitalPhotos.net. You can see the original image here. The output images from the code described above is shown below:

Thumbnail version
Medium Inline version
Large full size version

So there you have it. If the image paths need to be recorded in a database, this can be added quite simply in the code above as well. Its too late for this code to be of any use to the current project, but hopefully, having this as part of our codebase will help us in a similar project down the line.

4 comments (moderated to prevent spam):

Otamuka said...

Good article, very well explained and excellent code example.

Just what I needed.

Thanks

Sujit Pal said...

Thanks Pau.

Sir Knight Byron said...

Thanks Sujit, looks nice so far, you are quality amongst a lot of junky programmers across the world

Sujit Pal said...

Thanks Sir Knight Byron.