自分だけの画像フィルタ
素晴らしい (ように思えた) アイデア
中国を旅行していたとき、同じ場所から見た四季の景色を描いた絵をよく見かけた。四つの季節を視覚的に特徴付けていたのは色 ── 冬は白、春は薄い色、夏は濃い緑色、秋は赤色と黄色 ── だった。2011 年ごろ、私は素晴らしいアイデアを思いついた: たくさんの写真を色が少しずつ変わるように視覚化すれば、四季の移ろいを表現できるのではないだろうか?
しかし、私は画像の支配色 (dominant color, ドミナントカラー) を計算する方法を知らなかった。画像を 1x1 に縮小したときの色を使う方法も考えたものの、正しい方法でない気がした。ただ、画像をどのように並べたいかは分かっていた: sunflower layout と呼ばれるレイアウトである。sunflower layout を使うと、複数の円を最も効率良く並べることができる。
仕事、日々の生活、旅行、講演などで忙しかったので、このプロジェクトのことは長い間忘れていた。しばらくして時間ができたときに再度取り組んだところ、画像の支配色を計算する方法が分かり、思い描いていた視覚化を作成できた。しかしその結果として、このアイデアがそれほど素晴らしくないことも判明した: 四季の移ろいは思ったほど明確にならず、計算される支配色が最も美しい色とは限らず、支配色の計算には長い時間 (画像一枚につき数秒) がかかり、クールな視覚化を生成するには数百枚の画像が必要になる (図 1)。

この事実に私が落胆したと思ったかもしれない。しかし、このときまでに私は以前に知らなかった ── 色空間やピクセル操作に関する ── 様々な事項を学んでいた。そこで、次は部分的に色が付いたクールな画像を作ることにした。ロンドンの絵葉書で見かけることのある、バスや公衆電話ボックスだけが赤くなっていて、他の部分は白黒の写真のような画像である。
私は Processing というフレームワークを利用した。以前にプログラミング教育のカリキュラムを作成したときに触れたことがあったのに加えて、Processing を使うと視覚的なアプリケーションを簡単に作れると知っていたからである。元々はアーティスト向けに設計されたツールなので、ボイラープレートの多くが抽象化されており、様々なことを実験しながら楽しくコードを書くことができる。
大学生活、後に仕事によって、私が持つ時間は他人のアイデアや優先事項で埋められていった。このプロジェクトを完了させるには、自分自身のアイデアに取り組む時間を捻出する方法を学ぶ必要もあった。私は集中してプロジェクトに取り組む時間を一週間に最低でも四時間は確保することを自分に課した。このような状況で作業を効率化するツールは非常に有用であり、不可欠でさえあった ── ただ、特にテスト関連のツールでは、ツールの利用によって生じる問題も存在した。
プロジェクトが正しく動作しているかどうかを確認するとき、そして数週間 (ときには数か月) 単位で時間を空けた後にプロジェクトを確認・再開するとき、徹底的なテストは特別な重要性を持つと私は感じた。テスト (そしてブログ記事!) はプロジェクトのドキュメントとなる。実装方法が分からないために未実装の処理を失敗するテストを通じてドキュメントすることもできるし、コードの重要な振る舞いをテストが確認してくれるので、テストがあれば自信を持ってコードを変更できるようになる。
本章では Processing を簡単に解説し、色空間、画像をピクセルに分解して編集する処理、テストを念頭に設計されていないコードをユニットテストする手法といった話題に触れる。ただ、この他にも読者が最近時間が取れていないアイデアで進捗を生む気持ちになることを願っている。あなたのアイデアが私のものと同程度にひどいものだと判明したとしても、アイデアを具現化する中でクールなものが作れたり、素晴らしいことを学べたりするだろう。
アプリケーション
本章では、ユーザーが自分で作成したフィルタを使ってデジタル画像を編集できる画像フィルタアプリケーションを作成する方法を示す。このアプリケーションでは Processing と呼ばれる Java で作成されたプログラミング言語・開発環境を用いる。以降では Processing でアプリケーション開発を始める方法、Processing の基本的な機能、色表現、そしてカラーフィルタを作成する (伝統的な写真で用いられる手法を真似た) 方法を解説する。また、デジタルでのみ可能となる特殊なフィルタも作成する: 画像の支配色を計算してそれを表示・非表示にすることで、部分的に色が付いた不気味な雰囲気の画像を生成する。
最後に、本プロジェクトでは徹底的なテストスイートを書く。その中で Processing がテスト機能に関して持つ制限の一部に対処する方法を示す。
背景
現在、数十秒もあれば撮った写真を編集して友達全員と共有できる。しかし (デジタルの世界から見て) いにしえの時代には、同じことをするのに数週間かかっていた。
昔は写真を撮ったら、フィルムを使い切るまで待ってから (たいていは薬局で) 現像してもらう必要があった。現像した写真が手に入るのは申し込んでから数日後で、その写真に問題があることもよくあった。手が震えていた? 被写体でない人や物が写っていた? 露出が多すぎた? 露出が少なすぎた? 当然、こうした問題が写真を手にしたときに判明してもどうしようもない。
フィルムを写真にするプロセスを理解している人は少なかった。フィルムに光を当ててはいけないので、フィルムの取り扱いには注意が必要となる。現像は暗室と化学物質を使った作業であり、映画やテレビを通じて存在を知っている人がいる程度だった。
しかし、それよりも知っている人が少ないと思われるのが、スマートフォンのカメラアプリの画面をタッチしてからインスタグラムに表示される画像が作成されるまでのプロセスである。実は、二つのプロセスには多くの共通点がある。
写真: 古い方法
写真は感光面に光が当たったときの化学反応を利用して作られる。写真で使われるフィルムはハロゲン化銀結晶でコーティングされている (カラーの写真では追加のレイヤーが使われるが、ここでは簡単のため白黒写真を考えよう)。
昔ながらの ── フィルムを使った ── 写真を撮るとき、光は使用者がカメラを向けている方向からフィルムに到達し、フィルム上の結晶と化学反応を起こす。この化学反応がどれだけ進行するかは到達する光量に応じてフィルム上の位置ごとに異なる。その後、現像プロセスが銀塩を金属銀に変換し、ネガ (negative) を作成する。ネガに現れるのは本来の画像の白と黒が反転した画像であり、ここから白と黒を反転させて写真として印刷するためのステップがさらにある。
写真: 新しい方法
スマートフォンやデジタルカメラはフィルムを持たず、代わりにアクティブピクセルセンサー (active-pixel sensor) と呼ばれる部品が同様の役割を果たす。アクティブピクセルセンサーは銀結晶の代わりにピクセル ── 小さな正方形 ── を持つ1。デジタル画像はピクセルの集合体であり、画像の解像度が高いほど多くのピクセルが存在する。このため解像度の低い画像は「ギザギザして (pixelated)」見える ── ピクセルそのものが認識できてしまう。画像はピクセルは配列に格納され、配列の各要素が色を表す。
図 2 は NYC の MoMA (ニューヨーク近代美術館) で撮られた動物バルーンの高解像度画像である。図 3 も同じ被写体を映した画像であるものの、解像度が 24x32 ピクセルしかない。
低解像度の画像がぼやけているのが分かるだろうか? この現象をピクセル化 (pixelation) と呼ぶ。つまり、画像に含まれるピクセルの個数に対して表示が大きすぎるときに個々のピクセルを表した正方形が見えるようになることをピクセル化と言う。ピクセル化を起こした画像を見ると、デジタル画像が単色の正方形を並べたものであるという事実が理解できるだろう。
ピクセルとは具体的にどんなデータなのだろうか? Java には Integer.toHexString
という便利な関数があるので、これを使って座標 (10,10)
から座標 (10,14)
までのピクセルの値を出力すると、ピクセルのデータを 16 進数で表した次の出力が得られる:
FFE8B1
FFFAC4
FFFCC3
FFFCC2
FFF5B7
色は 6 文字の 16 進数で表される。最初の 2 文字は赤 (red) 成分、次の 2 文字が緑 (green) 成分、最後の二文字が青 (blue) 成分をそれぞれ表す (最後にアルファ値を表す 2 文字が追加される場合もある)。例えば FFFAC4
は次の成分を持った色を表す:
- 赤 =
FF
(16 進数) =255
(10 進数) - 緑 =
FA
(16 進数) =250
(10 進数) - 青 =
C4
(16 進数) =196
(10 進数)
アプリケーションの実行
図 4 に本章で説明するアプリケーションを実行した様子を示す。典型的な「開発者が設計したアプリケーション」であるものの、Java を 500 行しか書けない状況では何かを犠牲にせざるを得ない! 右側にあるのはコマンドのリストであり、例えば次の操作を行える:
- RGB フィルタを調整する。
- 「色相の閾値」を調整する。
- 支配色相フィルタを設定する。支配色相を表示するか表示しないかを選べる。
- 現在の設定を適用する (設定が変更されるたびに自動で適用するのは現実的でない)。
- 画像をリセットする。
- 作成した画像を保存する。

