ブログのとさか

技術的な話をしたりしなかったり

#MakeGirlsMoe でもっと遊べるプログラムを書いた(実質百合画像生成)

MakeGirls.moe

二次元キャラを自動生成してくれるMakeGirls.moeが今話題になっています。
f:id:tosaka2:20170815164557p:plain

make.girls.moe

今までの画像生成手法と比べてもかなり綺麗な画像が出力されるので驚きました。
PFNでアルバイトしている方が作ったそうです。流石…

ネットの評判(ニコニコの某動画)を見てみると、「ただパーツを組み合わせただけだろ」とか、「データベースからランダムに選んでるだけでしょ」といったコメントをしている人がいて、 いかに高度な画像生成ができているかがわかります。
技術的な詳細は公式ブログにまとまっています。

MakeGirls.moe Official Blog

ここでGANとは~~みたいな話をしてもいいのですが、今回はこのWebサービスでもっと遊ぶためのプログラムを書いたので導入方法と使い方を紹介します。

このプログラムを導入すると「2つのイラストの中間のイラスト」を出力することができます。
イメージとしては以下のツイートの通り。(実質百合では?)

導入方法

画像で説明していきますが、この画像を作ったときより少しアップデートされていて微妙に差があります。
Google Chromeでしか動作確認していません。MakeGirls.moeのアップデートですぐに動かなくなるかもしれないので注意してください。

工程1

※同じ階層にあるmain(なんちゃら).jsを開けばOKです。
f:id:tosaka2:20170815165154p:plain

工程2 (8/16 編集)

プログラムを更新したので画像と説明が少し異なります。

24661行目ではなく、return t.generate()から始まる行の左の数字をクリックしたください。(2017/8/16 現在 25066行目)
Ctrl+Fを押してreturn t.generate()で検索すれば一箇所だけヒットすると思います。
また、Consoleが表示されてない人は一度Consoleタブに切り替えても良いです。(Consoleタブの場所は工程3の画像に書いてあります。)
f:id:tosaka2:20170815165201p:plain
以下のプログラムをコピペしてください。

// return t.generate()の行にブレークポイント
var getObject = () => t;

var getState = () => getObject().state;
var getOption = () => getState().options;
var getNoise = () => getState().gan.noise;
var getNoiseOrigin = () => getState().gan.noiseOrigin;
var isRunning = () => getState().gan.isRunning;

// NoiseをFixedに
var fixNoise = () => getOption().noise.random = false;
// NoiseをRandomに
var randomizeNoise = () => getOption().noise.random = true;

var setRandomOption = (op, random) => {
    for (let param in op) {
        if (op[param]["random"] !== undefined) {
            op[param].random = random;
        }
    }
}

var fixOption = (op = null) => setRandomOption(op || getOption(), false);
var randomizeOption = (op = null) => setRandomOption(op || getOption(), true);

// Noiseを出力
var printNoise = () => "[" + getNoise().join(',') + "]";

// https://github.com/makegirlsmoe/makegirls.moe_web/blob/22272ae7fad6ef3a24a463ea497432a3b6913ead/src/utils/ImageEncoder.js
// GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 https://github.com/makegirlsmoe/makegirls.moe_web/blob/master/LICENSE.txt
var encodeNoiseOrigin = noiseOrigin => {
    let canvas = document.createElement('canvas');
    let canvasWidth = 128;
    let canvasHeight = 34;
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;
    let ctx = canvas.getContext("2d");
    let canvasData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);

    function drawLine(x, color) {
        for (var i = x * 4; i < canvasData.data.length; i += canvasWidth * 4) {
            canvasData.data[i] = color.r;
            canvasData.data[i + 1] = color.g;
            canvasData.data[i + 2] = color.b;
            canvasData.data[i + 3] = color.a;
        }
    }

    function getColor(x) {
        return {
            r: 255,
            g: Math.floor((1 - x[1]) * 256),
            b: Math.floor((1 - x[0]) * 256),
            a: 254
        };
    }

    function updateCanvas() {
        ctx.putImageData(canvasData, 0, 0);
    }

    for (let i = 0; i < canvasWidth; i++) {
        drawLine(i, getColor(noiseOrigin[i]));
    }

    updateCanvas();

    return canvas.toDataURL();
}

var setNoiseOrigin = a => {
    if (isRunning()) return;
    // setNoiseOriginは無くなってる
    document.querySelector('.noise-canvas').firstElementChild.src = encodeNoiseOrigin(a);
    
    cn = getState().gan.noiseOrigin;
    for (let i = 0; i < cn.length; i++)
        for (let j = 0; j < cn[i].length; j++)
            cn[i][j] = a[i][j];

    // 上と下のどちらかで良い?
    //getOption().noise.value = a;
}

