🏁

ドット絵をSVGのパスに変換する

2022/12/03に公開

ドット絵をくっきりはっきり拡大したいことはあると思いますが普通に画像やCanvasを使うと拡大でぼやけてしまいます。(現在はCSSの image-rendering: pixelated; が使えます。)

ドット絵を表示するのに最強なのはなんだろう……?そう考えた時に思いつく方法があります。

SVGで四角を大量に描画すればくっきりするじゃん!!と。

しかしこれはうまくいきません。微妙な拡大率の場合四角と四角の間に隙間が生じてしまいます。この隙間を埋めるためには、一つの色に対し1つのパスにするしかありません。

今回はドット絵を読み込みSVGのパスに変更するための手順をまとめていきたいと思います。

手順

  • ピクセルの取得
    • 今回は簡易化のために「透明」と「黒」の色しかなく、そのうち「黒」だけのパスを作成します。
  • 対象色を有向線分に変換
  • 不要有向線分の削除
  • 有向線分の並び替え
  • (最適化を入れる場合はここで有向線分の最適化を行う)
  • パス記法に変換

基本的な考え方

まずは1ピクセルを一方向につながった4つの有向線分に変換します。この記事では時計回りとします。

これをすべてのピクセルに対し行った後、反対の有向線分を消します。
例えば隣り合った1つのピクセルは、左のピクセルの上から下に向く右側にある有向線分と、右のピクセルの下から上に向く左側にある有向線分が反対の有向線分なので両方が消滅します。

これを見ればわかると思いますが、この時点で最低限の処理は完成です。後はこのパスの通りにSVGのパスを作れば同じ色のピクセルを1つのパスにすることができます。

この記事ではもう少し踏み込んで様々な処理を行い、もっと最適化されたパスとして出力されるように頑張ってみようと思います。

ちなみにコードはすべてTypeScriptで記述します。

ピクセルの取得

何らかの方法でどの座標にどの色があるか調べる必要があります。
例えば同じドメインならば <canvas> に画像を貼り付けて色を取得することが出来ます。

function LoadImage(src: string) {
	return new Promise<HTMLImageElement>((resolve, reject) => {
		const img = document.createElement('img');
		img.onabort = reject;
		img.onerror = reject;
		img.onload = () => {
			resolve(img);
		};
		img.src = src;
	});
}

LoadImage('image.png').then((image) => {
	// <canvas> の作成
	const canvas = document.createElement('canvas');
	// 画像と同じサイズに変更
	canvas.width = image.naturalWidth;
	canvas.height = image.naturalHeight;
	const context = canvas.getContext('2d');
	if (!context) {
		throw new Error('Failure to get context2D.');
	}
	// 画像を貼り付ける
	context.drawImage(image, 0, 0);
	// 全ピクセル情報を取得
	return context.getImageData(0, 0, canvas.width, canvas.height);
}).then((imageData: ImageData) => {
	// imageData.data には 1ピクセルの色が [R, G, B, A] の順番で4つの値として入っている。
	// 各値は0-255の範囲で入っていて、Aは不透明度なので0で透明、255で不透明となっている。
	// 例えば2x2のサイズなら左上から [R, G, B, A, R, G, B, A, R, G, B, A, R, G, B, A] の16の値が入っている。
});

コメントにも書いている通り、これで画像のすべてのピクセルの色が取得できます。
他ドメインの画像には使えない手法なので何らかの手段で色を得る必要がありますがそこはがんばってください。

すべてのドットを色ごとに分けてパスに出力していけば良いです。

今回はピクセルの位置を登録すると内部で有向線分に変換し、様々な処理を行うクラスを用意します。

対象色を有向線分に変換

今回の有向線分は始点と終点の座標を保持することにします。

interface DirectedSegment {
	sx: number;
	sy: number;
	ex: number;
	ey: number;
}

これを管理するクラスを作ります。

class DirectedSegments {
	protected directedSegments: DirectedSegment[] = [];

