開眼シェルスクリプト2013年3月号

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

15. 開眼シェルスクリプト 第15回画像処理で遊ぶ

 皆様、ラーメンのおいしい季節、いかがお過ごしでしょうか。
筆者は朝うどん、夜ラーメンという、太く短い人生を歩んでおります。

 今回の開眼シェルスクリプトは、
バイナリデータをシェルスクリプトでいじるという宣言をしてあったので、
何を扱おうか考えたのですが、

15.1. ppm形式

 画像は我々が普段書いているようなテキストファイルと比べてサイズが大きいので、
通常はバイナリ形式でファイルにします。
ただ基本的には、ピクセル(画素)ごとに
R(赤)、G(緑)、B(青)の値を記録したファイルならば、
ディスプレイの上に画像として再現できます。

 実はテキストで画像を表現する形式は存在しています。
ここでは、portable pixmap format (PPM) 形式について紹介します。

 ppm形式は、カラー画像の表現形式の一つです。
ppm形式のなかにはテキスト(アスキーコード)
でデータを持つ形式とバイナリで持つ形式がありますが、
ここではテキストの形式について説明します。

 説明のため、次のようなjpgの写真を準備しました。
これをppm形式にしてみましょう。

図1: 今回使う写真(ラーメン)

ラーメン

 画像の変換には、ImageMagickというツールが便利です。
GUIアプリケーションの裏で使われていることもあるので、
知らないうちに使っている人は多いかもしれません。
環境によっては結構インストールが面倒だったりするのですが、
ウェブ等で調べてなんとかインストールおねがいします。
Ubuntuなら sudo apt-get install imagemagick
で問題なくインストールされます。

 インストールしたら、 convert という、
ちょっとそのネーミングはどうなんだという名前のコマンドで
ImageMagickの機能が使えるようになります。
このように、オプションの最後の方に入力ファイル名と出力ファイル名を書いて変換します。

1
2
3
4
5
$ convert -compress none noodle.jpg noodle.ppm
//ちょっと大きすぎか・・・
$ ls -lh noodle.*
-rw-rw-r-- 1 ueda ueda 1.8M 12月 13 11:11 noodle.jpg
-rw-rw-r-- 1 ueda ueda  83M 12月 13 13:06 noodle.ppm

 できたファイルを head してみましょう。
こんなふうに、テキストとして読めたらうまく変換できています。

1
2
3
4
5
6
$ head noodle.ppm
P3
2448 3264
255
112 14 13 112 14 13 113 15 14 114 16 15 112 14 13 111 13 12 111 13 12
111 13 12 113 15 14 115 17 16 113 15 ...

 ppmは、 head の出力のように、
上数行のヘッダ部と、その下から始まる数字の羅列で構成されます。
ヘッダ部は最初の4個の数字で構成され、
順に画像の種類(P3:テキストのppm)、
幅、高さ、ピクセルの値の最大値を表します。
この画像は2448×3264、256階調で、
テキスト形式で保存されているという意味になります。
また、 # 記号があると、行末までコメント扱いされますので、
なにか処理するときは sed 's/#.*$//' などで除去します。

 ボディー部には、画像の上の段から順番に、左から右に向かってR, G, Bの順にピクセル値が並びます。
よく見ると数字が3個ごとに似ていることに気づきます。
改行とスペースが区切り文字になり、改行はどこに入れてもよいことになっています。

 ppm形式はテキストファイルですが、立派な画像ファイルでもあるので、
Linuxのデスクトップ環境ならば、GUI上でファイルをクリックするとjpegと同様、
画像が閲覧できると思います。
ppmにすると容量が巨大化しますし、
これからやる処理も速くはありません。
これは人間が手で画像処理するときに、
わかりやすいようにするためのお賽銭ということでご了承を。

15.2. シェルスクリプトで画像処理

 では、シェルスクリプトでこの画像をいじってみましょう。
ここで扱うことは convert のオプションで実現できることも多いので、
興味がある方はmanを読んでみてください。
本稿では、画像の形式変換のみでImageMagickを使います。

15.2.1. いつも扱っているようなデータ形式にする

 まずは、ppmのように数字が延々と並んでいるのは後処理が大変なので、
次の形式のように5列のデータに変換しましょう。

1
2
縦の位置 横の位置 Rの値 Gの値 Bの値
...

 コードは次のようなものを書きました。
