石橋を叩いて壊すページ

Javaでグレースケール画像を拡大縮小すると何故か淡くなる件

掲題の件と、ついでに画像の読み書き・綺麗に拡大縮小する方法について調べたので備忘録がてら。

Javaの標準APIで画像を読み込むにはImageIOクラスのreadメソッドが便利です。
こいつにファイルのパスを与えるとBufferedImageオブジェクトを返してくれます。
同様に、writeメソッドを呼べば画像を保存してくれます。簡単ですね。

//画像を読み込む
BufferedImage img1 = ImageIO.read(new File("test1.png"));

//画像を保存する
ImageIO.write(img1, "png", new File("test2.png"));

また、BufferedImageクラス(の基底クラスであるImageクラス)のgetScaledInstanceメソッドを使えば、画像の拡大縮小もお手の物です。簡単ですね。

//元の画像を100x100に拡大縮小した画像を得る
Image img2 = img1.getScaledInstance(100, 100, Image.SCALE_SMOOTH);

画像に線や文字を描画には、BufferedImageからGraphicsを取り出し、ここに定義されているメソッドを呼び出します。
Graphicsには文字を描画するdrawStringや、四角に塗りつぶすfillRect、色を指定するsetColorなど多数のメソッドがあります。簡単ですね。

//img1のx=20,y=20の位置に赤で「test」の文字を描画する
Graphics g = img1.getGraphics();
g.setColor(Color.RED);
g.drawString("test", 20, 20);

さて、BufferedImageには「イメージの形式」というパラメタがあります。
1画素ごとに光の三原色(赤青緑)を1バイトずつ持たせて画像を管理するTYPE_3BYTE_BGRとか、
上記に透明度の概念を追加したTYPE_4BYTE_ABGRとか、
白黒の濃淡に特化したTYPE_BYTE_GRAYなどがあります。

で、この記事で言いたいことは、
TYPE_BYTE_GRAY(グレースケール形式)を指定して作ったBufferedImageを、getScaledInstanceで拡大縮小すると、
なんでか知らんが淡くなる
、という話です。

実験

まずは試してみます。やってることは簡単。
(1)最初にTYPE_BYTE_GRAY(グレースケール形式)の画像を作成し、テストのために模様を描いてファイルに保存。
(2)(1)の画像をImage#getScaledInstanceメソッドで拡大縮小してからファイルに保存。
(3)(1)の画像をGraphics#drawImageメソッドで拡大縮小してからファイルに保存。
あくまで拡大縮小処理なので、多少の乱れはあったとしても、大きな違いは出ないはずです。

//TYPE_BYTE_GRAY形式のBufferedImageを生成し、テスト用の模様を描く
BufferedImage src = new BufferedImage(320, 100, BufferedImage.TYPE_BYTE_GRAY);
Graphics g = src.getGraphics();

//左が真っ黒(0x000000)、右が真っ白(0xffffff)の16段階グラデーションを描く
int c = 0x000000;
for (int i = 0; i < 16; i++) {
	g.setColor(new Color(c));
	g.fillRect(i * 20, 0, 20, 100);
	c += 0x111111;
}

//駄目押しで文字も書いてみる
g.setColor(new Color(0x888888));
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 30));
g.drawString("TestText", 10, 50);

//保存
ImageIO.write(src, "png", new File("元の画像.png"));

//実験1:元の画像をImage#getScaledInstanceで縮小してから保存する
BufferedImage img1 = new BufferedImage(320, 80, BufferedImage.TYPE_BYTE_GRAY);
img1.getGraphics().drawImage(src.getScaledInstance(320, 80, Image.SCALE_DEFAULT), 0, 0, null);
ImageIO.write(img1, "png", new File("実験1.png"));

//実験2:元の画像をGraphics#drawImageで縮小してから保存する
BufferedImage img2 = new BufferedImage(320, 80, BufferedImage.TYPE_BYTE_GRAY);
img2.getGraphics().drawImage(src, 0, 0, 320, 80, null);
ImageIO.write(img2, "png", new File("実験2.png"));

実験1では、ImageクラスのgetScaledInstanceを使って画像を縮小しています。
ただ、このメソッドの戻り値はImage型で、画像をファイルに保存するImageIO.writeはこれを直接扱えないため、同サイズのBufferedImageを別途用意してそこにImageを貼り付けて、貼り付けたBufferedImageのほうを保存する対応を行っています。
実験2では、GraphicsクラスのdrawImageを使って画像を縮小しています。こちらは終始一貫BufferedImageなので特別なことはしていません。

さて、出来た画像を見てみましょう。


左から、元の画像、実験1、実験2の結果。
グラデーションの左から2列目あたりに着目すると、元の画像と実験2は同じなのに、実験1は明らかに明るい。

ご覧の通り、なぜか実験1が淡くなります。
画素ごとの色を取ってみたら、一番左がrgb(0,0,0)なのは期待通りなのですが、その右の列が(17,17,17)であるべきところ、なぜか(73,73,73)になってました。
多少の差異は仕方ないよね?とは思いつつも、実験2はうまくいっているのでその理屈はあまり通らないでしょう。

