浮き彫り(レリーフ)加工をブラウザで再現する:写真の明るさではなく、線画を溝にする
に公開
トレカのアルティメットレアのような浮き彫りを再現した記録。写真の明るさから彫ると縞や肌荒れになったため、XDoGで取った線画を溝にして、傾きと斜面の向きが合った線だけ光らせる方式に作り替えた。コードと評価のぶれも書く。
目次
トレーディングカードのアルティメットレアのような、絵が彫り込まれて見える「浮き彫り」加工を、ブラウザで再現しました。最初は写真の明るさから彫りの明暗を作りましたが、縞や肌荒れになってしまい、写真から線画を起こして、その線を溝として使う方式に作り替えました。傾きの向きと溝の斜面の向きが合う線だけが光ります。作り方、使ったコード、評価がぶれた経緯を書きます。
何を作りたかったか
アルティメットレアは、カードの絵と枠が、金属板に彫刻したように浮き彫りに見えるレア表現です。傾けると、彫った線の片側が光り、反対側が暗くなります。これをシェーダで再現して、12種類のレア表現の1つとして入れました。
最初の版は、他のレア表現と同じ考え方で作りました。つまり、奥行きの画像と、写真そのものの明るさの細かい差から、彫りの明暗を作る方法です。
最初のやり方と、駄目だった所
写真の明るさから彫ると、縞になる
写真の明るさをそのまま彫りの高さとして使うと、次の問題が出ました。
- 顔や肌に使うと、肌のきめが彫られて荒れ、不気味になった。顔の楕円の範囲は彫らないことで外しました。
- 縞が出た。最初は、奥行きの階調(231階調あり)が段差になっているのかと疑いましたが、そうではありませんでした。正体は、指の境目や袖の柄など、写真の中の本物の線を、黒く彫っていたことでした。
- 強さの調整が定まらなかった。暗い側を弱め、明るい側の照りを主にしましたが、「地味」と「うるさい」の間を行ったり来たりしました。
調整を続けて、最終形では、奥行きから作る面の明暗をほぼ外しました(面がベタ塗りになるため)。写真の明るさの細かい差(1〜4画素幅)だけで彫りの線を作る形です。顔と手の荒れはほぼなくなり、髪、レース、袖のふちに浮き彫りの感じが出ました。ただし、右に傾けたときだけ少しうるさく見えました。この経験から、「写真の明るさから作る彫りには限界がある」と判断しました。
直した方法:線画を溝にする
紙幣の肖像や銅版画の彫りは、面を塗りつぶすのではなく、線で形を作ります。そこで、写真から線画を作り、その線を「溝」として扱う方式にしました。処理は scripts/make_relief.py にあります。
1. 肌のきめを消してから線を取る
線を取る前に、バイラテラルフィルタを2回かけて、肌のきめや圧縮のざらつきを消します。輪郭は残ります。
sm = img
for _ in range(2):
sm = cv2.bilateralFilter(sm, 7, 26, 5)
g = cv2.cvtColor(sm, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255
2. XDoG でペンの線のような線画にする
線の取り出しには XDoG を使いました。2つのぼかしの差から、ペンで描いたような線を取り出す方法です。
def xdog(g, sigma, k=1.6, tau=.985, eps=-.012, phi=55):
d = cv2.GaussianBlur(g, (0, 0), sigma) - tau * cv2.GaussianBlur(g, (0, 0), sigma * k)
return np.clip(1 - np.where(d >= eps, 1.0, 1 + np.tanh(phi * (d - eps))), 0, 1) # 1=線
line = np.maximum(np.maximum(xdog(g, .9, eps=-.004), .8 * xdog(g, 2.0, eps=-.008)),
.65 * xdog(g, 1.3, eps=-.0018)) # 3つ目は髪の流れなどの弱い線
ぼかしの大きさ(sigma)を3通りにして、細い線、太い線、髪の流れのような弱い線を重ねています。さらに、人物の外周(切り抜きのふち)と、奥行きの段差(手、腕、あごの境目)を Canny で取って足しました。点のようなごみは、小さい連結成分を消して取り除きます(面積45未満を除去)。
3. 面の中は平行線で彫る
面の内側には、紙幣の肖像のような細い平行線を入れました。線は奥行きに沿って曲がり、暗い所ほど太くなります。
phase = 2*np.pi * ((xx*.55 + yy*.83) / 8.5 + dsm * 13) # dsm = ぼかした奥行き
th = 1.02 - 1.25 * dark
hatch = np.clip((np.sin(phase) - th) / .3, 0, 1) * (1 - face) * (1 - body)
face は顔の楕円、body はあごより下です。顔は彫らず、あごより下(服、手)にも平行線を入れません。服の部分が斜線になっているのは良くない、という指摘を受けて、服は輪郭の線だけにしました。最終的に、平行線が入るのは髪の部分だけになりました。
4. 溝の斜面の向きを画像に保存する
線を溝(くぼみ)と見なして、斜面の向きをソーベルフィルタで求め、画像の色の3チャンネルに保存します。
height = -cv2.GaussianBlur(line, (0, 0), 1.5) # 線が溝
gx = cv2.Sobel(height, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(height, cv2.CV_32F, 0, 1, ksize=3)
sc = 1.0 / (np.percentile(np.hypot(gx, gy), 99.5) + 1e-6)
nx = np.clip(-gx*sc, -1, 1); ny = np.clip(-gy*sc, -1, 1)
out = np.dstack([line*255, (ny*.5+.5)*255, (nx*.5+.5)*255]) # BGR = 線, 上下, 左右
出力は赤が左右、緑が上下、青が線の濃さを持つ画像です。確認用の線画も別に保存します。
5. シェーダで、傾きと斜面の向きが合った線だけ光らせる
ブラウザ側のシェーダは、この画像を読み、斜面の向き n と傾きの向き L の内積を取ります。
vec4 R = texture2D(uScene, p);
vec2 n = (R.rg - .5) * 2.;
vec2 L = normalize(uHl.xy + vec2(.0001, .0002));
float e1 = dot(n, L) * inb * fd * A;
c = e1 > 0. ? vec3(1., .97, .88) : vec3(.1, .06, .05);
al = min(.9, abs(e1) * (e1 > 0. ? 1.7 : .8));
内積が正(斜面が光のほうを向いている)なら暖かい白、負なら暗い色で描きます。傾きを変えると、光る線と暗くなる線が入れ替わり、彫刻のように見えます。
測った結果と、残った弱点
別の評価者に見てもらった採点は、版によって10点満点で3〜6点とぶれました。平行線が均一だと「縞のフィルター」に見え、弱めると「光る場所が変わらない」と言われました。一方で、顔、色、縞の破綻は解消しました。評価の数字がぶれる以上、決め手は自分の目での判断でした。最終的な形(線画の溝、髪だけ平行線、服は輪郭のみ、顔は彫らない)で、いったん良しとしています。
残った弱点は、評価がぶれるように、細い線と光り方のバランスに正解がないことです。また、写真ごとに顔の大きさが違うと、顔を彫らない範囲が合わなくなるので、写真ごとに倍率を設定できるようにしました。ある顔のアップの写真では、基準の範囲だと頬や目に線が乗りました。
まとめ
写真の明るさから彫ると、本物の線(指の境目や柄)を黒く彫ってしまい、縞や肌荒れになります。写真から XDoG で線画を作り、その線を溝にして、溝の斜面の向きを画像に保存し、シェーダで傾きの向きと合う線だけを光らせると、彫刻のように見えました。全体のレア度の設計は レア度を4段階で設計した話 にあります。