	// ピクセルの座標を登録
	public addPixel(x: number, y: number) {
		// ピクセルを時計回りの有向線分4つに変換して登録する。
		this.directedSegments.push(
			{ sx: x, sy: y, ex: x + 1, ey: y },
			{ sx: x + 1, sy: y, ex: x + 1, ey: y + 1 },
			{ sx: x + 1, sy: y + 1, ex: x, ey: y + 1 },
			{ sx: x, sy: y + 1, ex: x, ey: y },
		);
	}
 }

後はすべてのピクセルに対して DirectedSegments.add(x, y) で登録していけばよいです。

// 今回は黒か透明しかないので1つだけ管理クラスのインスタンスを作成する。
const directedSegments = new DirectedSegments();

for (let i = 0; i < imageData.data.length; i += 4) {
	// 背景が透明な場合はスキップする。
	if (imageData.data[i + 3] === 0) {
		continue;
	}
	// i は読み込み開始座標なので、これを2ビットずらして4で割ることで左上から右に進んでいった時に何ピクセルかがわかる。
	const p = i >> 2;
	// X座標は読み込んだピクセル数を横幅で割った余りで取得できる。
	const x = p % imageData.width;
	// Y座標は読み込んだピクセルを横幅で割って小数点以下を無視すれば取得できる。
	const y = Math.floor(p / imageData.width);
	// ピクセルを登録する。
	directedSegments.addPixel(x, y);
}

不要有向線分の削除

ピクセルを登録して有向線分の一覧を作成することに成功しました。次に不要な有向線分を消していきます。

不要な有向線分は2つあります。

  • 重複した有向線分
    • 今回のサンプルコードでは発生しないが、同じ座標にピクセルを登録した場合に発生する。
  • 反対方向の有向線分
    • 今回は始点と終点のデータで管理するので、2つの有向線分AとBにおいて「Aの始点とBの終点」「Bの始点とAの終点」が同じ場合に反対方向となる。

例えば後からピクセルを追加した時に編集可能な状態を維持したいのであれば、前者と後者は性質が異なります。
前者は重複データなので1つにすればよいですが、後者は消すと二度と復帰不可能となります。

そこで重複に関してはクラス内のデータをそのまま削除、反対方向の有向線分を消す場合は別のデータを作って安全にデータを消していくように作っておきます。

重複した有向線分の削除

まずは重複データです。これは確実にいらないので元データからも消します。 DirectedSegments クラスに重複削除用のメソッドを追加しましょう。

class DirectedSegments {
	// 省略

	// 重複する有向線分を削除する
	protected notDuplicate() {
		this.directedSegments = this.directedSegments.filter((target, index) => {
			// 現在読み込んでいるところまでの間で調査
			for (let i = 0; i < index; ++i) {
				const now = this.directedSegments[i];
				// 同じ有効線分が以前に存在する場合は残さない
				if (now.sx === target.sx && now.sy === target.sy && now.ex === target.ex && now.ey === target.ey) {
					return false;
				}
			}
			// 重複する有向線分が手前に見つからなかったのでそのまま残す
			return true;
		});
	}
}

このメソッドはどこで呼んでも問題はないですが、諸々の変換作業の前に呼び出すのが良いでしょう。

反対方向の有向線分を消したデータの取得

この作業以降は取り返しがつかなくなるのでデータをコピーして処理を行うようにします。

class DirectedSegments {
	// 省略

