技術約14分で読めます

Qwen-Image-Edit 2511のピクセル位置ずれをローカルComfyUIで検証、標準ノードだけで0.1pxまで消えた

いけさん目次

Xで、Qwen-Image-Edit-2511のComfyUIノードを改造して「ピクセル位置パーフェクト編集」ができるようになったという投稿が流れてきた。
メインノードの改造に補助ノードを1つ足すとこの問題は解決する、これはDiT(Diffusion Transformer)の仕様に関わる問題で、AIに実装方法を調査させてもヒントは出るが補助ノードが果たしている機能までは出てこない、という内容。
イラストから線画推定させた動画がデモについていたようで、Photoshopで重ねてもズレがまったく出ないという。

QIEは編集のたびに体や輪郭を結構作り替えてくるモデルという印象があって、位置合わせがいる用途では信用していなかった。
手元にM1 MaxのローカルComfyUIと2511系の統合モデルがあるので、この主張が再現するのか、ずれを数値にして確かめることにした。

検証環境

区分内容
マシンM1 Max 64GB
UIローカルComfyUI 0.24.1
モデルQwen-Rapid-AIO v23(Qwen-Image-Edit 2511ベースの統合モデル、lightning蒸留込み)
生成の共通条件8step / sa_solver / beta / cfg 1.0 / seed 42固定
元画像自作キャラ2人の全身立ち絵 832×1216
ずれの測定エッジマップ(輝度勾配)の位相相関。画像全体+3×3ブロック

測定は元画像と出力それぞれのエッジマップを取り、位相相関でずれ量をサブピクセル精度で推定する方式にした。
画像全体の平均ずれに加えて、3×3に分割した各ブロックでも測る。
平行移動なら全ブロックのずれベクトルが同じ向きに揃い、拡縮の不一致が混ざるとブロックごとに向きと大きさが変わるので、ずれの種類まで区別できる。

ずれはどこで生まれるか

ComfyUIでQIEに参照画像を読み込ませる標準ノードは TextEncodeQwenImageEditPlus。この中に、入力画像を勝手にリスケールする処理が入っている。

# comfy_extras/nodes_qwen.py(抜粋)
if vae is not None:
    total = int(1024 * 1024)
    scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
    width = round(samples.shape[3] * scale_by / 8.0) * 8
    height = round(samples.shape[2] * scale_by / 8.0) * 8
    s = comfy.utils.common_upscale(samples, width, height, "area", "disabled")
    ref_latents.append(vae.encode(s.movedim(1, -1)[:, :, :, :3]))

参照画像は面積が約1MP(1024×1024相当)になるよう強制リスケールされ、8の倍数に丸めてからVAEでlatent化される。
今回の元画像832×1216を入れると、参照は848×1240に伸ばされる。
一方、出力側のlatentはEmptyLatentImageで指定した832×1216のまま。

QIEのDiTは、この参照latentと出力latentをトークン列として並べ、位置エンコーディングで「参照のこの位置は出力のこの位置」と対応づけながらデノイズする。
参照側だけ約1.9%大きいグリッドになっていると、この対応が画像の端に行くほどずれていく。
投稿にあった「DiTの仕様に関わる問題」の実体は、このlatentグリッドの不一致だと思う。

flowchart TD
    A[入力画像 832×1216] --> B[TextEncodeQwenImageEditPlus]
    B --> C[VL経路<br/>約384×384に縮小して<br/>視覚言語モデルが内容を認識]
    B --> D[参照latent経路<br/>約1MPへ強制リスケール<br/>832×1216 → 848×1240]
    E[EmptyLatentImage<br/>832×1216] --> F[KSampler]
    C --> F
    D --> F
    F --> G[出力 832×1216<br/>参照側とグリッドが約1.9%不一致]

VL(視覚言語モデル)経路の縮小は「何が写っているか」を認識するためのものなので位置精度に影響しない。
参照latent経路だけが実寸法からずれている。

実験1 線画推定でずれを測る

元ポストと同じ線画推定を編集タスクにした。
線が1pxずれただけでも重ね合わせで即分かるので、位置の検証としては一番厳しいテストでもある。

