幕末古写真ジェネレータをハックする

幕末古写真ジェネレーター というウェブサービスがちょっと前に話題になりました。どんな写真も幕末の写真のように加工してくれる面白いサービスです。

この仕組みを 幕末古写真ジェネレーターの仕組み? - 将来が不安 で解析していたのが面白かったので、続きをやってみることにしました。

→成果を先に見たい人は、こちら からご覧くださいませ。

1. 真っ黒な画像を渡す

まずは、真っ黒な写真を幕末風にしてもらいます。

これが幕末風にするための型紙です。以降はテンプレート画像と呼ぶことにします。

2. RGB の階調を渡す

直感的に、ジェネレータの実装は

(元画像+色補正)+テンプレート画像

と考えられます。テンプレート画像は得られたので、あとは色補正のパラメータを探れればハック完了です。

そのために、RGB の階調をジェネレータに渡してみました。

結果はこうなりました。

おー。青色がほとんど消えてますね。グラデーションの方は、白飛び・黒飛びしていて、ダイナミックレンジが小さくなっているのも一目瞭然ですね。

3. 差分をとる

さて、2. で得られた画像ですが、テンプレート画像が邪魔なので、差分をとってやります。

(元画像+色補正)+テンプレート画像

からテンプレート画像を引くことで、

(元画像+色補正)

を手に入れる、という算段です。

プログラムで処理してもいいのですが、Photoshop が手元にある人は、レイヤースタイルの「差の絶対値」が激しく便利でしょう。

2つの画像をレイヤーに配置するだけで、差分をとってくれます。

だいぶ雑念が消えましたね。

でも、まだノイズがあります。おそらく、ざらついた印象をつけるために、ランダムでノイズをのせているのでしょう。

4. ぼかす

ノイズが邪魔なら、平均値を取ればいいですね。平均値を取るにはどうするか。ぼかしを入れます

Photoshop のフィルタから、「ぼかし」>「ぼかし (ガウス)」を選択します。半径 6px ぐらいにしてやればこんな画像になります。

ノイズがなくなって、のっぺりしました。

上側の4つの四角から、色をスポイトで抜き出します。

#ff0000 -> #212121
#00ff00 -> #414141
#0000ff -> #0d0d0d

#ffffff -> #6d6d6d
#000000 -> #060606

RGB の各成分がどの程度の強さで変換されているかがわかります。