	// 反対方向の有向線分を対消滅させた後の一覧を作成
	protected annihilate() {
		// 有向線分かnullの入った配列に今のデータをコピーする
		const directedSegments: (DirectedSegment | null)[] = [...this.directedSegments];
		const max = directedSegments.length;
		for (let a = 0; a < max; ++a) {
			const target = directedSegments[a];
			if (!target) {
				continue;
			}
			// 現在位置より後ろで反対の有向線分を探す
			for (let b = a + 1; b < max; ++b) {
				const check = directedSegments[b];
				// すでに消滅している場合は飛ばす
				if (!check) {
					continue;
				}
				// 反対の有向線分でない場合は飛ばす
				if (target.sx !== check.ex || target.ex !== check.sx || target.sy !== check.ey || target.ey !== check.sy) {
					continue;
				}
				// 反対の有向線分は双方 null にして消す
				directedSegments[a] = directedSegments[b] = null;
				break;
			}
		}
		// filterでnull以外の値を持つ一覧を返す
		return <DirectedSegment[]> directedSegments.filter((directedSegment) => {
			return directedSegment !== null;
		});
	}
}

これで画像で説明した部分までの実装ができました。
次は暗黙で省略されているものの必要な処理を実装していきます。

有向線分の並び替え

今回のクラスのようにピクセルを登録するごとに配列に入れれば、一つの塊ごとに必ず有向線分が閉じる状態になっています。
しかし先程反対の有向線分を対消滅させてしまったので、配列を先頭から調べた場合有向線分がつながっていない可能性が高いです。
その場合変換処理の実装依存ですが、以下のようになったりします。

そこで、1つの塊ごとに必ず有向線分が繋がるように並び替えをする必要があります。

初めに始点を決めます。始点は角にするのが今後の処理の関係上都合が良いです。
上の図だと候補は0、2、3、5のどれかにするのが良いでしょう。

前提としてピクセルの塊は一周する場合すべての方向の有向線分が存在します。今回は時計回りなので以下のような条件で並び替えると角を検出することが出来ます。

  • 右方向の有向線分だけを対象にする
  • 一番左上=X座標とY座標がより小さいものを優先する

正確には始点としての角が必要なので、並び替えと言っても最初の1つさえ見つかれば後はソートする必要がないので、初めの1つが確定すればよいです。

class DirectedSegments {
	// 省略

	// 角になる有向線分の位置を調べる
	protected findCorner(directedSegments: DirectedSegment[], start: number) {
		let min = start;
		for (let i = start + 1; i < directedSegments.length; ++i) {
			// 今回は時計回りにしているので、右向き以外の有向線分は無視する
			if (directedSegments[i].ex <= directedSegments[i].sx) {
				continue;
			}
			// より左上にある有向線分がある場合にはそれを最小値とする
			if (
				directedSegments[i].sy < directedSegments[min].sy ||
				directedSegments[i].sx < directedSegments[min].sx
			) {
				min = i;
			}
		}
		// 一番左上の右向き有向線分を返す
		return min;
	}
}

後はこの角につながる有向線分を見つけてつなげていくだけなのですが注意事項があります。

このように離れているピクセルがある場合すべてを1つでつなげることが出来ません。
ちなみに画像に書かれている通りSVGではこの場合パスをいい感じに閉じた後座標移動を使うことで複数の塊を1つのパスにすることは可能なので、これを考慮すると以下のように有向線分を並び替える必要があります。

  1. スタートは0番目
  2. スタート地点が角になるように並び替え
    • 残った有向線分の中で一番左上になる右向き有向線分の座標を探す
    • その座標とスタート地点の有向線分を入れ替える
  3. スタート地点の有向線分が角なので、その角につながる有向線分を探す
    • スタート地点の次の場所にスタート地点とつながる有向線分を置く
    • スタート地点を+1して3まで戻る
  4. つながる有向線分がなくなったら判定を行う
    • もう残った有向線分がない場合は終了
    • まだ残っている場合は現在繋がりが確定している地点+1をスタート地点にして2に戻る

このようにソートしていきます。

class DirectedSegments {
	// 省略