じゃあ拡大縮小はgetScaledInstanceを使わず、drawImageでやればいいんだね?
いいえ、違います。
後で触れますが、drawImageの拡大縮小は画質が悪いのでお勧めしません。
代わりに、TYPE_BYTE_GRAY(グレースケール)形式の画像をTYPE_3BYTE_BGR(カラー)形式の画像に書き写して“形式変換”したうえで、 変換後の画像をgetScaledInstanceするとうまくいきます。

//※上の実験2からの続き

//実験3:元の画像をTYPE_3BYTE_BGR形式に書き写してからImage#getScaledInstanceで縮小して保存する
BufferedImage img3a = new BufferedImage(320, 100, BufferedImage.TYPE_3BYTE_BGR);
img3a.getGraphics().drawImage(src, 0, 0, null);
BufferedImage img3b = new BufferedImage(320, 80, BufferedImage.TYPE_BYTE_GRAY);
img3b.getGraphics().drawImage(img3a.getScaledInstance(320, 80, Image.SCALE_DEFAULT), 0, 0, null);
ImageIO.write(img3b, "png", new File("実験3.png"));

ここでは元の画像と同じサイズのTYPE_3BYTE_BGR(カラー)形式のBufferedImageを用意し、drawImageの引数が少ないほうのメソッドを使って等倍で画像を書き写すことで形式を変換させています。
そして変換後のほうの画像を縮小して新しい画像を作って保存しています。


左が元の画像、右が実験3の結果。
元の色合いを維持できました。

画像の拡大縮小について

上でも触れましたが、drawImageの画像の縮小処理は画質が悪いです。getScaledInstanceと比較してみます。

//オリジナルの画像を読み込む
Image original = ImageIO.read(new File("original.png"));

//実験1:Graphics#drawImageで縮小
BufferedImage img1 = new BufferedImage(400, 225, BufferedImage.TYPE_3BYTE_BGR);
img1.getGraphics().drawImage(original, 0, 0, 400, 225, null);
ImageIO.write(img1, "png", new File("img1.png"));

//実験2:実験1と同じだが、レンダリングヒントを指定
BufferedImage img2 = new BufferedImage(400, 225, BufferedImage.TYPE_3BYTE_BGR);
Graphics2D g2 = (Graphics2D) img2.getGraphics();
g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2.drawImage(original, 0, 0, 400, 225, null);
ImageIO.write(img2, "png", new File("img2.png"));

//実験3:Image#getScaledInstanceで縮小
//ただしImageIO#writeはImageを直接扱えないので、ImageをBufferedImageに貼り付ける一手間が必要
Image img3a = original.getScaledInstance(400, 225, Image.SCALE_SMOOTH);
BufferedImage img3b = new BufferedImage(400, 225, BufferedImage.TYPE_3BYTE_BGR);
img3b.getGraphics().drawImage(img3a, 0, 0, null);
ImageIO.write(img3b, "png", new File("img3.png"));


左から、実験1、実験2、実験3の結果。画像の下半分は、城の欄干部分を拡大したもの。
ちなみに実験に使ったオリジナルの画像はFF14のクガネの町のスクリーンショット。1920x1080サイズ。

拡大してみると、実験1はかなり画素ごとの色の差が大きくてMS-DOS時代の何かを思い出させる画質です。
実験2は実験1よりは多少マシになっているようです。
実験3はかなりアンチエイリアスが利いているいる感じがあります。
なお処理時間のほうは、5回平均でそれぞれ74ms、37ms、191msでした。実験3はさもありなんとして、実験2が実験1より速いのは意外ですね。

ということで、グレースケール画像を読み込んだときは処理にご注意くださいという話でした。
なお、元の画像がグレースケールでない場合は発生しませんでした。
また、拡大縮小時に画像サイズが変更前と変わらない場合も挙動に差はありませんでした。

ちなみになんで私がこんなことに気づいたかというと、ScanSnapというスキャナでグレースケール設定で読み込んで保存した画像ファイルをJavaで読み取るとTYPE_BYTE_GRAY形式なもので、複数のスキャン結果を縮小して並べたサムネイルを作ろうとしたら妙に淡くなったので実は半年くらい頭抱えてた、という経緯です。
(画質も含め、PHPの画像処理だとこうはならなかったのでそっちで代用してた。)
結局私は、画像ファイルを読み込んだときに形式がTYPE_BYTE_GRAY以外のときはそれをそのまま返し、TYPE_BYTE_GRAYだったときはTYPE_3BYTE_BGRに変換してから返す、という簡単な画像読み込みメソッドを書いて対処しました。

注意:
この記事ではエラー処理は一切省いています。
また、使い終わったImageおよびBufferedImageのオブジェクトはflush()、Graphicsオブジェクトはdispose()を呼んであげるとメモリ的にやさしいでしょう。
なお実験はWindows10、OpenJDK、11を使っています。

この記事を評価

この記事にコメント

  1. ...

【この記事にコメント】
お名前:
コメント:

Menu