PixelReader/PixelWriter

このエントリは JavaFX Advent Calendar の 12/4 担当分になります。

最近、いろいろなところで JavaFX 2.2 の新機能について話をしているのですが、今日はその中の 1 つである PixelReader/PixelWriter クラスについて使い方を説明していきます。

JavaFX 2.2 ではビットマップに関する機能がやっと導入されました。その中でも中心になるのが、ビットマップのピクセルを読み込む PixelReader クラスと、ピクセルを書き込む PixelWriter クラスです。

PixelReader クラスは Image クラスのピクセルを読み込むことができます。PixelWriter クラスが書き込むのは WritableImage クラスです。WritableImage クラスは Image クラスのサブクラスで、Java 2D でいうところの BufferedImage クラスのようなクラスです。

PixelReader/PixelWriter クラスではいくつかメソッドが定義されていますが、まずは 1 ピクセルの読み書きから。

たとえば、あるイメージをコピーしたイメージを作ってみましょう。

    Image src = new Image(...);
    PixelReader reader = src.getPixelReader();

    int width = (int)src.getWidth();
    int height = (int)src.getHeight();

    WritableImage dest = new WritableImage(width, height);
    PixelWriter writer = dest.getPixelWriter();

    for (int x = 0; x < width; x++) {
        for (int y = 0; y < height; y++) {
            // srcのイメージのピクセルを読み込んで、destに書き込む
            Color color = reader.getColor(x, y);
            writer.setColor(x, y, color);

            // こちらでも OK
//            int argb = reader.getArgb(x, y);
//            writer.setArgb(x, y, argb);
        }
    }

PixelReader オブジェクトは Image オブジェクトの getPixelReader メソッドで取得できます。

WritableImage オブジェクトは縦横を指定して生成します。そして、PixelWriter オブジェクトは getPixelWriter メソッドで取得します。

後は、PixelReader#getColor(x, y) で (x, y) 座標の Color オブジェクトを取得できます。同様に、PixelWriter#setColor(x, y, color) で、(x, y) 座標を Color オブジェクトが示す色にします。

同じように PixelReader#getArgb メソッド、PixelWriter#setArgb メソッドというのもあります。これは色を int で扱います。int の先頭 8 ビットが alpha、次の 8 ビットが赤、同様に 8 ビットずつ緑、青となります。

さて、イメージをコピーするのだけでは味気ないので、ここではイメージをぼかしてみます。ここではあまりキレイなぼかしにならないボックスブラーを扱います。

ブラーはあるピクセルの周りのピクセルも一緒に読み込んで、色の平均をとって、そのピクセルの色とするという処理で実現します。

周りのピクセルの取り方を四角にするとボックスブラーになります。

では、書いてみましょう。

    private void blur() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                double red = 0;
                double green = 0;
                double blue = 0;
                double alpha = 0;
                int count = 0;
                for (int i = -kernelSize; i <= kernelSize; i++) {
                    for (int j = -kernelSize; j <= kernelSize; j++) {
                        if (x + i < 0 || x + i >= width 
                           || y + j < 0 || y + j >= height) {
                            continue;
                        }
                        Color color = reader.getColor(x + i, y + j);
                        red += color.getRed();
                        green += color.getGreen();
                        blue += color.getBlue();
                        alpha += color.getOpacity();
                        count++;
                    }
                }
                Color blurColor = Color.color(red / count, 
                                              green / count, 
                                              blue / count, 
                                              alpha / count);
                writer.setColor(x, y, blurColor);
            }
        }
    }

ちょっと面倒くさいのが、縁の部分です。ここは周りのピクセルを全部読み込めないので、読み込めるところだけ読み込んで平均しています。

さて、せっかくなので、スライダでぼかし量を決められるようにしてみます。

        slider.valueProperty().addListener(new InvalidationListener() {
            @Override
            public void invalidated(Observable o) {
                DoubleProperty value = (DoubleProperty)o;
                int intValue = (int) value.get();
                if (intValue != kernelSize) {
                    kernelSize = intValue;
                    blur();
                }
            }
        });

Slier クラスの value プロパティは double なので、int に変換しています。実際に blur メソッドをコールしているのは、value プロパティが double なので 0.0 から 0.1 に変化しても invalidated メソッドがコールされてしまうのですが、int としてみれば変化していないとみなせるからです。

実行すると、下のようになります。


ところが、この方法だととてもパフォーマンスが悪いのです。特にぼかし量を増やした時は、顕著にパフォーマンスが落ちます。