	// 有向線分を繋げる
	protected connect(directedSegments: DirectedSegment[], start: number) {
		// スタート地点を基準につながる有向線分を調べる
		for (let i = start + 1; i < directedSegments.length; ++i) {
			// スタート地点の終点と対象の始点が同じかどうか調べる
			if (directedSegments[start].ex === directedSegments[i].sx && directedSegments[start].ey === directedSegments[i].sy) {
				// スタート地点の次の位置と対象の位置を入れ替える。
				++start;
				[directedSegments[start], directedSegments[i]] = [directedSegments[i], directedSegments[start]];
				// 次の検索開始場所を変更してもう一度ループさせる
				i = start;
			}
		}
		return start;
	}

	// 有向線分を塊ごとにつながるようにソートする
	protected sort(directedSegments: DirectedSegment[]) {
		if (directedSegments.length <= 0) {
			return directedSegments;
		}
		// 有向線分の残りがある限りループを続ける
		for (let i = 0; i < directedSegments.length; ++i) {
			// まず角となる有向線分の位置を調べる
			const corner = this.findCorner(directedSegments, i);
			// 現在地が角になるように入れ替えを行う
			if (i < corner) {
				[directedSegments[i], directedSegments[corner]] = [directedSegments[corner], directedSegments[i]];
			}
			// 角を基準に有向線分をつなげ、どこまで繋いだかの情報をもらう
			i = this.connect(directedSegments, i);
		}
		return directedSegments;
	}
}

これで有向線分を塊ごとに1つに繋がるように並び替えができました。

パス記法に変換

SVGの作成

まずSVGでのパスの描画方法についてです。

<svg width="16px" height="16px" viewBox="0 0 16 16">
<path fill="#000000" d="M1,6H2V9H5V8H4V7H3V2H4V1H5V0H11V1H12V2H13V7H12V8H11V9H15V13H14V10H11V13H10V16H9V13H7V16H6V13H5V10H1"></path>
</svg>

かなりシンプルなSVGでこれをHTMLに埋め込んでいると想定しています。

<svg> がSVG全体でサイズなどの設定ができます。
今回のパスは <path> で追加することが出来ます。fill 属性で色を指定し、d 属性になにやら文字列があると思いますがこれがパスです。

まずはコードで準備しておきましょう。

const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const width = 16;
const height = 16;
this.svg.setAttributeNS(null, 'width', `${width}px`);
this.svg.setAttributeNS(null, 'height', `${height}px`);
this.svg.setAttributeNS(null, 'viewBox', `0 0 ${width} ${height}`);

const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttributeNS(null, 'fill', '#000000');
path.setAttributeNS(null, 'd', 'ここにパス');

svg.appendChild(path);

パスの仕様

パスは命令と座標がいくつかありそれをつなげています。

命令 座標 命令 座標……

命令は大文字もしくは小文字のアルファベット1文字で、座標は相対と絶対の2種類があります。

  • 大文字の命令
    • 絶対座標
    • すべての座標は原点(左上)からみた絶対値
      • 現在地が (1,1) の時、M2,3(2,3) に移動するという意味
  • 小文字の命令
    • 相対座標
    • 座標は現在座標からの相対的な差分を指定する
      • 現在地が (1,1) の時、m2,-1(3,0) に移動するという意味

今回は座標の管理の都合上絶対座標のほうが楽なので命令はすべて大文字とします。

命令に使う座標は命令によって数が決まっていて、複数使用する場合にはスペースが , を使ってつなぎます。今回は座標のセットであることがわかりやすくするために , を使います。
また命令と座標の間にスペースがあっても問題ないですがプログラムで出力するので最も短くなるように空白は入れません。

これらを元にすると、例えば原点に置かれた1ピクセルの場合以下のような出力を得るのが今回の目標となります。

M0,0H1V1H0

パスは以下のような命令をつなげています。
今回の命令は大文字しか使わないので、座標を必要とする場合にX座標をx、Y座標をyとしておきます。

Mx,y

Mは座標の移動で2つの値が必要です。
これ自体は座標の移動しかしないので描画に関わるわけではありません。

初めに角まで移動し、そこから線を引いていけばパスが作れそうです。
また、塊が複数ある場合は1つの塊が完成した後に座標移動することで次の塊の描画が可能になります。

