コムセント 技術情報

  1. TOP
  2. コムセント 技術情報
  3. Stable Diffusion Web UI 産画像の png から PHP でプロンプトデータを読み取る(tEXt チャンクの読み取り)

Stable Diffusion Web UI 産画像の png から PHP でプロンプトデータを読み取る(tEXt チャンクの読み取り)

Stable Diffusion を動かすクライアントとして今なおディファクトスタンダードと呼べる Stable Diffusion Web UI ですが、生成した png 画像を「PNG Info」タブに投げ入れると使用したプロンプトや他の設定が閲覧できます。
特に Stable Diffusion Web UI のドキュメントやソースコードを確認したわけではないのですが、なんらかの形で png ファイルの表示とは関係ないメタデータ部分に情報が書き込まれているのだろうと思って調べたらその通りでした。
ならば PHP で読み込めぬわけはないだろうと開発をしたら、案外すんなり読み込めたので、png ファイルからメタデータを抜き出す方法を紹介したいと思います。
ちなみにライブラリなどは必要ありません。PHP: 8.2.0 で検証しました。

実際のコード

いわゆる Controller 部分などは省いて、実際に png 画像からパラメータを文字列 or 配列の形で返すクラスが下記です。

<?php

namespace PngInfo;

class PngInfo
{

/**
* 指定された png ファイル内に stable diffusion 用のパラメータがあればそれを返す
* 存在しなければ null を返す
* @param string $path
* @return string|null
*/
private function _getParametersString(string $path): ?string
{
//そもそもファイルが存在しない場合は null を返す
if ( ! is_file($path))
{
return null;
}

//ファイルをバイナリモードかつ、ポインタを先頭に置いて読み込み開始
$fp = fopen($path, 'rb');

$chu = fread($fp, 8);

/* PNGシグネチャが一致するかチェック */
if($chu !== "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a")
{
//ファイルを閉じる
fclose($fp);

//null を返す
return null;
}

//ポインタから 8 バイトぶんのデータを読み込む
//ループ内でも fread を行うので、毎回 8 ビットづつ読むわけではない
while($chunkHeader = fread($fp, 8))
{
//先頭8バイトから、チャンクデータの長さと種別を取得する
$chunk = unpack('Nsize/a4type', $chunkHeader);

//チャンクデータ本体
//Note: サイズ0の場合、fread関数がエラーを出すため空文字とする
$data = (0 < $chunk['size']) ? fread($fp, $chunk['size']) : '';

//CRC は特に使用しないが、次回読み込み分までポインタをずらす
fseek($fp, 4, SEEK_CUR);

//tEXt を見つけたら返す
if($chunk['type'] === "\x74\x45\x58\x74" && $data)
{
//一旦 hex 形式に変換
$hex = bin2hex($data);

//セパレータを探す
$separatorIndex = strpos($hex, '00');

//セパレータが無い
if ($separatorIndex === false)
{
continue;
}

//先頭パラメータが 'parameter' ではない
if (substr($hex, 0, $separatorIndex) !== "706172616d6574657273")
{
continue;
}

//ファイルを閉じる
fclose($fp);

//データを返す
return hex2bin(substr($hex, $separatorIndex + 2));
}
}

//ファイルを閉じる
fclose($fp);

//目当てのパラメータが無いので null を返す
return null;
}

/**
* $sting をカンマ、または改行で区切る
* 結果配列の値は trim() されたものとなる
* @param string $string
* @return array
*/
private function _separatePrompts(string $string): array
{
//結果配列
$prompts = [];

// 一時蓄積用文字列
$temp = '';

// カンマを区切りと認識しないフラグ
$ignoreFlg = false;

// 次のループを全て回しきるため一文字余計なカンマを追加
$string .= ',';

// 一文字づつ切り出してループ
foreach (mb_str_split($string) as $char)
{
// < だったら以降 > が確認されるまでカンマを無視
if ($char === '<')
{
$ignoreFlg = true;
}

// > だったら以降 < によるカンマ無視を解除
if ($char === '>')
{
$ignoreFlg = false;
}

// カンマ無視中、もしくはカンマでなければ $temp に文字を蓄積
if ($ignoreFlg || $char !== ',')
{
// 追加
$temp .= $char;

// 次の文字へ
continue;
}

// 変数の移し替え
$value = $temp;

// この時点で蓄積文字列をリセット
$temp = '';

// 結果が空白なら何もしない
if ($value === '')
{
continue;
}

// 結果に追加
$prompts[] = $value;
}

//返す
return $prompts;
}

/**
* 抜き出したパラメータ文字列を連想配列に変換して返す
* 抜き出しに失敗した場合は null を返す
* @param string $string
* @return array|null
*/
private function _parseParameters(string $string): ?array
{
//存在しない
if ( ! $string)
{
return null;
}

// $string は '{プロンプト}\nNegative prompt: {ネガティブプロンプト}\n{そのほかのパラメータ'}' という構造が予想される
// 手始めに \n で分割する
$strings = explode("\n", $string);

// プロンプトが存在する場合は前もって取り出しておく
$positives = isset($strings[2]) ? $this->_separatePrompts($strings[0]) : [];
$negatives = isset($strings[2]) ? $this->_separatePrompts($strings[1]) : [];

// そのほかのパラメータ部分を定義
$parameters = $strings[2] ?? $string;

// ([^:]+): ([^,]+)(, )? で分割し、キーと値を取得する
return preg_match_all('/([^:]+): ([^,]+)(, )?/', $parameters, $matches)
? compact('positives', 'negatives') + array_combine($matches[1], $matches[2])
: compact('positives', 'negatives');
}

/**
* 指定された png ファイル内に stable diffusion 用のパラメータがあればそれを返す
* パラメータが存在しなければ null を返す
* @param string $path
* @return string|null
*/
public function getParametersString(string $path)
{
return $this->_getParametersString($path);
}

/**
* 指定された png ファイル内に stable diffusion 用のパラメータがあれば連想配列にして返す
* パラメータが存在しなければ null を返す
* @param string $path
* @return array|null
*/
public function getParametersArray(string $path): ?array
{
//文字列を得る
$string = $this->_getParametersString($path);

//文字列の取得に失敗
if ( ! $string)
{
return null;
}

//パース
return $this->_parseParameters($string);
}

}

