UECジャーナル

開眼☆シェルスクリプト シェルで画像処理(2)―awkのパターンと配列を使う

画像ファイル(ppm形式)をシェルスクリプトで操作する方法を解説する。画像データは通常はバイナリ形式でファイルに保存されている。しかし、基本的にはピクセル(画素)ごとにR(赤)、G(緑)、B(青)の値を記録したデータがであれば画像としてデータを表現できる。

ppm形式はそうした画像データのの表現形式の1つ。ppm形式はテキスト(アスキーコード)でデータを持つ形式とバイナリで持つ形式がある。ここではテキストの形式について説明する。

AWK - パターンとアクション

データ処理にはAWKを使用する。AWKはパターンとアクションと呼ばれるブロックの繰り返しで構成されるスクリプト言語で、テキストデータの処理に向いている。

「パターン」は入力されたテキストデータから条件に合う行を抽出するための指定。パターンはgrep(1)の機能を担っていると考えてよい。grep(1)は抽出だけだが、AWKは抽出した行に対して「アクション」で演算ができる。

次の例はパターン部分で偶数を抽出し、アクション部分で「10で除算した結果を出力する」というもの。

$ seq 1 10 | awk '$1%2==0{print $1/10}'
0.2
0.4
0.6
0.8
1
$ 

パターンとアクションの組みはいくつも書くことができる。次のコードは偶数と奇数を数えるAWKのスクリプト。パターンは「BEGIN」と「END」も含めて4個ある。

$ cat oddeven.awk 
#!/usr/bin/awk -f

BEGIN   {even=0;odd=0}
$1%2==0 {even++}
$1%2==1 {odd++}
END     {print "奇数:",odd; print "偶数:",even}
$ 

実行すると次の結果が得られる。

$ jot 1 9 | ./oddeven.awk 
奇数: 5
偶数: 4
$ 

1つの行が複数のパターンにマッチする時は、次のようにパターンを書いた順にそのアクションが順次実行される。この挙動はif文とは違うので注意が必要。なお、最初のアクションにはパターンが指定されていないが、パターン指定がないものは「すべての行に適用する」というパターンを書いたのと同じになる。

$ echo 1 | awk '{print $1,"a"}NR==1{print $1,"b"}NR!=2{print $1,"c"}'
1 a
1 b
1 c
$ 

AWK - 関数

関数はfunction 名前(変数,...){文;文;...}というように表記する。次のAWKスクリプトは関数の名前の書き方と使い方を示している。

$ cat func.sh 
#!/bin/sh 

echo $1 |
awk '{print scream($1,10)}
      function scream(a,n){return n==1?a:(scream(a,n-1) a)}'
$ 

実行すると次の結果が得られる。

$ ./func.sh あ
ああああああああああ
$ 

この例のように関数は使う場所より後ろに書いても動作する。

AWK - 配列

AWKの配列は連想配列として実装されている。次のような使い方ができる。

$ awk 'BEGIN{a["猫"]="まっしぐら";print a["猫"]}'
まっしぐら
$ 

インデックスを文字列ではなく数字指定のように使うこともできる(ただし、その実装は連想配列になっている)。この場合、インデックスは1から始める。0を指定して使うこともできるが、関数が配列を返すときは1が最初のインデックスになっているので、1に合わせておいた方がセマンティックがずれなくてよい。

$ echo 南海 ホークス | awk '{\
	a[1]=$1;a[2]=$2;for(i=1;i<=2;i++){print a[i]}}'
南海
ホークス
$ echo 'OH!MY!GOD!' | awk '{split($1,a,"!");print a[2]}'
MY
$ 

AWKの関数のひとつsplit()は指定された文字列を、指定された文字を使って分解し配列に格納するもの。上記のsplit($1,a,"!")は、入力されてくるテキストデータの1列目($1)を、!で分割し、分割したものをaの配列に格納する、という処理になる。

インデックスに数字を指定できるため、JavaやC/C++の配列と同様のものだという印象を受けるが、そうではない。連想配列で実装されているので、次のように書くことができる。この記述ではJavaやC/C++では123,456,789+1個目の配列に10を代入せよ、という意味になるが、AWKでは123456789というインデックスをつけた配列に10を代入するという意味になる。連想配列であるため、123,456,789+1個分の配列を確保する必要なく、この1つだけが作成される。

$ awk 'BEGIN{a[123456789]=10;print a[123456789]}'
10
$ 

連想配列なので、インデックスは1からの連番でなくても使用できる。f[2]とf[3]に値を代入し、f[1]は作らないといった使い方ができる。

2次元配列は、次のようにインデックスをカンマで区切って表記する。数字表記も使うことができる。次に使用例を示す。

$ cat janken.sh
#!/bin/sh 