Lx,y

Lは現在地から指定座標まで線を引く命令で2つの値が必要です。
斜めなどの線を引くことも可能ですが今回は使わない予定です。

Hx

Hは水平移動して線を引きます。水平移動だけなので必要なのはX座標だけです。

Vy

Vは垂直移動して線を引きます。垂直移動だけなので必要なのはY座標だけです。

Z

現在地から始点(直近のM)まで線を引きます。座標指定は特にないです。

もし普通にピクセルを有向線分にして、それを今回のパス表記にすると以下のようになります。

M 0,0   0,0に移動
H 1     x=1まで移動するので1,0に移動
V 1     y=1まで移動するので1,1に移動
H 0     x=0まで移動するので0,1に移動
V 0     y=0まで移動するので0,0に移動
Z       始点まで戻るので0,0に移動

ですがZは最初の座標まで線を引くということなので以下のように省略できます。

M 0,0   0,0に移動
H 1     x=1まで移動するので1,0に移動
V 1     y=1まで移動するので1,1に移動
H 0     x=0まで移動するので0,1に移動
Z       始点まで戻るので0,0に移動

また、M以外の命令は「線を引く」命令であって、「塗りつぶし」の場合は始点までわざわざ線を引く必要はないので、線を表示しないなら以下まで省略できます。

M 0,0   0,0に移動
H 1     x=1まで移動するので1,0に移動
V 1     y=1まで移動するので1,1に移動
H 0     x=0まで移動するので0,1に移動
        この後Mで別座標に移動すれば新しくパスが始まるのでこのパスは閉じる

もちろん線を引くことを前提にするなら最後のZやMの前にZを入れるべきです。

有向線分の塊をパスに変換

必要情報は揃ったので以下のように有向線分をパスに変換していきます。

  • 開始地点の有向線分の始点をMに変換
    • 描画対象として開始地点を登録
  • 描画対象の有向線分の終点を元に線を引く命令に変換
    • 開始地点の始点と描画対象の終点が同じ場合はここで描画終了
    • 始点と終点のX座標が同じなら垂直描画なのでVに終点のY座標を渡す
    • 始点と終点のY座標が同じなら水平描画なのでHに終点のX座標を渡す
  • 描画対象を次の有向線分に進める
class DirectedSegments {
	// 省略

	// 最適化した有向線分の一覧を返す
	public optimize() {
		// 重複する有向線分を削除(元データから削除)
		this.notDuplicate();
		// 反対の有向線分を対消滅させて並び替える(元データをコピーして加工)
		return this.sort(this.annihilate());
	}

	// 有向線分を垂直か水平の描画命令に書き換える
	protected directedSegmentToString(directedSegment: DirectedSegment) {
		return directedSegment.sx === directedSegment.ex ? `V${directedSegment.ey}` : `H${directedSegment.ex}`;
	}

	// 有向線分を文字列のパスに変更する
	public toString() {
		// 最適化メソッドを通して出力用の有向線分を取得する
		const directedSegments = this.optimize();

		// 開始地点のメモ
		let start: { x: number; y: number } | null = null;
		// 有向線分を順番に命令に変換して返す
		return directedSegments.map((directedSegment) => {
			if (!start) {
				// 開始地点がないので設定する
				start = {
					x: directedSegment.sx,
					y: directedSegment.sy,
				};
				// 開始地点への移動と最初の右方向へ線を引く
				return `M${directedSegment.sx},${directedSegment.sy}${this.directedSegmentToString(directedSegment)}`;
			}

			// 開始地点と有向線分の終了地点が同じ場合は軽量化のため何もしない
			if (start.x === directedSegment.ex && start.y === directedSegment.ey) {
				// この後別の塊がある場合に座標移動するためにリセットしておく
				start = null;
				return '';
			}

			// 次の線を引く
			return this.directedSegmentToString(directedSegment);
		}).join('') + '';
	}
}

