JRubyでFilthyRichClients その6

Filthy Rich Clientsの第五章はパフォーマンスに関する話題です。
アプリの速度向上のために気をつけることが書かれています。

クリッピング

与えられたクリッピングの中だけで描画を行うようにする。

# クリッピングを使わないコード
def paintComponent(g)
  g.setColor(Color::BLACK)
  g.fillRect(0, 0, getWidth, getHeight)
end

# クリッピングを使ったコード
def paintComponent(g)
  g.setColor(Color::BLACK)
  clip = g.getClipBounds
  g.fillRect(clip.x, clip.y, clip.width, clip.height)
end

でも、クリッピングを簡単に使えないときは、無理に使う必要は無いみたい。

中間画像

画像に何度も同じ加工をしなければいけない時は、最初に画像を加工したら、加工した画像を取っておく。2回目以降は、取っておいた画像をdrawImageでコピーして使う。毎回画像を加工するより、コピーするほうが速い。

互換画像

実行している環境に最適化された画像を内部でもっておけば速い。画像を互換画像に変換して、互換画像のほうを使うようにする。
画像を互換画像に変換するmoduleをJRubyで書いてみた。

require 'java'

module MakeCompatibleImage
  import 'java.awt.GraphicsEnvironment'
  
  module_function
  
  def get_configuration
    GraphicsEnvironment.getLocalGraphicsEnvironment.getDefaultScreenDevice.getDefaultConfiguration
  end
  
  # 与えられた幅、高さ、透明度を持つ互換画像を作成する。
  # transparencyは、BufferedImage#getTransparencyで取得するか、以下の定数を指定できる。
  #   java.awt.Transparency::BITMASK
  #   java.awt.Transparency::OPAQUE
  #   java.awt.Transparency::TRANSLUCENT
  #   http://java.sun.com/j2se/1.5.0/ja/docs/ja/api/java/awt/Transparency.html
  def create_compatible_image(width, height, transparency)
    get_configuration.createCompatibleImage(width, height, transparency)
  end
  
  # 与えられた画像が、互換画像であるか調べる。
  def compatible_image?(image)
    image.getColorModel.equals(get_configuration.getColorModel)
  end
  
  # ソース画像をコピーした、新しい互換画像を作成する。
  # 既にソース画像が互換の場合は、ソース画像を返す。
  def to_compatible_image(image)
    return image if compatible_image?(image)
    
    compatible_image = create_compatible_image(image.getWidth, image.getHeight, image.getTransparency)
    g = compatible_image.getGraphics
    g.drawImage(image, 0, 0, nil)
    g.dispose
    compatible_image
  end
  
  # 引数で指定された画像へのパスから、互換画像を作成する。
  # パスはURLかファイル名を指定する。
  # ex:
  #   MakeCompatibleImage.load_compatible_image("http://sample.com/foo.png")
  #   MakeCompatibleImage.load_compatible_image("foo.png")
  def load_compatible_image(path)
    klass = fetch_resource_class(path)
    image = javax.imageio.ImageIO.read(klass.new(path))
    to_compatible_image(image)
  end
  
  def fetch_resource_class(path)
    return java.net.URL if path =~ %r!^http://!
    java.io.File
  end
end

if __FILE__ == $0
  include_class 'java.awt.image.BufferedImage'
  
  p MakeCompatibleImage.get_configuration
  
  img = BufferedImage.new(100, 50, BufferedImage::TYPE_INT_ARGB)
  p MakeCompatibleImage.create_compatible_image(img.getWidth, img.getHeight, img.getTransparency)
  
  [BufferedImage::TYPE_INT_ARGB, BufferedImage::TYPE_INT_RGB].each do |type|
    image = BufferedImage.new(100, 50, type)
    print "compatible_image?:"
    p MakeCompatibleImage.compatible_image?(image)
    p image
    p MakeCompatibleImage.to_compatible_image(image)
    p ""
  end
  
end

書いたんだけど、JavaのImageIOで、URLオブジェクトからimageを作ろうとすると例外が飛ぶ。
Fileだと上手くいくんだけど。。。↓のように単純化してもエラーになるんだけど、なんでだろ。。

  image = javax.imageio.ImageIO.read java.net.URL.new("http://www.google.co.jp/images/srpr/nav_logo13.png")
javax.imageio.ImageIO:-1:in `createImageInputStream': javax.imageio.IIOException: Can't create cache file! (NativeException)
	from javax.imageio.ImageIO:-1:in `read'

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を考えるのが良いのかも。

JRubyでFilthyRichClients その4

色々と自分でShapeを作れれば、Grapics2Dのfillやdrawを使って、これらのShapeを描画することができる。
Filthy Rich Clientsでは、

  • Area、Elipse2Dを使ったドーナツ型
  • GeneratePathを使った星型

などが紹介されている。このカスタムShapeをJRubyで書いてみた。

include Java

include_class %w( JFrame JComponent SwingUtilities ).map{|s| 'javax.swing.' + s}
include_class %w( Color GradientPaint RadialGradientPaint RenderingHints ).map{|s| 'java.awt.' + s}
include_class %w( MouseAdapter ).map{|s| 'java.awt.event.' + s}
include_class %w( Area Ellipse2D Point2D GeneralPath ).map{|s| 'java.awt.geom.' + s}