まぁ、当たり前ですね。1 ピクセルを書き込むのに、何度も読んでいるわけですから。パフォーマンスを向上させるには、もっと読み込み回数を減らす必要があります。

じゃあ、どうするか。まとめて読み込めばいいのです。

複数ピクセルの読み込み

PixelReader クラスには getPixels というまとめてピクセルを読み込むメソッドがあります。これを使えば読み込み回数を減らすことができます。

getPixels は読み込んだピクセルの格納方法により 3 種類のオーバーロードがあります。byte、int そして Buffer を使う 3 種類です。

ここでは int を使ってみます。

int を使うには、ピクセルのフォーマットを指定する必要があります。フォーマットは WritablePixelFormat クラスで指定します。WritablePixelFormat クラスのスーパークラスである PixelFormat の static メソッドで WritablePixelFormat オブジェクトのファクトリメソッドがあるので、それを使用します。

int が使えるのは getIntArgbInstance メソッドか getIntArgbPreInstance メソッドのどちらかです。しかし、getIntArgbPreInstance メソッドの Pre がよく分からないんですよね。PixelFormat.Type という enum にpremultiplied と記述されているのですが、どういう意味なんだろう?

ということで、getIntArgbPreInstance メソッドを使用します。

getPixels メソッドの引数は x, y, width, height, writablepixelformt, buffer, offset, scalingStride となります。buffer が int で、offset が buffer のオフセットを指定します。最後の scalingStride は列から次の列までの幅を示すのですが、width でいいと思うんですけどねぇ。

ということで、blur メソッドを書き換えた blur2 メソッドです。

    private void blur2() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                int centerX = x - kernelSize;
                int centerY = y - kernelSize;
                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                if (centerX < 0) {
                    centerX = 0;
                    kernelWidth = x + kernelSize;
                } else if (x + kernelSize >= width) {
                    kernelWidth = width - centerX;
                }

                if (centerY < 0) {
                    centerY = 0;
                    kernelHeight = y + kernelSize;
                } else if (y + kernelSize >= height) {
                    kernelHeight = height - centerY;
                }

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(centerX, centerY, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }

                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                writer.setArgb(x, y, blurColor);
            }
        }
    }

かなり長くなっているのは端の処理が面倒くさくなっているからです。

それでも、1 ピクセルごとに読み込んでいたのに比べると、かなり処理が軽くなっています。

比較できるように blur メソッドを使うか、blur2 メソッドを使うかをラジオボタンで指定できるようにしてみました。

複数ピクセルの書き込み

PixelReader クラスが複数ピクセルの読み込みがあるのですから、もちろん PixelWriter にも複数ピクセルをまとめて書き込むメソッドがあります。

これを使ってモザイク処理をしてみましょう。

ぼかしの時と同じようにピクセルの平均を取るのですが、それをそのピクセルすべての色にしてしまえばモザイクになります。

PixelWriter#setPixels メソッドは、PixelReader#getPixels メソッドと同じように、複数のオーバーロードがあります。byte、int、Buffer の 3 種類に加えて、PixelReader オブジェクトから読み込んだものを書き込むというオーバーロードもあります。

もちろんここでは int[] を使うものを使用します。

    private void mosaic() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = kernelSize; 
             x < width - kernelSize * 2; 
             x += kernelSize * 2 + 1) {
            for (int y = kernelSize; 
                 y < height - kernelSize * 2; 
                 y += kernelSize * 2 + 1) {

                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }
                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                Arrays.fill(buffer, blurColor);
                writer.setPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);
            }
        }
    }

同じようにラジオボタンで選択できるようにしてみました。

ということで、PixelReader クラスと PixelWriter クラスの使い方でした。

明日は @halcat0x15a さんです。

import java.nio.IntBuffer;
import java.util.Arrays;
import javafx.application.Application;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.DoubleProperty;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Slider;
import javafx.scene.control.ToggleGroup;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.image.WritablePixelFormat;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.paint.Color;
import javafx.stage.Stage;

public class WritableImageDemo extends Application {

    private Image src;
    private WritableImage dest;
    private int kernelSize = 1;
    private int width;
    private int height;
    
    private RadioButton blurButton;
    private RadioButton blur2Button;
    private RadioButton mosaicButton;

    @Override
    public void start(Stage stage) {

        AnchorPane root = new AnchorPane();

        initImage(root);

        Scene scene = new Scene(root);

        stage.setTitle("WritableImage Demo");
        stage.setResizable(false);
        stage.setScene(scene);
        stage.show();
    }