そして最適化へ……

今までの作業をまとめて実行してみます。

const directedSegments = new DirectedSegments();
directedSegments.addPixel(0, 0);
directedSegments.addPixel(1, 0);
directedSegments.addPixel(2, 0);
directedSegments.addPixel(0, 1);
directedSegments.addPixel(0, 2);
directedSegments.addPixel(2, 2);
console.log(directedSegments.toString());

実行結果は以下です。

M0,0H1H2H3V1H2H1V2V3H0V2V1M2,2H3V3H2

これを画像にすると次のようになります。

終点が開始地点の始点と同じ有効線分は省略されるので最後中途半端にパスが消えています。

それ以外を見てみると水平と垂直の移動で無駄があります。連続する移動はできれば削除してしまいたいです。

正規表現を使っていい感じに消す方法も考えられますが、今回は有向線分の方の最適化をやっていきたいと思います。

ちなみに目標は以下です。

最適化方針

今回は同じ向きのデータが連続する場合に繋げるという作業を行うので、ベクトルを作って判定をしたいと思います。

  1. 対象の有向線分からベクトルを作成
    • 必ず長さ1かつXかY方向にしか要素がないベクトルができる
  2. 対象の有向線分をコピーして新規有向線分を作る
  3. 順番に有向線分を調べていく
    • 同じように有向線分からベクトルを作成し比較する
    • 同じ場合は新規有向線分の終点を書き換える
    • 異なる場合は仕切り直しの処理を行う
      • 新規有向線分を新しい配列に追加
      • 現在の有向線分を新しい対象として1に戻る
  4. すべての有向線分を読み終えたら新しい配列を返す

ひとまずこの方針で有向線分をマージしていきます。

class DirectedSegments {
	// 省略

	// 有向線分を長さ1のベクトルに変換する
	protected directedSegmentToVector(directedSegment: DirectedSegment) {
		// 座標の差分をだしておく
		const dx = directedSegment.ex - directedSegment.sx;
		const dy = directedSegment.ey - directedSegment.sy;
		// 今回の仕様上どちらかは必ず0になるのでもう片方が-1か1になるよう調整する
		return {
			x: dx ? Math.floor(dx / Math.abs(dx)) : 0,
			y: dy ? Math.floor(dy / Math.abs(dy)) : 0,
		};
	}

	// 同じ方向の繋がった有向線分を1つにまとめる
	protected merge(directedSegments: DirectedSegment[]) {
		// マージ対象の方向
		let start: { x: number; y: number } | null = null;
		// マージ対象の有向線分の番号
		let target = -1;
		return <DirectedSegment[]> directedSegments.map((directedSegment, index) => {
			if (!start) {
				// マージ対象がいない場合は今の有向線分が開始地点になる
				start = this.directedSegmentToVector(directedSegment);
				target = index;
			} else {
				// 現在の有向線分からベクトルを計算する
				const vector = this.directedSegmentToVector(directedSegment);
				// ベクトルを比較し同じ方向を向いているか調べる
				if (start.x === vector.x && start.y === vector.y) {
					// 同じ方向なのでマージする
					directedSegments[target].ex = directedSegment.ex;
					directedSegments[target].ey = directedSegment.ey;
					// 現在の有向線分を消す
					return null;
				} else {
					// 
					start = vector;
					target = index;
				}
			}

			return directedSegment;
		}).filter((directedSegment) => {
			return directedSegment !== null;
		});
	}

	// 最適化した有向線分の一覧を返す(一部処理が変わっていることに注意)
	public optimize() {
		// 重複する有向線分を削除(元データから削除)
		this.notDuplicate();
		// 反対の有向線分を対消滅させて並び替える(元データをコピーして加工)
		// また最適化処理で方向が同じ有効線分をまとめる
		return this.merge(this.sort(this.annihilate()));
	}

}

最適化をちゃんと入れると結果は以下になります。