Processing を使うと画像編集を行う小さなアプリケーションを簡単に作成できる: Processing は視覚的な処理を非常に得意とする。本章では Java ベースのバージョンを用いるものの、現在 Processing は他の言語にも移植されている。
このチュートリアルでは Processing の core.jar
をビルドパスに置いた状態で Eclipse から利用する。Processing IDE を使うと大量のボイラープレートコードを Java で書く手間が省けるので、そちらを使うのもよいだろう。本章で説明するアプリケーションを Processing.js に移植してオンラインで利用可能にしたいと考えているなら、ファイルを選択する処理は自分で書く必要がある。
セットアップ方法の詳細はプロジェクトの GitHub レポジトリでスクリーンショットと共に説明してある。ただ、Eclipse と Java を使えるなら説明は必要ないだろう。
Processing の基本的な使い方
ウィンドウのサイズと背景色
これから作るアプリケーションをデフォルトの小さな灰色のウィンドウに収めるつもりはない。そこで setup()
と draw()
という二つの重要なメソッドをオーバーライドするところから始めよう。setup()
メソッドはアプリケーションの開始時に呼び出され、アプリケーションのウィンドウサイズの設定などを行う。draw()
メソッドはアニメーションループを実行しているときはループごとに呼び出されるのに加えて、それ以外のときは redraw()
の呼び出しを通じて明示的に呼び出すことができる (Processing のドキュメントにもあるように、draw()
を直接呼び出してはいけない)。
Processing はアニメーション (動き) を持った画像を簡単に作成できるように設計されている。しかし、私たちが作ろうと思っているアプリケーションはアニメーションを必要とせず2、キー入力に応答する必要がある。アニメーションを有効にするとパフォーマンスが低下するので、setup()
の中で noLoop()
を呼び出して停止させる。こうすると draw()
は setup()
の直後と、こちらから redraw()
を呼び出したときにだけ実行される。
private static final int WIDTH = 360;
private static final int HEIGHT = 240;
public void setup() {
noLoop();
// ウィンドウサイズの設定
size(WIDTH, HEIGHT);
background(0);
}
public void draw() {
background(0);
}
まだ簡単な処理しかしていないものの、アプリケーションを実行してみて、WIDTH
と HEIGHT
の値を変更したときウィンドウのサイズが変わることを確認してみるとよい。
background(0)
は背景を黒色に設定する。値を変更すると背景色が変わることを確認してみてほしい ── 引数が一つのときはアルファ値として解釈されるので、必ずグレースケールとなる。background(int r, int g, int b)
を呼び出せば色を自由に設定できる。
PImage
PImage
は画像を表現する Processing オブジェクトである。本章では PImage
を多用するので、ドキュメントを軽く読んでおいた方がいいだろう。PImage
が持つ三つのフィールドと本章で利用するメソッドを次の二つの表に示す。
pixels[] |
画像内の全ピクセルの色を格納した配列 |
width |
画像の幅 (単位はピクセル) |
height |
画像の高さ (単位はピクセル) |
PImage
のフィールド
loadPixels |
指定された画像のピクセルデータを pixels[] 配列に読み込む。 |
updatePixels |
pixels[] 配列にあるデータで画像を更新する。 |
resize |
指定した幅と高さとなるように画像のサイズを変更する。 |
get |
任意のピクセルの色を読み込む。または、ピクセルからなる長方形を取得する。 |
set |
任意のピクセルの色を設定する。または、画像を他の PImage に書き込む。 |
save |
画像をファイルに書き込む。フォーマットは TIFF, TARGA, PNG, JPEG に対応する。 |
PImage
のメソッド (一部)
ファイル選択
ユーザーにファイルを選択させる処理のほとんどは Processing が行ってくれる。私たちは (パブリックな) コールバックを定義した上で selectInput()
を呼び出せばよい:
// ユーザーにファイルを選択させる
private void chooseFile() {
selectInput("Select a file to process:", "fileSelected");
}
public void fileSelected(File file) {
if (file == null) {
println("User hit cancel.");
} else {
// ... 画像を保存する ...
redraw(); // 表示を更新する
}
}
Java に慣れている読者は、この設計を奇妙に感じたかもしれない。リスナーやラムダ式を使った方が自然に思える。しかし、Processing はアーティスト向けに開発されたツールなので、そういった高度な仕組みを排除することでコードの敷居を下げている。これは Processing の設計者が下した判断である: 柔軟性よりも単純性と親しみやすさが優先された。Eclipse から Processing をライブラリとして使うのではなく、機能が単純化された Processing エディタを使う場合は、クラスを定義する必要さえない。
当然のことながら、異なるユーザーを想定する他の言語設計者は違った選択をするだろう。例えば純粋関数言語 Haskell は関数型言語のパラダイムを他の何よりも優先するので、IO を要する操作の実行より数学的問題の解決に適したツールとなる。
キー入力への応答
通常 Java でキー入力に応答するには、リスナーの登録や無名関数の作成が必要になる。しかし、ここでもファイルの選択処理と同様に Processing が多くの処理を行ってくれる。私たちは keyPressed()
を実装するだけで済む:
public void keyPressed() {
print("key pressed: " + key);
}
このメソッドの定義を加えてからアプリケーションを実行すると、キーを入力するたびにメッセージがコンソールに出力される。入力されたキーに応じて異なる処理を実行したいときは、key
に対する switch
文を追加するだけで完了する (key
メンバーは親クラス PApplet
に存在し、最後に入力されたキーを保持する)。
テストの記述
このアプリケーションは小さいものの、バグの発生源となりかねない箇所はいくつか存在する。例えば、キーを入力したときに間違った処理が実行される可能性がある。これから複雑性を追加していくと、発生する可能性のある問題も増えていく。画像を誤った状態に更新するかもしれないし、フィルタ適用後にピクセルの色の計算を間違うかもしれない。また、私は (奇妙に思う人もいるようだが) ユニットテストを書くことが楽しいと感じる。テストはコードを窒息させる邪魔なものだと考える人もいるものの、私はテストを最高のデバッグツール、そしてコードの動作を深く理解する機会だと考えている。
私は Processing を気に入っている。しかし、その設計は視覚的アプリケーションの作成を優先しており、この分野ではユニットテストが大きな懸念事項ではないのかもしれない。Processing がテスト可能性を念頭に書かれていないのは明らかである。実際、Processing のコードは自身をテストできるように書かれていない。この理由の一端は Processing が複雑性を隠蔽し、その隠蔽された複雑性の一部がユニットテストを書く上で非常に有用となるためである。例えば static
または final
なメソッドが使われていると、子クラスの機能を利用するモック (システムの一部分を置き換え、オブジェクト同士の対話を記録することでシステムの他の部分の動作を検証するオブジェクト) の作成は格段に難しくなる。
テスト駆動開発 (Test Driven Development, TDD) を使って完璧なテストカバレッジの達成を目指す野心的なプロジェクトをゼロから始める状況も場合によってはあるかもしれない。しかし現実には、多種多様な人々によって書かれてきた巨大なコードを読み解きながら何が想定される動作なのか、そして何が想定通り動作していないのかを理解する状況の方が多い。そのような状況では完璧なテストを書くことができないかもしれないが、テストを書けば少なくとも状況を把握しやすくなり、何が起きているかを記録しながら前に進むことができる。
本章では複雑に絡み合った巨大なコードから「seam」と呼ばれる小さな部分を切り出し、その seam を個別に検証する手法を用いる。このためにモック可能なラッパークラスを作成することもある。そのラッパークラスは対象のクラスと同様のメソッドを実装するか、final
もしくは static
なメソッドを持つためにモックできないオブジェクトに呼び出しを委譲する。そのため実装は非常に面倒であるものの、seam を作成してコードをテスト可能にする上での鍵となる。
本章では Procecssing をライブラリとして Java から利用するので、テストでは JUnit を、モックの作成では Mockito を用いる。Mockito を使うにはリリースをダウンロードして、core.jar
ファイルと同様に JAR ファイルをビルドパスに配置すればよい。アプリケーションのモックとテストを可能にするためのヘルパークラスが二つある (これらのヘルパークラスを使わないと、PImage
と PApplet
のメソッドを呼び出すコードの振る舞いをテストできない)。
IFAImage
は PImage
の薄いラッパーであり、PixelColorHelper
は PApplet
のピクセルの色に関するメソッドを包むラッパーである。これらのラッパーは final
または static
なメソッドを呼び出すものの、呼び出し側のメソッドには static
と final
が付いていない ── そのためモックが可能となる。また、実装は意図的に不完全にしてある: メソッドをさらに充実させることはできるものの、Processing がテスト可能性に関して持つ大きな問題 (static
または final
なメソッド) を解決する上では必要ない。私たちの目標はアプリケーションの作成であり、Processing に対するユニットテストフレームワークの作成ではない!
ImageState
と呼ばれるクラスがアプリケーションの「モデル」となる。このクラスがあることで PApplet
を継承したクラスからロジックが可能な限り取り除かれ、テストが容易になる。さらに、設計の明確化と関心の分離にも役立っている: App
が制御するのは対話と UI だけで、画像の編集は行わない。
自分で作るフィルタ
RGB フィルタ
複雑なピクセル処理を始める前に、簡単な練習問題を通じてピクセルの操作に慣れておこう。これから標準的な (赤・緑・青の) カラーフィルタを作成する。このフィルタを使うと、カメラのレンズの前に色の付いたプレートを置いて特定の色だけがレンズに届くようにしたときのような効果を加えることができる。