class Canvas < JComponent
  def initialize()
    super()
    addMouseListener(ClickReceiver.new)
    
    @shapes = []
    @colors = []
    @get_star = true
  end
  
  def paintComponent(g)
    draw_background(g)
    g.setRenderingHint(RenderingHints::KEY_ANTIALIASING, RenderingHints::VALUE_ANTIALIAS_ON)
    draw_shape(g)
  end
  
  def create_shape(e)
    inner_size = 1 + rand(25)
    outer_size = inner_size + 10 + rand(15)
    if @get_star
      branch_count = (rand(8) + 5).to_i
      @shapes << generate_star(e.getX, e.getY, inner_size, outer_size, branch_count)
    else
      @shapes << generate_donut(e.getX-outer_size/2, e.getY-outer_size/2, inner_size, outer_size)
    end
    2.times do
      @colors << Color.new(rand(0xFFFFFF))
    end
    @get_star = !@get_star
    repaint()
  end
  
  private
  
  def draw_background(g)
    background = GradientPaint.new(0, 0, Color::GRAY.darker(),
                                   0, getHeight(), Color::GRAY.brighter())
    g.setPaint(background)
    g.fillRect(0, 0, getWidth(), 4*getHeight()/5)
    
    background = GradientPaint.new(0, 4*getHeight()/5, Color::BLACK,
                                   0, getHeight(), Color::GRAY.darker());
    g.setPaint(background)
    g.fillRect(0, 4*getHeight()/5, getWidth(), getHeight()/5)    
  end
  
  def draw_shape(g)
    @shapes.each_with_index do |shape, i|
      rect = shape.getBounds()
      center = Point2D::Float.new(rect.x + rect.width/2.0, rect.y + rect.height/2.0)
      radius = rect.width/2.0
      dist = [0.1, 0.9].to_java(:float)
      colors = [@colors[i*2], @colors[i*2+1]].to_java('java.awt.Color')   
      g.setPaint(RadialGradientPaint.new(center, radius, dist, colors))
      g.fill(shape)
    end
  end
  
  def generate_donut(x, y, inner_radius, outer_radius)
    a1 = Area.new(Ellipse2D::Double.new(x, y, outer_radius, outer_radius))
    inner_offset = (outer_radius - inner_radius)/2.0
    a2 = Area.new(Ellipse2D::Double.new(x + inner_offset, y + inner_offset, inner_radius, inner_radius))
    a1.subtract(a2)
    a1    
  end
  
  def generate_star(center_x, center_y, inner_radius, outer_radius, branch_count)
    path = GeneralPath.new
    path.moveTo(center_x + outer_radius, center_y)
    
    outer_angle_increment = 2.0 * Math::PI / branch_count
    outer_angle = 0.0
    inner_angle = outer_angle_increment / 2.0
    branch_count.times do
      x = Math.cos(outer_angle) * outer_radius + center_x
      y = Math.sin(outer_angle) * outer_radius + center_y
      path.lineTo(x, y)
      
      x = Math.cos(inner_angle) * inner_radius + center_x
      y = Math.sin(inner_angle) * inner_radius + center_y
      path.lineTo(x, y)
      
      outer_angle += outer_angle_increment
      inner_angle += outer_angle_increment
    end
    
    path.closePath()
    path
  end
end

class ClickReceiver < MouseAdapter
  def mouseClicked(e)
    c = e.getSource
    c.create_shape(e)
  end
end

SwingUtilities.invokeLater do
  f = JFrame.new
  f.add(Canvas.new)
  f.setSize(500, 500)
  f.setDefaultCloseOperation(JFrame::EXIT_ON_CLOSE)
  f.setLocationRelativeTo(nil)
  f.setVisible(true)  
end

実行結果


画面をクリックすると、ドーナツ型と星型の図形が交互にフレームに追加される。
generate_donutでドーナツ型、generate_starで星型のShapeを作っている。

generate_donutでは、大きい円a1と小さい円a2のShapeを作って、
a1からa2の領域を除く(a1.subtract(a2))ことでドーナツ型を作っている。

erbのyield

railsのviewの処理のように、erbのソースにyieldを使ってhtmlを埋めこむようなテクニックがあるけど、nanocのソースを読んでいたら、同様の処理を行うようなコードがあった。erbとyieldの使い方をメモしておこう。
erbのyieldは以下のように使える。

>> require 'erb'
=> true
>> class Context
>>   def get_binding
>>     binding
>>   end
>> end
=> nil
>> layout =<<EOS
<html>
<body>
  <%= yield %>
</body>
</html>
EOS
=> "<html>\n<body>\n  <%= yield %>\n</body>\n</html>\n"
>> content = "content"
=> "content"
>> e = ERB.new(layout)
=> #<ERB:0x18f9b75 @filename=nil, @src="_erbout = ''; _erbout.concat \"<html>\\n<body>\\n  \"\n\n; _erbout.concat(( yield ).to_s); _erbout.concat \"\\n</body>\\n</html>\\n\"\n\n\n; _erbout", @safe_level=nil>
>> e.result(Context.new.get_binding{ content })
=> "<html>\n<body>\n  content\n</body>\n</html>\n"

