まえがき
アニメを作っている。ものすごくたくさんの絵を描かなければいけないので、いろいろと問題が起きるし、手間も減らしたい。絵の描けない素人が嘘をつくためにアニメを作る でも一部紹介したが、今回は最近の成果を紹介する。
FILMASSEMBLERのページに載せるには、まだ熟れていないので、こちらに書く。
課題3つ
課題その1: 中割の作業見積もりをより正確にしたい
アニメと言われて大抵の人が想像する描き送りのアニメは、ちょっとずつ違った絵をたくさん描かなくてはいけない。もちろん量が多ければ作画時間は伸びるから、締切にも関わってくる。どれぐらいの量でどれぐらいの時間になるかわかると、「これは間に合いそうにないから欠番にしよう」とか「これはちょっと動きを減らしてやれば何時間で終わりそうだ」とかわかって便利だ。
しかし、いろいろ難しいので、とりあえず中割の作業時間の見積もりに挑戦することにした。中割り、といきなり言われても困ると思うので、例を示す。ここに二枚の絵がある。 
片方は目を開けていて、片方は目を閉じている。この二枚を切り替えるだけのアニメートは瞼が一瞬で動いて気色悪いので、間に目が半開きの絵を挿したい。この絵が中割だ。プロがどう呼んでいるかはよく知らない。僕はそう呼んでいる。

この場合は二枚の原画に対して(正確にはこれはすべて動画だが今のところ無視する)、一枚の中割が入っている。だからこの作業時間を1と見積もることにしたとする。
さて、別のカットからも原画を例示する。なお、ものすごく線が荒れているが、それは気にしなくて良い。説明すると長いし、本題ではない。簡単に言うと下手くそだからだ。

この2枚の間にも中割りを1枚入れたい。こんな絵が入る予定だ。
入れる絵は1枚だから作業量は1で、総合の作業時間見積もりは2である。んなバカな話があるか。どう考えたって後者のほうが大変にきまっている。こんなんで正確な作業時間の見積もりなんかできっこないのである。これが改善したい課題その1。
課題その2: 線をより滑らかにしたい
前掲の。絵の描けない素人が嘘をつくためにアニメを作る では彩色工程の効率向上のために、線を2値化するためにMayakaを使ったプログラムによる処理を行った。
が、やはりこの手法、線がはっきりしすぎて気に入らない。まったく絵の印象が変わってしまうのだ。例えば次に示す線で領域を分けたときのことを考える。
線を2値化して色を塗るとこうなる。

 2値化しなければこうだ。 