コメントがあるとヘッダの行がずれてしまうので、
まず、8行目でコメントの行を除去し、ヘッダ行を除いた一時ファイル
$tmp-ppm を作ります。
座標をつけるときに画像の幅が必要なので、
11行目で $tmp-ppm のヘッダから幅を取得しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ cat ppm2data
#!/bin/bash
# ppmを座標とピクセル値のレコードに変換
# written by R. Ueda / Dec. 13, 2012
tmp=/tmp/$$

#コメント行の除去
grep -v '^#' < /dev/stdin > $tmp-ppm

#幅(ヘッダ二行目の最初の数字)を代入
W=$(awk 'NR==2{print $1}' $tmp-ppm)

tail -n +4 $tmp-ppm                             |
#数字を縦に並べる
tr ' ' '\n'                                     |
#空行が入るので除去
grep -v '^$'                                    |
#3個ごとに数字を1レコードにする
awk '{printf("%d ",$1);if(NR%3==0){print ""}}'  |
awk -v w=$W '{n=NR-1;print int(n/w),n%w,$0}'    |
#出力: 1.縦の座標 2.横の座標 3-5. R,G,B値
awk '{print sprintf("%04d %04d",$1,$2),$3,$4,$5}'

rm -f $tmp-*
exit 0

 13行目以降が、ピクセルの値を並べ直して座標をレコードに付加するコードです。
先に計算結果を見てから説明します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ cat noodle.ppm | ./ppm2data > noodle.data
$ head -n 3 noodle.data
0 0 112 14 13
0 1 112 14 13
0 2 113 15 14
$ tail -n 3 noodle.data
0000 0000 112 14 13
0000 0001 112 14 13
0000 0002 113 15 14
//ppmよりさらに巨大化
$ ls -lh noodle.data
-rw-rw-r-- 1 ueda ueda 158M 12月 14 10:58 noodle.data

まず、13行目の tail -n +4 は、「4行目以降を出力」
という意味になります。数字にプラスを付けると、
その行数以降という意味になります。
15行目では、数字を全部縦に並べなおしています。
先に縦に並べて、19行目で3個ずつ横に並べています。
17行目は、余計な空白があると空行ができるので、それを取り除いています。

 19行目の awk は、読み込んだ数字を横に並べていって、
3回に一回改行を入れるという処理です。
print は文字列を出力後に改行を入れるので、
19行目のように空文字を出力すると改行の意味になります。

 20行目の awk は、各ピクセルのRGB値に座標を与えています。
AWKでは、物の個数はなんでも1から数えます。
NR は、今扱っているのが何レコード目かという変数ですが、
これも1からスタートします。
これは直感的でよいのですが、数学的には面倒な処理を生む原因になります。
20行目の処理では、0から行数をカウントする n という変数を作り、
そこから、各ピクセルが上から何行目、左から何列目に位置するかを計算しています。

 ところで、この処理は大きな画像で行うと結構時間を食いますので、
小さめの画像で試してから大きな画像を処理してみてください。
まあ、これはシェルスクリプトでやると高速処理は全く期待できません。
ただまあ、スクリプト言語はどの言語もピクセルごとに読み出して処理するのは苦手なようです。

15.2.2. 画像を切り出す

 さて、ここからは noodle.data を使って画像にいたずらしてみましょう。
まずは、基本として、画像の一部分を切り出してみましょう。
シェルスクリプトでもよいのですが、
あえて雑技団的な雰囲気を出すために端末でやってみました。
画像の上から(約)1000ピクセル、下から300ピクセル分を削る処理です。

1
2
3
4
5
6
7
8
$ awk '$1>"1000" && $1<"2764"' noodle.data > tmp
$ H=$(awk '{print $1}' tmp | uniq | wc -l)
$ W=$(awk '{print $2}' tmp | tail -n 1 | sed 's/^00*//' | awk '{print $1+1}')
$ awk '{print $3,$4,$5}' tmp > body
$ echo P3 > header
$ echo $W $H >> header
$ echo 255 >> header
$ cat header body > hoge.ppm

  noodle.data は、第1フィールドが縦の座標なので、