// Noiseを設定
var setNoise = a => { 
    if (isRunning()) return; 
    setNoiseOrigin(noiseToNoiseOrigin(a));
};

var setOption = op => {
    if (isRunning()) return;
    Object.assign(getOption(), op);
}

var cloneOption = op => {
    let obj = {};
    for (let param in op) {

        obj[param] = op[param]["random"] !== undefined
            ? Object.assign({}, op[param])
            : op[param];
            
        if (param === "noise") {
            arr = op.noise.value.map(x => x.map(y => y));
            obj.noise.value = arr;
        }
    }
    return obj;
}

// 中断フラグ
var _isAborted = false;
var _selected = [{option:null, img:null},{option:null, img:null}];

// Generate
var generate = async () => { 
    if (isRunning()) return;
    await getObject().generate();
};
// NoiseをRandomにしてGenerate
var generateByRandom = async () => { randomizeNoise(); await generate(); };
// 引数で指定したNoiseでGenerate
var generateBy = async a => { fixNoise(); setNoise(a); await generate() };
var cancel = () => { _isAborted = true; };

// ベクトルの操作
var add = (a, b) => a.map((x, i) => x + b[i]);
var sub = (a, b) => a.map((x, i) => x - b[i]);
var times = (a, t) => a.map((x, i) => x * t);
var norm = a => Math.sqrt(a.reduce((s, a) => s + a**2))
var interpolate = (a, b, p) => Array.isArray(a)
    ? (a.map((x, i) => x * (1-p) + b[i] * p))
    : (a * (1-p) + b * p);

// そこそこ誤差あるけど見てわかるほど影響は出無さそう?
var calculatebackToPixel = v => {
    let b = 255;
    let u = Math.sqrt( -2.0 * Math.log( 1 - b/ 256) );
    let tmp = v / u;

    // 大きすぎるノイズのとき
    if (Math.abs(tmp) > 1) tmp = Math.sign(tmp);

    let pg = (1 - (Math.acos(tmp) / (2 * Math.PI))) * 256;
    let g = Math.min(Math.floor(pg + 0.5), 255); //四捨五入
    return [b, g];
}

var noiseToPixels = a => a.map(calculatebackToPixel);
var pixelToNoiseOrigin = ps => ps.map(x => [1 - x[0]/256, 1 - x[1]/256]);
var noiseToNoiseOrigin = a => pixelToNoiseOrigin(noiseToPixels(a));
var noiseOriginToNoise = noiseOrigin =>
    noiseOrigin.map(([u, v]) => Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v));

var downloadImage = (img, name) => {
    img = img || document.body.querySelector(".result-canvas").firstChild;
    let a = document.createElement("a");
    a.href = img.src;
    a.target = "_blank";
    a.download = name;
    a.click();
}

// 生成済みの画像を下に表示
var addImg = (src, option) => {
    let results = document.body.querySelector(".imgs");
    if (!results) {
        results = document.createElement("div");
        results.className = "row imgs";
        document.body.querySelector(".App").appendChild(results);
    }
    
    let img = document.createElement("img");
    
    img.src = src;
    let op = cloneOption(option);
    
    img.onclick = () => {
        console.log(op);
        console.log(img);
        if (_selected[1].img && _selected[0].img != _selected[1].img) {
            _selected[1].img.style.border = "none";
        }
        
        //更新
        _selected = [{option: op, img: img}, _selected[0]];
        
        if (_selected[1].img){
            _selected[1].img.style.border = "dashed";
        }
        _selected[0].img.style.border = "solid";

        if (_selected[1].img && _selected[0].img == _selected[1].img) {
            downloadImage(img, "mgm.png");
        }
    }

    results.insertBefore(img, results.firstChild);
    return img;
}

// 下に表示してある画像をクリア
var clearImgs = () => {
    let imgs = document.body.querySelector(".imgs");
    for (let x of imgs.children) {
        x.onclick = null;
        x.src = "";
    }
    imgs.parentNode.removeChild(imgs);
}

// 下に表示してある画像を一枚消す
var deleteImg = img => {
    img.onclick = null;
    img.src = "";
    img.parentNode.removeChild(img);
}

// RandomなNoiseでn枚画像生成し,下に表示.
var generateRandomImages = async n => {
    if (isRunning()) return;
    let result = document.body.querySelector(".result-canvas").firstChild;

    for (let i = 0; i <= n - 1; i++) {
        if (_isAborted) {
            _isAborted = false;
            break;
        }
        await generateByRandom();
        addImg(result.src, getOption());
    }
}