echo $1 $2	|
awk 'BEGIN{
	a["グー","チョキ"] = "グー";
	a["パー","チョキ"] = "チョキ";
	a["グー","パー"]   = "パー";
	a["チョキ","パー"] = "チョキ";
	a["チョキ","グー"] = "グー";
	a["パー","グー"]   = "パー";
	}
      END{print a[$1,$2] "の勝ち"}'
$ 
$ ./janken.sh パー チョキ
チョキの勝ち
$ 

AWKの2次元配列はJavaやC/C++の2次元配列とはまったく異なる。AWKではインデックスをすべて連結した文字列をキーにして1つの連想配列にしている。文字列の連結は12,3と1,23が区別できるように行われる。

AWKとシェルスクリプトで画像処理

写真などのjpeg画像をppm画像に変換したものを処理する。画像形式の変換にはImageMagickやGraphicsMagickが利用できる。ImageMagickの使い方は開眼☆シェルスクリプト シェルで画像処理―バイナリデータをテキスト化して扱うにまとめたので、そちらを参照のこと。

アスキー形式のppm画像はスペースか改行区切りで数字の並んだテキストファイルになっている。次に例を示す。最初のP3が画像の形式、次の2つが画像のサイズ、次いで画素値の刻み幅(深さ)になっている。その後、左から右、上から下の画素に向けてr(赤)、g(緑)、b(青)の値が並ぶ。

$ head 1.ppm
P3               <- 画像のタイプ
#*               <- コメント
960 640          <- 画像の幅、高さ
255              <- 深さ
125 94 50 126 95 51 127 96 52 128 97 53 128 97 53...
$ 

パターンを使って画素を配列に記録

まず、画像をAWKの配列に格納する処理をする。6行目で画像($1に指定する)をppm画像に変換している。利用しているコマンドはImageMagickのconvert(1)コマンドだ。12〜15行目でppm画像を読み込み、データを縦1列に並べ、中間ファイルに出力している。18〜20行目でヘッダ部分(幅、高さ、深さ)を変数に格納した後、23行目以降で画像の本体部分の数字をAWKで処理している。

#!/bin/sh -xv

tmp=/tmp/$$

### 画像の変換
convert -compress none "$1" $tmp-i.ppm

### データを縦1列に並べる

#コメント除去
sed 's/#.*$//' $tmp-i.ppm	|
tr ' ' '\n'			|
#空行を除去
awk 'NF==1'	> $tmp-ppm

### ヘッダ情報取り出し
W=$(head -n 2 $tmp-ppm | tail -n 1)
H=$(head -n 3 $tmp-ppm | tail -n 1)
D=$(head -n 4 $tmp-ppm | tail -n 1)

### 画素の値を配列に格納
tail -n +5 $tmp-ppm	|
awk -v w=$W -v h=$H -v d=$D \
	'NR%3==1{n=(NR-1)/3;r[n%w,int(n/w)] = $1}
	NR%3==2{n=(NR-2)/3;g[n%w,int(n/w)] = $1}
	NR%3==0{n=(NR-3)/3;b[n%w,int(n/w)] = $1}'

rm -f $tmp-*
exit 0

AWKに書いてあるパターンは3つで、上から順にそれぞれr、g、bの値を2次元配列に代入している。入力されてくるテキストデータは1行目にr、2行目にg、3行目にbというように3個毎に値が並んでいる。これを「rgb」の組み合わせで処理したいので、NR(行番号)を3で割った余りでどのデータかを判定して、さらに行番号から画像での横位置、縦位置を求めた値をインデックスとして2次元配列に代入している。横位置は左側から0,1,2,...、縦位置は上側から0,1,2,...と数えることとした。AWKのセオリーに反して0から数えているが、これはn%wとint(n/w)に1を足すのが面倒なのでこのようにしている。

光をフラッシュさせる効果を作り出す

まず、画像の位置を使った処理を示す。図1のサンプル画像はユニバーサル・シェル・プログラミング研究所友の会のイベント写真。後ろの男性(筆者)は手にビール瓶を持っている。ビール瓶からフラッシュを出してみよう。

図1: 加工する画像

図2に変換後の図を示す。

図2: 加工後の画像

次にこの処理を行うAWKの部分を示す。配列に値を読み込む部分までは先に説明した処理と一緒で、新たにENDパターンに対する処理と、関数を1つ追加している。このシェルスクリプトの名前はflash.shとしている。

tail -n +5 $tmp-ppm     |
awk -v w=$W -v h=$H -v d=$D \
    'NR%3==1{n=(NR-1)/3;r[n%w,int(n/w)] = $1}
    NR%3==2{n=(NR-2)/3;g[n%w,int(n/w)] = $1}
    NR%3==0{n=(NR-3)/3;b[n%w,int(n/w)] = $1}
    END{
        print "P3",w,h,d;
        for(y=0;y<h;y++){
            for(x=0;x<w;x++){
                ex = x - w*0.87;
                ey = y - h*0.32;
                deg = atan2(ey,ex)*360/3.141592 + 360;
                weight = (int(deg/15)%2) ? 1 : 4;
    
                p(r[x,y]*weight);
                p(g[x,y]*weight);
                p(b[x,y]);
            }
        }
    }
    function p(n){ print (n>d)?d:n }'