png ファイルのメタデータ構造

まずは png ファイルのメタデータがどのような構造になっているかを理解する必要があります。
詳しくは以下の記事が大変参考になりました。

https://zenn.dev/knowledgework/articles/read-png-file

ここで最低限覚えておきたいのは、png データは先頭 8 byte を除き、それ以降はすべて「チャンク」と呼ばれる塊が連続してデータを形作っているという点です。

このチャンクはどんなチャンクであっても以下のような構造になっています。

  • Length: (4 bytes) Chunk Data のサイズを示す。
  • Chunk Type: (4 bytes)チャンクの種類を示す。
  • Chunk Data: (データサイズ可変) 実際のデータ。 Length が 0 の場合、この領域は存在しない。
  • CRC: (4byte) データの破損や改ざん検知用データ。

Chunk Data だけデータサイズが可変ですが、 Length によってチャンク全体のサイズが分かるので、次に続くチャンクとの境目が何 Bytes 目なのか判断することができます。

Chunk Type には様々なものがあり、予め決められている標準的なものと、png 作者が自由に設定したものがありますが、今回目的とする Chunk Type は tEXt という Chunk Type です。

ちなみに、tEXt の先頭と末尾が小文字なのには明確な理由があります。
Chunk Type は必ずアルファベット 4 文字で構成されていますが、各桁の大文字小文字にはそれぞれ Chunk Type の性質を表しています。
先頭が大文字の場合は png データに必須の Critical Chunk と呼ばれるもので、ブラウザを含む png を描画するアプリケーションが png を表示する際に参照すべき Chunk となります。一方、 tEXt のような先頭が小文字の Chunk は直接描画には必要のない、補助的な Chunk を示します。
一方、末尾が大文字の場合は png データを複製・編集などする際、単純にコピーすると png データ表示に不都合が生じる可能性があることを示します。例としてcHRM は色の原色と白色点の特性を定義するためのチャンクですが、画像を編集した際 cHRM を元のまま用いると色ズレが発生したりします。一方 tEXt チャンクなど末尾が小文字の場合、単純にチャンクをコピーしても画像そのものに悪影響がないため、単純なコピーが安全とされます。

