読者です 読者をやめる 読者になる 読者になる

No Bugs, No Life

読んだ本や、プログラミング、システム開発等のねたを中心に。文章を書く練習なので少し硬派に書くつもりだけど、どうなることやら。

AffineTransformで画像を回転

DEV MW Java

MementoWeaver開発記(11)ぐらい。
Java2DのAffineTransformを使用して画像ファイル(素材)の回転を実装した*1
Javaに詳しい人だったら既に常識かも知れないけど、それなりに嵌ってしまった点もあるので記録しておく。
要件は至ってシンプルで以下のことを行いたい。

  1. 素材のタグ付け画面に、ステージングエリアに配置されている画像を描画する。
  2. 画面上に配置しているボタンを押したら、画像を時計回りに90度回転する。
  3. その際、ステージングエリアに配置している素材そのものとサムネイルも併せて回転した状態で保存する。

はまった点は大きく以下の2点

  • 回転した画像を保存するときに、「他のプロセスが使用しています例外(FileSystemException)」で怒られる
  • 回転した画像が描画領域から外れていく(周りに黒い領域が増えていく)

回転した画像を保存するときに、「他のプロセスが使用しています例外(FileSystemException)」で怒られる

これはごく初歩的なミスなんだけど、要するにImageIO.writeで書き込んだOutputStreamをclose()していなかったのみ。

// 書き出し
Path tmpfile = Files.createTempFile("mwImageManipulator", ".tmp");
FileOutputStream os = new FileOutputStream(tmpfile.toFile());
ImageIO.write(rotatedImage, "jpeg", os);
os.close(); /* ここでclose()し忘れてた。*/
Files.move(tmpfile, sourcePath,StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE);

ここでos.close()を入れ忘れていたせいで、Files.move()の時に「他のプロセスが使用しています例外」で怒られてしまっていた。
言い訳だけど、ImageIO.read()はInputStreamをclose()してくれるのに、なんだImageIO.write()はclose()してくれないんだろう?
なぜかImageIOは勝手にclose()してくれるって頭があったらしい。

回転した画像が描画領域から外れていく(周りに黒い領域が増えていく)

これは、たぶん画像変換とかを組んだことのある人なら常識なんだろうけど、あまりネットで情報を見つけられなかったので、記録しておく。

発生した事象

事象としては、以下のような画像ファイルを時計回りに90度回転すると、、、
f:id:kazyury:20130102153254j:plain
以下のようになってしまう。
f:id:kazyury:20130311220736j:plain

原因

一言で言うと、AffineTransformが良く判っていなかったということなんだけど、ただ単純に画像の中心座標でrotate()するだけでは駄目ということ。
BufferedImageのビューポイント(正式な用語ではないと思うけど)の座標系とAffine変換後の画像の座標系がずれていた。文章だけで原因を説明するのは難しいので、以下に書き込み用BufferedImageの状態(図では黒い四角)、画像の状態*2とコードの関係をまとめる。
f:id:kazyury:20130311220239p:plain

対応案1:回転して平行移動

ネット上でもそのものズバリって答えを探し出せなかったので、一旦このような対応を行った。

// オリジナルの解(試行錯誤=3時間版)
affine.translate(-(sourceImage.getWidth()-sourceImage.getHeight())/2, (sourceImage.getWidth()-sourceImage.getHeight())/2);
affine.rotate(Math.toRadians(degree), sourceImage.getWidth()/2d, sourceImage.getHeight()/2d);

画像の中央で90度回転し、その後もとの画像の(width-height)/2分だけ、X軸、Y軸ともに平行移動した。
この方法ならば2回のアフィン変換は必要となるが、degreeが90でも270でも動作する。

対応案2:1回の回転できめる

どうも案1の方法では腹落ちが悪かったので、一晩寝て頭を冷やしてから考え直した。
要するにやりたいことはこのようなイメージ。
青い四角が元の画像。透過している薄黄色が回転後の画像で、90度回転後も原点に接していて、かつ第一象限からはみ出ていないようにしたい。
(左の図は横長画像、右の図は縦長の画像の場合)
f:id:kazyury:20130311222613p:plain
ちなみに、この図での座標系は、一般的な数学の座標系で、右上が第一象限なので注意。
Java2Dとか(コンピューター系全般かもしれないが)の座標系ではY軸の正負が逆なので、回転の向きが上下逆転することになる。

幾何学的に考えて*3みると、これは回転の中心を適切に定めれば、一度の回転操作でできる。
f:id:kazyury:20130311223331p:plain
この時の回転の中心座標は(元画像のHeight/2, 元画像のHeight/2)として表せる。
案2の実装はとして下のような形となった。

ImageInputStream is = new FileImageInputStream(sourcePath.toFile());
BufferedImage sourceImage = ImageIO.read(is);

// 一回の操作では90度回転か270度回転かしかないので、heightとwidthを入れ替えてBufferedImageを作成している。
BufferedImage rotatedImage = new BufferedImage(sourceImage.getHeight(), sourceImage.getWidth(), sourceImage.getType());

AffineTransform affine = new AffineTransform();
// 別解(これならaffine変換は一回ですむ。)
int h = sourceImage.getHeight();
affine.rotate(Math.toRadians(degree), h/2, h/2);

AffineTransformOp operator = new AffineTransformOp(affine,AffineTransformOp.TYPE_BICUBIC);
operator.filter(sourceImage, rotatedImage);

// (略)

とはいえ、この実装ではdegreeに270とかを渡されるとバグっているので、その対応は皆さんの宿題とします(笑)。

最終的にはアフィン変換のコストと、if文等によるコードの見通しの悪さを勘案して決めることとする。

*1:JPAの続きをまとめようとしたけど、RootとかExpressionとかSelectionの理解が追いついていないので、また追々ってことで

*2:こちらの観音様は正月に旅行した群馬の白蛇観音(笑)です。

*3:代数的に整理しようと思ったけど、行列も三角関数も高校を卒業して以来触れておらず挫折したことは内密にしておいて欲しい。