その網点、中間調で嘘をついている
リソグラフのシミュレータを、正確に作ろうとして分かったこと。
網点フィルタと、印刷された網点は、別のものです。フィルタは写真の上に点の模様を 置きます。印刷機は写真を版に分解し、それぞれを網点にし、平らでもなければ均一に インクを吸うわけでもない紙の上に、順番に刷ります。面白いことはだいたい、この二つの 説明の隙間で起きています。
その隙間を埋める作業を、iOS 向けのリソグラフ・シミュレータ tone + のためにやりました。エンジンは 5000 行ほど、 サードパーティ依存はゼロ、アプリのサイズは 3.2 MB です。以下は、その中で自分が 驚いた部分の話です。
1. 色分解は、角が 27 個ある最小二乗問題
写真から二色刷りを作る一番わかりやすい方法は、輝度をカラーランプに割り当てることです。 デュオトーンのフィルタがやっているのはこれで、デュオトーンのフィルタが「フィルタに 見える」理由もこれです。写真が持っていた色情報を捨てて、代わりに色を発明している。
印刷機は違うことをします。N 種類のインクを重ねると、それぞれの透過率が 掛け算 に なります。光学濃度(透過率の逆数の対数)で書くと、掛け算は足し算になります。
D(x) = Σᵢ aᵢ(x) · Dᵢ aᵢ ∈ [0, 1]
Dᵢ はインク i のベタ濃度、aᵢ(x) は画素 x にそのインクがどれだけ乗るか。
つまり写真から版を求める作業は、未知数が最大3個の線形最小二乗問題で、しかも
各変数が [0,1] の箱に閉じ込められている。色の割り当てではなく、フィッティングです。
厄介なのは、その箱の制約です。制約なしの最小二乗なら解は一行で書けますが、
0 ≤ aᵢ ≤ 1 を足した途端、教科書の答えは「反復しろ」になります。射影勾配法、
アクティブセット法、NNLS。どれも収束の調整が要るし、どれも百万画素に対して
ベクトル化するのが苦痛です。
しかし次元を見てください。箱制約つきの凸二次計画は、必ず箱のどれかの面で最適値を
取ります。そして各インクの状態は3通りしかない —— 0 に固定、1 に固定、自由。
n ≤ 3 なら面は全部で 3ⁿ ≤ 27 個です。
27 は小さい数です。各面の最小解は閉じた形で書けて、しかも —— ここが実用になる理由 ですが —— 出てくる行列はインクの組み合わせだけで決まり、画素には依存しません。 だから画像全体で一度だけ計算しておいて、27 面すべてを全画素について同時に評価し、 実行可能なものの中で一番良いものを取る。行列積を数回やるだけです。
反復なし。収束判定なし。誰かの写真でだけ外れる許容誤差パラメータもなし。 閉じた形の厳密な大域最適解が、ベクトル化された状態で出ます。
一つ小細工があります。各面で自由な添字だけの k×k の系を解くのではなく、固定された
座標をオフセットベクトルに畳み込んだ n×n のアフィン写像として面を丸ごと持ちます。
乗算が少し無駄になる代わりに、面ごとのファンシーインデックスが全部消える。
毎フレーム百万画素という規模では、その添字操作のほうが演算より高くつきます。
おまけもついてきて、固定された座標はちょうど 0.0 か 1.0 で出てくるので、
「全成分が [0,1] に入っているか?」という一括判定が、そのまま自由変数の実行可能性
判定を兼ねます。
箱の上の密なグリッド探索と突き合わせて検証しました。負けたことは一度もありません。
2. ずれていく網点
ここからが、見つかると思っていなかったバグの話です。そして今では、かなり多くの 網点実装に同じものが入っているのではないかと疑っています。
網点は閾値関数です。セルがあり、要求された被覆率 c ∈ [0,1] があり、そのセルの
面積の c を点で埋めたい。よくあるやり方は、セル上に滑らかな解析的な閾値場を書いて
(コサインなり距離関数なり、閉じた形で扱いやすいもの)、それを c で閾値処理する
ことです。
問題は、形状場を c で切っても、セルの c がインクで埋まるとは限らないことです。
実際に埋まるのは「場の値が c 未満である領域の割合」であって、これが c と一致する
のは場の値のヒストグラムが一様な場合だけ。丸い点では一様ではありません。
楕円の点では、全く一様ではありません。結果として、刷り上がった面積は要求した階調を
近似的にしか追わず、しかも誤差が最大になるのは、目が一番敏感な中間調です。
これは形状ごとの補正カーブを手で調整して隠せます。グレーがそれらしく見えるまで 数字をいじる。それは、足りていない考えの代わりにマジックナンバーを置く行為です。
正しい直し方は、閾値が何を生むかを推測するのをやめて、計算することです。点の形状を セル上でサンプリングして値のヒストグラムに落とせば、「閾値 → インクが乗る面積」の 厳密な対応が手に入ります。その累積分布を反転すれば、任意の要求被覆率をちょうど生む 閾値が求まる。形状ごとに一度計算してキャッシュするだけです。
結果として、あらゆる点の形・あらゆる線数について、全階調で要求被覆率を 1% の何分の一 かの精度で再現します。手作業の補正はゼロ。点を丸から十字に変えても、階調はずれません。
アンチエイリアスを同じ計算に畳み込むこと。 これが端点で正しくなる理由です。 点の縁はステップではなくランプになっていて、中間調では対称なランプは自分自身で 打ち消し合います(内側で得たインクと外側で失ったインクが等しい)。しかし 0% と 100% では打ち消しません。ランプの片側がセル境界で切り落とされるからです。縁を面積と別に 扱うと、白紙のセル全部に幽霊のような点が残り、ベタ全部にピンホールが空きます。 アンチエイリアス後の場のヒストグラムを取れば、反転した対象が現実そのものなので、 どちらも消えます。
3. 重ね刷りはアルファブレンドではない
二つの版が重なったとき、紙は何色になるか。
アルファブレンドの答えは「補間しろ」です。赤い版の上に青い版なら、だいたい平均に 近い何か —— どちらのインクにも似ておらず、誰も手に取ったことのない印刷物の色をした、 濁った灰紫色が出ます。
インクは表面に置かれた絵の具ではありません。光が往路と復路の二回通り抜けるフィルタ です。だから版の寄与は、すでに紙に乗っているものに対して掛け算になります。
R ← R · T
ただし実際のリソグラフのインクも、完全な透明ではありません。ある程度の隠蔽力を持った 顔料の層で、不透明度のコントロールが表しているのはまさにそれです。フィルタを適用する 前に下地を白へ補間してやると、一つの式で両端が出ます。
R ← T · lerp(R, white, opacity)
opacity = 0 ならインクは純粋な乗算で、下に刷ったものを全部拾います。opacity = 1
なら下に何があろうと自分の色を見せる。その中間では、本物のインクがやることをやります。
この lerp ひとつが、赤の上の青を濁りではなくもっともらしい濃い紫にしている理由です。
演算3つで、「これは印刷物に見える」という感覚のかなりの部分を担っています。
4. ノイズ場は一つ、効果は二つ
紙は、一番手間がかからないと思っていて、結局一番手間をかけた場所です。
わかりやすいモデルはこうです。紙にはテクスチャがあるから、最終画像にノイズ場を 掛ける。これで手に入るのは「粒子が乗った写真」です。
「網点フィルタをかけた写真」と「印刷物を撮った写真」を分けているのは、紙の凹凸が 同時に二つの別々のことをしている、という点です。
- 表面が均一に光を反射しない(みんながモデル化するのはこっち)
- その同じ表面が、均一にインクを受け取らない。浮いた繊維にはインクが乗り、 窪みは拾われない
効くのは 2 番のほうで、しかもこれは 1 番と同じノイズ場から来なければいけません。 実際の紙では、両方をやっているのが同じ繊維だからです。独立にモデル化すると、無相関な 粒子が二つ出てきて、ただのノイズに見えます。一つの場から駆動すると、インクムラが 出る —— 広いベタ面が「バケツで流し込んだ」ように見えるのを止めているのは、これです。
特徴サイズは基準画素で保持してレンダースケールを掛けるので、900px のプレビューと 4000px の書き出しで粒子の視覚的な大きさが揃います。粒子が4倍粗いプレビューは、 プレビューではありません。
5. 「47 lpi」は数値ではない
lines per inch は比です。紙が何インチなのかを言わない限り何も意味しませんし、 デジタル画像にインチはありません。
これは細かい話ではなく、プレビューと書き出しが一致するかどうかの話です。ここを 外すと、画面で見た校正と保存したファイルで網点が変わる。この分野で唯一、ユーザーが 確実に気づくバグです。
だから測りました。実際の出力をフーリエ変換にかけると、網点が自分から名乗ります。 周期構造は原点からの距離が線数、向きが角度である一対のピークとして現れる。それを 読み取り、紙の横断方向に何個の点が並んでいるかを数え、公称の lpi で割る。 出てきた答えは 3.44 インチの紙 —— 45°、47 lpi、横切って約 162 点。
これで lpi が意味を持ちます。線数は物理的な紙のサイズに対して定義され、レンダー スケールは画素寸法から自動的に決まり、プレビューと最大解像度の書き出しが同じ網点を 見せる。どちらも同じ 3.44 インチの紙の上の網点だからです。
6. 二重実装は、意図的
エンジンは二回存在します。NumPy を使った Python と、Swift。移植ではなく、照合の ためです。
数値計算のコードは、自信を持って間違っていることにかけては際立っています。動くし、 画像も出るし、その画像はもっともらしく見えるし、中間調が 4% ずれている。テストは 役に立ちますが、テストは「自分が assert しようと思いついた性質」の分しか役に立ちません。
同じ数学の独立した実装を二つ作って数値的に突き合わせると、どちらの単体テストでも 捕まらない種類の誤りが捕まります —— 両方で間違った性質を assert したが、実装の 仕方が違ったというやつです。Swift の網点と NumPy の網点が、あらゆる形状の あらゆる階調で許容誤差内に一致したとき、その一致は、単一実装のテストが持ち得ない 意味を持ちます。
高くつきます。もう一度やると思います。
追記: ビルド構成を測ること
Swift の Debug は -Onone です。インライン化なし、ジェネリクスの特殊化なし、
境界チェックとオーバーフローチェックは残ったまま。このエンジンは数百万画素に対する
画素単位の数値ループ —— まさに最適化で生死が決まるコードです。
このプロジェクトでの実測値: 約 80 倍。
面白いのは、その症状の出方です。レンダリングはメインアクターの外で走るので、UI は 完璧に応答し続けます。固まらない。ビーチボールも出ない。写真を入れると、刷り上がりが 永久に出てこない。遅いアプリには見えず、壊れたアプリに見えます。 端末に入っている構成を確認しようと思いつくまでに、デバッグのセッションを一つ 無駄にしました。