明らかに後者のほうが元の線の質を保てている。同時に僕の線の品質の悪さも見事に再現しているが、絶対後者の方がいい。
課題その3:塗色効率を向上させたい
前も書いたが、流し込みペイントツールによる塗色はコツがいろいろあり、線を2値化しなければとても面倒で、2値化しても面倒なのだ。次に示すように、順番に気を使って、レイヤの表示非表示を切り替えて正しい順序で行わなければならない。
自動領域分割による3つの課題の改善
まず、課題1を考えたい。2つの例における中割の難易度の違いは、補間すべき2枚の画像の変化が違う、と言える。しかし、変化が非常に大きくても、例えば単純な平面の回転運動であれば、前後どちらかの画像を回転させて作画参考にすればよいわけだし、拡縮処理でも同様である。したがって「中割りの難易度が高い変化の違い」をコンピュータにわからせやすい形に落とし込めれば、より見積もりの精度が高まると言える。
課題2は単純な画像処理の問題だが、課題3が課題2の実行を阻んでいるため、課題3を解決するためにはどうしたらよいかを考える。しかしそれが簡単にはわからないから手作業で塗っていたわけだ。では手作業で塗っているときにどんなことをしているか考える。
まず僕は、1.先行して塗色すべき対象領域を判断する。続いて、そこに2.どのチャネルの塗り分け線を有効化して塗色すべきか判断する。次に、3.塗色に用する色を選択する。最後に、4.どこからバケツツールを発動すべきかを判断する。
この時機械が苦手な「判断」は3回入っている。この判断をなきものにすることで、自動化への道が拓ける。それは課題3を解決に近づけ、ひいては課題2を実現させる。
1.と2.の判断は「どの順番で塗るか」が結果を変えてしまうから起きていることだ。であるならば、「プログラムを書いて順番を固定する」か「順番に関係なく結果を確定する手順を用意する」ことで解決する。3.を機械に判断させようとすると地獄を見るのであきらめる。4.も3.と密接に関わっているので無視する。要は「領域で分けられた部分が求める品質の境界処理で別の色で塗色される」という状態を自動化してやり、「別の色」が何であるかさえ人間が渡すようにすれば、課題2および3は改善されるわけだ。
これにより副産物として「誰かに作業を任せやすくなる」という利点が発生する。塗り間違えはしかたないが、どこにどの色を塗るかさえ正しければいいので、作業が楽になるのだ。ミスもわかりやすいものになる。機械でやる利点の一つは人間の判断領域を局限できる、ということだ。将来役に立つだろう。
課題2と3の解決には「領域で分けられた部分」を機械が認識する必要があるが、これにより課題1も解決に少し向かうのではないかと考えられる。2つの中割の例の変化の違いは「画像中の部分領域の数と面積比の変化の大きさ」と言えるからだ。単純な平面の回転や拡縮処理であれば、領域の数や面積比は大きく変化しない。
一応書いておくと、こんなことプロ用のソフトやどうかするとクリスタでもできるのかもしれないが、自分で書いておくと、あとで便利そうなのでやってみているだけだ。そもそもいらないのかもしれない。まあ、よい。
念の為に書いておくが、目標は解決ではない。改善だ。少しでも楽にしたい。もちろん、解決すればそれに越したことはない。
塗り分けを自動化する
さて、塗り分けを自動化しなければならない。実に面倒である。何か有名なアルゴリズムがあるのかもしれないが、調べるのも面倒だし、どうせわけのわからない数式を並べ立てていて読めない可能性が高い。「微分して」と書いてあったので心して読んだら、単純な四則演算の組み合わせで済んだ、みたいなことが何度もあった。バイキュービック補間をなぜ出力画像基準で説明しないのかとキレた記憶もある。今まで散々苦労してきたので、付き合っている余裕はない。
適当にでっち上げることにしよう。まあ、低脳画像処理なので、バカバカしいほど単純なアルゴリズムを積み重ねていく富豪プログラミングを行いつつ、それで失う時間を単純な最適化で補うことを組み合わせて問題の解決にあたるとしよう。
いきなり巨大な画像を入力に使うと、デバッグに破綻をきたすので、簡単な小さい画像を用意する。