実際に PHP で tEXt チャンクを読んでみる

png データからデータを抜き出しているのは以下のメソッドです。 $path 引数には /var/www/html/hoge/fuga/piyo.png のようなパスを受け取ります。

  private function _getParametersString(string $path): ?string
{
//そもそもファイルが存在しない場合は null を返す
if ( ! is_file($path))
{
return null;
}

//ファイルをバイナリモードかつ、ポインタを先頭に置いて読み込み開始
$fp = fopen($path, 'rb');

$chu = fread($fp, 8);

/* PNGシグネチャが一致するかチェック */
if($chu !== "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a")
{
//ファイルを閉じる
fclose($fp);

//null を返す
return null;
}

//ポインタから 8 バイトぶんのデータを読み込む
//ループ内でも fread を行うので、毎回 8 ビットづつ読むわけではない
while($chunkHeader = fread($fp, 8))
{
//先頭8バイトから、チャンクデータの長さと種別を取得する
$chunk = unpack('Nsize/a4type', $chunkHeader);

//チャンクデータ本体
//Note: サイズ0の場合、fread関数がエラーを出すため空文字とする
$data = (0 < $chunk['size']) ? fread($fp, $chunk['size']) : '';

//CRC は特に使用しないが、次回読み込み分までポインタをずらす
fseek($fp, 4, SEEK_CUR);

//tEXt を見つけたら返す
if($chunk['type'] === "\x74\x45\x58\x74" && $data)
{
//一旦 hex 形式に変換
$hex = bin2hex($data);

//セパレータを探す
$separatorIndex = strpos($hex, '00');

//セパレータが無い
if ($separatorIndex === false)
{
continue;
}

//先頭パラメータが 'parameter' ではない
if (substr($hex, 0, $separatorIndex) !== "706172616d6574657273")
{
continue;
}

//ファイルを閉じる
fclose($fp);

//データを返す
return hex2bin(substr($hex, $separatorIndex + 2));
}
}

//ファイルを閉じる
fclose($fp);

//目当てのパラメータが無いので null を返す
return null;
}
先頭 8 bytes から、指定されたファイルが本当に png データか判定する

まず、扱おうとしているファイルが本当に png ファイルなのかを調べているのが以下です。

    //ファイルをバイナリモードかつ、ポインタを先頭に置いて読み込み開始
$fp = fopen($path, 'rb');

$chu = fread($fp, 8);

/* PNGシグネチャが一致するかチェック */
if($chu !== "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a")
{
//ファイルを閉じる
fclose($fp);

//null を返す
return null;
}

png ファイルの先頭には固定で "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" というデータが入っています。 HTML の doctype 宣言のようなものですね。これが一致していなければおそらく png データでないので弾いてしまいます。先述したチャンクデータではない先頭の 8 bytes がこれに当たります。
ちなみにこの表示形式は16進数エスケープシーケンスと呼ばれる、特にバイナリデータを対象に扱う際の表記方法です。

チャンクの Lnegth と Chunk Type を切り取る

この 8 bytes ぶんを読み飛ばして最初のチャンク先頭まで読み込み位置を移動します。
そこから先は以下のループです。

  1. 1. チャンクの先頭位置から 8 bytes ぶんを読み込む。読み込み位置もこのとき 8 bytes ぶん移動する。
  2. 2. 先頭から 8 bytes は Length (4 bytes)と Chunk Type (4bytes) なので、後半の 4 bytes 部分が tEXt か確認する。
  3. 3. 後半 4 bytes が tEXt だった場合、現在の読み込み位置から Length が示す bytes ぶんが Chunk Data なので、この部分のみ抜き出して処理する。
  4. 4. 後半 4 bytes が tEXt ではなかった場合、そのチャンクは目的のチャンクではないため次のチャンク先頭に読み込み位置を移動させる。 Length が示す Chunk Data ぶんの bytes 数と、 CRC の 4 bytes を読み飛ばす。