1行目で1001ピクセル目からのピクセルが抽出できます。
2,3行目は、画像の高さと幅を計算してそれぞれファイルに保存しています。
なぜこうなるかは考えてみてください。
4行目で、画像のボディー部を作ります。
座標を取り除けばそのままppmのデータとして使えます。
あとはヘッダを一行ずつ書いていって、拡張子が ppm
のファイルに保存して一丁上がりです。

 私の環境では、ファイルをクリックすると、
次のように画像を見ることができます。
・・・お腹がすいてきました。

ラーメン

 見られない人は、 convert でjpgかなにかに変換しましょう。

1
$ convert hoge.ppm hoge.jpg

15.2.3. ネガを作る

 次に、色を反転させてみましょう。
これは簡単で、RGB値それぞれを反転させればよいということになります。

1
2
3
$ cat noodle.data | awk '{print 255-$3,255-$4,255-$5}' > body
//もとのヘッダをつける
$ head -n 3 hoge.ppm | cat - body > nega.ppm

 次のような画像になります。カラーじゃないのが残念ですが、
今度は淡青色スープにウミウシのようなチャーシューと黒髪のような白髪ネギを搭載した、
大変食欲を無くすラーメン画像になります。

図: 食欲を無くす、ネガティブラーメン画像

ネガ

15.2.4. 画像を合成

 次は、ラーメン画像に別の画像を合成してみましょう。
偶然(嘘)、私の画像ディレクトリに、 noodle.ppm
と同じ大きさの次のような画像 curry.ppm がありました。
(脚注:素直に「ラーメン」としないのは性格上の問題です。)

図:合成する画像

これを次のように処理します。

1
2
3
4
$ cat curry.ppm | ./ppm2data > curry.data
$ loopj num=2 noodle.data curry.data > tmp
$ cat tmp | awk '{print $3*$6/255,$4*$7/255,$5*$8/255}' | sed 's/\.[0-9]*//g' > body
$ head -n 3 noodle.ppm | cat - body > curry_noodle.ppm

loopj は Open usp Tukubai のコマンドで、
次のような動きをします。
二つ以上のファイルの各レコードについて、
キーが同じレコードを連結します。
num=1 は左から1フィールドをキーするという意味です。
キーはソートされている必要があり、
あるファイルにあるキーのレコードがないと、
0でパディングされます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ueda@uedadsk:~/GIT/SD_GENKOU/201303$ cat file1
001 aaa 123
003 bbb 234
ueda@uedadsk:~/GIT/SD_GENKOU/201303$ cat file2
001 AAA
002 BBB
004 CCC
ueda@uedadsk:~/GIT/SD_GENKOU/201303$ loopj num=1 file1 file2
001 aaa 123 AAA
002 0 0 BBB
003 bbb 234 0
004 0 0 CCC

 ですので、2行目は、画素の位置をキーにして、
noodle.datacurry.data を連結しているという意味になります。
tmp の最初の部分を示します。

1
2
3
4
$ head -n 3 tmp
0000 0000 112 14 13 255 255 255
0000 0001 112 14 13 255 255 255
0000 0002 113 15 14 255 255 255

 3行目は、 noodlecurry のピクセルを比較して、
curry の字のない部分(RGBそれぞれ値が255)については、
noodle の値、字のある部分については画素が黒くなる演算をしています。
例えば $3*$6/255$6=255 なら答えは $3 の値になるし、
$6=0 なら答えは 0 になります。
3行目の sed は、演算結果の小数点部分を削除する働きをします。
この、計算のような文字列処理が入るのは、
シェルを操作しておもしろいことの一つです。

 最後、4行目でヘッダをつけて次のような画像の完成です。

図:カレーラーメンではありません。

カレーラーメン

15.2.5. モザイクをかける

 最後は、もうちょっと難しいことをしてみましょう。
ブーム(脚注:Nudiferで検索を。)
に乗ってラーメンにモザイクをかけてみます。

 まず、ラーメンの画像を100ピクセルごとに区切ってブロック化します。
次のように、 noodle.data の座標からグループのコードを作ります。
tail の出力のように、例えば (3263,2445)
はグループ (32,24) ということを、各レコードの後ろに付加しておきます。

1
2
3
4
5
$ awk '{print $0,substr($1,1,2),substr($2,1,2)}' noodle.data > tran
$ tail -n 3 tran
3263 2445 199 132 90 32 24
3263 2446 198 131 89 32 24
3263 2447 199 132 90 32 24

 次に、各グループの画素値を平均します。
