MAX 8 / Jitter で減色エフェクトを作る

はじめに

MAX 8 / Jitterの練習をするために、減色エフェクトを作成してみました。 今回はかなり大きなパッチャーとなりました。

alt text

実現のために実装した機能は主に以下のようなものです。

  • 色をクラスタリングして、各クラスタの代表値を取得する(パレット)
  • 画像の各ピクセルについて、パレットの色それぞれとの距離を計算する
  • 各ピクセルについて、パレットの中で最も距離の近い色をそのピクセルの色とする

色のクラスタリング

MAX 8 の機能だけでクラスタリングを行おうとしましたが、なかなか難しかったので、node.scriptを使って、Node.jsのライブラリを活用してクラスタリングを実現しようと考えました。

まずはクラスタリングに使用する画像のデータをファイルに出力します。 (パッチャーでは R/G/B の各チャンネルを分離して出力していますが、まとめて1ファイルにしても実装は可能です。)

jit.fprintは出力データのフォーマットを選ぶことができますが、デフォルトでplaneの区切りは , 列の区切りは\t, 行の区切りは\nです。

alt text

次に Node.jsのスクリプトを作成してクラスタリングを行い、結果をMAXに戻します。

alt text

コードの内容は以下の通りです。


import maxAPI from "max-api";
import { getClusterColor, zfill } from "./reduce_color";


maxAPI.addHandler("calc_cluster", async (num_clusters: number) => {
    maxAPI.post("calc_cluster: " + num_clusters);
    let data = await getClusterColor(num_clusters);
    let r = zfill(data.map(d=>Math.floor(d[0])));
    let g = zfill(data.map(d=>Math.floor(d[1])));
    let b = zfill(data.map(d=>Math.floor(d[2])));
    maxAPI.post(r.concat(g).concat(b));
    await maxAPI.outlet(r.concat(g).concat(b));
});

reduce_color.ts

import fs from 'fs';
import kmeans from 'node-kmeans';
import {zip3} from 'itertools';
import { promisify } from 'util';


async function loadData (fileName): Promise<number[][]>  {
    let data = await fs.readFileSync(fileName);
    let text = data.toString();
    let lines = text.split('\n');

    let table: number[][] = [];
    for (let line of lines){
        let columns = line.split('\t');
        let row: number[] = [];
        for (let column of columns){
            let f = parseFloat(column);
            row.push(parseFloat(column));
        }
        table.push(row);
    }
    return table;
}

async function makeImageVector(tr:number[][], tg: number[][], tb: number[][] ): Promise<[number,number, number][]>{
    let vectors: [number, number, number][] = [];
    for (let [rr,rg,rb] of zip3(tr, tg, tb)){
        for(let [r,g,b] of zip3(rr, rg, rb)){
            if (isNaN(r) && isNaN(g) && isNaN(b)) continue;
            if(isNaN(r)){
                continue;
            }
            if(isNaN(g)){
                continue;
            }
            if(isNaN(b)){
                continue;
            }
            vectors.push([r,g,b]);
        }
    }
    return vectors;
}

export function zfill(original_array: number[]): number[]{
    // convert to 256 length array, filled with 0
    let result: number[] = new Array(256).fill(0);
    for (let i=0; i<original_array.length; i++){
        result[i] = original_array[i];
    }
    return result;
}


export async function getClusterColor(num_clusters: number): Promise<number[][]>{
    let tr = await loadData('r.data');
    let tg = await loadData('g.data');
    let tb = await loadData('b.data');

    let vectors = await makeImageVector(tr, tg, tb);

    let result = await promisify(kmeans.clusterize)(vectors, {k: num_clusters});
    if (!result){
        return [];
    }

    result = result.sort((a, b) => a.clusterInd.length - b.clusterInd.length)

    return result.map(cluster => cluster.centroid);
}

出力として各色(R/G/B)ごとの数値の列を作成し、それらを結合したリストを返します。 こうするのは、MAX 8 で値を受け取ったあと、3つの jit.matrix に分解したのちに jit.pack で結合し、色の matrixを得るためです。

この際、プログラム側が正確に256色の色データを返すように気を付けてください。理由は後のセクションで解説します。

各ピクセルの色を決める

各ピクセルの色を決めるために、減色されたパレット(減色パレットとします)上のすべての色について、各ピクセルとの距離を計算します。パレットの中でピクセルの色に一番近い(=距離が小さい)色を選んで、表示します。

alt text

前のセクションにあるとおり、減色パレットは256 * 1 の matrix に格納します。このパレットは連続的な値を想定しているようで、256より少しでも多かったり少なかったりすると、256色に収まるように補完されてしまいます。

今回の減色パレットは連続的な色ではないので、それぞれのindex に対して正確に256色が出力されるように(不要な部分も黒などで埋めておきます)注意深く実装しましょう。

各ピクセルの色を決めるため、一時的なデータを保存するためのjit.matrixをいくつか作成します。

  • color_numbers各ピクセルの、減色パレット上でこれまででもっとも近い色の色番号を保持するmatrixで、初期値は255です。色番号はクラスタの番号に対応します。
  • distances各ピクセルの、これまでで最短の距離を保持します。

1ステップごとに対象となる色について

  • 色番号をjit.matrix newcolorに格納
  • jit.matrix colorsからgetcellで色を取得し、その色ですべてのピクセルを満たしたmatrixを作成

します。

一つ目のjit.pixでその色と各ピクセルとの距離を計算します。ここで、jit.pixの中では数値が 0-1にマッピングされていることに注意が必要です。今回は入力matrixがchar型ですので、値は0-255です。jit.matrixで255となっている値は、jit.pixでは1.0となり、jit.matrixで0となっている値はjit.pixでは0となります。

jitterにおける差の計算(jit.-)では、結果が負になった場合にも0となってしまうことがあることにも注意が必要です。例えばRGB (128, 128, 128)から(255, 255,255)を引くと、結果は(0,0,0)となります。

alt text

次に、その距離とこれまでで最も小さな距離を比較して、その色の距離が最短であれば、色番号を更新します。

alt text

これを Uzi で必要な回数繰り返すことで、各ピクセルの色番号が決まります。 最後に jit.charmapで色番号とパレットを結合して、目的の画像(映像)を得ます。

おわりに

jit.scriptjit.pixによる無限の拡張性を感じました。あたらめて、MAXプログラミングのの楽しさを感じました。一方で暗黙的な動作仕様が多いこと(それに対するドキュメントが少ないこと)を感じました。型アノテーションのレイヤーができれば、より使いやすいツールになるのではないかと思います。