// 2つのオプションの内分点を計算
var interpolateOption = (op1, op2, p) => {
    let obj = { };
    for (let param in op1) {
        if (param === "amount" || param === "currentModel") {
            obj[param] = op1[param];
        }
        else if (param === "noise") {
            let a = noiseOriginToNoise(op1.noise.value);
            let b = noiseOriginToNoise(op2.noise.value);
            let newNoise = interpolate(a, b, p);

            obj.noise = {random: false, value: noiseToNoiseOrigin(newNoise)};
            continue;
        }
        else {
            obj[param] = {random: false, value: interpolate(op1[param].value, op2[param].value, p)};
        }
    }
    
    return obj;
};

// オプションレベルの補完画像を生成
var generateInterpolations = async (op1, op2, n) => {
    if (isRunning()) return;
    let result = document.body.querySelector(".result-canvas").firstChild;
    let moto = cloneOption(getOption());

    for(let i = 0; i < n; i++) {
        if (_isAborted) {
            _isAborted = false;
            break;
        }
        let op = interpolateOption(op1, op2, i / (n - 1));
        setOption(op);
        setNoiseOrigin(op.noise.value);
        
        await generate();
        addImg(result.src, op);
    }
    
    setOption(moto);
}

// ボタン追加処理
var addButton = (text, func) => {
    let buttons = document.body.querySelector(".exbtns");
    if (!buttons) {
        buttons = document.createElement("div");
        buttons.className = "row exbtns";
        document.body.querySelector(".options-container").lastChild.appendChild(buttons);
    }
    let b = document.body.querySelector(".btn-primary").cloneNode();
    b.textContent = text;
    b.onclick = func;
    buttons.appendChild(b);
}

(() => {
    let buttons = document.body.querySelector(".exbtns");
    if (buttons) {
        buttons.parentNode.removeChild(buttons);
    }
    
    addButton("生成10", () => generateRandomImages(10));
    addButton("100", () => generateRandomImages(100));
    addButton("1000", () => generateRandomImages(1000));
    addButton("∞", () => generateRandomImages(100000000000000000));
    addButton("補間", () => generateInterpolations(cloneOption(_selected[0].option), cloneOption(_selected[1].option), 10));
    addButton("百合", () => generateInterpolations(cloneOption(_selected[0].option), cloneOption(_selected[1].option), 3));
    addButton("中断", () => { cancel() });
    // isRunning弾かないとgetNoise()でバグる
    addButton("下へ", () => {
        if (isRunning()) return;
        addImg(document.body.querySelector(".result-canvas").firstChild.src, getOption());
    });
    addButton("上へ", async () => {
        let op = cloneOption(_selected[0].option);
        fixOption(op);
        setOption(op);
        await generate();
    });
    // 選択したやつなのか、表示してる画像なのかわかりにくいけど許して
    addButton("口パク", () => {
        let op = _selected[0].option;
        if (!op) op = getOption();
        let op1 = cloneOption(op);
        let op2 = cloneOption(op);
        op1.open_mouth = {random: false, value: 1};
        op2.open_mouth = {random: false, value: -1};
        generateInterpolations(op1, op2, 10);
    });
    
    addButton("笑顔", () => {
        let op = _selected[0].option;
        if (!op) op = getOption();
        let op1 = cloneOption(op);
        let op2 = cloneOption(op);
        op1.smile = {random: false, value: 2};
        op2.smile = {random: false, value: -1};
        generateInterpolations(op1, op2, 10);
    });

    addButton("クリア", () => clearImgs());
    addButton("1枚削除", () => {
        if (_selected[0].img) deleteImg(_selected[0].img);
        _selected[0] = _selected[1];
    });

    addButton("オプション固定", () => fixOption());
    addButton("オプションランダム化", () => randomizeOption());
})();

工程3

同じく24661行目ではなくなっていますが、前の工程でクリックした場所と同じ場所をクリックしてください。
f:id:tosaka2:20170815165137p:plain

工程4

最後の工程です。
この画像を作ったときから少しプログラムを変えたのでボタンの数が増えています。 f:id:tosaka2:20170815165145p:plain

画像が重複して表示されてしまう人(8/16 追記)

プログラム中のwait_sec = 7となっている場所(2箇所あり)を変えてください。
これは生成を何秒待つ必要があるかを秒単位で指定するパラメータです。手元の実行環境の生成速度に合わせてデフォルトで7秒にしていますが、これより生成が速い場合は短く、遅い場合は長くしてください。

