Arnoldで、レンダリング後デノイズを自動で走らせる

:::note warn
その後Arnoldのアップデートにより、この記事で述べている内容の相当する機能が組み込まれました。
それ以前のバージョンのArnoldを使っている場合のみ、この記事の内容が有効です。
(20210802追記)
:::


本記事は[Maya Advent Calendar 2020](https://qiita.com/advent-calendar/2020/maya) 14日目の記事です。
~~まだ空いているようなので、日々Mayaをお使いの方はぜひどうぞ。~~ 今年も枠が全部埋まったみたいですね。完走お疲れ様でした。

***

現在Mayaに標準で付いてくるレンダラ「Anorld Renderer」は、設定項目が少なく学習コストが低く、レンダリングコストが高いことで知られています。ノイズが収束しません。
なので以前は

- パパッと絵を決めて、あとはサーバーに任せる

という富豪戦法が常道となっていましたが、最近は選択肢が増えました。
GPUやDenoiserの登場です。GPUパワーで計算を速めたり、デノイズ用のpassを出力しておくことで後計算でノイズを軽減しようという戦い方です。
ArnoldのGPUレンダリングは、高速化はもちろんのことポリシーとして「CPUレンダリングとGPUレンダリングとで絵が変わらないように」を目指して開発されているそうで、好感が持てます。ただ、ここでは扱いません。
この記事で扱うのはデノイザー、ノイズ除去に関する内容です。

Arnoldに用意されているデノイザーは二種類あり、
Nvidia GPUを要求する「OptiX™ Denoiser」と、
謹製のAnorld Denoicer **「noice」** とが選べます。
前者はフレーム間でちらつきが生じるという問題があるらしいです。注意ですね。
というわけでここではnoiceに注目します。

https://docs.arnoldrenderer.com/display/A5AFMUG/Denoising

noiceを用いる場合、
レンダリング後にその画像を専用UIから指定してデノイズを掛けます。
レンダリング数分・デノイズに数十秒かけた結果が、素直にレンダリングして30分費やした結果よりもクリアだったりするのでありがたいです。

https://docs.arnoldrenderer.com/display/A5ARNTUT/Denoising+a+Room+Interior

ありがたいですが、レンダリング後に **手動でデノイズを実行するという雑用が発生** するのはいただけません。
デノイズを実行するとスクリプトエディタの出力欄から確認できますが、noiceはコマンドラインから利用できます。
これをポストレンダーMELと組み合わせて、
レンダリングが終わったらその画像をnoiceへ渡して、待っているだけでデノイズされるようにしたいと思います。

# ログを確認する

デノイズ後のログを確認してみると、

```
C:\Program Files\Autodesk\Arnold\maya2020\bin\noice -i /path/to/image.0001.exr -o /path/to/image_denoised.0001.exr -ef 0 -sr 9 -pr 3 -v 0.5 
```

このようなコマンドが実行されていることがわかりました。
ここで方針としては

- 今レンダリングした画像のパスを取得し、 `-i` `-o` へ代入する
- `-ef` 以下はとりあえずそのまま使う

……ことにします。

長いのでnoiceのパスも代入で渡すことにしました。

```python:
cmd = '"{noice_path}" -i "{i}" -o "{o}" -ef 0 -sr 9 -pr 3 -v 0.5' 
cmd.format(noice_path = '/path/to/noice',
            i = 'これから取得',
            o = 'これから取得',
            )
```

# 現在のレンダリング画像のパス

## 現在のフレーム

```
pm.currentTime(q=True)
```

これで取得できます。ただし、小数で返ってきてしまいます。
パス文字列に組み込むにあたって4桁ゼロ埋めしたいので、
intに変換し、strに変換してから、zfillメソッドをかけることにします。

```
current_frame = str(int(pm.currentTime(q=True))).zfill(4)
```

## レンダリング出力パスを解決する

プロジェクトセットしたimagesフォルダとか、レンダリング設定に入力したフォルダ階層とかファイル名とか、
それぞれ取得して文字列としてつなぐこともできますが、
`renderSettings` コマンドを使うとそれらを解決した状態の文字列（のリスト）を返してくれます。

```
pm.renderSettings(fullPath=True,genericFrameImageName='aaaaaa' )
```

genericFrameImageNameに渡すのは、画像のフレーム番号としてファイル名と拡張子の間に挿入される文字列です。
なので、ここにさっき取得した current_frame を渡します。

```
input_img = pm.renderSettings(fullPath=True,genericFrameImageName=current_frame )[0]
```

リストで返ってきますが一つしかいらないので [0] で取り出して input_img 変数に格納。

## デノイズ後のファイルパスをつくる

input_img を元に、 current_frame の直前に「_denoised」を挿入したパスを作ります。

パス操作しやすいように pm.Pathクラスに一度変換して、
パスの最後のファイル名(basename)だけ取り出して「.」で分割します。

```
output_img = pm.Path(input_img)
split_basename = output_img.basename().split('.')
```

この split_basename リストは、

1. ファイル名本体
2. 連番（4桁ゼロ埋め）
3. 拡張子

という要素が並んでいます。最初の要素の末尾にだけ「_denoised」を足し、
その上で「.」で連結して元に戻すことにします。

```
result = []
for i,v in enumerate(split_basename):
    if i==0:
        result.append(v+'_denoised')
    else:
        result.append(v)
```

### 小手先のリスト内包表記

forでまわすときに、enumerateを使うとループの回数を取得できますので、
初回（0回目）のみ「_denoised」を足し、あとはそのままappendします。

resultとかappendとかしゃらくさいという場合には、リスト内包表記で一行で結果のリストを取れます。

```
[ v+'_denoised' if i==0 else v for i,v in enumerate(split_basename)]
```

普段、リスト内包表記でifでフィルタリングをかけるときには

```
[ v+'_denoised' for i,v in enumerate(split_basename) if i==0 else v ]
```

……みたいに for〜 のあとに if〜 書いて来たんですが、今回はこれではダメでした。
else〜 を書きたい場合には、forの前の枠に入れるとのことです。

「_denoised」込みのファイル名ができたら、それをもとのファイルパス（dirname）と連結します。

```
output_img = pm.Path(input_img)
split_basename = output_img.basename().split('.')
split_basename = [ v+'_denoised' if i==0 else v for i,v in enumerate(split_basename)]

output_img = output_img.dirname() / '.'.join(split_basename)
```

そういえば操作しやすいようにPathに入れてましたが、oに渡すときにはstrに変換して、
.replace('\\','/') してスッキリさせておきます。

```
...
o=str(output_img)).replace('\\','/')
...
```


# まとめる

これで cmd.format に渡す引数が揃いました。

ここまでを合わせると、このようになります。

```
current_frame = str(int(pm.currentTime(q=True))).zfill(4)
input_img = pm.renderSettings(fullPath=True,genericFrameImageName=cur_fr )[0]

output_img = pm.Path(input_img)
split_basename = output_img.basename().split('.')
split_basename = [ v+'_denoised' if i==0 else v for i,v in enumerate(split_basename)]
output_img = output_img.dirname() / '.'.join(split_basename)

cmd = '"{noice_path}" -i "{i}" -o "{o}" -ef 0 -sr 9 -pr 3 -v 0.5'
cmd = cmd.format(noice_path =r'C:\Program Files\Autodesk\Arnold\maya2020\bin\noice',
                    i=input_img,
                    o=str(output_img)).replace('\\','/')
```

# 実行してもらう

ここまではMaya内のPythonで、noiceに仕事をさせるための cmd　を作って来ました。
最後にこれをシステムへ投げて実行してもらう必要があります。

実行には subprocess モジュールの check_call を使います。
check_callはリストを受け取りますが、文字列を渡してそのままshell文として実行してもらうオプションもあります。でも非推奨なので避けます。
shell文を適切なリストに分解してもらうためのモジュールがあるため、それを使います。

```
import shlex
import subprocess

subprocess.check_call(shlex.split(cmd))
```

この辺りのことは [Mayaのスクリプトについて解説している拙著にも書いていますので、
気になった方はぜひ手に取ってみてください!!（ダイレクトマーケティング）](https://www.amazon.co.jp/dp/4798047422 "Mayaのスクリプトの本")


# スクリプト

ここまで cmd をつくり、check_call へ渡すということをやって来ましたが、
それをまとめてMayaから見える位置に保存します。

```python:auto_noice.py
# -*- coding: utf-8 -*-
import shlex
import subprocess

from pymel import core as pm

def doIt():
    current_frame = str(int(pm.currentTime(q=True))).zfill(4)
    input_img = pm.renderSettings(fullPath=True,genericFrameImageName=current_frame )[0]

    output_img = pm.Path(input_img)
    split_basename = output_img.basename().split('.')
    split_basename = [ v+'_denoised' if i==0 else v for i,v in enumerate(split_basename)]
    output_img = output_img.dirname() / '.'.join(split_basename)

    cmd = '"{noice_path}" -i "{i}" -o "{o}" -ef 0 -sr 9 -pr 3 -v 0.5'
    cmd = cmd.format(noice_path =r'C:\Program Files\Autodesk\Arnold\maya2020\bin\noice',
                        i=input_img,
                        o=str(output_img)).replace('\\','/')
    
    subprocess.check_call(shlex.split(cmd))
```

# post render frame mel へ登録

Mayaから見える位置にさきほどの .py を置いたら、普通にimport・実行できるはずです。

```
import auto_noice as anc
anc.doIt()
```

ただし、post render frame mel欄にはPythonではなくMELスクリプトを書かないといけないので、これでは困ります。
MELの `python` コマンドを使って実行させましょう。文字列を渡すとそれをPythonスクリプトとして走らせることができます。

```
python( "import auto_noice as anc;anc.doIt()" )
```

複数行のPythonスクリプトは「;」で区切ることで一行にまとめられます。

これをレンダリング設定の post render frame mel に登録すれば、作業完了です。

***

# デノイズについて

あとはひたすらレンダリングして、デノイズされるのを待つだけです。
結構粗めにノイズが載っていても除去してくれるので、
Camera AA をどこまで下げてDenoise結果に満足できるかの我慢比べになります。

きれいになったと言っても、素直にレンダリングした結果を「正しい」とするならば、
デノイズの結果は「捏造」、不正確です。

冒頭、noiceのオプションをひとまず無視しましたが、

```
 -ef 0 -sr 9 -pr 3 -v 0.5
```

シーンの内容とレンダリング速度、許容できる品質とのバランスを
このあたりを調整しつつ取っていくことになると思います。
Anorldの持ち味であるシンプルさが削げてしまったようで微妙に肩透かしですが、
お手頃な計算力が遍く人類に提供されているわけではないので仕方ない。

開発中と噂の爆速M1チップ（M1X？M1Pro？）がなんとかしてくれないですかね。

***

明日12/15日は [mono-g](https://qiita.com/mono-g) さんの記事「 [視認性万歳　Maya用ツールのちょいこん](https://qiita.com/mono-g/items/2f28e77426cf64d564d3) 」です。