M0,0H3V1H1V3H0M2,2H3V3H2

無事最適化に成功しました。

まとめ

ドット絵をパスにすると聞くと難しい処理が必要かと思いますが、実際には「有向線分に変換」と「反対の有向線分を削除」というポイントさえ押さえれば他は単純な力技でなんとかなるかと思います。
これでいい感じにドット絵を最適化されたパスに変換することが出来ましたが、まだまだこれには問題があります。
その問題と対処の一例をまとめておきます。

実際のコード

今回作ったコードは以下です。

https://github.com/azulamb/PixelCanvas/blob/v0.0.1/libs/pixelsvg.ts

より最適化

このように角でつながっている場合、今の最適化だと不十分なため1つにならずに分割される場合があります。
移動は座標が2つ必要なことからもわかるように使わないほうが容量を減らせるので、塊ごとに分割して角で結合可能ならばつながっている部分に挿入するなどしてさらなる最適化が可能です。

複数色の実装

今回の実装は1色に対しての処理なので、複数の色を扱う場合は複数の管理が必要になります。
最も簡単なのはキーを色にして値を DirectedSegments にしてすべての色を読み込みながらピクセルを追加していくことでしょう。

// 色の強さ(0~255)を渡すと#RRGGBBAA 形式で色を文字列化する
function ConvertColorCode(r: number, g: number, b: number, a: number) {
	return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${a.toString(16).padStart(2, '0')}`;
}

const fills: { [keys: string]: DirectedSegments } = {};

// ピクセルの登録
for (let i = 0; i < imageData.data.length; i += 4) {
	// 不透明度が0の場合はスキップ
	if (imageData.data[i + 3] === 0) {
		continue;
	}

	const p = i >> 2;
	const x = p % imageData.width;
	const y = Math.floor(p / imageData.width);
	// 赤、緑、青、不透明度の値を渡して #RRGGBBAA の形の色コードに変換しキーとして使う
	const key = ConvertColorCode(imageData.data[i], imageData.data[i + 1], imageData.data[i + 2], imageData.data[i + 3]);

	// もし色が初出の場合は DirectedSegments を新しく作る
	if (!fills[key]) {
		fills[key] = new DirectedSegments();
	}

	// ピクセルを登録する
	fills[key].addPixel(x, y);
}

for (const key in fills) {
	// DirectedSegments を得る
	const directedSegment = fills[key];
	// <path>を作る
	const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
	// keyは色コードなので塗りつぶしであるfill属性に渡す
	path.setAttributeNS(null, 'fill', key);
	// ピクセルをパスに変換してd属性に渡す
	path.setAttributeNS(null, 'd', directedSegment.toString());
	// <svg> に追加
	svg.appendChild(path);
}

複数色の隙間

最初の微妙な拡大率で隙間ができる問題は単色内では解決していますが複数色になるとまだ発生します。

このように背景色を予め決めて、背景になる部分はすべてのピクセルを登録し、背景色以外は普通に色を登録します。
その後全てのパスをSVGに追加すれば、かなりマシになるでしょう。

const fills: { [keys: string]: DirectedSegments } = {};

const background = '#RRGGBBAA形式の背景色';
if (background) {
	fills[background] = new DirectedSegments();
}

// ピクセルの登録
for (let i = 0; i < imageData.data.length; i += 4) {
	// 省略

	// ピクセルを登録する
	fills[key].addPixel(x, y);
	// keyが背景色以外なら背景にピクセルを追加
	// 不透明度がある場合は imageData.data[i + 3] === 255 のように不透明な場合のみ追加など工夫する必要があるかもしれない
	if (background !== key) {
		fills[background].addPixel(x, y);
	}
}

また、正確にするなら色の重なり合わせを制御して可能な限り隙間から別の色が出にくい実装は可能だと思いますが、そこまでの労力をかけるならこれくらいに留めるのが現実的かなと思います。

Discussion