元画像はAnimaで生成した自作キャラ2人(けい・かな)の全身立ち絵。

元画像。金髪ロングと茶髪サイドポニーの2人が制服で正面に立つ全身イラスト、832×1216

プロンプトは位置を動かすなと明示した線画化指示。

Convert Picture 1 into clean black line art on a pure white background.
Trace every contour and detail exactly where it is in the original image.
Do not move, resize, crop, or redraw anything. No color, no shading, no gray tones.

条件は3つ。

条件構成入力寸法
A標準ノードにVAEを直結(既定の使い方)832×1216
B標準ノードのまま、入力を1024×1024ちょうどに切り出し1024×1024
CノードのVAE入力を外し、VAEEncode + ReferenceLatentでネイティブ寸法のlatentを注入832×1216

Bは「内部リスケールが恒等変換になる寸法ならずれが消えるか」の切り分け用。
面積が1024×1024ちょうどなら scale_by が1になり、リスケールが実質何もしなくなる。
Cが元ポストの「補助ノード」に相当する構成で、ComfyUI標準搭載のReferenceLatentノードだけで組める。

結果

条件全体のずれ3×3ブロックの傾向
Adx +8.0px / dy −3.3px左端+3px→右端+14pxの勾配。拡縮の不一致
Bdx +0.1px / dy +0.2px全ブロックほぼゼロ
Cdx +1.1px / dy +0.5px全ブロック約1pxで均一

Aのずれは平行移動ではない。
水平方向のずれが左端の+3pxから右端の+14pxへ一直線に増えていて、参照が1.9%拡大されたまま対応づけられた形がそのまま出ている。
元画像のエッジを赤、出力のエッジをシアンで重ねると、輪郭が全部二重になる。

条件A(標準ノード直結)
条件Aのエッジ重ね合わせ。輪郭が赤とシアンに分離して二重になっている
条件C(ReferenceLatent)
条件Cのエッジ重ね合わせ。輪郭がほぼ白一色に重なっている

Bは0.1〜0.2pxまで消えた。
エッジ検出込みの測定誤差レベルなので、事実上のピクセルパーフェクト。
モデル自体は最初から位置を完全に保持する能力を持っていて、ずれていたのは入力段の寸法だけだった。

Cは全面均一に約1px残った。
ブロック別のずれに勾配がないのでグリッド対応は成立していて、残りはVAE往復や線幅の偏りが乗った分と思われる。
クロップせずネイティブ寸法のまま使えるので、実用はこちらの構成になる。

出力された線画はこうなった。

条件Cの線画出力。2人の全身立ち絵が均一な黒線で線画化されている

学習もLoRAもなしでこの線画が出て、位置まで合っている。
元ポストの「このモデル元々とんでもなく性能が高かった」という感想は、測ってみると裏が取れた。

ここまでで言えるのは、メインノードの改造は実は必須ではないということ。
VAE入力を外した標準ノード+VAEEncode+ReferenceLatentの3つで同じ効果が得られる。
ただし入力寸法が16の倍数(latentが8pxグリッド、さらにDiTのパッチ化で2×2にまとまる)から外れる場合は、寸法をスナップする前処理がいる。そこまで含めて自動化するならノードを自作することになる。

実験2 表情だけ変えて、体が動かないか

線画推定は絵を全部描き直すタスクなので、位置が合うだけで成立する。
実用で欲しいのは、「表情だけ変えて、それ以外は一切変わらない」のような部分編集ができるかどうか。
QIEは頼んでいない場所まで作り替えてくることがよくあるモデルなので、こちらが本命の検証になる。

編集指示は表情変更。2人の表情を閉じ目+開口の笑顔に変えさせる。

もうひとつ、プロンプト側の寄与も切り分けたい。
非編集領域が保存されたとして、それがlatentグリッド整合の効果なのか、プロンプトに書いた「他は変えるな」が効いているだけなのかは区別がつかない。
そこでkeep節あり・なしの2種類を用意した。