ERB#resultにブロックを付けたbindingを渡せばよい。
ブロックが無いと、以下のように例外が出る。

>> e.result
LocalJumpError: yield called out of block
        from /opt/env/ruby/jruby-1.5.0/lib/ruby/1.8/irb/ruby-token.rb:46
Maybe IRB bug!!
>> e.result(binding)
LocalJumpError: yield called out of block
        from /opt/env/ruby/jruby-1.5.0/lib/ruby/1.8/irb/ruby-token.rb:102:in `irb_binding'
Maybe IRB bug!!
>> e.result(Context.new.get_binding)
LocalJumpError: yield called out of block
        from /opt/env/ruby/jruby-1.5.0/lib/ruby/1.8/irb/ruby-token.rb:102:in `get_binding'
Maybe IRB bug!!

nanocのソースを読む(DirectedGraph)

nanocのDirectedGraphクラスのソースを読んだときのメモ。
このクラスが表現していることを理解するには、"有向グラフ"を検索してグラフの形を見ておくとわかりやすいかも。
irbでDirectedGraphインスタンスを作って、ソースのコメントに載っているサンプルの動作を確認してみる。

>> require 'pp'
>> require 'nanoc3'
?> g = Nanoc3::DirectedGraph.new(%w( a b c d ))
>> pp g
#<Nanoc3::DirectedGraph:0xfc0
 @from_graph={},
 @predecessors={},
 @successors={},
 @to_graph={},
 @vertice_indexes={"a"=>0, "b"=>1, "c"=>2, "d"=>3},
 @vertices=["a", "b", "c", "d"]>

add_edge

add_edge('a', 'b')は、有向グラフで言えばaからbに線を引くようなもの。

>> g.add_edge('a', 'b')
=> {}
>> pp g
#<Nanoc3::DirectedGraph:0xfc0
 @from_graph={"a"=>#<Set: {"b"}>},
 @predecessors={},
 @successors={},
 @to_graph={"b"=>#<Set: {"a"}>},
 @vertice_indexes={"a"=>0, "b"=>1, "c"=>2, "d"=>3},
 @vertices=["a", "b", "c", "d"]>
=> nil
>> g.add_edge('b', 'c')
=> {}
>> pp g
#<Nanoc3::DirectedGraph:0xfc0
 @from_graph={"a"=>#<Set: {"b"}>, "b"=>#<Set: {"c"}>},
 @predecessors={},
 @successors={},
 @to_graph={"b"=>#<Set: {"a"}>, "c"=>#<Set: {"b"}>},
 @vertice_indexes={"a"=>0, "b"=>1, "c"=>2, "d"=>3},
 @vertices=["a", "b", "c", "d"]>
=> nil
>> g.add_edge('c', 'd')
=> {}
>> pp g
#<Nanoc3::DirectedGraph:0xfc0
 @from_graph={"a"=>#<Set: {"b"}>, "b"=>#<Set: {"c"}>, "c"=>#<Set: {"d"}>},
 @predecessors={},
 @successors={},
 @to_graph={"b"=>#<Set: {"a"}>, "c"=>#<Set: {"b"}>, "d"=>#<Set: {"c"}>},
 @vertice_indexes={"a"=>0, "b"=>1, "c"=>2, "d"=>3},
 @vertices=["a", "b", "c", "d"]>
=> nil

direct_predecessors_of

direct_predecessors_of('d')は、dへ直接線を引いている要素の集合を返す。
いまの場合、add_edge('c', 'd')でcからdへ線を引いたので、cが返る。
predecessors_of('d')は、dへたどりつける線が存在する要素の集合を返す。
たとえば、add_edge('a', 'b')、add_edge('b', 'c')、add_edge('c', 'd')としていたので、
a→b→c→dで、aはpredecessors_of('d')で返される要素の1つとなっている。
これと対応するような、direct_successors_of、successors_ofというメソッドもある。

>> g.direct_predecessors_of('d').sort
=> ["c"]
>> g.predecessors_of('d').sort
=> ["a", "b", "c"]

remove_edge

remove_edge("a", "b")は、add_edge("a", "b")を取り消す。aからbへの線を消すことに値する。

>> g.remove_edge('a', 'b')
=> {}
>> pp g
#<Nanoc3::DirectedGraph:0xfc0
 @from_graph={"a"=>#<Set: {}>, "b"=>#<Set: {"c"}>, "c"=>#<Set: {"d"}>},
 @predecessors={},
 @successors={},
 @to_graph={"b"=>#<Set: {}>, "c"=>#<Set: {"b"}>, "d"=>#<Set: {"c"}>},
 @vertice_indexes={"a"=>0, "b"=>1, "c"=>2, "d"=>3},
 @vertices=["a", "b", "c", "d"]>
=> nil

これぐらい概要を書いておけば、あとで思い出すときもすぐ思い出せるかな。