どうすればカラーフィルタを実装できるだろうか? 次の処理が必要になる:
- フィルタを設定する (赤・緑・青のフィルタを組み合わせることもできる。上図では効果を分かりやすくするため組み合わせていない)。
- 画像の各ピクセルに対して、その RGB 値をチェックする。
- もし赤要素が赤フィルタの設定値より小さいなら、赤要素をゼロにする。
- もし緑要素が緑フィルタの設定値より小さいなら、緑要素をゼロにする。
- もし青要素が青フィルタの設定値より小さいなら、青要素をゼロにする。
画像は二次元であるのに対して、ピクセルのデータは一次元配列に格納される。最初の要素が一番左上のピクセルに対応し、その後は左から右、上から下の順にピクセルが並ぶ。4x4 の画像に含まれるピクセルの添え字を次に示す:
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
この点に注意すれば、カラーフィルタは次のように実装できる:
public void applyColorFilter(PApplet applet, IFAImage img, int minRed,
int minGreen, int minBlue, int colorRange) {
img.loadPixels();
int numberOfPixels = img.getPixels().length;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = img.getPixel(i);
float alpha = pixelColorHelper.alpha(applet, pixel);
float red = pixelColorHelper.red(applet, pixel);
float green = pixelColorHelper.green(applet, pixel);
float blue = pixelColorHelper.blue(applet, pixel);
red = (red >= minRed) ? red : 0;
green = (green >= minGreen) ? green : 0;
blue = (blue >= minBlue) ? blue : 0;
image.setPixel(i, pixelColorHelper.color(applet, red, green, blue, alpha));
}
}
色
最初の画像フィルタの例から分かるように、色の概念とプログラムにおける色の表現はフィルタの動作を理解する上で非常に重要となる。次のフィルタに取り掛かる前に、色の概念についてもう少し詳しく説明しよう。
前節で「色空間」という言葉を使った。色空間とは色をデジタルに表現する手段である。子供が絵の具を混ぜるときに学ぶように、ある色は他の色から作ることができる。デジタルでも少し異なる (絵の具で服を汚す心配がない!) だけで基本的には同じことが言える。Processing では非常に簡単に色空間を扱えるものの、色空間を選ぶにはその特徴を知っておく必要がある。
RGB
多くのプログラマーが慣れ親しんでいる色空間は RGB である: 赤 (red)、緑 (green)、青 (blue)、の三要素で色が表される。16 進数で表す場合、最初の二桁が赤、次の二桁が緑、さらに次の二桁が青の要素を表す。各要素は 00 (十進数の 0) から FF (十進数の 255) の値を取る。最後にアルファ (alpha) 要素が付く場合もある。アルファ要素の値が表すのは透明度であり、0 は完全に透明、最大値は完全に不透明であることを表す。
HSB (HSV)
この色空間は RGB より知名度が低い。最初の数字が色相 (hue)、次の数字が彩度 (saturation)、最後の数字が明度 (brightness) を表す。HSB 色空間は円錐で表現できる: 色相は円板上の回転角、彩度は中心からの距離、明度は高さに対応する (明度 0 の唯一の色は黒であり、この色が円錐の頂点に対応する)。HSB と HSV は同じ色空間を意味する。
支配色相の抽出
以上でピクセルに関する基本的な操作が理解できたと思うので、デジタルでのみ可能な処理を書いてみよう。画像をデジタルに扱うとき、一様でない編集が可能になる。
自分が撮った写真を見返すと、写真ごとに全体的な色があることが分かる。香港で日没後に船上から撮った港の写真は暗い色が多く、北朝鮮は灰色、バリ島は濃い緑色、アイスランドは氷のような白色や明るい青色が多い。写真が与えられたとき、その中で全体的に使われている色を計算できるだろうか?
この計算では HSB 色空間を使うのが理にかなっている ── 全体的に使われている色を知りたいとき、考えているのは色相である。同じ処理は RGB を使っても可能かもしれないが、三つの値が関連するので複雑になり、加えて暗い部分の影響を大きく受けるようになるだろう。Processing で色空間を切り替えるには colorMode 関数を用いる。
HSB 色空間を使うと、処理は RGB 色空間を使うよりも単純になる。各ピクセルの色相を求め、最も「ありふれた」色相 (支配色相) を求めればよい。ただ、厳密に一つの色相を求める必要はおそらくない ── よく似た色相は一つにまとめた方がいい。この処理は二つのステップで行われる。
まず、色相を表す値を適当な単位で丸める。こうすると、各ピクセルを入れるべき「バケツ」が分かりやすくなる。次に色相の値域を変更する。上述した円錐による HSB 色空間の表現を思い出すと、色相は 0 から 360 までの値を持つと考えられる。デフォルトだと Processing は RGB と同様に 0 から 255 までの値で色相を表す (255 は十六進法で FF となる)。色相の値域が広いと、画像に含まれる色が区別しやすくなる。逆に色相の値域を狭めると、似た色相をまとめることができる。例えば 0 から 360 の値域を使うとき、244 の色相と 255 の色相の違いは非常に小さいので区別できない可能性が高い。値域を 0 から 120 に狭めると、二つの色相は両方とも 75 で表される。
colorMode
の第二引数は値域を設定する。例えば colorMode(HSB, 120)
と呼び出すと、通常の 0 から 255 の値域を使う場合と比べて色相の分解能を半分以下にできる。言い換えれば、各ピクセルの色相は 120 個の「バケツ」のいずれかに収まる。よって画像の支配色相を求めるには、画像に含まれるピクセルを走査し、ピクセルの色相の値を取得し、その値に対応するカウントを増加させていけばよい。最も多くカウントされた値に対応する色相が解答となる。この処理は各ピクセルに対して一定の処理を行うので、ピクセル数を \(n\) とするとき \(O(n)\) 時間で実行できる。
for(int px in pixels) {
int hue = Math.round(hue(px));
hues[hue]++;
}
色相の値域を変えたときの画像と支配色相の関係を次の画像に示す:

抽出した支配色相を使って画像を編集してみよう。まずは支配色相だけを表示する。何らかの閾値を設定し、支配色相と色相の差がこの閾値に収まっていないピクセルには明度を使ってグレースケールの色を設定する。色相の値域を 240 として計算した支配色相と数種類の閾値を使った画像の編集結果を次の図に示す。支配色相から最大で閾値だけ離れた色相までが表示されている。

逆に、支配色相を除去することもできる。次の図において、中央にあるのがオリジナルの画像であり、左にあるのが支配色相 (歩道の茶色) を表示したもの、右にあるのが支配色相を除去したものである (いずれも色相の値域は 320、閾値は 20 とした)。

この編集には二つのパス (全ピクセルの走査) が必要になるので、ピクセル数の多い画像を処理すると人間が知覚できる程度の時間がかかる。
public HSBColor getDominantHue(PApplet applet, IFAImage image, int hueRange) {
image.loadPixels();
int numberOfPixels = image.getPixels().length;
int[] hues = new int[hueRange];
float[] saturations = new float[hueRange];
float[] brightnesses = new float[hueRange];
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
int hue = Math.round(pixelColorHelper.hue(applet, pixel));
float saturation = pixelColorHelper.saturation(applet, pixel);
float brightness = pixelColorHelper.brightness(applet, pixel);
hues[hue]++;
saturations[hue] += saturation;
brightnesses[hue] += brightness;
}
// 支配色相 (最もよく使われる色相) を見つける
int hueCount = hues[0];
int hue = 0;
for (int i = 1; i < hues.length; i++) {
if (hues[i] > hueCount) {
hueCount = hues[i];
hue = i;
}
}
// 表示できる色を返す
float s = saturations[hue] / hueCount;
float b = brightnesses[hue] / hueCount;
return new HSBColor(hue, s, b);
}
public void processImageForHue(PApplet applet, IFAImage image, int hueRange,
int hueTolerance, boolean showHue) {
applet.colorMode(PApplet.HSB, (hueRange - 1));
image.loadPixels();
int numberOfPixels = image.getPixels().length;
HSBColor dominantHue = getDominantHue(applet, image, hueRange);
// 画像の編集: 支配色相から離れた色相を持つピクセルをグレースケールにする
float lower = dominantHue.h - hueTolerance;
float upper = dominantHue.h + hueTolerance;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
float hue = pixelColorHelper.hue(applet, pixel);
if (hueInRange(hue, hueRange, lower, upper) == showHue) {
float brightness = pixelColorHelper.brightness(applet, pixel);
image.setPixel(i, pixelColorHelper.color(applet, brightness));
}
}
image.updatePixels();
}
フィルタの組み合わせ
以前に示した UI から分かるように、ユーザーは赤・緑・青のカラーフィルタを組み合わせられる。しかし、これらのカラーフィルタと支配色相フィルタを組み合わせると、色空間の違いが原因で思った通りの結果が得られないことがある。
Processing は画像編集用のfilter
関数を持つ。この関数を使うと色の反転やブラーを適用できる。
先鋭化、ブラー、セピア加工といった効果では行列が使われる。画像の全ピクセルに対して、そのピクセルの値および近くにあるピクセルの値とフィルタ行列 (filter matrix) の対応する要素の積の和が新しい色として設定される。例えば画像を先鋭化するための特別なフィルタ行列の値が知られている。
アーキテクチャ
本章で説明するアプリケーションは三つの主要な構成要素を持つ (図 9)。