このデータがモザイクのレイヤーになります。

1
$ awk '{print $6,$7,$3,$4,$5}' tran | sort -k1,2 -s | sm2 +count 1 2 3 5 | awk '{print $1,$2,$4/$3,$5/$3,$6/$3}' | sed 's/\.[0-9]*//g' > mean

上のコードでは、まずグループを左側に持ってきてキーにして、
ソートし、Tukubai コマンドの sm2 で足し込んでいます。
sm2 の後の出力は次のようになります。

1
2
3
4
$ awk '{print $6,$7,$3,$4,$5}' tran | sort -k1,2 -s | sm2 +count 1 2 3 5 | head -n 3
00 00 10000 1096186 274854 214869
00 01 10000 1049205 268678 207120
00 02 10000 1048624 266316 212040

sm2 +count 1 2 3 5 は、1,2列目をキーにして、
キーごとに3~5列目を足し込むという意味になります。
+count をつけると、足し込むときにキーの数を数えておき、
レコードの出力の際にキーの横に数を付加します。
ですので、この出力の6,7,8列目を3列目で割ると、
各グループの平均のRGB値になります。

  sort -k1,2 -s-s ですが、
これは、ソートキーが同じレコードの順番を変えない
「安定ソート」のオプションです。
この処理では安定ソートは不要ですが、 sort
コマンドは安定ソートの方が早く終わるので経験的に付けています。

  mean のレコードの一部を次に示します。
この部分の処理は、元の画像が大きかったので
open版の sm2 だと10分程度かかってしまいました。
AWKでこの計算をすると、もっと速く処理できます。

1
2
3
4
$ tail -n 3 mean
32 22 194 132 78
32 23 200 137 86
32 24 200 138 89

 モザイクのレイヤーのRGB値が計算できたら、
さきほど作った tran ファイルに mean
ファイルを連結します。

1
2
3
4
5
6
$ cjoin1 key=6/7 mean tran | delf 6 7 > tmp
$ tail -n 3 tmp
3263 2445 199 132 90 200 138 89
3263 2446 198 131 89 200 138 89
3263 2447 199 132 90 200 138 89
//↑座標、もとのRGB値、モザイクのRGB値

  cjoin1 という Tukubai コマンドを使いました。
このコマンドは、 tran
の第6,7列目のデータと mean の左2列を比較して、
mean の内容を tran に連結します。
join1 というコマンドもあるのですが、
こちらは tran 側が6,7列目でソートしていないと使えません。
マスタ扱いされる mean の方は、
cjoin1 でもキーでソートされている必要があります。

  delf は指定した列を消すコマンドで、
既に不要なグループのキーを消去しています。

  tmp が作成できたら、もう少しです。
以下のようにコマンドを打ちます。
読むのが大変ですが、要は画像の範囲指定をして、
範囲内ならモザイクのRGB値、
範囲外なら元の画像のRGB値を出力しているだけです。

1
2
$ awk '{if($1>=1000&&$1<=2400&&$2>=100&&$2<=2000){print $6,$7,$8}else{print $3,$4,$5}}' tmp > body
$ head -n 3 noodle.ppm | cat - body > moz.ppm
モザイク

15.3. 終わりに

 今回はシェルでバイナリデータを扱うということで、
画像処理をやってみました。

 しかし、よくよく考えてみると、
バイナリデータを最初に
ImageMagick でテキストにしてしまったので、
バイナリだからどうという処理は出てきませんでした。
結局、相互に変換する道具さえあればよいということで、
両者に本質的な違いはなく、
シェルスクリプトで行うようなテキスト処理に落とし込むことができます。

 ただし、jpgのように圧縮効率のよいデータの形式と、
テキストのようにベタなデータでは、
サイズに100倍近い違いがありました。
テキストを圧縮してもjpgにはサイズにはかないません。

 一方で、EXIF情報のように、
人が読めない形式(= catgrep で読めない形式)
で情報が保存されてしまうと、いろいろ問題が起こりがちです。
もしかしたら、
テキストファイルで画像を持つことが普通になる日が来るかもやしれません。

 次回は、今回のおふざけが編集様の怒りにふれなければ、
もうちょと本格的な画像処理をやってみたいと考えております。

Pocket
LINEで送る