以下が Length と Chunk Type を読み込んでいる部分です。

//ポインタから 8 バイトぶんのデータを読み込む
//ループ内でも fread を行うので、毎回 8 ビットづつ読むわけではない
while($chunkHeader = fread($fp, 8))

fread 関数は人間が本を読むときと同じく、指定サイズまでデータを読み込むと読み進めた位置を保持してくれており、次回 freed 関数で読み込みを再開した際はその位置から読み始めます。
したがってこの while ブロックの先頭に至った時点で、読み込み位置は Length と Chunk Type を読み飛ばして Chunk Data の先頭部分に来ていることになります。ただし、チャンクによっては Chunk Data が存在しない可能性があるため、その場合の読み込み位置は CRC の先頭部分です。

チャンクの Lnegth と Chunk Type を展開する

続いて、while 文の中で取得した 8 bytes ぶんの情報を解析します。

//先頭8バイトから、チャンクデータの長さと種別を取得する
$chunk = unpack('Nsize/a4type', $chunkHeader);

ここで unpack という関数を用いるのですが、この unpuk はバイナリデータを PHP で扱いやすい連想配列に展開してくれる関数です。対となる pack という関数はバイナリデータを作成するものですが、 pack と unpack が第一引数にとる「フォーマット」部分は独特の指定をします。 date 関数で Ymd が特別な書式文字として解され、それぞれ年月日として展開されるのと似ているでしょうか。
さらに独特なのが、それら書式文字に続く文字列は展開後の連想配列における添え字として用いられる点と、 / で展開後のデータを区切る点です。

書式文字の一覧を以下に示します。

https://www.php.net/manual/ja/function.pack.php

今回の例では 'Nsize/a4type' というフォーマットが指定されていますが、まずスラッシュによってデータを Nsizea4type という二つに区切って解釈したいという意味になります。

Nsize では、まず最初の N が書式文字として解釈されます。N の意味は

unsigned long (常に 32 ビット、ビッグエンディアンバイトオーダー)

というもので、つまり 32 ビットの int 型データ(数値)のことです。
N は固定で 32 ビットとされているので、以降の文字列は書式文字として解釈されず、続く size が展開後の連想配列における添字として用いられます。
32 ビットとはすなわち 8 bytes なので、先述したチャンク構造における 4 bytes の Length 部分にあたります。

先頭から 8 bytes ぶん読み込んだ後、残りの部分は a4type によって展開されます。
先頭の a が書式文字として解釈されますが、 a の定義は以下です。

NUL で埋めた文字列

これだけでは何のことか分かりづらいですが、ようは数値(int)や論理値(bool)ではなく文字列を読み取る場合に使用される書式文字です。より具体的に言うと読み取り対象のバイナリデータ中に NUL 文字(0x00)が含まれていても途切れずに文字列を読み込んでくれます。
対比として、例えば a ではなく z を用いると、読み込み範囲となるバイナリデータの途中に NUL 文字があると、それ以降を無視して取得します。

a に続く 4 はどの直前の書式文字による読み取りを何回繰り返すかの指定となります。 N の場合は固定で 32 ビットなのでこの指定が不必要だったのですが、アルファベット 4 文字ぶんを読み取りたいので 4 を指定する必要があります。ここまでで読み取るデータ型と範囲が明確になったので、続く type 部分は添字として使用されます。この部分がチャンク構造における 4 bytes の Chunk Type 部分になります。

結果的に、

['size' => 99999, 'type' => "\x74\x45\x58\x74"]

のような連想配列が得られます。

Chunk Data を読み取る