ちなみにプログラムを更新するときははじめからやり直す必要はなく、最初の2行だけを飛ばしてConsoleタブにコピペすれば大丈夫です。

この問題は解決しました(8/16)

既知のバグ

- 「補間」で生成した画像を元に補間ができない。(ノイズが正しく_tmpsに入っていない。)→多分直った(8/16)

その他の機能

「中断」を押すと画像の生成を中断できます。
生成した画像の中から好きな画像を2枚選んで「補間」を押すと10枚の間のイラストを生成します。
f:id:tosaka2:20170815180203j:plain

「複製」は元の画像表示領域にある画像を下に表示させます。
「クリア」は下の画像領域の画像をすべて消します。
「百合」は選択した2つのイラストの中点の画像を1枚だけ生成します。(左右に元画像を表示します。)

「Hat x2」等ではHatオプションを強調できます。通常通りHat Onにしただけでは上手く生成できなかったイラストにも、これを押してから生成すればはっきり帽子が出ることが多いです。
公式Expert Modeの追加により削除しました。
- Hat On
f:id:tosaka2:20170818173539j:plain
- Hat x3
f:id:tosaka2:20170818173519j:plain

「下へ」を押すと下の画像表示領域に画像が複製されます。 「上へ」を押すと上の元の画像表示場所に画像が再生成されます。(「Current Noise更新」はここに吸収されました。)

下の画像をダブルクリックすると画像がダウンロードできます。

また、Consoleタブからプログラムを入力すれば他にも色々な操作が可能です。詳しくは上記プログラムを見てみてください。(適当なJavaScriptの知識で書いてあるのは許して)

生成した画像

とりあえず100枚生成して気に入った2枚を選んで補間すると良いのが出たりします。
f:id:tosaka2:20170815173205p:plainf:id:tosaka2:20170815173330p:plainf:id:tosaka2:20170815172920p:plainf:id:tosaka2:20170815173018p:plainf:id:tosaka2:20170815172348p:plainf:id:tosaka2:20170815172350p:plainf:id:tosaka2:20170815172352p:plainf:id:tosaka2:20170815172354p:plain

他にもたくさん良いのがあるんですがとりあえずこの辺で。

補足

自動で100枚も生成させたらサーバの負荷になるだろ!と言われそうなので一応捕捉しておくと、MakeGirls.moeの画像生成処理は全てブラウザ上で行われています。
WebAssemblyでモデルを動かしてくれるWebDNNというライブラリが使われています。また、Macの場合はGPUを利用して100倍高速に実行できるそうです。うらやましい…
WebGPUまたはWebGLが動作する環境ではそちらを使って超高速生成ができるようになりました(9/15)

詳しくは以下のFaster generationを見てください。
make.girls.moe

ひとりごと

Chromeデバッガを使わないといけない行はgetObject = () => t;の部分だけなので、このオブジェクトがデバッガ無しで取ってこれればもっと楽に導入できるんだけどなぁ。
どなたか即時関数内で定義されているオブジェクトを取得する方法を知っている人がいたらおしえてください。

wait_secは環境によって異なる値なのに直書きしてしまったのでハマってる人がいるみたいです。アレなプログラムで申し訳ない。改良するかも?
本当はPromiseオブジェクトとかを取ってきてちょうどいい時間待てれば良いんですが、それが簡単に出来るのかは未検証です。
→できました(8/16)

8/16 追記

GIFにしている方がいるようです。

口パク生成に関してはこちら

qiita.com

口パクボタン追加しました(8/16)
補間ボタンと同じように下に表示されている画像を選択してから実行してください。
f:id:tosaka2:20170816171053p:plain
他にも笑顔/メガネ/1枚削除(下の表示から)/ダブルクリックで保存機能をつけました。

gif生成サービスと組み合わせると楽しい!
GIFアニメーション画像作成ツール - フォトコンバイン f:id:tosaka2:20170816200705g:plain

8/29 追記

本家のアップデートに伴い、こちらの拡張プログラムで不具合が発生しているようです。
ちょっと今時間が取れないので、対応は先になると思います。(Expertモードでoptionの仕様が変わったのでそのあたりかなあとは思います。)

9/1 追記

現在のバージョンでも正しく動作するように修正しました。

9/12 追記

補完ボタンを押すとオプションレベルで補完するように変更しました。
オプション固定/ランダム化ボタンを追加しました。(ボタンを押してもUIには反映されませんが、generateや生成ボタンを押すと反映されていることがわかります。)