MAX 8 / Jitterの練習をするために、減色エフェクトを作成してみました。 今回はかなり大きなパッチャーとなりました。
実現のために実装した機能は主に以下のようなものです。
MAX 8 の機能だけでクラスタリングを行おうとしましたが、なかなか難しかったので、node.script
を使って、Node.jsのライブラリを活用してクラスタリングを実現しようと考えました。
まずはクラスタリングに使用する画像のデータをファイルに出力します。 (パッチャーでは R/G/B の各チャンネルを分離して出力していますが、まとめて1ファイルにしても実装は可能です。)
jit.fprint
は出力データのフォーマットを選ぶことができますが、デフォルトでplaneの区切りは
, 列の区切りは\t
, 行の区切りは\n
です。
次に Node.jsのスクリプトを作成してクラスタリングを行い、結果をMAXに戻します。
コードの内容は以下の通りです。
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色の色データを返すように気を付けてください。理由は後のセクションで解説します。
各ピクセルの色を決めるために、減色されたパレット(減色パレットとします)上のすべての色について、各ピクセルとの距離を計算します。パレットの中でピクセルの色に一番近い(=距離が小さい)色を選んで、表示します。
前のセクションにあるとおり、減色パレットは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)となります。
次に、その距離とこれまでで最も小さな距離を比較して、その色の距離が最短であれば、色番号を更新します。
これを Uzi
で必要な回数繰り返すことで、各ピクセルの色番号が決まります。
最後に jit.charmap
で色番号とパレットを結合して、目的の画像(映像)を得ます。
jit.script
やjit.pix
による無限の拡張性を感じました。あたらめて、MAXプログラミングのの楽しさを感じました。一方で暗黙的な動作仕様が多いこと(それに対するドキュメントが少ないこと)を感じました。型アノテーションのレイヤーができれば、より使いやすいツールになるのではないかと思います。