JRubyでFilthyRichClients その5

第4章はスケーリングに関する品質とパフォーマンスの話題です。
Java2Dでは画像のスケーリングに使えるオプションがたくさんあり、オプションによって品質とパフォーマンスが異なっている。

Filthy Rich Clientsでは以下のスケーリングの品質を比較している。
(この順番は品質の低い順番から並んでいるとのこと。)

  • NEAREST_NEIGHBOR
  • BILINEAR
  • BICUBIC
  • AREA_AVERAGING

本によると、AREA_AVERAGINGは、品質は良いけどパフォーマンスがその他のアルゴリズムより
かなり悪く、あまり推奨されていないようだ。(どうか使わないでと書いてあった)
代わりに、AREA_AVERAGINGのように品質が良くて、かつパフォーマンスも良いProgressive Bilinearという方法が紹介されていた。
Progressive Bilinearというのは以下のようなもの。

  • BILINEAR方式は、50%より小さく縮小処理を行った場合に品質が悪くなることが分かっている。
  • 50%より小さい縮小処理を行うときは、50%の縮小を繰り返して目的のサイズに縮小する。

これらのスケーリングの品質とパフォーマンスを、比較するためのコードがJavaで書かれていたので、JRubyで書き直して検証してみた。

NEAREST_NEIGHBOR、BILINEAR、BICUBIC、AREA_AVERAGINGを使うには、Graphicsインスタンスのメソッドにオプションを渡せばよい。
Progressive Bilinearは、BILINEARを反復する処理を記述する必要があるので、moduleにまとめた。(ImageUtil#get_scaled_instance)

require 'java'

include_class %w( JFrame JComponent SwingUtilities ).map{|s| 'javax.swing.' + s}
include_class %w( Color RenderingHints BorderLayout Image Transparency ).map{|s| 'java.awt.' + s}
include_class 'javax.imageio.ImageIO'
include_class 'java.awt.image.BufferedImage'

class ScaleTest < JComponent
  FULL_SIZE = 190
  PADDING = 5
  QUAD_SIZE = FULL_SIZE / 2
  SCALE_FACTOR = 0.17
  
  def initialize
    super()
    @original_image = BufferedImage.new(FULL_SIZE, FULL_SIZE, BufferedImage::TYPE_INT_RGB);
    @original_image_painted = false
  end
  
  def paintComponent(g)
    paint_original_image() unless @original_image_painted
    x = 5
    y = 20
    
    # nearest neighbor
    benchmark(g, x, y, "NEAREST") do
      draw_image(g, y)
    end
 
    # bilinear
    y += FULL_SIZE + PADDING
    g.setRenderingHint(RenderingHints::KEY_INTERPOLATION,
                       RenderingHints::VALUE_INTERPOLATION_BILINEAR)
    benchmark(g, x, y, "BILINEAR") do
      draw_image(g, y)
    end
    
    # bicubic
    y += FULL_SIZE + PADDING
    g.setRenderingHint(RenderingHints::KEY_INTERPOLATION, 
                       RenderingHints::VALUE_INTERPOLATION_BICUBIC)
    benchmark(g, x, y, "BICUBIC") do
      draw_image(g, y)
    end

    # getScaledInstance
    y += FULL_SIZE + PADDING
    g.setRenderingHint(RenderingHints::KEY_INTERPOLATION, 
                       RenderingHints::VALUE_INTERPOLATION_NEAREST_NEIGHBOR)
    benchmark(g, x, y, "getScaled") do
      draw_image_getScaledInstance(g, y)
    end
   
    # Progressive Bilinear
    y += FULL_SIZE + PADDING
    benchmark(g, x, y, "Progressive") do
      draw_image_progressive_bilinear(g, y)
    end
    
    # Draw image sizes
    yield_draw_parametor do |x, scaled_size|
       g.drawString("#{scaled_size} x #{scaled_size}", x + [0, scaled_size/2 - 20].max, 15)
    end
  end
  
  private
  
  def paint_original_image
    g = @original_image.getGraphics()
    # clear background
    g.setColor(Color::BLACK)
    g.fillRect(0, 0, FULL_SIZE, FULL_SIZE)
    
    draw_rgb_rect(g, 0, 0, QUAD_SIZE)
    draw_picture(g, QUAD_SIZE, 0, QUAD_SIZE)
    draw_smile(g, 0, QUAD_SIZE, QUAD_SIZE)
    draw_grid(g, QUAD_SIZE, QUAD_SIZE, QUAD_SIZE)

    g.dispose()
    @original_image_painted = true
  end
  
  def draw_rgb_rect(g, x, y, width)
    colors = [Color::RED, Color::GREEN, Color::BLUE]
    0.step(width, colors.size) do |i|
      colors.each_with_index do |color, j|
        g.setColor(color)
        g.drawLine(x+i+j, y, x+i+j, y+width)
      end
    end   
  end
  
  def draw_picture(g, x, y, width)
    picture = ImageIO.read(java.io.File.new("lib/images/BBGrayscale.png"))
    # Center picture in quadrant area
    x_diff = width - picture.getWidth()
    y_diff = width - picture.getHeight()
    g.drawImage(picture, x + x_diff/2, y + y_diff/2, nil);    
  end
  
  def draw_smile(g, x, y, width)
    g.setColor(Color::WHITE)
    g.fillRect(x, y, width, width)
    g.setColor(Color::BLACK)
    g.drawOval(x + 2, y + 2, width-4, width-4)
    g.drawArc(x + 20, y + 20, width - 40, width -40, 190, 160)
    
    eye_size = 7
    eye_pos = 30 - (eye_size / 2)
    g.fillOval(x + eye_pos, y + eye_pos, eye_size, eye_size)
    g.fillOval(x + width - eye_pos - eye_size, y + eye_pos, eye_size, eye_size)
  end
  
  def draw_grid(g, x, y, width)
    g.setColor(Color::WHITE)
    g.fillRect(x, y, width, width)
    g.setColor(Color::BLACK)
    1.step(width, 4) do |i|
      g.drawLine(x, y+i, x+width, y+i)
      g.drawLine(x+i, y, x+i, y+width)
    end
  end
  
  def yield_draw_parametor
    x = 100
    delta = SCALE_FACTOR * FULL_SIZE
    FULL_SIZE.step(1, -delta) do |scaled_size|
      yield(x, scaled_size)
      x += scaled_size + 20
    end  
  end
  
  def draw_image(g, y)
    yield_draw_parametor do |x, scaled_size|
      g.drawImage(@original_image, x, y + (FULL_SIZE-scaled_size)/2, scaled_size, scaled_size, nil)
    end
  end
  
  def draw_image_getScaledInstance(g, y)
    yield_draw_parametor do |x, scaled_size|
      scaled_image = @original_image.getScaledInstance(scaled_size, scaled_size, Image::SCALE_AREA_AVERAGING)
      g.drawImage(scaled_image, x, y + (FULL_SIZE - scaled_size)/2, nil)   
    end
  end
  
  def draw_image_progressive_bilinear(g, y)
    yield_draw_parametor do |x, scaled_size|
      scaled_image = ImageUtil.get_scaled_instance(@original_image, scaled_size, scaled_size)
      g.drawImage(scaled_image, x, y + (FULL_SIZE - scaled_size)/2, nil)
    end
  end
  
  def benchmark(g, x, y, type)
    start_time = java.lang.System.nanoTime()
    yield
    draw_result_time(g, x, y + FULL_SIZE/2, type, java.lang.System.nanoTime(), start_time)
  end
  
  def draw_result_time(g, x, y, type, end_time, start_time)
    g.drawString(type + " ", x, y)
    g.drawString(((end_time - start_time) / 1000000).to_s + " ms", x, y + 15);    
  end
end

module ImageUtil
  module_function
  
  # progressive bilinear
  def get_scaled_instance(img, target_width, target_height, hint=RenderingHints::VALUE_INTERPOLATION_BILINEAR)
    type = (img.getTransparency() == Transparency::OPAQUE) ? 
            BufferedImage::TYPE_INT_RGB : BufferedImage::TYPE_INT_ARGB
    ret = img
    w, h = img.getWidth(), img.getHeight()
    scratch_image = BufferedImage.new(w, h, type)
    g = scratch_image.createGraphics()
    prev_w, prev_h = ret.getWidth(), ret.getHeight()
    
    while(w != target_width || h != target_height)
      if w > target_width
        w /= 2
        w = target_width if w < target_width
      end
      
      if h > target_height
        h /= 2
        h = target_height if h < target_height
      end
      g.setRenderingHint(RenderingHints::KEY_INTERPOLATION, hint)
      g.drawImage(ret, 0, 0, w, h, 0, 0, prev_w, prev_h, nil)
      prev_w, prev_h = w, h
      ret = scratch_image
    end
    g.dispose()
    
    if (target_width != ret.getWidth() || target_height != ret.getHeight())
      scratch_image = BufferedImage.new(target_width, target_height, type)
      g = scratch_image.createGraphics()
      g.drawImage(ret, 0, 0, nil)
      g.dispose()
      ret = scratch_image
    end
    
    ret
  end
end

SwingUtilities.invokeLater do
  f = JFrame.new
  f.add(ScaleTest.new)
  f.setSize(900, 50 + (5 * ScaleTest::FULL_SIZE) + (6 * ScaleTest::PADDING))
  f.setDefaultCloseOperation(JFrame::EXIT_ON_CLOSE)
  f.setVisible(true)  
end

実行結果

NEAREST NEIGHBOR

BILINEAR

BICUBIC

AREA_AVERAGING

Progressive Bilinear

品質

縮小率が小さくなると、品質は、AREA_AVERAGINGとProgressive Bilinearが
他のアルゴリズムより良いと思われる。

パフォーマンス

アルゴリズム 時間(ms)
NEAREST NEIGHBOR 2
BILINEAR 1
BICUBIC 97
AREA_AVERAGING 55
Progressive Bilinear 10

AREA_AVERAGINGが一番遅いと思っていたが、BICUBICの方が遅い。
品質の良かったProgressive Bilinearがパフォーマンスもなかなか。

この結果を踏まえると、普段はNEAREST NEIGHBORを使って、品質を高めたいときはProgressive Bilinearを考えるのが良いのかも。