flash.shを実行して加工後の画像が得られている。

$ ./flash.sh 1.jpg > flash.ppm 
$ convert flash.ppm flash.jpg
$ 

ENDパターンでは、まず8行目でppm画像のヘッダ部分を出力している。その後の二重のfor 文で、1画素ずつr、g、bの順番に値を加工して出力している。

for構文のループ内では、まず11と12行目で、その画素が光を出す中心の画素に対してどの位置にあるかを求めている。中心の画素は著者が手で調べてハードコーディングした。

その後、13行目で「その画素が光を出す中心に対してどの方角にあるか」を求めている。atan2()関数はC言語にもある関数だが、見たことがない方もいるかもしれない。atan2()関数は次のように角度を返す関数だatan2()の返した値を円周率で割って360をかけると角度(度)になる。

図3: atan2(y,x)の返す角度

AWKのatan2()は引数が(0,0)だった場合、次のように0を返す。

$ awk 'BEGIN{print atan2(0,0)}'
0
$ 

14行目では、角度15度刻みでweightという変数の値を1にしたり4にしたりしている。変換後の画像は15度刻みで光っている。atan2()が返す値は正数と負数、それに0の場合があるため、0度から360度まで15度刻みになるように、13行目で360を足してdegの値が正数になるようにしている。

16〜18行目で標準出力へデータを出力していく。金色(黄色)に光らせたいので、rとgの値にweightをかけて強調する。pという関数は22行目で実装しており、値が最大値dを超えるとdで打ち切って出力するとしている。AWKの変数は基本的にグローバル変数なので、オプションで定義されたd は関数の中でも使える。

エンボス加工する

もう1つ加工処理を次に示す。これはエンボス加工風に画像を変換する処理だ。先ほどと同様、先のawk(1)コマンドのパターンとして追加して使用する。

tail -n +5 $tmp-ppm     |
awk -v w=$W -v h=$H -v d=$D \
        'NR%3==1{n=(NR-1)/3;r[n%w,int(n/w)] = $1}
        NR%3==2{n=(NR-2)/3;g[n%w,int(n/w)] = $1}
        NR%3==0{n=(NR-3)/3;b[n%w,int(n/w)] = $1}
        END{print "P3",w-2,h-2,d;
            for(y=1;y<h-1;y++){
                for(x=1;x<w-1;x++){
                        a = 2*g[x-1,y-1] + g[x-1,y] + g[x,y-1] - g[x,y+1] - g[x+1,y] - 2*g[x+1,y+1];
                        p(r[x,y] - a); p(g[x,y] - a); p(b[x,y] - a);
                }
        }}
        function p(v){print (v < 0) ? 0 : (v > d ? d : v)}'

次に処理前後の画像を示す。

図4: 変換前の画像

画像を処理すると絵画のような風合いになる。

図5: 変換後の画像 (エンボス加工後)

先の処理では、まず変数aに画素とその周囲の画素のg値を比較した値を代入している。この処理は「sobelフィルタ」と言われるもので、この演算だと画像の斜め方向で緑色が急激に変わっている画素のaの値が正、あるいは負の方向に大きくなる。

aの値でグレースケール画像を作ると次のようになる。gだけでなくr,g,bの値で平均値をとってa値を求めるべきだが、コードがややこしくなるので緑だけにしている。

図5: a の値で画像を作ったもの

図6: グレースケール画像へ加工したもの

aの値を、10行目のように各rgb値から引くと、色の変化の急激なところが強調されて人間の目には画像に凹凸があるように見える。

おわりに

今回は、AWKとシェルスクリプトで画像処理をした。データをテキストにすると処理の流れが分かりやすいので、画像処理の教育素材としても活用できる。データ状態をエディタやページャで目視できるというのは、想像以上の高い作業効果を生む。

今回はAWKの説明を充実させた。パターンや配列、関数の書き方などを説明した。特徴的なのはパターンの存在そのものと、あとは配列の実装だ。パターンをたくさん並べてプログラミングをすると、「1行ずつ読み込み、パターンで振り分けて何かする」という、他の言語との違いが際立ちる。この動作はシェルスクリプトで使う他のコマンドと似ており、相性という点でAWKとシェルスクリプトは親和性が良い。そのため、AWKが使いこなせると、シェルスクリプトも使いこなしやすくなる。

Software Design 2013年4月号 上田隆一著、「テキストデータならお手のもの 開眼シェルスクリプト 【16】シェルで画像処理(2)―awkのパターンと配列を使う」より加筆修正後転載

Last modified: 2014-01-13 00:00:00