(keep節あり)
Change both girls' facial expressions to a big cheerful open-mouth smile
with happy closed eyes. Keep the pose, body shape, clothing, hair, colors,
lighting and background exactly the same. Do not move, resize, or redraw
anything except the faces.

(keep節なし)
Change both girls' facial expressions to a big cheerful open-mouth smile
with happy closed eyes.

条件は4つ。VAE往復だけの条件は、QIEを通す以上絶対に発生する画質変化の下限を測るためのもの。

条件構成プロンプト
VVAEEncode→VAEDecodeの往復のみ(生成なし)なし
A標準ノードにVAEを直結keep節あり
C-keepReferenceLatentでネイティブ寸法注入keep節あり
C-minReferenceLatentでネイティブ寸法注入keep節なし

測定は元画像とのRGB平均絶対差。しきい値(20/255)を超えた画素の割合を、画像全体・顔領域内・顔領域外に分けて数える。
顔領域は2人の顔を囲う目視のバウンディングボックス2つで定義した。

結果

条件平均差分変化画素率(全体)顔領域内顔領域外
V(VAE往復のみ)0.670.02%0.13%0.01%
A(標準ノード)21.321.6%58.8%19.0%
C-keep2.671.8%16.7%0.8%
C-min2.661.7%16.9%0.7%

Aは顔以外の19%の画素が変化した。
表情変更を頼んだつもりが、位置ずれのせいで全身の輪郭まで動いている。
QIEに対して持っていた「体を勝手に作り替えてくる」という印象の少なくとも一部は、モデルの癖ではなく入力段の寸法不一致だったことになる。

Cは顔領域内だけ16〜17%変化し、顔以外は0.7〜0.8%まで落ちた。
差分ヒートマップにすると、変化が目と口に集中しているのがそのまま出る。
顔以外に残るうっすらした縁は、実験1で測った約1pxのずれが輪郭部の画素差として乗った分。

条件A(標準ノード)
条件Aの差分ヒートマップ。全身の輪郭に沿って画像全体が赤く光っている
条件C(ReferenceLatent)
条件Cの差分ヒートマップ。赤い領域が2人の目と口にだけ集中している

編集後の出力では、表情以外はポーズも服のしわも髪の流れも元画像のまま。

条件Cの表情編集出力。2人とも閉じ目で口を開けた笑顔になり、それ以外は元画像と同じ

そしてkeep節あり・なしの差はほぼゼロだった(0.8% vs 0.7%)。
非編集領域の保存はプロンプトで頼み込んだ結果ではなく、latentグリッドが合ってさえいれば勝手にそうなる。
グリッドがずれている条件Aでは、keep節を書いても全身が動いた。
「他は変えるな」と指示しても、位置ずれは残った。

実験3 差分の量産が回るか

ここまでで位置を固定する仕組みは確定したので、実用を想定した連続テストに移る。
構成はすべてReferenceLatent方式、プロンプトは実験2で十分と分かった編集指示1行のみ。
表情5種・衣装変更2種・ポーズ変更を同じベース画像から連続生成した。

表情5種

表情変化画素率(全体)顔領域内顔領域外
怒り1.5%13.9%0.67%
泣き1.7%17.0%0.70%
驚き1.6%14.9%0.73%
照れ2.6%29.2%0.73%
ウインク1.6%15.7%0.68%

顔領域外の変化率が5種すべて0.67〜0.73%の帯に収まった。
実験2の笑顔(0.70%)と合わせて6種、プロンプトを差し替えて投げるだけで、体・服・背景が画素レベルで固定された表情差分が何枚でも出てくる。

怒り顔の差分出力。2人とも眉を吊り上げた不機嫌顔になり、体と服は元画像のまま

ただし出力を並べて眺めると、位置とは別の問題が残っている。
描き直された顔がQIEの絵柄になっていて、元絵の顔立ちに似ていない。
怒り顔は眉と目つきがきつい劇画寄りの造形になり、泣き顔も涙の描き方が重い。
元絵の顔をベースに表情筋だけ動かすのではなく、「QIEが思う怒り顔」をその位置に描き直している。
位置の固定と画風の維持は別問題で、差分測定の「顔内14%変化」の中身が本人の顔かどうかは測れていない。