一旦 Length と Chunk Type を保持し終えたら、今度は Chunk Data を読み込みます。読み込むべきデータサイズである Length は unpack 関数によって $chunk 変数へ格納されています。

Length が 0 の場合は Chunk Data そのものが存在しないため、読み込みを行わず空文字として保持しておきます。

//チャンクデータ本体
//Note: サイズ0の場合、fread関数がエラーを出すため空文字とする
$data = (0 < $chunk['size']) ? fread($fp, $chunk['size']) : '';

上記コードでは三項演算子により Length が 0 だった場合に対応した条件分岐が行われていますが、 freed 関数が読み込み位置を移動する性質を利用し、 Chunk Data があろうがなかろうが、読み込み位置が CRC の手前になるようになっています。

CRC を読み飛ばし、読み込み位置を次回のチャンク先頭に移動する

CRC はデータが破損・改ざんされていないか調べるための領域ですが、今回は特に使用しません。領域の長さが固定で 4 bytes なので、次のチャンク先頭まで読み飛ばすために fseek 関数を用います。

//CRC は特に使用しないが、次回読み込み分までポインタをずらす
fseek($fp, 4, SEEK_CUR);

これにより、次の while ループで次のチャンク先頭から処理を解析できるようになりました。

Chunk Type が tEXt チャンクか調べる

この時点で次の while ループに処理が移動しても次のチャンクが調査できるようになったので、現在調査しているチャンクが tEXt チャンクかどうかを判定します。 unpack 関数によって得られた Chunk Type を比較します。

if($chunk['type'] === "\x74\x45\x58\x74" && $data)

"\x74\x45\x58\x74" は png データの先頭でも登場した16進数エスケープシーケンスで 'tEXt' と同義です。
ついでに、 $data の中身が空でないかも調べています。

Chunk Data の中身から、目的の tEXt チャンクか判定する

チャンクが tEXt であると判明したら今度はそのチャンクデータが Stable Diffusion Web UI が仕込んだものか判定します。tXEt チャンクは一つの png ファイルに複数ある可能性があるため、 tEXt チャンクを見つけてもそれが今回の目的であるデータとは限りません。

tXEt チャンクは一般的にキーバリュー型の構造でデータを保存します。今回目的とするキーは 'parameter' というキーです。
このキーとバリューは HEX 形式で '00' というデータで区切られているため、一旦 HEX 形式に変換をかけた上で判定を行います。

//一旦 hex 形式に変換
$hex = bin2hex($data);

 //セパレータを探す
$separatorIndex = strpos($hex, '00');

//セパレータが無い
if ($separatorIndex === false)
{
  continue;
}

strpos 関数により、'00' が現れる位置を検索し、$separatorIndex に保持しておきます。
$separatorIndex より手前がキー、それ以降がデータ部分になるのですが、そもそも '00' が見つからない場合は明らかに対象外のデータなので、 continue で次のチャンク検索に離脱します。

//先頭パラメータが 'parameter' ではない
if (substr($hex, 0, $separatorIndex) !== "706172616d6574657273")
{
continue;
}

//ファイルを閉じる
fclose($fp);

//データを返す
return hex2bin(substr($hex, $separatorIndex + 2));

"706172616d6574657273" は HEX 形式における 'parameter' の値です。キー部分がこれに一致しない場合も対象外なので、 continue で次のチャンク検索に離脱します。

ここまで条件に合致したら '00' 以降が目的のデータです。これ以上ファイルを読み込む必要がないので fclose 関数でファイルを開放し、目的のデータを元のバイナリデータに復元しつつ return で返却します。

まとめ

ここまで解説してきた png のチャンクデータは Stable Diffusion Web UI に固有のものではなく、様々な用途で使用されています。JPEG における Exif のような使われ方もしますが、作者やアプリケーションの都合によってより柔軟に定義できるのが強みと言えるでしょう。

Web システムによっては有用かもしれないので、参考にしていただけると幸いです。

プログラマー/N.Go

このメンバーの記事一覧へ

おすすめ記事