    private void initImage(AnchorPane root) {
        src = new Image("macaron.jpg");
        ImageView srcView = new ImageView(src);
        root.getChildren().add(srcView);
        AnchorPane.setTopAnchor(srcView, 0.0);
        AnchorPane.setLeftAnchor(srcView, 0.0);

        width = (int) src.getWidth();
        height = (int) src.getHeight();
        root.setPrefSize(width * 2.0, height + 50);

        dest = new WritableImage(width, height);
        ImageView destView = new ImageView(dest);
        destView.setTranslateX(width);
        root.getChildren().add(destView);
        AnchorPane.setTopAnchor(destView, 0.0);
        AnchorPane.setRightAnchor(destView, (double) width);

        Slider slider = new Slider(0, 10, kernelSize);
        slider.setPrefSize(width, 50);
        slider.setShowTickLabels(true);
        slider.setShowTickMarks(true);
        slider.setSnapToTicks(true);
        slider.setMajorTickUnit(1.0);
        slider.setMinorTickCount(0);

        slider.valueProperty().addListener(new InvalidationListener() {
            @Override
            public void invalidated(Observable o) {
                DoubleProperty value = (DoubleProperty) o;
                int intValue = (int) value.get();
                if (intValue != kernelSize) {
                    kernelSize = intValue;
                    if (blurButton.isSelected()) {
                        blur();
                    } else if (blur2Button.isSelected()) {
                        blur2();
                    } else {
                        mosaic();
                    }
                }
            }
        });

        root.getChildren().add(slider);
        AnchorPane.setBottomAnchor(slider, 0.0);
        AnchorPane.setRightAnchor(slider, 10.0);

        HBox hbox = new HBox(10);
        hbox.setAlignment(Pos.CENTER);
        hbox.setPrefWidth(width);
        hbox.setPrefHeight(50);
        root.getChildren().add(hbox);
        AnchorPane.setBottomAnchor(hbox, 0.0);
        AnchorPane.setLeftAnchor(hbox, 10.0);

        ToggleGroup group = new ToggleGroup();
        blurButton = new RadioButton("Blur");
        blurButton.setToggleGroup(group);
        blurButton.setSelected(true);
        hbox.getChildren().add(blurButton);
        blur2Button = new RadioButton("Blur2");
        blur2Button.setToggleGroup(group);
        hbox.getChildren().add(blur2Button);
        mosaicButton = new RadioButton("Mosaic");
        mosaicButton.setToggleGroup(group);
        hbox.getChildren().add(mosaicButton);

        blur();
    }

    private void blur() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                double red = 0;
                double green = 0;
                double blue = 0;
                double alpha = 0;
                int count = 0;
                for (int i = -kernelSize; i <= kernelSize; i++) {
                    for (int j = -kernelSize; j <= kernelSize; j++) {
                        if (x + i < 0 || x + i >= width 
                           || y + j < 0 || y + j >= height) {
                            continue;
                        }
                        Color color = reader.getColor(x + i, y + j);
                        red += color.getRed();
                        green += color.getGreen();
                        blue += color.getBlue();
                        alpha += color.getOpacity();
                        count++;
                    }
                }
                Color blurColor = Color.color(red / count, 
                                              green / count, 
                                              blue / count, 
                                              alpha / count);
                writer.setColor(x, y, blurColor);
            }
        }
    }

    private void blur2() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                int centerX = x - kernelSize;
                int centerY = y - kernelSize;
                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                if (centerX < 0) {
                    centerX = 0;
                    kernelWidth = x + kernelSize;
                } else if (x + kernelSize >= width) {
                    kernelWidth = width - centerX;
                }

                if (centerY < 0) {
                    centerY = 0;
                    kernelHeight = y + kernelSize;
                } else if (y + kernelSize >= height) {
                    kernelHeight = height - centerY;
                }

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(centerX, centerY, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }
                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                writer.setArgb(x, y, blurColor);
            }
        }
    }

    private void mosaic() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = kernelSize; x < width - kernelSize * 2; x += kernelSize * 2 + 1) {
            for (int y = kernelSize; y < height - kernelSize * 2; y += kernelSize * 2 + 1) {
                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }
                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                Arrays.fill(buffer, blurColor);
                writer.setPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);
            }
        }
    }

    public static void main(String[] args) {
        launch(args);
    }
}