プロンプトでかわいい側に寄せられないかも、怒り顔で書き方を3通り追試した。

書き方指示出力の変化
絵柄維持を明示same cute anime art style, keep the original eye shape眉のきつさが少し和らぐ程度
強度を下げるslightly angry, cute pouting, puffed cheeks劇画顔からむくれ顔に方向転換。ただし頬の膨らみが即物的で風船状に
タグ寄せannoyed, pout, puffy cheeks, large expressive eyes同傾向。むくれ方向で安定

表情強度を下げた指示の出力。2人ともむくれ顔になったが、左の子の頬の膨らみが誇張されている

表情の方向と強度はプロンプトで変えられる。
劇画寄りの怒り顔がむくれ顔になる程度の違いは出たが、元絵の顔立ちそのものは3通りとも戻らなかった。
はっきり言って、位置がいくら完璧でも顔がこれでは、表情差分の素材としては使い物にならない。
生成自体は回るのに、出てきたものが使えない。
表情を言語ではなく画像で指定する手が残っているので、これは実験5で試す。

衣装変更とキャラ単位の編集

編集全体顔領域内左半分右半分
2人とも白ワンピースに16.3%4.4%16.3%16.3%
左の子だけ黒セーラー服に14.6%3.4%28.8%0.42%

衣装変更では顔がほぼ温存される(4.4%の大半は襟周り)。
注目は左だけ変更した場合の右半分で、指示していない右の子は0.42%と、輪郭1px縁とノイズ床しか動いていない。
2キャラ立ち絵の片方だけ着せ替える、という編集がキャラ単位で成立する。

左キャラだけ黒セーラー服に変更した出力。靴下も黒ニーソックスに変わり、右キャラは元画像のまま

セーラー服の指定に合わせて靴下まで黒のニーソックスに変わっており、「衣装」の解釈は一式単位。

ポーズ変更で等身が保つか

ポーズを変えれば画素一致は当然崩れる。
知りたいのはその時に体格まで書き換わるかどうかで、QIEで以前から困っていたのはまさにここだった。
「片手を上げて手を振る」という同一プロンプトを、標準ノード構成とReferenceLatent構成の両方に投げ、白背景を利用して左右キャラそれぞれのシルエットの頭頂位置・背丈を画素計測した。

左キャラ背丈右キャラ背丈頭頂位置(左/右)
元画像1167px1179pxy=24 / y=16
ReferenceLatent1167px(一致)1179px(一致)y=24 / y=16(不動)
標準ノード1190px(+2.0%)1203px(+2.0%)y=9 / y=0(上端に接触)

ReferenceLatent側は、腕が動いて画素の14%が変化しているのに、背丈と立ち位置が1pxも動かなかった。
標準ノード側は体格が2.0%拡大されて、右の子は頭が画面上端に当たっている。
参照の1.9%拡大リスケールが、そのまま出力の体格に漏れた形。

ReferenceLatent(背丈1pxも不変)
ReferenceLatent構成のポーズ変更出力。2人とも手を振るポーズに変わり、背丈と立ち位置は元画像と一致
標準ノード(体格が2%拡大)
標準ノード構成のポーズ変更出力。同じポーズだが2人とも一回り大きくなり頭が画面上端に迫る

「QIEでポーズを変えると等身が変わる」と感じていた現象の少なくとも一部も、モデルの癖ではなく入力段の寸法不一致だったことになる。
なお、このポーズ変更では頼んでいない表情まで笑顔に変わった(顔内28.5%変化)。
「手を振る」という文脈にモデルが表情を合わせにいったもので、ここは位置の固定では制御できない。

実験4 境界条件と往復編集

ツール化する時に問題になりそうな条件も3つ確認した。

テスト結果
1MP超え(1248×1824 ≒ 2.3MP)の線画化位置ずれ0.2px。1MPを超えても位置は保たれる
非16倍数の寸法(831×1215)エラーにならず、出力が824×1208に切り下げられる
線画→再着色の往復線画に対して0.01px。配色は再発明される