アプリケーション
メインのアプリケーションは ImageFilterApp.java
という一つのファイルから構成される。ImageFilterApp
は Processing が提供するアプリケーション向け基底クラス PApplet
を継承し、レイアウトの管理やユーザーとの対話を行う。このクラスは最もテストが難しいので、可能な限り小さくするのが望ましい。
モデル
モデルは三つのファイルからなる。HSBColor.java
は HSB 色空間で表された色に対応する (色相・彩度・明度からなる) コンテナを定義する。IFAImage.java
はテストのために用意された PImage
のラッパーを定義する (PImage
は final
が付いたモック不可能なメソッドを多く持つ)。最後に ImageState.java
は画像の状態 ── どのフィルタをどのように適用するべきか ── の管理と画像の読み込みを担当するクラスを定義する (カラーフィルタの閾値が下げられたとき、そして支配色相が再計算されたときは画像の再読み込みが必要になる。実際のコードでは処理を簡単にするため、画像の処理が始まるたびに再読み込みが実行される。)。
色
色に関する処理は二つのファイルで実装される。 ColorHelper.java
は画像編集とフィルタに関する全ての処理を実装し、PixelColorHelper.java
は PApplet
が持つピクセルの色を操作する final
付きのメソッドを抽象化してテスト可能にする。
ラッパークラスとテスト
先ほど簡単に説明したように、IFAImage
と PixelColorHelper
はラッパークラスであり、テストを可能にするために用意される。Java では final
の付いたメソッドはオーバーライド不可能であり、子クラスを使って隠すことができない。これはそのメソッドがモック不可能であることを意味する。
PixelColorHelper
は PApplet
のメソッドを包むラッパーである。そのため、このクラスのメソッドは第一引数に PApplet
を受け取る (初期化時に PApplet
を受け取って記録する設計も考えられる)。
package com.catehuston.imagefilter.color;
import processing.core.PApplet;
public class PixelColorHelper {
public float alpha(PApplet applet, int pixel) {
return applet.alpha(pixel);
}
public float blue(PApplet applet, int pixel) {
return applet.blue(pixel);
}
public float brightness(PApplet applet, int pixel) {
return applet.brightness(pixel);
}
public int color(PApplet applet, float greyscale) {
return applet.color(greyscale);
}
public int color(PApplet applet, float red, float green, float blue,
float alpha) {
return applet.color(red, green, blue, alpha);
}
public float green(PApplet applet, int pixel) {
return applet.green(pixel);
}
public float hue(PApplet applet, int pixel) {
return applet.hue(pixel);
}
public float red(PApplet applet, int pixel) {
return applet.red(pixel);
}
public float saturation(PApplet applet, int pixel) {
return applet.saturation(pixel);
}
}
IFAImage
は PImage
を包むラッパーである。そのため、他の部分のコードは PImage
ではなく IFAImage
を初期化することになる ── ただし Processing と対話するときは PImage
を使う必要があるので、image
メソッドで内部の PImage
にアクセスできるようにしてある。
package com.catehuston.imagefilter.model;
import processing.core.PApplet;
import processing.core.PImage;
public class IFAImage {
private PImage image;
public IFAImage() {
image = null;
}
public PImage image() {
return image;
}
public void update(PApplet applet, String filepath) {
image = null;
image = applet.loadImage(filepath);
}
// PImage のメソッドのラッパー
public int getHeight() {
return image.height;
}
public int getPixel(int px) {
return image.pixels[px];
}
public int[] getPixels() {
return image.pixels;
}
public int getWidth() {
return image.width;
}
public void loadPixels() {
image.loadPixels();
}
public void resize(int width, int height) {
image.resize(width, height);
}
public void save(String filepath) {
image.save(filepath);
}
public void setPixel(int px, int color) {
image.pixels[px] = color;
}
public void updatePixels() {
image.updatePixels();
}
}
色を扱うためのクラスは Processing と Java の両方に存在することを知っている読者もいるかもしれない。この二つのクラスは詳しく説明しないものの、いずれも RGB で表された色の扱いに特化しており、特に Java のクラスは私たちが必要とするよりはるかに複雑なために使われていない。Java の awt.Color
なら使うこともできたかもしれないが、AWT の GUI は Processing で使えないので、私たちの用途では必要な要素だけを持った上述の単純なコンテナクラスが最も簡単な選択肢となる。
package com.catehuston.imagefilter.model;
public class HSBColor {
public final float h;
public final float s;
public final float b;
public HSBColor(float h, float s, float b) {
this.h = h;
this.s = s;
this.b = b;
}
}
ColorHelper
とテスト
次に示す ColorHelper
は画像編集に関する全ての処理を実装する。このクラスのメソッドは PixelColorHelper
を利用しないなら static
にできる (static
メソッドの利点についてはここで議論しない)。
package com.catehuston.imagefilter.color;
import processing.core.PApplet;
import com.catehuston.imagefilter.model.HSBColor;
import com.catehuston.imagefilter.model.IFAImage;
public class ColorHelper {
private final PixelColorHelper pixelColorHelper;
public ColorHelper(PixelColorHelper pixelColorHelper) {
this.pixelColorHelper = pixelColorHelper;
}
public boolean hueInRange(float hue, int hueRange, float lower, float upper) {
// 色相は環状なものとして解釈される
if (lower < 0) {
lower += hueRange;
}
if (upper > hueRange) {
upper -= hueRange;
}
if (lower < upper) {
return hue < upper && hue > lower;
} else {
return hue < upper || hue > lower;
}
}
public HSBColor getDominantHue(PApplet applet, IFAImage image, int hueRange) {
image.loadPixels();
int numberOfPixels = image.getPixels().length;
int[] hues = new int[hueRange];
float[] saturations = new float[hueRange];
float[] brightnesses = new float[hueRange];
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
int hue = Math.round(pixelColorHelper.hue(applet, pixel));
float saturation = pixelColorHelper.saturation(applet, pixel);
float brightness = pixelColorHelper.brightness(applet, pixel);
hues[hue]++;
saturations[hue] += saturation;
brightnesses[hue] += brightness;
}
// 支配色相 (最もよく使われる色相) を見つける
int hueCount = hues[0];
int hue = 0;
for (int i = 1; i < hues.length; i++) {
if (hues[i] > hueCount) {
hueCount = hues[i];
hue = i;
}
}
// 表示できる色を返す。
float s = saturations[hue] / hueCount;
float b = brightnesses[hue] / hueCount;
return new HSBColor(hue, s, b);
}
public void processImageForHue(PApplet applet, IFAImage image, int hueRange,
int hueTolerance, boolean showHue) {
applet.colorMode(PApplet.HSB, (hueRange - 1));
image.loadPixels();
int numberOfPixels = image.getPixels().length;
HSBColor dominantHue = getDominantHue(applet, image, hueRange);
// 画像の編集: 支配色相から離れた色相を持つピクセルをグレースケールにする
float lower = dominantHue.h - hueTolerance;
float upper = dominantHue.h + hueTolerance;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
float hue = pixelColorHelper.hue(applet, pixel);
if (hueInRange(hue, hueRange, lower, upper) == showHue) {
float brightness = pixelColorHelper.brightness(applet, pixel);
image.setPixel(i, pixelColorHelper.color(applet, brightness));
}
}
image.updatePixels();
}
public void applyColorFilter(PApplet applet, IFAImage image, int minRed,
int minGreen, int minBlue, int colorRange) {
applet.colorMode(PApplet.RGB, colorRange);
image.loadPixels();
int numberOfPixels = image.getPixels().length;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
float alpha = pixelColorHelper.alpha(applet, pixel);
float red = pixelColorHelper.red(applet, pixel);
float green = pixelColorHelper.green(applet, pixel);
float blue = pixelColorHelper.blue(applet, pixel);
red = (red >= minRed) ? red : 0;
green = (green >= minGreen) ? green : 0;
blue = (blue >= minBlue) ? blue : 0;
image.setPixel(i, pixelColorHelper.color(applet, red, green, blue, alpha));
}
}
}
テストでは特徴が分かっていて結果が予測できる画像を使うのが望ましく、適当に用意した画像を使うべきではない。既知の画像によるテストを実現するため、ピクセルの配列を返す画像のモックを作成する。この手法を使うと振る舞いが期待通りかどうかを検証できる。先述したモックオブジェクトの概念をここで用いる。モックオブジェクトフレームワークとしては Mockito を利用する。
インスタンス変数に @Mock
注釈を付けると、実行時に MockitoJUnitRunner
がその変数をモックする。
メソッドをスタブする (振る舞いを設定する) には、次の呼び出しを用いる:
when(mock.methodCall()).thenReturn(value)
メソッドが呼び出されたことの確認は verify(mock.methodCall())
で行う。
実際のテストケースの一部をここに示す。全てのテストケースを確認したい場合は GitHub レポジトリを参照してほしい。
package com.catehuston.imagefilter.color;
/* ... その他の import ... */
@RunWith(MockitoJUnitRunner.class)
public class ColorHelperTest {
@Mock PApplet applet;
@Mock IFAImage image;
@Mock PixelColorHelper pixelColorHelper;
ColorHelper colorHelper;
private static final int px1 = 1000;
private static final int px2 = 1010;
private static final int px3 = 1030;
private static final int px4 = 1040;
private static final int px5 = 1050;
private static final int[] pixels = { px1, px2, px3, px4, px5 };
@Before public void setUp() throws Exception {
colorHelper = new ColorHelper(pixelColorHelper);
when(image.getPixels()).thenReturn(pixels);
setHsbValuesForPixel(0, px1, 30F, 5F, 10F);
setHsbValuesForPixel(1, px2, 20F, 6F, 11F);
setHsbValuesForPixel(2, px3, 30F, 7F, 12F);
setHsbValuesForPixel(3, px4, 50F, 8F, 13F);
setHsbValuesForPixel(4, px5, 30F, 9F, 14F);
}
private void setHsbValuesForPixel(int px, int color, float h, float s, float b) {
when(image.getPixel(px)).thenReturn(color);
when(pixelColorHelper.hue(applet, color)).thenReturn(h);
when(pixelColorHelper.saturation(applet, color)).thenReturn(s);
when(pixelColorHelper.brightness(applet, color)).thenReturn(b);
}
private void setRgbValuesForPixel(int px, int color, float r, float g, float b,
float alpha) {
when(image.getPixel(px)).thenReturn(color);
when(pixelColorHelper.red(applet, color)).thenReturn(r);
when(pixelColorHelper.green(applet, color)).thenReturn(g);
when(pixelColorHelper.blue(applet, color)).thenReturn(b);
when(pixelColorHelper.alpha(applet, color)).thenReturn(alpha);
}
@Test public void testHsbColorFromImage() {
HSBColor color = colorHelper.getDominantHue(applet, image, 100);
verify(image).loadPixels();
assertEquals(30F, color.h, 0);
assertEquals(7F, color.s, 0);
assertEquals(12F, color.b, 0);
}
@Test public void testProcessImageNoHue() {
when(pixelColorHelper.color(applet, 11F)).thenReturn(11);
when(pixelColorHelper.color(applet, 13F)).thenReturn(13);
colorHelper.processImageForHue(applet, image, 60, 2, false);
verify(applet).colorMode(PApplet.HSB, 59);
verify(image, times(2)).loadPixels();
verify(image).setPixel(1, 11);
verify(image).setPixel(3, 13);
}
@Test public void testApplyColorFilter() {
setRgbValuesForPixel(0, px1, 10F, 12F, 14F, 60F);
setRgbValuesForPixel(1, px2, 20F, 22F, 24F, 70F);
setRgbValuesForPixel(2, px3, 30F, 32F, 34F, 80F);
setRgbValuesForPixel(3, px4, 40F, 42F, 44F, 90F);
setRgbValuesForPixel(4, px5, 50F, 52F, 54F, 100F);
when(pixelColorHelper.color(applet, 0F, 0F, 0F, 60F)).thenReturn(5);
when(pixelColorHelper.color(applet, 20F, 0F, 0F, 70F)).thenReturn(15);
when(pixelColorHelper.color(applet, 30F, 32F, 0F, 80F)).thenReturn(25);
when(pixelColorHelper.color(applet, 40F, 42F, 44F, 90F)).thenReturn(35);
when(pixelColorHelper.color(applet, 50F, 52F, 54F, 100F)).thenReturn(45);
colorHelper.applyColorFilter(applet, image, 15, 25, 35, 100);
verify(applet).colorMode(PApplet.RGB, 100);
verify(image).loadPixels();
verify(image).setPixel(0, 5);
verify(image).setPixel(1, 15);
verify(image).setPixel(2, 25);
verify(image).setPixel(3, 35);
verify(image).setPixel(4, 45);
}
}
次の点に注目してほしい:
MockitoJUnit
ランナーを利用する。PApplet
,IFAImage
,ImageColorHelper
をモックする。IFAImage
はモックができるように作られたクラスである。-
テストメソッドには
@Test
注釈を付ける3。テストを (例えばデバッグ中に) 無視したい場合は@Ignore
注釈が利用できる。 setup()
でピクセルの配列が作成され、画像のモックがこの配列を返すように設定する。- 何度も使われる処理をまとめたヘルパーメソッド (
set*ForPixel()
など) が存在する。
画像の状態とテスト
ImageState
は画像の「状態」を保持する ── 画像の状態は画像に関するデータ、そして適用されるフィルタとその設定からなる。ここでは ImageState
の完全な実装を示すことはせず、テストの方法が分かる部分だけを解説する。GitHub レポジトリを確認すればソースコード全体を確認できる。
package com.catehuston.imagefilter.model;
import processing.core.PApplet;
import com.catehuston.imagefilter.color.ColorHelper;
public class ImageState {
enum ColorMode {
COLOR_FILTER,
SHOW_DOMINANT_HUE,
HIDE_DOMINANT_HUE
}
private final ColorHelper colorHelper;
private IFAImage image;
private String filepath;
public static final int INITIAL_HUE_TOLERANCE = 5;
ColorMode colorModeState = ColorMode.COLOR_FILTER;
int blueFilter = 0;
int greenFilter = 0;
int hueTolerance = 0;
int redFilter = 0;
public ImageState(ColorHelper colorHelper) {
this.colorHelper = colorHelper;
image = new IFAImage();
hueTolerance = INITIAL_HUE_TOLERANCE;
}
/* ... ゲッターとセッター ... */
public void updateImage(PApplet applet, int hueRange, int rgbColorRange,
int imageMax) { ... }
public void processKeyPress(char key, int inc, int rgbColorRange,
int hueIncrement, int hueRange) { ... }
public void setUpImage(PApplet applet, int imageMax) { ... }
public void resetImage(PApplet applet, int imageMax) { ... }
// テストでのみ使われる
protected void set(IFAImage image, ColorMode colorModeState,
int redFilter, int greenFilter, int blueFilter, int hueTolerance) { ... }
}
特定の状態における適切な振る舞いを確認するテストを次に示す。画像編集の適用やキー入力がテストされている。
package com.catehuston.imagefilter.model;
/* ... その他の import ... */
@RunWith(MockitoJUnitRunner.class)
public class ImageStateTest {
@Mock PApplet applet;
@Mock ColorHelper colorHelper;
@Mock IFAImage image;
private ImageState imageState;
@Before public void setUp() throws Exception {
imageState = new ImageState(colorHelper);
}
private void assertState(ColorMode colorMode, int redFilter,
int greenFilter, int blueFilter, int hueTolerance) {
assertEquals(colorMode, imageState.getColorMode());
assertEquals(redFilter, imageState.redFilter());
assertEquals(greenFilter, imageState.greenFilter());
assertEquals(blueFilter, imageState.blueFilter());
assertEquals(hueTolerance, imageState.hueTolerance());
}
@Test public void testUpdateImageDominantHueHidden() {
imageState.setFilepath("filepath");
imageState.set(image, ColorMode.HIDE_DOMINANT_HUE, 5, 10, 15, 10);
imageState.updateImage(applet, 100, 100, 500);
verify(image).update(applet, "filepath");
verify(colorHelper).processImageForHue(applet, image, 100, 10, false);
verify(colorHelper).applyColorFilter(applet, image, 5, 10, 15, 100);
verify(image).updatePixels();
}
@Test public void testUpdateDominantHueShowing() {
imageState.setFilepath("filepath");
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
imageState.updateImage(applet, 100, 100, 500);
verify(image).update(applet, "filepath");
verify(colorHelper).processImageForHue(applet, image, 100, 10, true);
verify(colorHelper).applyColorFilter(applet, image, 5, 10, 15, 100);
verify(image).updatePixels();
}
@Test public void testUpdateRGBOnly() {
imageState.setFilepath("filepath");
imageState.set(image, ColorMode.COLOR_FILTER, 5, 10, 15, 10);
imageState.updateImage(applet, 100, 100, 500);
verify(image).update(applet, "filepath");
verify(colorHelper, never()).processImageForHue(any(PApplet.class),
any(IFAImage.class), anyInt(), anyInt(), anyBoolean());
verify(colorHelper).applyColorFilter(applet, image, 5, 10, 15, 100);
verify(image).updatePixels();
}
@Test public void testKeyPress() {
imageState.processKeyPress('r', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 5, 0, 0, 5);
imageState.processKeyPress('e', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('g', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 5, 0, 5);
imageState.processKeyPress('f', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('b', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 5, 5);
imageState.processKeyPress('v', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('h', 5, 100, 2, 200);
assertState(ColorMode.HIDE_DOMINANT_HUE, 0, 0, 0, 5);
imageState.processKeyPress('i', 5, 100, 2, 200);
assertState(ColorMode.HIDE_DOMINANT_HUE, 0, 0, 0, 7);
imageState.processKeyPress('u', 5, 100, 2, 200);
assertState(ColorMode.HIDE_DOMINANT_HUE, 0, 0, 0, 5);
imageState.processKeyPress('h', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('s', 5, 100, 2, 200);
assertState(ColorMode.SHOW_DOMINANT_HUE, 0, 0, 0, 5);
imageState.processKeyPress('s', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
// 機能が割り振られていないキーは何もしない
imageState.processKeyPress('z', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
}
@Test public void testSave() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
imageState.setFilepath("filepath");
imageState.processKeyPress('w', 5, 100, 2, 200);
verify(image).save("filepath-new.png");
}
@Test public void testSetupImageLandscape() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
when(image.getWidth()).thenReturn(20);
when(image.getHeight()).thenReturn(8);
imageState.setUpImage(applet, 10);
verify(image).update(applet, null);
verify(image).resize(10, 4);
}
@Test public void testSetupImagePortrait() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
when(image.getWidth()).thenReturn(8);
when(image.getHeight()).thenReturn(20);
imageState.setUpImage(applet, 10);
verify(image).update(applet, null);
verify(image).resize(4, 10);
}
@Test public void testResetImage() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
imageState.resetImage(applet, 10);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
}
}
次の点に注目してほしい:
- テスト用の状態を持ったシステムのセットアップを簡単にするために、
protected
な初期化メソッドset
を公開する。 PApplet
,ColorHelper
,IFAImage
をモックする。IFAImage
はモックができるように作られたクラスである。- このテストではヘルパーメソッド
assertState
を使って画像の状態のアサーションを行う。
テストカバレッジの測定
Eclipse から EclEmma を利用して測定したところ、全体のテストカバレッジは 81% だった。ImageFilterApp
は一切テストされず、ImageState
のカバレッジは 94.8%、ColorHelper
のテストカバレッジは 100% だった。
ImageFilterApp
これまでに説明してきたクラスを一つのアプリケーションにまとめるクラスが ImageFilterApp
である。アプリケーションのコードは多くがレイアウト関係のコードなので、ユニットテストが難しい。そのため、このクラスは可能な限り小さいことが望ましい。ただ、これまでアプリケーションの機能の多くをテストを持った個別のクラスに収めてきたので、重要な部分は正しく動作すると自信を持つことができる。
ImageFilterApp
クラスの setup
メソッドはアプリケーションのサイズを設定し、レイアウトを構築する (レイアウトの正しさは人間がアプリケーションを実行して目視で確認される ── テストカバレッジがどれだけ高かったとしても、このステップを飛ばしてはいけない!)。
package com.catehuston.imagefilter.app;
import java.io.File;
import processing.core.PApplet;
import com.catehuston.imagefilter.color.ColorHelper;
import com.catehuston.imagefilter.color.PixelColorHelper;
import com.catehuston.imagefilter.model.ImageState;
@SuppressWarnings("serial")
public class ImageFilterApp extends PApplet {
static final String INSTRUCTIONS = "...";
static final int FILTER_HEIGHT = 2;
static final int FILTER_INCREMENT = 5;
static final int HUE_INCREMENT = 2;
static final int HUE_RANGE = 100;
static final int IMAGE_MAX = 640;
static final int RGB_COLOR_RANGE = 100;
static final int SIDE_BAR_PADDING = 10;
static final int SIDE_BAR_WIDTH = RGB_COLOR_RANGE + 2 * SIDE_BAR_PADDING + 50;
private ImageState imageState;
boolean redrawImage = true;
@Override
public void setup() {
noLoop();
imageState = new ImageState(new ColorHelper(new PixelColorHelper()));
// ウィンドウの大きさを設定する
size(IMAGE_MAX + SIDE_BAR_WIDTH, IMAGE_MAX);
background(0);
chooseFile();
}
@Override
public void draw() {
// 画像を描画する
if (imageState.image().image() != null && redrawImage) {
background(0);
drawImage();
}
colorMode(RGB, RGB_COLOR_RANGE);
fill(0);
rect(IMAGE_MAX, 0, SIDE_BAR_WIDTH, IMAGE_MAX);
stroke(RGB_COLOR_RANGE);
line(IMAGE_MAX, 0, IMAGE_MAX, IMAGE_MAX);
// 赤い線を描画する
int x = IMAGE_MAX + SIDE_BAR_PADDING;
int y = 2 * SIDE_BAR_PADDING;
stroke(RGB_COLOR_RANGE, 0, 0);
line(x, y, x + RGB_COLOR_RANGE, y);
line(x + imageState.redFilter(), y - FILTER_HEIGHT,
x + imageState.redFilter(), y + FILTER_HEIGHT);
// 緑色の線を描画する
y += 2 * SIDE_BAR_PADDING;
stroke(0, RGB_COLOR_RANGE, 0);
line(x, y, x + RGB_COLOR_RANGE, y);
line(x + imageState.greenFilter(), y - FILTER_HEIGHT,
x + imageState.greenFilter(), y + FILTER_HEIGHT);
// 青色の線を描画する
y += 2 * SIDE_BAR_PADDING;
stroke(0, 0, RGB_COLOR_RANGE);
line(x, y, x + RGB_COLOR_RANGE, y);
line(x + imageState.blueFilter(), y - FILTER_HEIGHT,
x + imageState.blueFilter(), y + FILTER_HEIGHT);
// 白色の線を描画する
y += 2 * SIDE_BAR_PADDING;
stroke(HUE_RANGE);
line(x, y, x + 100, y);
line(x + imageState.hueTolerance(), y - FILTER_HEIGHT,
x + imageState.hueTolerance(), y + FILTER_HEIGHT);
y += 4 * SIDE_BAR_PADDING;
fill(RGB_COLOR_RANGE);
text(INSTRUCTIONS, x, y);
updatePixels();
}
// selectInput() に渡されるコールバックなので、public である必要がある
public void fileSelected(File file) {
if (file == null) {
println("User hit cancel.");
} else {
imageState.setFilepath(file.getAbsolutePath());
imageState.setUpImage(this, IMAGE_MAX);
redrawImage = true;
redraw();
}
}
private void drawImage() {
imageMode(CENTER);
imageState.updateImage(this, HUE_RANGE, RGB_COLOR_RANGE, IMAGE_MAX);
image(imageState.image().image(), IMAGE_MAX/2, IMAGE_MAX/2,
imageState.image().getWidth(), imageState.image().getHeight());
redrawImage = false;
}
@Override
public void keyPressed() {
switch(key) {
case 'c':
chooseFile();
break;
case 'p':
redrawImage = true;
break;
case ' ':
imageState.resetImage(this, IMAGE_MAX);
redrawImage = true;
break;
}
imageState.processKeyPress(key, FILTER_INCREMENT, RGB_COLOR_RANGE,
HUE_INCREMENT, HUE_RANGE);
redraw();
}
private void chooseFile() {
// ユーザーにファイルを選択させる
selectInput("Select a file to process:", "fileSelected");
}
}
次の点に注目してほしい:
ImageFilterApp
はPApplet
を継承する。- ほとんどのタスクは
ImageState
で実行される。 fileSelected()
はselectInput()
に渡されるコールバックである。static final
な定数はクラス定義の最初で定義される。
プロトタイピングの重要性
現実世界のプログラミングではソフトウェアを「プロダクト」にするための作業に多くの時間が費やされる。中心的なアルゴリズムの改善よりも、見た目の改善や 99.9% のアップタイプの維持といったコーナーケースに時間が費やされる。
こういった制約や要件はユーザーにとって重要である。しかし、プログラマーが自由に遊んだり実験したりするための時間も必要となる。
あるとき、私は本章で説明したアプリケーションをネイティブモバイルアプリケーションとして移植することにした。Processing は Androind サポートを持つものの、多くのモバイル開発者と同じように、私は iOS 版を最初に開発したいと思った。CoreGraphics に触れたことがなかったとはいえ、私は数年の iOS 開発経験を持っていた。しかし仮に CoreGraphics をよく知っていたとしても、iOS への移植がすぐに完成したとは思わない。iOS では RGB 色空間が強制され、画像からのピクセル抽出が難しい (C がこんにちは)。メモリ消費量と待機時間の増加が大きなリスクだった。
開発中に爽快な気分になる瞬間がいくつもあった。初めて動作したとき、自分のデバイスでクラッシュせずに動いたとき、メモリ使用量を 66% 削減したとき、実行時間を数秒単位で短縮したとき...。暗室に閉じこもり、断続的にボソボソと悪態をつきながら長い時間を過ごすこともあった。
私はプロトタイプを手にしていたので、ビジネスパートナーや同じチームのデザイナーに自分の考えやアプリケーションの具体的な処理を説明できた。これは、アプリケーションの動作を私が深く理解していたこと、そして iOS という新しいプラットフォームで問題なく動作させるタスクだけが残されていたことを意味する。私は自分がすべきことを知っていたので、一日中アプリケーションと格闘して進捗がほとんど無かったとしても、作業を続けることができた...。そして次の朝には興奮した気分でマイルストーンに到達できた。
あなたが撮った写真の支配色は何色だろうか? Show & Hide というアプリを使うと確認できる。