赤(#212121)と緑(#414141)と青(#0d0d0d)を足したら、だいたい白(#6d6d6d)の色になっています。RGB の色調補正を行っている、という推測が正しいことを裏付けていますね。

ここから、色調補正の手順が推測できますね。書き下してみるとこんな感じです。

  1. ダイナミックレンジをいじる
    真ん中 1/3~2/3 のみを生かすぐらいに絞る。トーンカーブで表すとこんな具合。
  2. 色あいを調整する
    赤を 1/8、緑を 1/4、青を 1/16 ぐらいまで落とす。

5. 実装する

アルゴリズムが分かれば、あとは ActionScript で実装するだけです。

(元画像+色補正)+テンプレート画像

を順番に実装していきます。サーバーレスで実装したかったので、BitmapData は使っていません。

色調補正は ColorMatrixFilter を2回適用することで実現しました。テンプレート画像との合成は、テンプレート画像を透明度50%で重ねることで実現しました。50% で重ねても薄くならないよう、2つの画像を2倍の明るさで作成しておきました。

出来上がったのが次の Flash です。URL を入力して、ボタンを押すと幕末写真化されますよ。

かなり本家に近くなった気のではないでしょうか。アルファチャンネルの扱いがオリジナルと違うけど、それは Flash の制限上、やむなし…。

本家に比べて何がすごいかというと、クライアント側だけで画像加工しているので高速です。即座に試せます。

大きな写真を表示させたい場合は、特設ページ 最速幕末古写真ジェネレータ からどうぞ。

ソースコード

ソースは以下に(120行)。

package{
    import flash.display.*;
    import flash.text.*;
    import flash.events.*;
    import flash.filters.*;
    import flash.net.*;
    import flash.filters.ColorMatrixFilter;
    
    [SWF(backgroundColor="#ffffff")]
    public class Bakumatsu extends Sprite{
        [Embed(source="BakumatsuTexture.jpg")]
        private var Texture:Class;

        private var canvas:Sprite;

        public function Bakumatsu(){
            stage.scaleMode = "noScale";
            stage.align = "TL";

            // 入力欄
            var tf:TextField = new TextField;
            tf.border = true;
            tf.text = "http://tech.nitoyon.com/img/title-blog.jpg";
            tf.type = "input";
            tf.width = 380;
            tf.height = 22;
            tf.x = tf.y = 5;
            addChild(tf);

            // ボタン
            var button:Sprite = new Sprite();
            button.graphics.beginFill(0xcccccc);
            button.graphics.drawRect(0, 0, 50, 22);
            button.graphics.endFill();
            button.filters = [new BevelFilter(2, 45, 0xffffff, 0.5, 0x000000, 0.5)];
            button.mouseChildren = false;
            var label:TextField = new TextField();
            label.width = 50;
            label.height = 22;
            label.htmlText = "<p align='center'>幕末化</p>";
            button.addChild(label);
            button.buttonMode = true;
            button.x = 395;
            button.y = 5;
            addChild(button);

            // キャンバス
            canvas = new Sprite();
            canvas.x = 5;
            canvas.y = 35;
            addChild(canvas);

            button.addEventListener("click", function(event:Event):void{
                var loader:Loader = new Loader();
                loader.contentLoaderInfo.addEventListener(Event.COMPLETE, completeHandler);
                loader.contentLoaderInfo.addEventListener("ioError", ioErrorHandler);
                var req:URLRequest = new URLRequest(tf.text);
                loader.load(req);

                showMsg("Loading...");
            });
            button.dispatchEvent(new Event("click"));
        }

        private function completeHandler(event:Event):void{
            var li:LoaderInfo = event.currentTarget as LoaderInfo;
            var loader:Loader = li.loader;
            bakumatsuNize(loader);
        }

        private function ioErrorHandler(event:Event):void{
            showMsg("Not found!!");
        }

        private function showMsg(msg:String):void{
            var tf:TextField = new TextField();
            tf.htmlText = "<font size='40'>" + msg + "</font>";
            tf.autoSize = "left";
            tf.selectable = false;
            bakumatsuNize(tf);
        }

        private function bakumatsuNize(img:DisplayObject):void{
            while(canvas.numChildren > 0){
                canvas.removeChildAt(0);
            }
            canvas.graphics.clear();
            canvas.addChild(img);

            var f:ColorMatrixFilter = new ColorMatrixFilter([
                 3,  0,  0, 0, -200, 
                 0,  3,  0, 0, -200, 
                 0,  0,  3, 0, -200, 
                 0,  0,  0, 1, 0
            ]);

            var f2:ColorMatrixFilter = new ColorMatrixFilter([
                1 / 4, 1 / 2, 1 / 8, 0, 0, 
                1 / 4, 1 / 2, 1 / 8, 0, 0, 
                1 / 4, 1 / 2, 1 / 8, 0, 0, 
                    0,     0,     0, 1, 0
            ]);
            img.filters = [f, f2];

            var texture:Bitmap = new Texture();
            texture.width = img.width;
            texture.height = img.height;
            canvas.addChild(texture);
            texture.alpha = 0.5;

            // 枠
            img.x = img.y = texture.x = texture.y = 5;
            canvas.graphics.beginFill(0xf3f3f3);
            canvas.graphics.lineStyle(1, 0xcccccc);
            canvas.graphics.drawRect(0, 0, img.width + 10, img.height + 10);
            canvas.graphics.endFill();
            canvas.filters = [new DropShadowFilter(2, 45, 0, 0.4)];
        }
    }
}