1MP超えは標準ノードでは試すこと自体ができない(参照が1MPに縮められるため)。
ReferenceLatent構成なら2.3MPの原画を縮めずそのまま編集でき、位置も0.2pxで保った。
ただしM1 Maxでの生成時間は832×1216の約5分に対して約16分まで伸びる。

非16倍数の壊れ方は「ずれる」ではなかった。
831×1215を入れると、latent化の床丸めで出力が824×1208になり、右端と下端の約7pxがサイレントに消える。
エラーが出ないぶん気づきにくく、ツール化するなら入力段で16の倍数へスナップする前処理が必須になる。

往復編集は、実験1で出た線画をもう一度QIEに入れて着色させた。
線画に対する位置ずれは0.01pxで、カラー→線画→再着色と編集を2回通しても位置は完全に保たれる。
一方、線画には色の情報がないので配色はQIEの再発明になり、茶髪だった子が紫髪になった。
実験3の表情と同じく、位置は保たれたが中身は変わった。

線画から再着色した出力。位置は元画像と一致しているが、右の子の髪が紫に、服の配色も別物になっている

実験5 表情は言語でなく参照画像で指定する

実験3の結論は「言語で表情を指定すると、QIEが自分の絵柄で顔を描き直す」だった。
なら表情そのものを画像で見せればいい。
ちょうどキャラLoRAの学習用に、本人の表情ポートレート(怒り・むくれ・泣きかけ)を作ってあったので、これをPicture 2/3として追加で入力した。

構成は今までのReferenceLatent方式に画像入力を2つ足しただけ。
ベース画像がimage1でVL経路とReferenceLatentの両方に入り、表情参照はimage2/3としてVL経路にだけ入る。
参照側のlatentは注入しないので、位置はベース画像だけを基準にしたまま。

In Picture 1, change only the two girls' facial expressions.
The brown-haired girl's new expression should match the expression shown in Picture 2.
The blonde girl's new expression should match the expression shown in Picture 3.
Copy only the facial expressions from Picture 2 and Picture 3, nothing else.
Keep the clothing, pose and everything else in Picture 1 unchanged.
テスト顔領域外の変化転写の成否
泣きかけ0.80%2人とも成功。本人の顔のまま参照の表情になった
怒り0.80%茶髪の子は成功。金髪の子は閉口むくれの参照に対し開口の怒鳴り顔
泣きかけ(2人とも参照通り)
表情参照で泣きかけに変更した出力。2人とも眉を下げた泣きそうな顔で、涙目と頬染めまで参照に忠実
怒り(左の子だけ参照から外れた)
表情参照で怒りに変更した出力。右の子は控えめな怒り顔で参照通りだが、左の子は口を開けて怒鳴る顔になっている

4面中3面が、本人の顔立ちのまま参照の表情に変わった。
実験3で言語プロンプトを3通り試しても直らなかった顔の画風問題が、参照画像を1枚足しただけでほぼ解消した。
泣きかけのかなは涙目と頬染めの入り方まで参照に忠実で、この品質なら差分素材として通る。

外れた1面は混線で、けいの参照は口を閉じたむくれ顔なのに、Picture 2側の怒りに引っ張られて怒鳴り顔になった。
2キャラ同時に別々の表情を指定すると参照の対応づけが揺れることがある、という制約は残る。
確実にやるなら1キャラずつ処理して、変化領域が局在する性質(実験3の右半分0.42%)を使って合成すればいい。

LoRAの学習データセットを持っているなら、そこの表情ポートレートがそのまま差分量産の入力になる。
立ち絵1枚と表情参照N枚から、位置固定の表情差分がN枚出てくる構図になった。

差分ゼロがいるなら

残る変化は、VAE往復の分(0.02%)と輪郭の約1px縁。
色や微細ディテールも厳密にはビット一致しないので、「元画像と完全に差分ゼロ」が要件の用途では、差分マスクで元画像と合成する後処理を1段挟むことになる。
今回の条件Cなら差分が編集箇所に局在しているので、このマスクはしきい値処理だけでほぼ自動で作れる。