背景は透明で、周囲に最低1ピクセルの空きを用意して、目を描いた。
これの領域を分割するために、まず線の色を表した符号を定義しておく。
(defconstant +l-line+ 0)
(defconstant +r-line+ 1)
(defconstant +g-line+ 2)
(defconstant +b-line+ 3)
これがどう作用するかは、見ていけばわかる。
続いて、愚直な処理を行う関数region-map-from-image
とそれらが利用する型とユーティリティを書こう。構造体にしても良かったのだが、それはまたあとで。
(deftype region-map () "画素がどの領域に所属するかを定めた領域マップ" '(simple-array fixnum (* *)))
(defunsafe make-region-map region-map ((fixnum width height))
(make-array (list width height) :element-type 'fixnum :initial-element -1))
(defunsafe region-map-from-pixels (region-map fixnum list) ((pixels pixels) (fixnum width height))
"PIXELSから領域マップを生成し、領域マップ、必要な色数、を多値として返す。 "
(typed-let (;; 領域マップ
((region-map region-map) (make-region-map width height))
;; 現在の領域番号
((fixnum current-region-number) (1+ +b-line+))
;; 線を超えているか判定
((boolean over-the-line) nil))
;; 色の塗り分けを記録
(loop for y fixnum from 0 below height
;; 列更新によって線を超えていると判断=新領域である(画像右端の領域が左端に続いているとは判断しない)
do (setq over-the-line t)
do (loop for x fixnum from 0 below width
do (typed-multiple-value-bind ((component-t r g b a)) (pixels-components pixels (offset x y width))
;; 画素成分値束縛
(when (and (> a -@) (> @ (min r g b)))
;; 線を超えた時はそれをフラグで保持する
(setq over-the-line t))
(typed-let1 component-t brightness (rgb->brightness r g b)
;; 線は線を領域マップに登録
;; 線以外の領域は現在の領域番号を領域マップに登録
(cond ((and (> r (* @ 0.75)) (> a -@) (> r g) (> r b)) ;; 赤の塗り分け線
(setf (aref region-map x y) +r-line+))
((and (> g (* @ 0.75)) (> a -@) (> g b) (> g r)) ;; 緑の塗り分け線
(setf (aref region-map x y) +g-line+))
((and (> b (* @ 0.75)) (> a -@) (> b g) (> b r)) ;; 青の塗り分け線
(setf (aref region-map x y) +b-line+))
((and (> a -@)
(> @ brightness)) ;; 主線
(setf (aref region-map x y) +l-line+))
(t (when over-the-line
;; 境界線を超えたあとなら領域番号を更新せよ
(setq over-the-line nil)
(setq current-region-number (1+ current-region-number)))
(setf (aref region-map x y) current-region-number)))
))))
(values region-map current-region-number)))
出力はないので、多値である返り値の一つ目、領域マップと呼ぶことにしようregion-map
をダンプする関数dump-region-map
を書く。
(defunsafe dump-region-map boolean ((region-map region-map) ((fixnum width height)))
(loop for y fixnum from 0 below height
do (format t "~%")
do (loop for x fixnum from 0 below width
do (format t "~2,D~T" (aref region-map x y))))
t)
これで結果を次に示すコードで見てみる。
(let1 image (load-image “/path/to/image”)
(bind-width-and-height image (width height)
(typed-let1 pixels src-pixels (image-pixels image)
;; 画像から領域マップを作成する。
(typed-multiple-value-bind ((region-map region-map) (fixnum number-of-colors))
(region-map-from-pixels src-pixels width height)
(declare (ignore number-of-colors))
(dump-region-map region-map width height)))))
出力を以下に示す。
5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6
7 7 7 7 0 0 0 0 0 8 8 8 8 8 8 8
9 9 9 0 0 0 0 0 0 0 0 0 10 10 10 10
11 11 0 0 0 0 12 1 1 0 0 0 0 13 13 13
14 0 0 0 0 0 15 1 1 0 0 0 0 0 16 16
17 0 0 0 0 18 1 1 1 0 0 0 0 0 19 19
20 0 0 0 0 1 1 1 21 0 0 0 0 0 0 22
23 23 23 0 0 1 1 1 24 0 0 0 0 0 0 25
26 26 26 0 0 1 1 27 27 0 0 28 28 0 0 29
30 30 30 0 0 0 31 31 0 0 0 32 32 0 0 33
34 34 34 0 0 0 0 0 0 0 0 35 35 35 35 35
36 36 36 36 0 0 0 0 0 0 37 37 37 37 37 37
38 38 38 38 0 0 0 0 0 39 39 39 39 39 39 39
40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40
41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
すこし見づらいので、色付けした結果を示す。
5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 |
6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
7 | 7 | 7 | 7 | 0 | 0 | 0 | 0 | 0 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
9 | 9 | 9 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 10 | 10 | 10 | 10 |
11 | 11 | 0 | 0 | 0 | 0 | 12 | 1 | 1 | 0 | 0 | 0 | 0 | 13 | 13 | 13 |
14 | 0 | 0 | 0 | 0 | 0 | 15 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 16 | 16 |
17 | 0 | 0 | 0 | 0 | 18 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 19 | 19 |
20 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 21 | 0 | 0 | 0 | 0 | 0 | 0 | 22 |
23 | 23 | 23 | 0 | 0 | 1 | 1 | 1 | 24 | 0 | 0 | 0 | 0 | 0 | 0 | 25 |
26 | 26 | 26 | 0 | 0 | 1 | 1 | 27 | 27 | 0 | 0 | 28 | 28 | 0 | 0 | 29 |
30 | 30 | 30 | 0 | 0 | 0 | 31 | 31 | 0 | 0 | 0 | 32 | 32 | 0 | 0 | 33 |
34 | 34 | 34 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 35 | 35 | 35 | 35 | 35 |
36 | 36 | 36 | 36 | 0 | 0 | 0 | 0 | 0 | 0 | 37 | 37 | 37 | 37 | 37 | 37 |
38 | 38 | 38 | 38 | 0 | 0 | 0 | 0 | 0 | 39 | 39 | 39 | 39 | 39 | 39 | 39 |
40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 | 40 |
41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 | 41 |
線の部分はきれいに領域が分けられているものの、見事に2値化されている。しかし、この時点では問題ない。線でない部分が、横方向には領域が正しくわけられているが、縦方向にはそうでないことがわかる。
縦方向に領域分割を適正化するために、そこで次に領域色マップと呼ぶ(今名前を考えた)ものをつくる。
(deftype region-color-map () "領域番号と色番号の対応を示す配列" '(simple-array fixnum (*)))
(defunsafe region-color-map-from-region-map list ((region-map region-map) (fixnum number-of-regions))
"領域マップと領域の数から、各領域に該当する色番号の配列を返します"
(typed-let (((fixnum width) (array-dimension region-map 0))
((fixnum height) (array-dimension region-map 1))
((fixnum region-color-map-length) (1+ number-of-regions)))
(typed-let1 (simple-array fixnum (*)) region-color-map (make-array (list region-color-map-length) :element-type 'fixnum)
;; 領域色マップをまずは変化した色数分だけ
(loop for index fixnum from 0 to number-of-regions do
(setf (aref region-color-map index) index))
;; 隣接する色の参照先を統合
(loop for y from (- height 2) downto 0 do
(loop for x from (1- width) downto 0 do
(typed-let (((fixnum bc) (aref region-map x (1+ y)))
((fixnum cc) (aref region-map x y)))
(typed-let (((fixnum cc0) (aref region-color-map cc))
((fixnum bc0) (aref region-color-map bc)))
(when (and (< +b-line+ cc)
(< +b-line+ bc)
(not (= cc0 bc0)))
(setq number-of-regions (1+ number-of-regions))
;; この画素たちはどちらも線じゃないのに、領域色マップの参照番号は全然違うので一緒になるべき
(loop for index fixnum from 0 below region-color-map-length
do (when (or (= (aref region-color-map index) cc0)
(= (aref region-color-map index) bc0))
;; 参照番号がどちらかに該当する色は全部新色である
(setf (aref region-color-map index) number-of-regions)))
)))))
region-color-map)))
region-color-map-from-region-map
の返り値をダンプした結果を次に示す。
#(0 1 2 3 4 74 74 74 74 74 74 74 67 74 74 67 74 74 18 74 74 60 74 74 60 74 74
60 74 74 74 60 74 74 74 74 74 74 74 74 74 74)
これは領域マップのある位置において塗色すべき色番号を返す配列である。これを先ほどの結果に適用して可視化すれば何を意味しているかがわかるはずだ。領域マップを単純に結合していかなかったのは、単に面倒だからだ。
74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 |
74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 |
74 | 74 | 74 | 74 | 0 | 0 | 0 | 0 | 0 | 74 | 74 | 74 | 74 | 74 | 74 | 74 |
74 | 74 | 74 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 74 | 74 | 74 | 74 |
74 | 74 | 0 | 0 | 0 | 0 | 67 | 1 | 1 | 0 | 0 | 0 | 0 | 74 | 74 | 74 |
74 | 0 | 0 | 0 | 0 | 0 | 67 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 74 | 74 |
74 | 0 | 0 | 0 | 0 | 18 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 74 | 74 |
74 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 60 | 0 | 0 | 0 | 0 | 0 | 0 | 74 |
74 | 74 | 74 | 0 | 0 | 1 | 1 | 1 | 60 | 0 | 0 | 0 | 0 | 0 | 0 | 74 |
74 | 74 | 74 | 0 | 0 | 1 | 1 | 60 | 60 | 0 | 0 | 74 | 74 | 0 | 0 | 74 |
74 | 74 | 74 | 0 | 0 | 0 | 60 | 60 | 0 | 0 | 0 | 74 | 74 | 0 | 0 | 74 |
74 | 74 | 74 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 74 | 74 | 74 | 74 | 74 |
74 | 74 | 74 | 74 | 0 | 0 | 0 | 0 | 0 | 0 | 74 | 74 | 74 | 74 | 74 | 74 |
74 | 74 | 74 | 74 | 0 | 0 | 0 | 0 | 0 | 74 | 74 | 74 | 74 | 74 | 74 | 74 |
74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 |
74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 | 74 |
これで領域を任意の色で塗り分けられることが明らかになった。この時点で課題1は改善可能と言える。
同じ領域色番号を用いている画素の数を数え上げてやれば画像ごとの領域の数や広さを割り出すことができる。副産物として、線の総延長も「だいたい」わかる。絶対的なものを割り出すのは難しかろうが、画像間で比較の用途に供するなら十分だ。より正確にと思うのなら、領域外縁部を輪郭追跡して行けばよかろう。
仮の色をつくる
色の指定が問題だが、後回しにする。ここでは領域色番号と色相を対応させるテーブルを生成して、当座を凌ぐことにする。
(defunsafe temp-color-list-from-region-color-map list ((region-color-map region-color-map))
"領域色マップから仮の色相リストを作成して返します。"
(declare ((simple-array fixnum (*)) region-color-map))
(typed-let1 list colors '()
;; 重複する領域色番号を除去したリストを作成
(loop for index fixnum from 0 below (array-dimension region-color-map 0)
for color fixnum = (aref region-color-map index)
do (unless (position color colors)
(setq colors (cons color colors))
))
;; 隣り合う色同士が色相を8度ずつずらすように調整
(loop for i fixnum from 0 below (length colors)
for c in colors
collect (cons c (* 8.0 (mod i 36)))
)))
なお、色相とあるが、犯罪係数とは関係ない。この色相を用いて、彩度と輝度を最大値で塗色してやれば、とりあえずよかろうという判断だ。
当然45色しかないので、同じ色が偶然隣り合ってしまう可能性は非常に高いが、もしかすると4色で済むかもしれんとかいう話に展開しかねないし、まあ、なんとなく塗り分けられていることがわかればいいので、このまま行こう。結果を次に記す。
((60 . 0.0) (18 . 8.0) (67 . 16.0) (74 . 24.0) (4 . 32.0) (3 . 40.0) (2 . 48.0)
(1 . 56.0) (0 . 64.0))
使用されていない領域色番号がいくつかあるが、これは塗り分け線のための予約番号である。
領域周辺を処理する
例の説明
さて、いよいよ本丸である。とりあえず簡単な例を元に話を進めよう。
ある入力画像における、座標Yを考える。例では、入力画像から幅11で部分を切り出した。ちょうど中心に一本の縦線がペンで入れられていたので、X方向の画素の並びは下に示すものとなった。なお、白色は透明色である。
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
入力画像 | |||||||||||
これをどう塗色していくかを考えていくわけだ。
領域は次に示す状態に分割された。
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
領域番号 | 5 | 5 | 5 | 1 | 1 | 1 | 1 | 1 | 6 | 6 | 6 |
領域番号5には青を、同じく6には赤を塗色することとしよう。本来なら領域色マップと領域マップは別のものだが、便宜上一緒になってもらうことにした。
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
領域番号 | 5 | 5 | 5 | 1 | 1 | 1 | 1 | 1 | 6 | 6 | 6 |
こういう、2列目と3列目が極端に色変化するのが2値化した状態なので、本来のペンのタッチを活かせばこうはならないはずである。
基本的な塗色
まずx=2
までは愚直に青を塗っていけば良い。x=2
まで処理を終えた状態を下に示す。
ポインタ | ↓ | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
領域 | 5 | 5 | 5 | 1 | 1 | 1 | 1 | 1 | 6 | 6 | 6 |
入力画像 | |||||||||||
出力画像 | |||||||||||
previous-color |
min-alpha |
塗色プロセスにおいては、2つの状態管理変数が必要になる。まずprevious-color
だが、これにはx-1
において塗色した色が基本的に格納される。現状青くなっているのはそのためだ。min-alpha
については後ほど説明する。
ペン領域への侵入
時間を進めて、x=3
の処理を終えた状態を次に示す。
ポインタ | ↓ | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
領域 | 5 | 5 | 5 | 1 | 1 | 1 | 1 | 1 | 6 | 6 | 6 |
入力画像 | |||||||||||
出力画像 | |||||||||||
previous-color |
min-alpha |
何を行ったか説明する。まず、ポインタの向き先がペン領域であることを判断した。つづいて、入力画素を取得し、その不透明度が最大値でなく半透明であることを判定した。これにより、この座標でのprevious-color
の更新は行わないことになった。続いて、previous-color
の画素に、入力画素を合成し、出力画像に書き込んだ。最後にmin-alpha
を入力画素の不透明度で上書きした。
これをx=5
まで終えた状態を次に示す。
ポインタ | ↓ | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
領域 | 5 | 5 | 5 | 1 | 1 | 1 | 1 | 1 | 6 | 6 | 6 |
入力画像 | |||||||||||
出力画像 | |||||||||||
previous-color |
min-alpha |
ペン領域からの離脱
x=7
まで処理を終えた状態を次に示す。
ポインタ | ↓ | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
領域 | 5 | 5 | 5 | 1 | 1 | 1 | 1 | 1 | 6 | 6 | 6 |
入力画像 | |||||||||||
出力画像 | |||||||||||
previous-color |
min-alpha |
どうしてこうなったか説明する。min-black
と入力画素の不透明度を比較して、previous-color
の画素を入力画素に合成するか判断しているのである。こうしないと、向かった先の領域が違う色だったときに面倒なことになる。
順方向処理の完了
1列の処理を終えた状態を次に示す。
ポインタ | ↓ | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
領域 | 5 | 5 | 5 | 1 | 1 | 1 | 1 | 1 | 6 | 6 | 6 |
入力画像 | |||||||||||
出力画像 | |||||||||||
previous-color |
min-alpha |
入力画素の不透明度が0になったので、previous-color
の記録が再開し、min-alpha
がリセットされていったのでこのようになる。
逆方向の処理他
さらに、x=10
からx=0
まで処理を行い、この際は「出力画素と入力画素が一致した場合=愚直に線を出力している場合」のみ処理を適用するという条件を設けることで、望みの出力結果が得られる。
くわえて万全を期すため、Y軸方向への走査も逆転して行う。なお、容易に考えられる例外として「複数の線が不透明度0にならないほどの近距離で隣接している場合」が挙げられるがそんな高密度作画は過剰品質であるのでリテイクである。したがって問題ない。
以下にコードを示す。この関数を画像全体を走査しつつ呼べば良い。
(defunsafe paint-pixel (component-t component-t) ((fixnum x y width)
(pixels src-pixels dst-pixels)
(region-map region-map)
(region-color-map region-color-map)
(list colors)
(fixnum previous-color-number alpha-color-number)
(component-t min-alpha))
(typed-let1 fixnum color-number (aref region-color-map (aref region-map x y))
(when (= color-number alpha-color-number)
;; 外周色は彩色しない
(set-pixels-components dst-pixels (offset x y width) -@ -@ -@ -@)
(return-from paint-pixel (values color-number -@)))
(typed-let1 fixnum offset (offset x y width)
(typed-multiple-value-bind ((component-t r-src g-src b-src a-src)) (pixels-components src-pixels (offset x y width))
(typed-multiple-value-bind ((component-t r-dst g-dst b-dst a-dst)) (pixels-components dst-pixels (offset x y width))
(cond ((= color-number +l-line+)
;; 主線
(when (or (= r-dst g-dst b-dst a-dst -@) ;; 完全に未彩色
(and (= r-src r-dst) (= g-src g-dst) (= b-src b-dst) (= a-src a-dst))) ;; 元画像からコピィしてきただけ
;; 未彩色の場合のみ次の処理を実行
(if (> a-src min-alpha)
;; より不透明度が下がるのであれば色を伸ばしてペンの実際の色と乗算合成する
(if (= previous-color-number alpha-color-number)
;; 前の色が外周色であれば、合成を行わない
(set-pixels-components dst-pixels offset r-src g-src b-src a-src)
;; 前の色が外周色以外であれば、合成する
(typed-multiple-value-bind ((component-t r-tmp b-tmp g-tmp))
(hsb->rgb (cdr (assoc previous-color-number colors)) @ @)
(typed-multiple-value-bind ((component-t r-rst b-rst g-rst a-rst))
(c-compose-source-over r-tmp g-tmp b-tmp @ r-src g-src b-src a-src)
(set-pixels-components dst-pixels offset r-rst g-rst b-rst a-rst))))
(set-pixels-components dst-pixels offset r-src g-src b-src a-src)))
(values previous-color-number a-src))
((or (= color-number +r-line+) (= color-number +g-line+) (= color-number +b-line+))
(set-pixels-components dst-pixels offset -@ -@ -@ -@)
;; 塗り分け線
(typed-multiple-value-bind ((component-t r-tmp g-tmp b-tmp))
(hsb->rgb (cdr (assoc previous-color-number colors)) @ @)
(typed-multiple-value-bind ((component-t r-rst g-rst b-rst a-rst))
(if (< a-src min-alpha)
;; より不透明度が上がるのであれば色を伸ばしつつペンの不透明度を適用する。1.5は結果が半透明になることを防ぐ措置。
(c-compose-source-over r-dst g-dst b-dst a-dst r-tmp g-tmp b-tmp (* a-src 1.5))
;; より不透明度が下がるのであれば色を伸ばしつつペンの不透明度を反転して適用する。
(c-compose-source-over r-dst g-dst b-dst a-dst r-tmp g-tmp b-tmp (* (invert-component a-src) 1.5)))
(set-pixels-components dst-pixels offset r-rst g-rst b-rst a-rst)))
(values previous-color-number a-src))
(t
;; ベタ塗り
(typed-multiple-value-bind ((component-t r0 g0 b0))
(hsb->rgb (cdr (assoc color-number colors)) @ @)
(set-pixels-components dst-pixels offset r0 g0 b0 @))
(values color-number 0.0))))))))
結果と評価
入力画像を次に示す。
出力画像を次に示す。
肌に色がないのは、外周として認識されているからである。もみあげの塗り分けが消失しているのは「偶然隣り合う色が一致してしまっただけ」である。また、エラーが幾つか確認できるが、概ね「豊住が動画線を引けないのが悪い」ので、改善可能である。些細なノイズなら握り潰すとか、やりかたは色々ある。
参考に、線を2値化して塗色した画像を次に示す。なお、塗り分け線の処理は面倒なのでしていない。
こうして比べると、圧倒的に線の品質が向上したことがわかる。太さが元と変わりなく、また斜めの線も滑らかだ。線を繋いだところが不必要に強調されることもない。
色の指定
さて、勝手に割り当てた色で彩色すると、なかなかの結果が得られることがわかった。しかし、実用に供するためには、色を任意に指定できなくてはならない。しかし、これはそれほど難しい話ではないのだ。新たな入力画像を下に示す。耳を描き忘れているが気にしないように。
まず、この画像から領域色マップを画像として出力する。結果を下に示す。
この画像を元に各領域に彩色を行う。2値化されているので、塗り残しは下に示す画像のように、はっきりと認識できるだろう。実際には塗り分け線と領域色が一致しないようにすべきだが、今はテストなので目を瞑ろう。
塗り終えた画像を下に示す。
あとは、前述の塗色工程において塗る色をこの画像の同じ座標から取得してくれば良い。結果を下に示す。
若干のノイズ、瑕疵は認められるものの、かなり良好な結果を得ていることがわかる。少しの細工を施せば、実用に耐える期待が持てる。
今後について
だいぶいい線行っているので運用でカバーしてしまっても良いのだが、まだ髪のハイライトなどやりたいことがあるのでもう少し続ける予定だ。小さな瑕疵も修正できたらしたい。
いくつかの問題は塗色工程が手続き的な状態依存の処理のため、左上から右下、右上から左下、という順序で塗っていることが原因だ。それらは、8倍の時間を消費し(といってもそれほどかからないし、放っておけばいいのだが)、8つの画像を比較暗合成することで解決する。例として2種類の結果を合成した結果を下に示す。
目立つところとしては、こちらから見て右側の瞳内部の塗り漏れが潰れたことがわかるだろう。
Common LispとMayaka(KOMADORI)について
よく誤解されているが、Common Lispは関数型プログラミング言語ではない。このように入力と出力を単純に関係で表すことの難しいタイプのプログラム、つまり手続き的な処理も手軽に書くことができるし、それと関数型プログラミングの簡潔かつ明快な記述を柔軟に同居させることができる。とはいえ、手続き的な処理、前述の通り状態依存の反復などが入ってくると、見てくれの良いプログラムを書くのが難しくなる傾向にあると評価している。今後はこのあたりも直していけるとよい。
(loop for y fixnum from 0 below height
do (loop for x fixnum from 0 below width
do (typed-multiple-value-bind ((pixel-t painted-color) (component-t new-min-alpha))
(paint-pixel x y width src-pixels col-pixels dst-pixels
region-map region-color-map previous-color alpha-color-number min-alpha)
(setq previous-color painted-color)
(setq min-alpha new-min-alpha))
)
do (loop for x fixnum from (1- width) downto 0
do (typed-multiple-value-bind ((pixel-t painted-color) (component-t new-min-alpha))
(paint-pixel x y width src-pixels col-pixels dst-pixels
region-map region-color-map previous-color alpha-color-number min-alpha)
(setq previous-color painted-color)
(setq min-alpha new-min-alpha))
))
お世辞にもきれいなプログラムとはいえないだろう。x
とy
、previous-color
とpainted-color
だけを残して部分適用して、という手も考えられるが、どのみちsetq
は残る。なに?再帰だ?むずかしい話はやめてほしい。
なかなか面倒なことをしているように見えるが、それは僕のおつむがあんまりよろしくないからである。多分、頭のいい人ならもっと良い方法を思いつくだろう。とはいえ、文法が一つしかないので、僕でも実用的なプログラムを書けるのがCommon Lispのいいところだ。覚えることが少なくて済む。
僕はこういう画像処理の試行錯誤を行うために、Mayaka(KOMADORI)を作った。壁を殴るようにゴリゴリ書いて、結果を確認して、処理を修正するという反復を高速で繰り返せる環境が欲しかったのだ。Mayakaはなかなか速いので(たまにCより速い)、ちょっとバカっぽいプログラムを書いても、試行錯誤の邪魔をしない。ここに書いたこともさらっと書いているだけで、本当に作るのが大変だった。いろいろ迷って(もっとこうしたほうがいいんじゃないかとか)しまったし。
こんな感じで使える(この映像は演技)。
画像の拡大できないのがホントうらめしい pic.twitter.com/nnuIkGGHeX
— 豊住耕一@土東h31a (@TOYOZUMIKouichi) 2016年12月24日
最近放置していた結果、最新のSBCLではとうとうコンパイルが通らなくなってしまったが、修正するつもり(手元では動いているが、SIMD関連を完全にキャンセルしている)。2月頭には作業時間を取りたい。
宣伝など
作っている自主アニメ「THE NAME OF THE HEROINE」の同人誌をコミケで出すのでよかったらお求めください。新刊は技術的な(とはいえコンピュータプログラミングに関する部分に限られていない)内容が中心。既刊の方がお話とかわかります。お話はとてもおもしろいよ。おもしろくなきゃ作らないから。
C91告知です。3日目12月31日土曜日東h31a水無月追跡所は、夏に引き続きオリジナル作品TNOTH関連本「ScoutReport 2 我々の武器、および戦術」を出します。前回は設定が多かったですが、今回は製作工程が主です。300円予定。既刊も持っていきます。続報出します。 pic.twitter.com/n5qDybAi6K
— 豊住耕一@土東h31a (@TOYOZUMIKouichi) 2016年12月25日