darask0 commited on
Commit
dad59f3
·
verified ·
1 Parent(s): 0e9ee21

Replace 2511 sheet with verified Qwen Image 2.1 turbo, mannequin guides and PNG-only export

Browse files
.gitattributes CHANGED
@@ -33,3 +33,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/cowboy_back.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/cowboy_front.png filter=lfs diff=lfs merge=lfs -text
38
+ assets/full_back.png filter=lfs diff=lfs merge=lfs -text
39
+ assets/full_front.png filter=lfs diff=lfs merge=lfs -text
40
+ assets/knees_back.png filter=lfs diff=lfs merge=lfs -text
41
+ assets/knees_front.png filter=lfs diff=lfs merge=lfs -text
42
+ assets/pose_preview.png filter=lfs diff=lfs merge=lfs -text
43
+ assets/prone_back.png filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,16 +1,4 @@
1
  __pycache__/
2
- *.pyc
3
  .venv/
4
- out/
5
- ref_test.png
6
- verify.py
7
- exp/
8
- exp2/
9
- exp3/
10
- out2/
11
- out3/
12
- experiment*.py
13
- test_cpu_quant.py
14
- *.log
15
- live/
16
- live_dataset.zip
 
1
  __pycache__/
2
+ *.py[cod]
3
  .venv/
4
+ .env
 
 
 
 
 
 
 
 
 
 
 
 
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
NOTICE ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Character View Sheet - Turbo Trial
2
+
3
+ The eleven-view selection and feature requirements are based on:
4
+ https://huggingface.co/spaces/darask0/Qwen-Image-Edit-2511-Lora-Sheet
5
+ Upstream Space by darask0 and contributors (Apache-2.0).
6
+
7
+ This package contains a rewritten experimental application, a procedural
8
+ mannequin renderer, its rendered assets, and CPU-only behavioral tests.
9
+ It does not include model weights or generated character training data.
10
+
11
+ The application code and procedural mannequin assets are distributed under
12
+ Apache-2.0. Model weights remain subject to their respective upstream licenses.
13
+ Qwen-Image-2.1-Turbo uses the Qwen Research License, not Apache-2.0.
README.md CHANGED
@@ -1,346 +1,198 @@
1
  ---
2
- title: Character View Sheet (Qwen-Image-Edit-2511)
3
  emoji: 🎨
4
- colorFrom: pink
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.49.1
 
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
- short_description: 1 枚の参照画像から LoRA 学習用 11 視点をキャプション付きで生成
12
  ---
13
 
14
- # キャラクター視点シート生成 Space
15
-
16
- 参照キャラクター画像を **1 枚** アップロードするだけで、キャラクター LoRA の学習に使う
17
- **11 視点**を生成し、**kohya-ss 形式のキャプション付きデータセット**として ZIP で出力する。
18
-
19
- 生成する視点は**チェックボックスで個別に外せる**。ZeroGPU の無料枠は要求秒数で
20
- 入場判定されるため、不要な視点を外すほど通りやすくなる。
21
-
22
- ## 視点カタログ
23
-
24
- | # | 視点 | ポーズ基準 | カメラ (`<sks>`) | 条件画像 | 解像度 |
25
- |---|---|---|---|---|---|
26
- | 1 | 全身 正面 | 立ち | `front view eye-level shot wide shot` | 参照画像 | 832×1216 |
27
- | 2 | 全身 左側面 | 立ち | `left side view eye-level shot wide shot` | 全身正面 | 832×1216 |
28
- | 3 | 全身 右側面 | 立ち | `right side view eye-level shot wide shot` | 全身正面 | 832×1216 |
29
- | 4 | 全身 背面 | 立ち | `back view eye-level shot wide shot` | 全身正面 | 832×1216 |
30
- | 5 | カウボーイショット 正面 | 立ち | `front view eye-level shot medium shot` | **全身正面のクロップ** | 1024×1024 |
31
- | 6 | カウボーイショット 背面 | 立ち | `back view eye-level shot medium shot` | **全身背面のクロップ** | 1024×1024 |
32
- | 7 | 上半身 正面 | 立ち | `front view eye-level shot close-up` | **全身正面のクロップ** | 1024×1024 |
33
- | 8 | 顔 正面 | 立ち | `front view eye-level shot close-up` | **全身正面のクロップ** | 1024×1024 |
34
- | 9 | 膝抱え 正面 | 膝抱え座り | `front view eye-level shot wide shot` | 全身正面 | 832×1216 |
35
- | 10 | 膝抱え 背面 | 膝抱え座り | `back view eye-level shot wide shot` | 膝抱え正面 | 832×1216 |
36
- | 11 | うつ伏せ 後方から | うつ伏せ | `back view high-angle shot wide shot` | 全身正面 | 1216×832 |
37
-
38
- 解像度は kohya-ss の標準バケット (いずれも約 1MP) に合わせてあるため、学習前のリサイズが不要。
39
-
40
- ### 寄り視点はクロップした参照画像で条件付けする (実機検証で確定)
41
-
42
- RTX PRO 6000 Blackwell (ZeroGPU と同世代) での実測により、**全身画像を条件にして
43
- プロンプトで「寄れ」と指示しても、Qwen-Image-Edit は入力のフレーミングを保とうとして
44
- 全身のまま出力する**ことが分かった。言い回しを 8 通り試しても、カウボーイショットは
45
- 一度も成功しなかった。
46
-
47
- そのため寄り視点 (#5-#8) では、**条件画像をあらかじめ目的の構図に切り出してから**
48
- 渡している (`framing.py`)。白背景から被写体のバウンディングボックスを求め、
49
- 頭頂から一定割合 (顔 20% / 上半身 45% / カウボーイ 72%) を切り出す方式。
50
-
51
- | 視点 | プロンプトのみ | クロップ条件付け |
52
- |---|---|---|
53
- | 顔 | ✅ | ✅ |
54
- | 上半身 | 腰上まで | ✅ 胸上 |
55
- | **カウボーイ 正面/背面** | ❌ **全身のまま** | ✅ **腿上** |
56
-
57
- あわせて、寄り視点では**全身のポーズ説明文を外している**。「両腕を腿の脇に下ろし、
58
- 脚は左右対称に…」という記述が寄り指示と競合して、全身が出力される原因になっていた。
59
-
60
- UI の「寄り視点を高品質モード」を有効にすると、クロップ画像に加えて全身画像も
61
- 条件に渡す (2 枚入力)。情報量が増えて精細になるが、条件系列が倍になるため
62
- 生成時間は約 1.8 倍 (実測 4.6 秒 → 8.2 秒) になる。
63
-
64
- 視点は `views.py` の `VIEWS` テーブルに 1 行として定義されている。UI のチェックボックス・
65
- duration 見積り・キャプションの構図タグは**すべてこのテーブルから導出**されるので、
66
- 斜め 45 度 (quarter view) や表情差分を足したい場合は行を追加するだけでよい。
67
-
68
- ## モデル構成
69
-
70
- | 役割 | リポジトリ | 備考 |
71
- |---|---|---|
72
- | ベース画像編集モデル | [`Qwen/Qwen-Image-Edit-2511`](https://huggingface.co/Qwen/Qwen-Image-Edit-2511) | `QwenImageEditPlusPipeline` |
73
- | 高速化 LoRA | [`lightx2v/Qwen-Image-Edit-2511-Lightning`](https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning) | 4-step 蒸留 |
74
- | **カメラ角度 LoRA** | [`fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA`](https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA) | 96 カメラ位置 (8 方位 × 4 仰角 × 3 距離) |
75
- | タガー A | [`SmilingWolf/wd-swinv2-tagger-v3`](https://huggingface.co/SmilingWolf/wd-swinv2-tagger-v3) | ONNX / CPU 実行 |
76
- | タガー B | [`Grio43/OppaiOracle`](https://huggingface.co/Grio43/OppaiOracle) | ONNX / CPU 実行 |
77
-
78
- ### カメラ指定について
79
-
80
- 以前は `"Move the camera to a back-facing position so the full body..."` のような散文で
81
- 角度を指示していたが、再現性が低かった。現在は角度 LoRA のトリガ語彙を使っている:
82
-
83
- ```
84
- <sks> {方位} {仰角} {距離}
85
- ```
86
-
87
- - 方位 (8): `front view` / `front-right quarter view` / `right side view` / `back-right quarter view` / `back view` / `back-left quarter view` / `left side view` / `front-left quarter view`
88
- - 仰角 (4): `low-angle shot` / `eye-level shot` / `elevated shot` / `high-angle shot`
89
- - 距離 (3): `close-up` / `medium shot` / `wide shot`
90
-
91
- この語彙以外の文字列は効かない。「うつ伏せを後ろから見下ろす」構図が
92
- `back view` + `high-angle shot` として厳密に指定できるのがこの方式の利点。
93
-
94
- ### 生成の流れ
95
-
96
- 1. ユーザーの参照画像から **立ちポーズの基準画像 (アンカー)** を作る。
97
- 2. 各視点は、そのポーズのアンカーを条件入力にして**カメラだけを動かして**生成する。
98
- 3. ポーズが違う視点 (膝抱え / うつ伏せ) は、立ちアンカーからそのポーズのアンカーを作り、
99
- そこからカメラを動かす。
100
- 4. アンカーのカメラと一致する視点は、アンカー画像をそのまま流用して生成を 1 回省く。
101
-
102
- 全 11 視点を選んだ場合、生成回数はちょうど 11 回。一部だけ選んだ場合は必要なアンカーだけが
103
- 作られる (例: 「膝抱え 背面」だけを選ぶと 立ち → 膝抱え → 背面 の 3 回)。
104
-
105
- ## ZeroGPU クォータ設計
106
-
107
- **要点: ZeroGPU のクォータは「実行時間」ではなく「要求した `duration`」ぶん消費される。**
108
- 10 秒で終わる処理でも 60 秒要求すれば 60 秒引かれる。入場判定も
109
- `残りクォータ < 要求 duration` で即拒否される。
110
-
111
- | アカウント | 1 日のクォータ |
112
- |---|---|
113
- | 未認証 | 120 秒 |
114
- | 無料会員 | 300 秒 |
115
- | PRO | 2400 秒 (延長可) |
116
-
117
- さらに **`xlarge` は要求 duration の 2 倍が引かれる**。本モデルは 53.8GB あり
118
- `large` (48GB) に載らないため `xlarge` 必須で、この 2 倍が常にかかる。
119
-
120
- 既定では**分割せず 1 回の GPU 呼び出しにまとめる** (`CHUNK_SIZE=12`)。
121
- 分割すると GPU 取得の固定費 (約 9 秒) を毎回払い、それも 2 倍されるため、
122
- 総消費がかえって増える。分割の利点 (残クォータが少なくても途中まで進める) より、
123
- 総量を抑えるほうが無料枠では効く。
124
-
125
- | 選択 | 生成枚数 | 要求 | **実消費** | 無料 (300秒) | 未認証 (120秒) |
126
- |---|:---:|:---:|:---:|:---:|:---:|
127
- | 全 11 視点 | 11 | 67 秒 | **134 秒** | ✅ 1日2回 | ❌ |
128
- | 全 11 視点 (高品質モード) | 11 | 83 秒 | 166 秒 | ✅ | ❌ |
129
- | 三面図のみ (4視点) | 4 | 32 秒 | 64 秒 | ✅ | ✅ |
130
- | 最小構成 (2視点) | 2 | 22 秒 | 44 秒 | ✅ | ✅ |
131
-
132
- > 見積り式は `duration = 12 + 5 × 枚数`。係数は環境変数
133
- > `CALL_OVERHEAD_SEC` / `PER_VIEW_SEC` / `CHUNK_SIZE` で調整できる。
134
-
135
- **ZeroGPU 実測 (xlarge / MIG 4g.96gb / bf16)**
136
-
137
- | 項目 | 実測 |
138
- |---|---|
139
- | GPU 取得 + モデル転送 | 約 9 秒 |
140
- | 1 枚目 (ウォームアップ込) | 4.8 秒 |
141
- | 2 枚目以降 | **2.9 秒/枚** (832×1216, 4 step) |
142
- | 4 枚チャンクの GPU 時間 | 13.5 秒 |
143
- | 全 11 視点の実行 (DL・タグ付け・ZIP 込み) | 約 255 秒 |
144
-
145
- 参考: ローカルの RTX PRO 6000 Blackwell (フル 96GB / fp8) では 4.4 秒/枚だった。
146
- ZeroGPU の `xlarge` は MIG 4g.96gb で、実測ではこちらのほうが速い。
147
-
148
- なお一部の視点は、寄りの切り出し元やポーズ基準として**中間生成**を必要とする。
149
- 例えば「カウボーイ背面」だけを選んだ場合は 立ち → 全身背面 → カウボーイ背面 の
150
- 3 回生成になる。UI のクォータ表示はこの中間生成も含めた実消費秒数を出す。
151
-
152
- > **未達の目標**: 当初は未認証ユーザー (120 秒) でも全 11 視点を通すことを狙っていたが、
153
- > `xlarge` 強制により 134 秒となり達成できていない。`large` (2 倍消費なし) に戻すには
154
- > モデルを 42 GiB 未満に縮める必要がある (fp8 transformer の採用など)。
155
-
156
- ## 高速化 (fp8 + FlashAttention-3 + AoTI)
157
-
158
- ZeroGPU は 2026/05 に **H200 (~70GB) から RTX Pro 6000 Blackwell** へ移行し、
159
- 既定の `large` サイズは **48GB** になった。Qwen-Image-Edit-2511 は bf16 で
160
-
161
- - transformer: 40.9 GB
162
- - text encoder (Qwen2.5-VL-7B): 16.6 GB
163
- - 合計: **約 57 GB**
164
-
165
- あり、余裕がない。そのため `optimization.py` で
166
-
167
- 1. FlashAttention-3 の attention processor を適用
168
- 2. torchao の fp8 動的量子化 (transformer を約半分に)
169
- 3. PyTorch ahead-of-time コンパイル (`spaces.aoti_compile`)
170
-
171
- を行う。速度改善 (1.3-1.8×) 以上に **VRAM を削ること**が目的。
172
- 系列長を動的次元にしてあるので、視点ごとに解像度が変わっても再コンパイルは走らない。
173
-
174
- ### fp8 量子化を CPU 側でやってはいけない (デプロイして判明)
175
-
176
- ローカル GPU では「CPU で量子化してから GPU へ転送」がロード時ピークを
177
- **55.1GB → 34.8GB** に下げられて有利だった。しかし **ZeroGPU では動かない**。
178
-
179
- ZeroGPU はモジュール階層の `.to("cuda")` を CUDA エミュレーション層
180
- (`spaces/zero/torch/patching.py`) で横取りし、テンソルを `aten.empty_like` で
181
- スナップショットする。torchao の `Float8Tensor` はこの演算を実装していないため、
182
- 起動時に `NotImplementedError` で落ちる。
183
-
184
- ```
185
- NotImplementedError: Float8Tensor dispatch: attempting to run unimplemented
186
- operator/function: func=<OpOverload(op='aten.empty_like', overload='default')>
187
  ```
188
 
189
- 量子化するなら GPU コンテキスト内で行うこと
190
- ([zerogpu-aoti](https://huggingface.co/blog/zerogpu-aoti) と同じ手順)。
191
- 現状 fp8 と AoTI は既定で無効 (`ENABLE_AOTI=1` で有効化)。
192
-
193
- ### `large` に戻すには (未実施)
194
-
195
- `xlarge` の 2 倍消費をやめるには、登録テンソル量を 42 GiB 未満にする必要がある。
196
- 候補は fp8 済みの transformer
197
- [`prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19`](https://huggingface.co/prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19)
198
- (19.0 GiB, diffusers 形式, 非 gated) を `QwenImageEditPlusPipeline.from_pretrained(..., transformer=...)`
199
- で差し込む方法。ただし
200
-
201
- - ネイティブ fp8 重みへの PEFT の `Linear.merge()` 直接適用は安全でない。
202
- bf16 で読み込んで fuse したあと `enable_layerwise_casting(storage_dtype=torch.float8_e4m3fn,
203
- compute_dtype=torch.bfloat16)` で保存する手順が必要。
204
- - 「Rapid AIO」は蒸留系の改変が入っている可能性があり、Lightning LoRA を
205
- 重ねてよいか要確認。
206
-
207
- 実施する場合は `GPU_SIZE=large` に戻し、`QUOTA_MULTIPLIER` が 1 になることを確認する。
208
-
209
- ### 実測値 (RTX PRO 6000 Blackwell / fp8 / FA3・AoTI なし)
210
-
211
- | 項目 | 実測 |
212
- |---|---|
213
- | ロード + fp8 量子化 | 32.4 秒 |
214
- | ロード後 VRAM | 34.8 GB |
215
- | **生成時ピーク VRAM** | **38.9 GB** (48GB 枠内) |
216
- | 1 視点あたり | 4.4 秒 |
217
- | 11 視点 合計 | 48.0 秒 |
218
- | キャプション 11 視点 × 2 タガー | 22.6 秒 (CPU) |
219
-
220
- ZeroGPU では FA3 と AoTI が有効になるため、これより速くなる見込み。
221
-
222
- 問題が出た場合は環境変数で個別に切れる: `DISABLE_FP8` / `DISABLE_AOTI` / `DISABLE_FA3`。
223
- いずれも失敗時は自動でフォールバックし、Space 自体は止まらない。
224
-
225
- ### ⚠️ `@spaces.GPU` には必ず `size` を明示すること (最重要)
226
-
227
- **これが本 Space が一時的にまったく生成できなくなっていた原因。**
228
- `spaces 0.51.1` の GPU サイズ自動選択にバグがある。
229
-
230
- 1. 起動時に `zero/__init__.py` の `startup()` が登録テンソル量を測り、
231
- Blackwell の閾値 `45097156608` (42.0 GiB, `zero/configs.json`) を超えると
232
- `_default_gpu_size = 'xlarge'` を立てる。
233
- 2. ところが `zero/client.py` の `schedule()` は、その既定値を **duration の計算には
234
- 使うのに、実際の割り当て要求には元の `gpu_size=None` をそのまま渡す**。
235
- 3. 結果、53.8GB のモデルが 48GB の `large` に転送されて worker が即死する。
236
- 4. `worker_init()` は `torch.move()` を**デコレート関数の実行前**に呼ぶため、
237
- `wrappers.py` が `res is None` を受けて `"GPU task aborted"` を投げる。
238
-
239
- このため **Python トレースバックが一切残らず**、引数を取らない自己診断関数まで
240
- 含めてあらゆる GPU 呼び出しが同じように落ちる。要求 duration を 212 秒まで
241
- 上げても変わらない (duration の問題ではない)。公式 `Qwen/Qwen-Image-Edit-2511`
242
- Space が動くのは `spaces` 0.50.2 のままリビルドされていないから。
243
-
244
- 対策は `@spaces.GPU(..., size="xlarge")` の明示指定のみ。`spaces` を
245
- `requirements.txt` でピンして回避することは**できない** —
246
- HF Spaces がビルドコマンドで `spaces==0.51.1` を強制注入するため
247
- `ResolutionImpossible` になる。
248
-
249
- 診断用に `gpu_selftest` / `gpu_pipe_selftest` を UI の「診断」に残してある。
250
- GPU が取れているかを最小コストで確認できる。
251
-
252
- ### 依存について
253
-
254
- - **torch は書かない。** ベースイメージのものを使う (現在 2.11.0+cu130)。
255
- - **`spaces` と `gradio` も書かない。** ビルドコマンドで強制注入される。
256
- - `kernels` は 0.15 以降 `get_kernel()` に `version=` / `revision=` が必須になり、
257
- vendoring した FA3 processor の呼び出しが通らないため `kernels<0.15` に固定。
258
- ただし現行 ZeroGPU には対応するカーネルビルドが無く
259
- (`min() arg is an empty sequence`)、**FA3 は現状使えない**。フォールバックして
260
- 既定の attention で動作する。
261
-
262
- > 角度 LoRA は AoTI コンパイル後に切り替えられないため、起動時に fuse して固定している
263
- > (既定強度 0.9、`ANGLES_LORA_SCALE` で変更可)。
264
-
265
- ## キャプション生成 (WD Tagger v3 vs OppaiOracle)
266
-
267
- **タグ付けは onnxruntime の CPU 実行**にしてあり、ZeroGPU のクォータを一切消費しない。
268
-
269
- | | WD Tagger v3 | OppaiOracle V1.1 |
270
  |---|---|---|
271
- | タグ数 | 10,861 | 19,294 |
272
- | 入力 | 448×448 NHWC / BGR / 0-255 | 448×448 NCHW / letterbox (114,114,114) / mean=std=0.5 |
273
- | ONNX 入出力 | `input` → `output` | `pixel_values` + `padding_mask` → `probabilities` |
274
- | 既定閾値 | 0.35 | **0.805** (`pr_thresholds.json` の micro.f1_optimal) |
275
- | ライセンス | Apache-2.0 | Apache-2.0 |
276
-
277
- **���者はスコア分布がまったく違う。** 実測で OppaiOracle は閾値 0.805 で 21 タグ、
278
- 0.5 で 445 タグ、0.35 で 2768 タグになる。同じ閾値で比較すると意味を成さないため、
279
- UI のスライダーはタガーごとに別レンジにしてある。
280
-
281
- UI で「両方 (比較する)」を選ぶと、ZIP に比較レポート `caption_comparison.md` が入り、
282
- 共通タグ / 片方だけのタグが視点ごとに一覧できる。
283
-
284
- ### 構図タグは推論に頼らない
285
-
286
- `full body` / `upper body` / `portrait` / `cowboy shot` / `from behind` / `from above` /
287
- `lying` / `on stomach` などの構図タグは、**カメラパラメータから決定的に注入**している。
288
- タガーは構図を安定して当てられないため。
289
-
290
- さらに、確定タグと矛盾するタガー出力は落とす。例えばうつ伏せの視点で、タガーが条件付け元の
291
- 立ち絵に引きずられて `standing` を返すことがあるが、これは除去される (実測で発生する)。
292
-
293
- ### ZIP の構成
294
-
 
 
 
 
 
 
 
 
295
  ```
296
- dataset/01_full_front.png
297
- dataset/01_full_front.txt <- 主タガーのキャプション (kohya-ss 形式)
298
- ...
299
- captions_alt/01_full_front.txt <- 比較用のもう一方のタガー
300
- caption_comparison.md
301
- ```
302
-
303
- `dataset/` をそのまま kohya-ss の学習フォルダに指定できる。
304
 
305
- ### キャプションの形
 
 
 
 
306
 
 
 
 
307
  ```
308
- {トリガーワード}, {構図の確定タグ}, {タガーが推定したタグ}
309
- ```
310
-
311
- 除外タグ欄に髪色・目色などを入れておくと、その特徴をキャプションから外して
312
- LoRA 側に焼き込める。背景を焼き付けたくない場合に備えて、`white background` /
313
- `simple background` は確定タグとして**明示的に**入れてある (無名の前提にしない)。
314
-
315
- ## Krea 2 Turbo との比較
316
-
317
- 「Krea 2 turbo に置き換えるべきか」を検討した結果、**Qwen-Image-Edit-2511 を継続**する。
318
-
319
- | 項目 | Qwen-Image-Edit-2511 | Krea 2 Turbo |
320
- |---|---|---|
321
- | タスク | **画像編集がネイティブ** (多画像入力可) | stock diffusers では **text-to-image 専用** |
322
- | パラメータ | 20B MMDiT + Qwen2.5-VL-7B | 12B MMDiT + Qwen3-VL-4B |
323
- | 重み (bf16) | 40.9GB + 16.6GB ≈ 57GB | ~24GB + ~8GB ≈ **32GB** |
324
- | ステップ | 4 (Lightning LoRA), true_cfg 1.0 | 8 (TDM 蒸留), guidance 0.0 |
325
- | diffusers | `QwenImageEditPlusPipeline` | `Krea2Pipeline` (0.39.0+, t2i のみ) |
326
- | Hub | 非 gated / **Apache-2.0** | `krea/Krea-2-Turbo` は **gated** / Krea 2 Community License |
327
- | 編集手段 | ネイティブ | 非公式 LoRA + ComfyUI 専用ノード、または非公式 diffusers 移植 |
328
- | カメラ角度制御 | **専用 LoRA が存在 (96 カメラ位置)** | 同等物なし |
329
- | 公開ホスト時の義務 | なし | **入出力セーフティ分類器の実装が必須** |
330
-
331
- VRAM が軽いのは Krea 2 の明確な利点。しかし「参照画像 1 枚から同一キャラの多視点を派生させる」
332
- という本 Space の中核機能が公式に存在せず、角度制御 LoRA のエコシステムも無い。
333
-
334
- 実測比較は別 Space [`Krea2-vs-Qwen-Edit-Bench`](./bench) で行う (同リポジトリの `bench/` を参照)。
335
 
336
- ## ライセンス・注意
 
 
 
337
 
338
- - 本 Space コード: **Apache-2.0**
339
- - 使用モデル各種のライセンスは上流リポジトリに従う
340
- - アップロード画像は**第三者の著作物・肖像権を侵害しないものに限る**
341
 
342
- ## ベースにした Space
343
 
344
- [tori29umai/Qwen-Image-2509-CharacterSheet](https://huggingface.co/spaces/tori29umai/Qwen-Image-2509-CharacterSheet)
345
- (1 枚から 4 視点ターンアラウンドを生成) を参照。`optimization.py` と
346
- `qwenimage/qwen_fa3_processor.py` は同 Space の実装を流用している。
 
 
 
 
1
  ---
2
+ title: Character View Sheet (Qwen Image 2.1 turbo)
3
  emoji: 🎨
4
+ colorFrom: blue
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 5.49.1
8
+ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
+ short_description: ポーズ人形による11視点生成の速度・同一性・追従精度を評価する研究デモ
13
  ---
14
 
15
+ # Character View Sheet (Qwen Image 2.1 turbo)
16
+
17
+ 参照キャラクター画像と、付属する11種類のポーズ人形を使い、各視点を1枚ずつ生成する
18
+ Hugging Face Gradio Space用の研究試作版です。**ローカル実機で11視点生成を検証済み**です。
19
+ 公開Space: [Character View Sheet (Qwen Image 2.1 turbo)](https://huggingface.co/spaces/darask0/Qwen-Image-Edit-2511-Lora-Sheet)
20
+
21
+ **HF ZeroGPU largeでも、認証情報を送らないAPI接続から標準サイズ11視点の完走を確認しました。**
22
+ 2026-10-12、GPU内処理68.0秒、割当・転送込み68.5秒、
23
+ ピーク割当33.01GiB。角度LoRAあり・人形あり・白背景全身の切り出しあり・Seed0です。
24
+ 確認GPUは`NVIDIA RTX PRO 6000 Blackwell Server Edition MIG 2g.48gb`。110秒予約でPNG11枚を取得しました。
25
+ 1例・1回の匿名API試験で、ブラウザ操作や他の入力・混雑時の完走を保証するものではありません。
26
+ 上記は別の検証用Spaceで取得した計測です。この検証済みTurbo版で既存の2511 Spaceを置き換えました。
27
+ URLは従来のものを継続し、表示名・使用モデル・アプリ内容を更新しています。
28
+
29
+ RTX PRO 6000 Blackwell(96GB、PyTorchの割当を48GiBに制限)では、標準サイズ・速度優先設定で
30
+ 11枚を推論36.1秒、PNG保存込み39.3秒、ピーク割当33.01GiBで生成しました。
31
+ これは全GPUの演算能力を使用した値です。ZeroGPU largeの半GPU性能、転送、待ち時間は再現していません。
32
+ 高速設定は11枚を推論17.7秒・保存込み19.0秒で完走しました。
33
+ 人物1例の評価で、構図や衣装の細部に誤りが残るため、学習用画像は選別してください。
34
+
35
+ 同じ人物では、標準・速度優先+参照切り出し+2.1角度LoRA(強度0.8)が最も良い結果でした。
36
+ この条件は11枚を推論37.3秒・保存込み40.2秒、ピーク割当33.02GiBで完走しました。
37
+ 短パン化と背中への胸模様の転写が解消し、顔・上半身の寄り構図も改善しましたが、
38
+ 左右非対称の袖模様、背景色、細かな関節位置は一致しません。
39
+
40
+ 用途は研究・評価です。未ログイン利用者も参加できる研究用デモとして、
41
+ 生成速度、キャラクター同一性、ポーズ人形への追従精度を検証する想定です。
42
+
43
+ 対象モデルは `Qwen/Qwen-Image-2.1-Turbo`。未ログイン利用者の120秒枠で11視点を
44
+ 生成できるよう、最大110秒のGPU予約、低解像度設定、実行時間の記録を用意しています。
45
+ 110秒は処理の上限であり、11枚の完成所要時間を測った値ではありません。
46
+
47
+ ## 変更内容
48
+
49
+ - 元Spaceと同じ11視点を選択可能。
50
+ - キャプション生成、タガー、比較レポート、トリガーワード/除外タグ設定を削除。
51
+ - 出力ZIPは `dataset/NN_view.png` のみ。TXT、キャプション、ポーズ人形は混入しません。
52
+ - 全視点でユーザーの元画像を参照。生成画像を次の視点に回す中間アンカー生成はありません。
53
+ - 白背景の直立全身画像に限り、寄り4視点の参照切り出しを選べます(既定は無効)。
54
+ - 11枚のグレーのポーズ人形PNGを同梱。人形参照のオン・オフを比較可能。
55
+ - Turboの保存済み8ステップsigmaを使用。CFG=1、KV cache有効。
56
+ - GPUサイズは明示的に `large`。1ジョブずつ処理し、VAE tilingを有効化。
57
+ - 時間が不足しそうなら次の視点を開始せず、完成済みの画像を返します。
58
+ - モデル内のステップ境界でも期限を確認。通常の後続視点エラーでも完成画像を保持します。
59
+ ただしプロセス強制終了・GPU割当失敗・ステップ途中の長時間停止は救済できません��
60
+
61
+ ## ポーズ人形
62
+
63
+ ![11種類の人形](assets/pose_preview.png)
64
+
65
+ `pose_guides.py` が同じ立体モデルを異なるカメラから投影して作ります。
66
+ 画像生成AIや外部の人形画像は使用していません。個別PNGには文字が入りません。
67
+ 一覧だけに確認用ラベルがあります。
68
+
69
+ 1. 全身の正面・左側面・右側面・背面
70
+ 2. 腿上の正面・背面
71
+ 3. 上半身正面・顔正面
72
+ 4. 膝を抱えた座り姿の正面・背面
73
+ 5. うつ伏せの後方俯瞰
74
+
75
+ 条件画像は `[元のキャラ画像, その視点の人形画像]` の2枚です。
76
+ 「白背景の直立全身画像」の切り出しを有効にすると、腿上・上半身・顔用に元画像から
77
+ それぞれ前景高さの72%・45%・20%を切り出します。縦横比を保ち、白い余白を付けます。
78
+ 背景付き画像、座り姿、既に寄った画像には使わないでください。寄り構図は改善しましたが、
79
+ 切断面を衣装の裾と解釈し長ズボンを短パンに変える例もあり、実験オプションです。
80
+ 指示文でも「Image 1から外見、Image 2からポーズと構図」を区別します。
81
+ これは画像編集の参照機能であり、ControlNetや骨格座標による強制制御ではありません。
82
+ 顔や衣装の保持、背面の補完、関節配置への追従は生成結果で確認する必要があります。
83
+ 人形は標準体型のため、デフォルメ体型では余白や手足の配置を調整する余地があります。
84
+
85
+ 再生成:
86
+
87
+ ```bash
88
+ python pose_guides.py --regenerate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  ```
90
 
91
+ ## HF Spaceでの検証手順
92
+
93
+ 1. このディレクトリの内容をGradio Spaceのルートに置きます。
94
+ 2. HardwareをZeroGPUに設定します。アプリの閲覧者にHFログインを要求する設定はありません。
95
+ 3. 既定の `GPU_SIZE=large`、標準・速度優先、人形参照ありで起動します。公開Spaceは `ENABLE_ANGLES_LORA=1` で、検証済みの2.1角度LoRAを使用します。
96
+ 4. 所有者の利用枠で、正面・側面・背面をまず比較し、続いて11視点を生成します。
97
+ 5. 「実行時間の記録」からJSONを保存し、全11視点の完成、ピークVRAM、1枚ごとの時間を確認します。
98
+ 6. 同じ参照画像・Seed・出力サイズで人形参照のオン/オフを比較します。
99
+ 7. 未ログイン状態では、その日の残り利用枠がある状態で11視点を検証します。
100
+
101
+ HF上で依存インストール・起動・ZeroGPU推論・画像ZIP取得を確認済みです。
102
+ HFのビルドが供給したTorch2.13.0を維持し、Torchvision0.28.0へ解決されました。
103
+ Spaces0.51.3、Gradio5.49.1、Python3.12.12、Transformers5.19.0、PEFT0.21.2、Accelerate1.15.0で動作しました。
104
+ `requirements.txt` はQwen公式案内に沿い、Turboを扱える上流Diffusersコミットを固定しました。
105
+ ZeroGPUが供給するtorch/spaces/Gradioはrequirementsで重ねて固定しません。
106
+
107
+ 旧 `tagging.py` / `optimization.py` / `qwenimage/` / `bench/` は削除しました。
108
+ 旧requirementsを追加せず、本版のものを使用します。
109
+
110
+ ## 解像度と時間枠
111
+
112
+ | 設定 | 縦長 | 正方形 | 横長 | 条件画像の面積設定 |
113
+ |---|---|---|---|---|
114
+ | 高速 | 512×768 | 640×640 | 768×512 | `output_resolution=640` |
115
+ | 標準・速度優先(既定) | 832×1216 | 1024×1024 | 1216×832 | `output_resolution=640` |
116
+ | 標準・細部優先 | 832×1216 | 1024×1024 | 1216×832 | `output_resolution=1024` |
117
+
118
+ 条件画像をPILで縮小するだけではモデル内部で拡大されるため、`output_resolution`も明示しています。
119
+ 高速モードの画像は標準より小さく、学習用細部の情報量も減ります。
120
+ 標準・速度優先は出力サイズを保ち、参照画像の処理面積を減らします。
121
+ 細かい模様の再現は別途比較が必要です。元2511版との同一条件の速度比較は未実施です。
122
+
123
+ GPU時間枠は `min(MAX_GPU_SECONDS, max(45, 15 + 10 × 視点数))` 秒。
124
+ この式は測定結果からの推定ではなく、試作の予約上限です。
125
+ 初期転送に15秒、生成終了処理に6秒の余裕を取り、残り時間で生成します。
126
+ 11視点は110秒予約、`large`では120秒の匿名日次枠内に予約できますが、他のSpaceでの利用や
127
+ 残り枠、混雑、実行速度によって利用・完成を保証するものではありません。
128
+
129
+ 公式の現行仕様では `large`は48GB・1倍消費、`xlarge`は96GB・2倍消費です。
130
+ `xlarge`へ変更すると11視点の予約が220秒相当になり、未ログイン枠を超えます。
131
+ Turboのsafetensorsファイル合計は約32.44GBで、演算時のVRAMはこれより増えます。
132
+ ローカル実測で標準解像度・2参照画像のピーク割当は36.10GiB、標準・速度優先では33.01GiBでした。
133
+ HF largeでも標準・速度優先のピーク割当は33.01GiBでした。
134
+
135
+ | Space Variable | 既定値 | 意味 |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  |---|---|---|
137
+ | `GPU_SIZE` | `large` | `large` / `xlarge`。明示指定 |
138
+ | `MAX_GPU_SECONDS` | `110` | 45〜110。実行時間枠の上限 |
139
+ | `ENABLE_ANGLES_LORA` | `0` | `1`で2.1用Multiple-Angles v2を追加する比較実験 |
140
+
141
+ ## 角度LoRA
142
+
143
+ 旧2511用のLightning/Multiple-Anglesは使用しません。
144
+ 2.1は異なるモデル構造です。必要なら `akhaliq/Qwen-Image-2.1-Multiple-Angles-LoRA` の
145
+ v2、強度0.8を起動時に適用できます。`<mva>`語彙を使います。
146
+ 作者の学習実装とDiffusersではMLPの層名と構造が異なるため、`angles_adapter.py`が
147
+ 結合されたgate/upのLoRAを分割変換し、全224モジュール・448テンソルの読込を確認します。
148
+ 未対応層を黙って無視しません。変換後のモデルで11視点の実GPU推論を完走しました。
149
+ 本検証の人物1例では衣装と寄り構図が改善しました。品質比較用オプションで、既定では無効です。
150
+ この比較を再現するには `ENABLE_ANGLES_LORA=1`、標準・速度優先、白背景全身用の切り出しを選びます。
151
+
152
+ ## 利用条件
153
+
154
+ アプリのコードと手続き的に作成した人形はApache-2.0です。
155
+ **モデル重みのライセンスは別**で、TurboはQwen Research Licenseです。
156
+ 公開本文では非商用を研究・評価目的と定義し、商用利用には別許諾を求めています。
157
+ 無料公開であることだけから利用可能とは判断できません。
158
+ 出力を用いて学習・改善したAIモデルを配布・提供する場合、関連文書への
159
+ `Built with Qwen` または `Improved using Qwen` 表示条件があります。
160
+ 本試作版は、上記の研究・評価用途を前提とします。
161
+
162
+ ## ローカルで検証した範囲
163
+
164
+ PillowとPython標準ライブラリで、CPU側のデータ処理テストを実行できます。
165
+ モデル重みをダウンロードすることはありません。
166
+
167
+ ```bash
168
+ python -m unittest discover -s tests -v
169
  ```
 
 
 
 
 
 
 
 
170
 
171
+ 画像だけのZIP、11視点の選択、各視点の参照画像、途中失敗時の保存、時間枠の中断、
172
+ 入力画像の透過処理・縮小、選択順に依存しないSeedを確認します。
173
+ 人形画像は目視および寸法・RGBモードを確認しました。
174
+ CPUテストは生成品質を評価しません。GPUベンチマークは次のコマンドで別途実行できます。
175
+ モデルのダウンロードには約33GB、実行にはCUDA環境が必要です。
176
 
177
+ ```bash
178
+ python benchmark_local.py --image character.png --output-dir results \
179
+ --resolution balanced --pose-guides on --reference-crop on --memory-limit-gib 48
180
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
+ `--reference-crop on` は白背景・直立全身の場合だけ指定します。
183
+ `--resolution fast/standard/balanced`、`--angles-lora on/off`、`--repeat 2`で比較できます。
184
+ `--create-sample`は検証用の成人キャラクターを先に生成し、その時間を11枚の計測から分離します。
185
+ `--image`と`--create-sample`を省略すると人形の速度試験となり、人物同一性の評価には使えません。
186
 
187
+ 計測JSONは各視点の同期済み推論時間、PNG保存込み時間、ロード時間、ピークVRAM、
188
+ モデルrevision、プロンプト、Seedを記録します。48GiB制限はPyTorch allocatorだけを制限し、
189
+ 半GPUの演算性能やZeroGPU環境を再現しません。
190
 
191
+ ## 一次資料(2026-10-12確認)
192
 
193
+ - [置き換え前の11視点Spaceのソース](https://huggingface.co/spaces/darask0/Qwen-Image-Edit-2511-Lora-Sheet/tree/0e9ee21fcd738c71b4ccd0606e854b79521fbbd3)
194
+ - [Turbo公式モデルカード](https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo)
195
+ - [使用するDiffusers実装](https://github.com/huggingface/diffusers/blob/85c9fa4caee33d1c5b473395784471d2d7957c1b/src/diffusers/pipelines/qwenimage21/pipeline_qwenimage21.py)
196
+ - [2.1用角度LoRAの作者資料](https://huggingface.co/akhaliq/Qwen-Image-2.1-Multiple-Angles-LoRA)
197
+ - [HF ZeroGPU仕様・利用枠](https://huggingface.co/docs/hub/en/spaces-zerogpu)
198
+ - [Turboライセンス](https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/blob/main/LICENSE)
angles_adapter.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lossless ai-toolkit Qwen Image 2.1 fused-MLP LoRA conversion.
2
+
3
+ ai-toolkit's Qwen Image 2.1 transformer stores ``gate_up = [gate; up]``;
4
+ Diffusers stores ``gate_layer`` and ``proj`` separately. For a fused update
5
+ ``B @ A``, duplicate A and split B's output rows. Rank and scale do not change.
6
+
7
+ Sources:
8
+ https://github.com/ostris/ai-toolkit/blob/main/extensions_built_in/diffusion_models/qwen_image_2/src/transformer.py
9
+ https://github.com/huggingface/diffusers/blob/85c9fa4caee33d1c5b473395784471d2d7957c1b/src/diffusers/models/transformers/transformer_qwenimage21.py
10
+
11
+ This deliberately rejects unknown tensors or unmatched modules instead of
12
+ silently loading only the attention part of a checkpoint.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from collections.abc import Mapping
19
+ from typing import Any
20
+
21
+
22
+ def convert_angles_state_dict(state_dict: Mapping[str, Any], transformer: Any) -> dict[str, Any]:
23
+ """Convert A/B-only weights and validate every target against the model.
24
+
25
+ The published v2 checkpoint uses PEFT A/B weights without alpha tensors.
26
+ Its rank/alpha are both 64, hence the effective update is B @ A. An alpha
27
+ tensor is rejected here so a differently scaled format cannot be mistaken
28
+ for this one. No adapter tensors are discarded or rescaled.
29
+ """
30
+ pairs: dict[str, dict[str, Any]] = {}
31
+ for key, tensor in state_dict.items():
32
+ name = key
33
+ for prefix in ("diffusion_model.", "transformer."):
34
+ if name.startswith(prefix):
35
+ name = name[len(prefix):]
36
+ break
37
+ suffix = next((s for s in (".lora_A.weight", ".lora_B.weight") if name.endswith(s)), None)
38
+ if suffix is None or tensor.ndim != 2:
39
+ raise ValueError(f"Unsupported LoRA tensor: {key}")
40
+ module_name = name[:-len(suffix)]
41
+ kind = suffix[6]
42
+ pair = pairs.setdefault(module_name, {})
43
+ if kind in pair:
44
+ raise ValueError(f"Duplicate LoRA tensor: {key}")
45
+ pair[kind] = tensor
46
+ if not pairs:
47
+ raise ValueError("The LoRA checkpoint has no adapter tensors.")
48
+
49
+ converted: dict[str, Any] = {}
50
+
51
+ def add_pair(module_name: str, a: Any, b: Any) -> None:
52
+ try:
53
+ module = transformer.get_submodule(module_name)
54
+ # Also works when another adapter has already wrapped this Linear.
55
+ base = module.get_base_layer() if hasattr(module, "get_base_layer") else module
56
+ expected = tuple(base.weight.shape)
57
+ except (AttributeError, KeyError) as exc:
58
+ raise ValueError(f"LoRA targets a missing model module: {module_name}") from exc
59
+ if len(expected) != 2 or tuple((b.shape[0], a.shape[1])) != expected or b.shape[1] != a.shape[0]:
60
+ raise ValueError(f"LoRA shape mismatch for {module_name}: A={tuple(a.shape)}, B={tuple(b.shape)}, base={expected}")
61
+ for kind, tensor in (("A", a), ("B", b)):
62
+ key = f"transformer.{module_name}.lora_{kind}.weight"
63
+ if key in converted:
64
+ raise ValueError(f"Overlapping fused and split LoRA tensors: {key}")
65
+ converted[key] = tensor
66
+
67
+ for name, pair in pairs.items():
68
+ if set(pair) != {"A", "B"}:
69
+ raise ValueError(f"Incomplete A/B pair: {name}")
70
+ a, b = pair["A"], pair["B"]
71
+ if name.endswith(".img_mlp.gate_up"):
72
+ if b.shape[0] % 2:
73
+ raise ValueError(f"Fused gate/up output width must be even: {name}")
74
+ halfway = b.shape[0] // 2
75
+ prefix = name[:-len("gate_up")]
76
+ add_pair(prefix + "gate_layer", a, b[:halfway].contiguous())
77
+ add_pair(prefix + "proj", a, b[halfway:].contiguous())
78
+ else:
79
+ add_pair(name, a, b)
80
+ return converted
81
+
82
+
83
+ def load_angles_adapter(pipe: Any, checkpoint_path: str, *, adapter_name: str = "angles", strength: float = 1.0) -> dict[str, Any]:
84
+ """Load the complete adapter and verify that every A/B pair was installed.
85
+
86
+ Use a fresh adapter name. Conversion and shape checks happen before model
87
+ mutation. If installation validation fails, remove this adapter and raise.
88
+ ``strength`` is the sole additional multiplier; rank is never halved when
89
+ splitting gate/up and no sqrt-rank scaling is introduced.
90
+ """
91
+ from safetensors.torch import load_file
92
+
93
+ if not math.isfinite(strength) or strength < 0:
94
+ raise ValueError("Adapter strength must be finite and nonnegative.")
95
+ if adapter_name in getattr(pipe.transformer, "peft_config", {}):
96
+ raise ValueError(f"Adapter already exists: {adapter_name}")
97
+ source = load_file(str(checkpoint_path), device="cpu")
98
+ weights = convert_angles_state_dict(source, pipe.transformer)
99
+ expected = {
100
+ key.removeprefix("transformer.").removesuffix(".lora_A.weight"): tuple(tensor.shape)
101
+ for key, tensor in weights.items() if key.endswith(".lora_A.weight")
102
+ }
103
+ fused_count = sum(key.endswith(".img_mlp.gate_up.lora_A.weight") for key in source)
104
+ try:
105
+ pipe.load_lora_weights(weights, adapter_name=adapter_name)
106
+ for name, a_shape in expected.items():
107
+ module = pipe.transformer.get_submodule(name)
108
+ for kind in ("A", "B"):
109
+ bank = getattr(module, f"lora_{kind}", {})
110
+ wanted = weights[f"transformer.{name}.lora_{kind}.weight"]
111
+ if adapter_name not in bank or tuple(bank[adapter_name].weight.shape) != tuple(wanted.shape):
112
+ raise RuntimeError(f"Adapter weight was not installed completely: {name}.lora_{kind}")
113
+ # PEFT must use alpha/rank = 1 before the requested user strength.
114
+ if module.r[adapter_name] != a_shape[0] or not math.isclose(float(module.scaling[adapter_name]), 1.0):
115
+ raise RuntimeError(f"Unexpected rank or alpha scaling: {name}")
116
+ pipe.set_adapters([adapter_name], adapter_weights=[strength])
117
+ except Exception:
118
+ if adapter_name in getattr(pipe.transformer, "peft_config", {}):
119
+ pipe.delete_adapters(adapter_name)
120
+ raise
121
+ return {
122
+ "input_tensors": len(source),
123
+ "loaded_tensors": len(weights),
124
+ "fused_mlp_blocks": fused_count,
125
+ "loaded_modules": len(expected),
126
+ "ranks": sorted({shape[0] for shape in expected.values()}),
127
+ "strength": strength,
128
+ }
app.py CHANGED
@@ -1,697 +1,127 @@
1
- """キャラクター視点シート生成 Space (Qwen-Image-Edit-2511).
2
-
3
- 設計の要点:
4
-
5
- 1. カメラは fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA の構造化トリガで指定する。
6
- 散文で "Move the camera to a back-facing position..." と書くより再現性が高い。
7
-
8
- 2. `@spaces.GPU` には **必ず size を明示する**。自動選択には spaces 0.51.1 の
9
- バグがあり、48GB の large にモデルが載らず GPU タスクが黙って落ちる (下記 GPU_SIZE)。
10
-
11
- 3. ZeroGPU のクォータは **要求した duration ぶんそのまま** 消費される
12
- (実行時間ではない)。さらに xlarge はその 2 倍が引かれる。そのため
13
- - duration を実際の生成枚数から見積もる
14
- - 選択内容に応じた推定消費秒数を UI に常時出す
15
- - GPU 取得の固定費を何度も払わないよう既定では分割しない
16
- という 3 点で無料枠 (300 秒/日) に収める。
17
-
18
- 4. タグ付けは onnxruntime の CPU 実行にして、クォータを一切消費しない。
19
- """
20
-
21
- import io
22
- import os
23
  import random
24
  import tempfile
25
- import zipfile
26
- from typing import Dict, List, Tuple
27
 
28
  import gradio as gr
29
- import numpy as np
30
- import spaces
31
- import torch
32
- from diffusers import QwenImageEditPlusPipeline
33
- from PIL import Image
34
-
35
- import tagging
36
- import views as V
37
- from framing import crop_framing
38
- from optimization import apply_fa3_, optimize_pipeline_
39
-
40
- # --- モデル読み込み ---------------------------------------------------------
41
-
42
- DTYPE = torch.bfloat16
43
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
44
-
45
- BASE_MODEL = "Qwen/Qwen-Image-Edit-2511"
46
- LIGHTNING_REPO = "lightx2v/Qwen-Image-Edit-2511-Lightning"
47
- LIGHTNING_WEIGHT = "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors"
48
- ANGLES_REPO = "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA"
49
- ANGLES_WEIGHT = "qwen-image-edit-2511-multiple-angles-lora.safetensors"
50
-
51
- #: 角度 LoRA の推奨強度は 0.8-1.0。AoTI コンパイル後は実行時に切り替えられないため
52
- #: 起動時に fuse して固定する。`<sks>` トリガの無いプロンプト (ポーズ生成) への
53
- #: 副作用が出る場合はこの値を下げる。
54
- ANGLES_LORA_SCALE = float(os.environ.get("ANGLES_LORA_SCALE", "0.9"))
55
-
56
- # LoRA は CPU 上で fuse してから GPU に載せる。
57
- # (fp8 量子化を CPU 側でやるのは不可。torchao の Float8Tensor が ZeroGPU の
58
- # エミュレーション層が使う `aten.empty_like` を実装しておらず、起動時に落ちる。)
59
- pipe = QwenImageEditPlusPipeline.from_pretrained(BASE_MODEL, torch_dtype=DTYPE)
60
-
61
- pipe.load_lora_weights(LIGHTNING_REPO, weight_name=LIGHTNING_WEIGHT, adapter_name="fast")
62
- pipe.load_lora_weights(ANGLES_REPO, weight_name=ANGLES_WEIGHT, adapter_name="angles")
63
- pipe.set_adapters(["fast", "angles"], adapter_weights=[1.0, ANGLES_LORA_SCALE])
64
- pipe.fuse_lora(adapter_names=["fast", "angles"], lora_scale=1.0)
65
- pipe.unload_lora_weights()
66
-
67
- pipe.to(DEVICE)
68
-
69
- apply_fa3_(pipe)
70
-
71
- # --- AoTI + fp8 は既定で無効 (ENABLE_AOTI=1 で有効化) -----------------------
72
- #
73
- # 起動時の AoTI コンパイルは、以前 NVML アサートで失敗していた。原因は後述の
74
- # GPU_SIZE のバグ (48GB の large にモデルが載らない) と同一で、`optimize_pipeline_`
75
- # 内の `@spaces.GPU` に size を渡していなかったため。現在は理屈のうえでは
76
- # size を指定すれば通るはずだが未検証。
77
- #
78
- # また現行 ZeroGPU では FA3 のカーネルビルドが無く (kernels が
79
- # "min() arg is an empty sequence" で失敗)、AoTI 込みの高速化は未確認。
80
- # 既定は素の bf16。試す場合は Space の Variables に ENABLE_AOTI=1 を設定する。
81
- if os.environ.get("ENABLE_AOTI", "").strip() in ("1", "true", "True"):
82
- # AoTI のグラフ捕捉は **実際の推論と同じ経路** で行う必要がある。
83
- # true_cfg_scale を既定 (4.0) のままにすると classifier-free guidance が有効になり、
84
- # transformer が cond/uncond の 2 バッチで呼ばれる。バッチ次元は動的次元に
85
- # 含めていないため、推論時 (1 バッチ) と形が合わずコンパイル結果が使えなくなる。
86
- optimize_pipeline_(
87
- pipe,
88
- image=[Image.new("RGB", (832, 1216))],
89
- prompt="warmup",
90
- negative_prompt=" ",
91
- height=1216,
92
- width=832,
93
- num_inference_steps=1,
94
- true_cfg_scale=1.0,
95
- )
96
- else:
97
- print("[app] AoTI/fp8 は無効 (ENABLE_AOTI=1 で有効化)")
98
-
99
-
100
- MAX_SEED = np.iinfo(np.int32).max
101
-
102
- # --- ZeroGPU の duration 見積り ---------------------------------------------
103
- #
104
- # ZeroGPU 実測値 (xlarge / MIG 4g.96gb / bf16, 2026-08):
105
- # GPU 取得 + モデル転送 : 約 9 秒 (引数なしの selftest が 8.9 秒で完了)
106
- # 1 枚目 (ウォームアップ込): 4.8 秒
107
- # 2 枚目以降 : 2.9 秒/枚 (832x1216, 4 step)
108
- # 4 枚のチャンク合計 : 13.5 秒
109
- CALL_OVERHEAD_SEC = int(os.environ.get("CALL_OVERHEAD_SEC", "12"))
110
- PER_VIEW_SEC = int(os.environ.get("PER_VIEW_SEC", "5"))
111
-
112
- # 分割は既定で無効 (11 視点なら 1 回の呼び出しに収める)。
113
- # xlarge はクォータを 2 倍消費するうえ、GPU 取得の固定費が呼び出しごとに
114
- # かかるため、分割するほど総消費が増える。分割の利点 (残クォータが少なくても
115
- # 途中まで進める) より、総量を抑えるほうが無料枠では効く。
116
- # 視点をさらに増やして 1 回では長くなりすぎる場合はここを下げる。
117
- CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "12"))
118
-
119
- FREE_QUOTA_SEC = 300 # 無料会員 (ログイン済み)
120
- ANON_QUOTA_SEC = 120 # 未認証
121
-
122
- # --- GPU サイズは明示指定が必須 ---------------------------------------------
123
- #
124
- # spaces 0.51.1 のバグ回避。ZeroGPU は起動時に登録テンソル量を測り、
125
- # Blackwell の閾値 45097156608 (42.0GiB, zero/configs.json) を超えると
126
- # zero/__init__.py の startup() で _default_gpu_size='xlarge' を立てる。
127
- # ところが zero/client.py の schedule() は、その既定値を duration の計算には
128
- # 使うのに、実際の割り当て要求には元の gpu_size=None をそのまま渡してしまう。
129
- # 結果、本モデル (57.7GB) が 48GB の `large` に転送されて worker が即死し、
130
- # wrappers.py が res=None を受けて "GPU task aborted" を投げる
131
- # (デコレート関数に入る前なので Python トレースバックは残らない)。
132
- #
133
- # したがって size は自動選択に任せず、必ず明示的に渡すこと。
134
- # xlarge はクォータを 2 倍消費する。モデルを 42GiB 未満に縮められたら
135
- # 環境変数 GPU_SIZE=large に戻せる。
136
- GPU_SIZE = os.environ.get("GPU_SIZE", "xlarge").strip() or "xlarge"
137
- #: xlarge は要求 duration が内部で 2 倍されてクォータから引かれる
138
- QUOTA_MULTIPLIER = 2 if GPU_SIZE == "xlarge" else 1
139
-
140
-
141
- def _chunks(items: List, size: int) -> List[List]:
142
- return [items[i : i + size] for i in range(0, len(items), size)]
143
-
144
-
145
- def _chunk_duration(payload, *args, **kwargs) -> int:
146
- """@spaces.GPU の動的 duration。実際の生成コストぶんだけを要求する。
147
-
148
- 寄り視点を「クロップ + 全身」の 2 枚で条件付けすると条件系列が倍になり、
149
- 実測で 4.6 秒 -> 8.2 秒になる。その分は units に反映されている。
150
- """
151
- units = sum(item.get("units", 1.0) for item in payload) or 1.0
152
- return int(CALL_OVERHEAD_SEC + PER_VIEW_SEC * units)
153
 
 
 
 
 
154
 
155
- def estimate_quota(
156
- selected_keys: List[str], two_stage_prone: bool = False, hq_framing: bool = False
157
- ) -> Tuple[int, int, int]:
158
- """(要求秒数の合計, 生成枚数, チャンク数) を返す。"""
159
- plan = V.build_plan(selected_keys, two_stage_prone)
160
- if not plan:
161
- return 0, 0, 0
162
- chunks = _chunks(plan, CHUNK_SIZE)
163
- total = sum(_chunk_duration([_build_payload(s, "", hq_framing) for s in c]) for c in chunks)
164
- return total, len(plan), len(chunks)
165
 
166
 
167
- # --- 生成 -------------------------------------------------------------------
168
-
169
-
170
- @spaces.GPU(duration=_chunk_duration, size=GPU_SIZE)
171
- def _run_chunk(payload: List[dict], sources: Dict[str, Image.Image], seed: int, steps: int, cfg: float):
172
- """1 回の GPU 予約で payload 内の視点をまとめて生成する。
173
-
174
- payload の各要素: {"key", "prompt", "source", "width", "height"}
175
- sources: このチャンクが条件付けに使う画像 (key -> PIL)
176
- """
177
- import time as _time
178
-
179
- t_enter = _time.perf_counter()
180
- print(f"[gpu] chunk start: {len(payload)} 枚 / 要求 {_chunk_duration(payload)}秒", flush=True)
181
-
182
- produced: Dict[str, Image.Image] = {}
183
- pool = dict(sources)
184
-
185
- for item in payload:
186
- src = pool.get(item["source"])
187
- if src is None:
188
- # GPU 子プロセス側なので gr.Error ではなく素の例外を投げる
189
- raise RuntimeError(f"条件画像 {item['source']} が見つかりません")
190
-
191
- # 寄り視点は、条件画像をあらかじめ目的の構図に切り出しておく。
192
- # プロンプトだけでは Qwen-Image-Edit が入力のフレーミングを保とうとして
193
- # 寄りが効かない (特にカウボーイショット) ことが実測で分かっている。
194
- if item.get("crop"):
195
- cropped = crop_framing(src, item["crop"], item["width"], item["height"])
196
- cond = [cropped, src] if item.get("hq") else [cropped]
197
- else:
198
- cond = [src]
199
-
200
- t0 = _time.perf_counter()
201
- generator = torch.Generator(device=DEVICE).manual_seed(int(seed))
202
- image = pipe(
203
- image=cond,
204
- prompt=item["prompt"],
205
- negative_prompt=" ",
206
- height=item["height"],
207
- width=item["width"],
208
- num_inference_steps=steps,
209
- generator=generator,
210
- true_cfg_scale=cfg,
211
- num_images_per_prompt=1,
212
- ).images[0]
213
- produced[item["key"]] = image
214
- pool[item["key"]] = image
215
- # アンカー生成がそのまま視点の出力を兼ねている場合、同じチャンク内の
216
- # 後続ステップがその視点 key を条件画像として参照することがある
217
- # (例: cowboy_front は full_front を切り出す)。ここで別名を張っておかないと
218
- # 単一チャンク実行時に条件画像が見つからない。
219
- if item.get("alias"):
220
- pool[item["alias"]] = image
221
- print(
222
- f"[gpu] {item['key']}: {_time.perf_counter() - t0:.1f}秒 "
223
- f"({item['width']}x{item['height']}, cond={len(cond)})",
224
- flush=True,
225
- )
226
-
227
- # 最初の 1 枚までの時間には、ZeroGPU がモデル (57.7GB) を GPU へ転送する
228
- # コストが含まれる。チャンクごとに毎回発生するため、duration 見積りの
229
- # 固定費 (CALL_OVERHEAD_SEC) はこれを吸収できる値でなければならない。
230
- print(f"[gpu] chunk done: {_time.perf_counter() - t_enter:.1f}秒", flush=True)
231
- return produced
232
-
233
-
234
- # --- 診断用 -----------------------------------------------------------------
235
- # "GPU task aborted" が本体コードに到達する前に起きるため、どの段階で壊れているかを
236
- # 切り分けるための最小テスト。UI の「診断」から実行できる。
237
-
238
-
239
- @spaces.GPU(duration=60, size=GPU_SIZE)
240
- def gpu_selftest() -> str:
241
- """GPU が取得できるか (= 57.7GB のモデル転送が通るか) だけを見る。"""
242
- lines = [
243
- f"torch={torch.__version__}",
244
- f"cuda_available={torch.cuda.is_available()}",
245
- ]
246
- if torch.cuda.is_available():
247
- free, total = torch.cuda.mem_get_info()
248
- lines += [
249
- f"device={torch.cuda.get_device_name(0)}",
250
- f"total={total / 1024**3:.1f}GB",
251
- f"free={free / 1024**3:.1f}GB",
252
- f"allocated={torch.cuda.memory_allocated() / 1024**3:.1f}GB",
253
- ]
254
- x = torch.randn(64, 64, device="cuda", dtype=torch.bfloat16)
255
- lines.append(f"matmul_ok={bool(torch.isfinite((x @ x).sum()))}")
256
- return " | ".join(lines)
257
-
258
-
259
- @spaces.GPU(duration=120, size=GPU_SIZE)
260
- def gpu_pipe_selftest() -> str:
261
- """単純な引数だけで pipe() を 1 回呼ぶ。本体との差は引数の型と呼び出し元だけ。"""
262
- t0 = __import__("time").perf_counter()
263
- img = pipe(
264
- image=[Image.new("RGB", (832, 1216), "white")],
265
- prompt="<sks> front view eye-level shot wide shot The background is plain white.",
266
- negative_prompt=" ",
267
- height=1216,
268
- width=832,
269
- num_inference_steps=4,
270
- generator=torch.Generator(device=DEVICE).manual_seed(0),
271
- true_cfg_scale=1.0,
272
- ).images[0]
273
- dt = __import__("time").perf_counter() - t0
274
- return f"ok size={img.size} elapsed={dt:.1f}s peak={torch.cuda.max_memory_allocated() / 1024**3:.1f}GB"
275
-
276
-
277
- def _build_payload(step: V.Step, user_extra: str, hq_framing: bool = False) -> dict:
278
- if step.kind == "anchor":
279
- prompt = V.anchor_prompt(step.prompt_of, user_extra)
280
- else:
281
- prompt = V.view_prompt(step.prompt_of, user_extra)
282
- hq = bool(step.crop_kind and hq_framing)
283
- return {
284
- "key": step.key,
285
- # アンカーが視点を兼ねる場合の別名。同一チャンク内の後続ステップが
286
- # この key で条件画像を引くため、GPU 関数側にも渡す必要がある。
287
- "alias": step.view_key if step.kind == "anchor" else "",
288
- "prompt": prompt,
289
- "source": step.source,
290
- "width": step.size[0],
291
- "height": step.size[1],
292
- "crop": step.crop_kind,
293
- "hq": hq,
294
- # 2 枚条件付けは実測で約 1.8 倍の時間がかかる
295
- "units": 1.8 if hq else 1.0,
296
- }
297
-
298
-
299
- def _gallery_items(images: Dict[str, Image.Image], selected: List[str]) -> List[Tuple[Image.Image, str]]:
300
- items = []
301
- for key in selected:
302
- img = images.get(key)
303
- if img is not None:
304
- items.append((img, V.VIEW_BY_KEY[key].label_ja))
305
- return items
306
-
307
-
308
- def generate(
309
- image,
310
- selected_keys: List[str],
311
- extra_prompt: str,
312
- trigger_word: str,
313
- exclude_tags: str,
314
- tagger_choice: str,
315
- wd_threshold: float,
316
- oppai_threshold: float,
317
- seed: int,
318
- randomize_seed: bool,
319
- true_guidance_scale: float,
320
- num_inference_steps: int,
321
- two_stage_prone: bool,
322
- hq_framing: bool,
323
- ):
324
- """チャンクごとに GPU を取り直しながら生成し、途中経過��随時 yield する。
325
-
326
- ジェネレータ自体は GPU 関数ではない。GPU を掴むのは `_run_chunk` だけで、
327
- 1 回あたりの要求 duration を小さく保つことでクォータ不足による門前払いを避ける。
328
- """
329
- if image is None:
330
- raise gr.Error("入力画像をアップロードしてください")
331
- if not selected_keys:
332
- raise gr.Error("視点を 1 つ以上選択してください")
333
-
334
- if randomize_seed:
335
- seed = random.randint(0, MAX_SEED)
336
-
337
- reference = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
338
-
339
- plan = V.build_plan(selected_keys, two_stage_prone)
340
- chunks = _chunks(plan, CHUNK_SIZE)
341
- total_req, n_gen, n_chunks = estimate_quota(selected_keys, two_stage_prone, hq_framing)
342
-
343
- images: Dict[str, Image.Image] = {"reference": reference}
344
- done = 0
345
-
346
- yield (
347
- [],
348
- gr.DownloadButton(value=None, interactive=False),
349
- seed,
350
- f"生成開始: {n_gen} 枚 / {n_chunks} 回に分割 / 要求 {total_req} 秒",
351
- "",
352
  )
353
-
354
- for ci, chunk in enumerate(chunks, start=1):
355
- payload = [_build_payload(s, extra_prompt, hq_framing) for s in chunk]
356
- needed = {p["source"] for p in payload}
357
- sources = {k: v for k, v in images.items() if k in needed}
358
-
359
- labels = " / ".join(s.label_ja for s in chunk)
360
- yield (
361
- _gallery_items(images, selected_keys),
362
- gr.DownloadButton(value=None, interactive=False),
363
- seed,
364
- f"[{ci}/{n_chunks}] 生成中: {labels}",
365
- "",
366
- )
367
-
368
- produced = _run_chunk(payload, sources, int(seed), int(num_inference_steps), float(true_guidance_scale))
369
- images.update(produced)
370
-
371
- # アンカー生成がそのまま視点の出力を兼ねている場合はここで別名を張る
372
- for s in chunk:
373
- if s.kind == "anchor" and s.view_key:
374
- images[s.view_key] = images[s.key]
375
-
376
- done += len(chunk)
377
- yield (
378
- _gallery_items(images, selected_keys),
379
- gr.DownloadButton(value=None, interactive=False),
380
- seed,
381
- f"[{ci}/{n_chunks}] 完了 ({done}/{n_gen} 枚)",
382
- "",
383
- )
384
-
385
- # --- ここから CPU 処理 (ZeroGPU クォータを消費しない) ---
386
- yield (
387
- _gallery_items(images, selected_keys),
388
- gr.DownloadButton(value=None, interactive=False),
389
- seed,
390
- "キャプションを生成中 (CPU)...",
391
- "",
392
- )
393
-
394
- want = {"WD Tagger v3": ["wd"], "OppaiOracle V1.1": ["oppai"], "両方 (比較する)": ["wd", "oppai"]}
395
- active = want.get(tagger_choice, [])
396
- thresholds = {"wd": wd_threshold, "oppai": oppai_threshold}
397
-
398
- per_view: Dict[str, Dict[str, tagging.TagResult]] = {}
399
- for key in selected_keys:
400
- img = images.get(key)
401
- if img is None:
402
- continue
403
- per_view[key] = {}
404
- for name in active:
405
- try:
406
- per_view[key][name] = tagging.TAGGERS[name].predict(img, thresholds[name])
407
- except Exception as exc: # noqa: BLE001 - タグ付け失敗で生成物を捨てない
408
- print(f"[app] タガー {name} が失敗: {exc}")
409
-
410
- primary = active[0] if active else None
411
- alt = active[1] if len(active) > 1 else None
412
-
413
- zip_path = _build_zip(images, selected_keys, per_view, primary, alt, trigger_word, exclude_tags)
414
- report = tagging.comparison_markdown(per_view) if (per_view and alt) else ""
415
-
416
- yield (
417
- _gallery_items(images, selected_keys),
418
- gr.DownloadButton(value=zip_path, interactive=True),
419
- seed,
420
- f"完了: {len(per_view)} 視点 / 要求クォータ 合計 {total_req} 秒 (seed {seed})",
421
- report,
422
- )
423
-
424
-
425
- def _build_zip(
426
- images: Dict[str, Image.Image],
427
- selected_keys: List[str],
428
- per_view: Dict[str, Dict[str, tagging.TagResult]],
429
- primary: str,
430
- alt: str,
431
- trigger_word: str,
432
- exclude_tags: str,
433
- ) -> str:
434
- """kohya-ss にそのまま食わせられる構成で ZIP を作る。
435
-
436
- dataset/NN_key.png + NN_key.txt <- 主タガーのキャプション
437
- captions_alt/NN_key.txt <- 比較用のもう一方
438
- caption_comparison.md
439
- """
440
- exclude = tagging.parse_exclude(exclude_tags)
441
- tmp_dir = tempfile.mkdtemp()
442
- zip_path = os.path.join(tmp_dir, "character_views.zip")
443
-
444
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
445
- for i, key in enumerate(selected_keys, start=1):
446
- img = images.get(key)
447
- if img is None:
448
- continue
449
- base = f"{i:02d}_{key}"
450
-
451
- buf = io.BytesIO()
452
- img.convert("RGB").save(buf, format="PNG")
453
- zf.writestr(f"dataset/{base}.png", buf.getvalue())
454
-
455
- fixed = V.caption_tags(V.VIEW_BY_KEY[key])
456
- results = per_view.get(key, {})
457
-
458
- if primary:
459
- predicted = results[primary].names() if primary in results else []
460
- zf.writestr(
461
- f"dataset/{base}.txt",
462
- tagging.build_caption(fixed, predicted, trigger_word, exclude),
463
- )
464
- else:
465
- # タガーを使わない場合でも構図タグだけのキャプションは出しておく
466
- zf.writestr(
467
- f"dataset/{base}.txt",
468
- tagging.build_caption(fixed, [], trigger_word, exclude),
469
- )
470
-
471
- if alt and alt in results:
472
- zf.writestr(
473
- f"captions_alt/{base}.txt",
474
- tagging.build_caption(fixed, results[alt].names(), trigger_word, exclude),
475
- )
476
-
477
- if per_view and alt:
478
- zf.writestr("caption_comparison.md", tagging.comparison_markdown(per_view))
479
-
480
- return zip_path
481
-
482
-
483
- # --- UI ---------------------------------------------------------------------
484
-
485
- CSS = """
486
- #col-container {margin: 0 auto; max-width: 1400px;}
487
- .notice {
488
- background: #fff5f5; border: 1px solid #fca5a5; color: #7f1d1d;
489
- padding: 12px 14px; border-radius: 10px; font-weight: 600;
490
- line-height: 1.5; margin-bottom: 10px;
491
- }
492
- .quota {
493
- background: #f0f9ff; border: 1px solid #7dd3fc; color: #0c4a6e;
494
- padding: 10px 14px; border-radius: 10px; line-height: 1.6;
495
- }
496
- """
497
-
498
-
499
- def quota_markdown(*group_values) -> str:
500
- selected = [k for group in group_values[:-2] for k in group]
501
- two_stage = bool(group_values[-2])
502
- hq = bool(group_values[-1])
503
- requested, n_gen, n_chunks = estimate_quota(selected, two_stage, hq)
504
- if not n_gen:
505
- return "<div class='quota'>視点が選択されていません。</div>"
506
-
507
- # xlarge は要求 duration が内部で 2 倍されてクォータから引かれる
508
- total = requested * QUOTA_MULTIPLIER
509
-
510
- def verdict(limit: int) -> str:
511
- if total <= limit:
512
- return f"✅ {limit}秒以内 (残り {limit - total}秒)"
513
- return f"❌ {total - limit}秒 超過"
514
-
515
- extra = ""
516
- if n_gen > len(selected):
517
- extra = (
518
- f"<br><small>※ 生成 {n_gen} 枚のうち {n_gen - len(selected)} 枚は、"
519
- "寄りの切り出し元やポーズ基準として必要な中間生成です。</small>"
520
- )
521
-
522
- size_note = (
523
- f" <small>(GPU size <code>{GPU_SIZE}</code> のため要求 {requested} 秒 × 2)</small>"
524
- if QUOTA_MULTIPLIER > 1
525
- else ""
526
- )
527
- return (
528
- "<div class='quota'>"
529
- f"<b>推定 ZeroGPU 消費: {total} 秒</b>{size_note} "
530
- f"({len(selected)} 視点 / 生成 {n_gen} 枚 / {n_chunks} 回に分割)<br>"
531
- f"無料会員 (300秒/日): {verdict(FREE_QUOTA_SEC)}<br>"
532
- f"未認証 (120秒/日): {verdict(ANON_QUOTA_SEC)}"
533
- f"{extra}"
534
- "</div>"
535
- )
536
-
537
-
538
- with gr.Blocks(css=CSS) as demo:
539
- gr.Markdown("# キャラクター視点シート生成 (Qwen-Image-Edit-2511 + 角度LoRA)")
540
  gr.Markdown(
541
- "参照画像 1 枚から、LoRA 学習用の視点セットを生成します。"
542
- "カメラ位置は `fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA` の"
543
- "96 カメラ位置から指定しているため、散文プロンプトより構図が安定します。\n\n"
544
- "💡 **ZeroGPU のクォータは「要求した秒数」がそのまま引かれます** "
545
- "(実行時間ではありません)。不要な視点のチェックを外すほど 1 回の要求が小さくなり、"
546
- "無料枠で通りやすくなります。生成は自動で複数回に分割されるため、"
547
- "途中でクォータが尽きてもそこまでの結果は残ります。"
548
  )
549
-
550
- with gr.Column(elem_id="col-container"):
551
- gr.HTML(
552
- "<div class='notice'>"
553
- "注意:他者が作成した画像のアップロードはご遠慮ください。"
554
- "他人の著作物・肖像権を侵害する恐れがあります。"
555
- "当アプリ作成者は、アップロード内容による権利侵害について一切の責任を負いません。"
556
- "</div>"
557
- )
558
-
559
- with gr.Row():
560
- with gr.Column(scale=1):
561
- input_image = gr.Image(label="入力画像 (参照キャラクター)", type="pil", height=420)
562
- extra_prompt = gr.Textbox(
563
- label="追加プロンプト (全視点の末尾に追記)",
564
- placeholder="例: anime style, soft lighting, pastel colors",
565
- lines=2,
566
- )
567
-
568
- with gr.Column(scale=1):
569
- gr.Markdown("### 生成する視点")
570
- group_boxes = []
571
- for group in V.GROUP_ORDER:
572
- members = [v for v in V.VIEWS if v.group_ja == group]
573
- group_boxes.append(
574
- gr.CheckboxGroup(
575
- label=group,
576
- choices=[(v.label_ja, v.key) for v in members],
577
- value=[v.key for v in members],
578
- )
579
- )
580
- with gr.Row():
581
- preset_buttons = [gr.Button(name, size="sm") for name in V.PRESETS]
582
- hq_framing = gr.Checkbox(
583
- label="寄り視点を高品質モードで生成 (時間 約1.8倍)",
584
- value=False,
585
- info="顔・上半身・カウボーイを「クロップ + 全身」の2枚で条件付けします。"
586
- "情報量が増えて精細になりますが、要求クォータも増えます。",
587
- )
588
- quota_box = gr.HTML(value=quota_markdown(*[[v.key for v in V.VIEWS], False, False]))
589
-
590
- run_button = gr.Button("🎨 生成", variant="primary")
591
- status_text = gr.Textbox(label="ステータス", interactive=False)
592
- gallery = gr.Gallery(
593
- label="生成結果", columns=4, height=620, object_fit="contain", show_download_button=True
594
- )
595
- download_btn = gr.DownloadButton(
596
- label="📦 学習用データセット (画像 + キャプション) を ZIP でダウンロード",
597
- value=None,
598
- variant="secondary",
599
- interactive=False,
600
- )
601
-
602
- with gr.Accordion("🏷️ キャプション設定 (LoRA 学習用)", open=True):
603
- gr.Markdown(
604
- "タグ付けは **CPU (onnxruntime)** で実行するため ZeroGPU のクォータを消費しません。\n\n"
605
- "構図タグ (`full body` / `from behind` / `from above` / `cowboy shot` など) は"
606
- "カメラ設定から**決定的に注入**しています。タガーは構図を安定して当てられないためです。"
607
- )
608
- with gr.Row():
609
- trigger_word = gr.Textbox(
610
- label="トリガーワード (全キャプションの先頭に付与)",
611
- placeholder="例: mychar",
612
- )
613
- exclude_tags = gr.Textbox(
614
- label="除外タグ (カンマ区切り)",
615
- placeholder="例: black hair, blue eyes ← LoRA に焼き込みたい特徴を外す",
616
- )
617
- tagger_choice = gr.Radio(
618
- label="使用するタガー",
619
- choices=["WD Tagger v3", "OppaiOracle V1.1", "両方 (比較する)"],
620
- value="両方 (比較する)",
621
  )
622
- with gr.Row():
623
- wd_threshold = gr.Slider(
624
- label="WD Tagger 閾値",
625
- minimum=tagging.WDTagger.threshold_range[0],
626
- maximum=tagging.WDTagger.threshold_range[1],
627
- step=tagging.WDTagger.threshold_range[2],
628
- value=tagging.WD_DEFAULT_THRESHOLD,
629
- )
630
- oppai_threshold = gr.Slider(
631
- label="OppaiOracle 閾値 (WD よりスコア分布が高い)",
632
- minimum=tagging.OppaiOracleTagger.threshold_range[0],
633
- maximum=tagging.OppaiOracleTagger.threshold_range[1],
634
- step=tagging.OppaiOracleTagger.threshold_range[2],
635
- value=tagging.OPPAI_DEFAULT_THRESHOLD,
636
- )
637
- comparison_md = gr.Markdown(label="タガー比較")
638
-
639
- with gr.Accordion("⚙️ 詳細設定", open=False):
640
- seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
641
- randomize_seed = gr.Checkbox(label="ランダムシード", value=True)
642
- true_guidance_scale = gr.Slider(
643
- label="True guidance scale", minimum=1.0, maximum=10.0, step=0.1, value=1.0
644
- )
645
- num_inference_steps = gr.Slider(
646
- label="生成ステップ数 (Lightning LoRA 前提で 4 推奨)",
647
- minimum=1, maximum=40, step=1, value=4,
648
- )
649
- gr.Markdown("#### 診断")
650
- with gr.Row():
651
- selftest_btn = gr.Button("GPU 取得テスト", size="sm")
652
- pipetest_btn = gr.Button("pipe() 単発テスト", size="sm")
653
- selftest_out = gr.Textbox(label="診断結果", interactive=False)
654
-
655
- two_stage_prone = gr.Checkbox(
656
- label="うつ伏せを 2 段階で生成する (品質優先 / 生成 +1 枚)",
657
- value=False,
658
- info="ポーズ変更とカメラ移動を分けます。1 段階で破綻する場合に有効化してください。",
659
- )
660
-
661
- selftest_btn.click(fn=gpu_selftest, inputs=None, outputs=selftest_out)
662
- pipetest_btn.click(fn=gpu_pipe_selftest, inputs=None, outputs=selftest_out)
663
-
664
- # --- イベント ---
665
- quota_inputs = group_boxes + [two_stage_prone, hq_framing]
666
- for comp in quota_inputs:
667
- comp.change(fn=quota_markdown, inputs=quota_inputs, outputs=quota_box)
668
-
669
- for button, preset_name in zip(preset_buttons, V.PRESETS):
670
- keys = V.PRESETS[preset_name]
671
-
672
- def _apply(keys=keys):
673
- return [
674
- gr.CheckboxGroup(value=[v.key for v in V.VIEWS if v.group_ja == g and v.key in keys])
675
- for g in V.GROUP_ORDER
676
- ]
677
-
678
- button.click(fn=_apply, inputs=None, outputs=group_boxes).then(
679
- fn=quota_markdown, inputs=quota_inputs, outputs=quota_box
680
- )
681
-
682
- def _collect_and_generate(image, *args):
683
- n_groups = len(V.GROUP_ORDER)
684
- selected = [k for group in args[:n_groups] for k in group]
685
- yield from generate(image, selected, *args[n_groups:])
686
-
687
- run_button.click(
688
- fn=_collect_and_generate,
689
- inputs=[input_image, *group_boxes, extra_prompt, trigger_word, exclude_tags,
690
- tagger_choice, wd_threshold, oppai_threshold, seed, randomize_seed,
691
- true_guidance_scale, num_inference_steps, two_stage_prone, hq_framing],
692
- outputs=[gallery, download_btn, seed, status_text, comparison_md],
693
- )
694
-
695
 
696
  if __name__ == "__main__":
697
- demo.launch()
 
1
+ """Character View Sheet: 11 views, optional mannequin guides, PNG-only export."""
2
+ import json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import random
4
  import tempfile
5
+ import time
6
+ from pathlib import Path
7
 
8
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ from backend import MAX_GPU_SECONDS, QUOTA_MULTIPLIER, USE_ANGLES, run_gpu
11
+ from core import build_zip, prepare_reference, requested_seconds
12
+ from pose_guides import ensure_guides, get_guide
13
+ from views import BY_KEY, DEFAULT_KEYS, SIZES, VIEWS, validate_keys
14
 
15
+ GUIDES = ensure_guides()
 
 
 
 
 
 
 
 
 
16
 
17
 
18
+ def allocation_message(keys):
19
+ if not keys:
20
+ return "視点を選択してください。"
21
+ allocation = requested_seconds(keys, MAX_GPU_SECONDS)
22
+ quota = allocation * QUOTA_MULTIPLIER
23
+ message = (
24
+ f"選択: {len(set(keys))}視点。1回のGPU時間枠は最大{allocation}秒"
25
+ f"(利用枠への換算: {quota}秒)。\n\n"
26
+ "未ログインの1日枠は120秒です。他のZeroGPU利用分と共有されます。"
27
+ "11視点の完成時間は設定や混雑で変わります。余裕がなくなったら、完成済み画像を返します。"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  )
29
+ if quota > 120:
30
+ message += "\n\n現在のGPU設定では未ログインの1日枠を超えます。"
31
+ return message
32
+
33
+
34
+ def pose_preview(keys):
35
+ return [(str(GUIDES[key]), BY_KEY[key].label) for key in (keys or []) if key in BY_KEY]
36
+
37
+
38
+ def generate(reference, keys, pose_enabled, resolution, extra, seed, random_seed, crop_close_views=False):
39
+ # Invalid retry inputs must not leave a previous character's ZIP visible.
40
+ yield [], None, None, "入力を確認しています。", seed
41
+ if reference is None:
42
+ raise gr.Error("キャラクターの参照画像をアップロードしてください。")
43
+ try:
44
+ keys = validate_keys(keys)
45
+ except ValueError as exc:
46
+ raise gr.Error(str(exc)) from exc
47
+ if resolution not in SIZES:
48
+ raise gr.Error("解像度を選択してください。")
49
+ seed = random.randint(0, 2**31 - 1) if random_seed else int(seed)
50
+ reference = prepare_reference(reference, 768 if resolution == "fast" else 1216)
51
+ guides = None
52
+ if pose_enabled:
53
+ guides = {key: get_guide(key, *SIZES[resolution][BY_KEY[key].shape]) for key in keys}
54
+
55
+ # Clear stale downloads and results before requesting GPU time.
56
+ yield [], None, None, "生成中です。", seed
57
+ started = time.perf_counter()
58
+ try:
59
+ images, reason, metrics = run_gpu(reference, keys, guides, resolution, seed, extra or "", crop_close_views)
60
+ except Exception as exc:
61
+ print(f"[app] GPU request failed: {type(exc).__name__}: {exc}", flush=True)
62
+ # Keep infrastructure quota errors visible; no invented successful result.
63
+ yield [], None, None, f"生成できませんでした: {exc}", seed
64
+ return
65
+ metrics["request_wall_seconds"] = round(time.perf_counter() - started, 3)
66
+ gallery = [(images[key], BY_KEY[key].label) for key in keys if key in images]
67
+ status = f"{len(images)}/{len(keys)}視点を生成しました。Seed: {seed}"
68
+ if reason:
69
+ status += "\n" + reason
70
+ # Gradio caches yielded files before requesting the next generator item.
71
+ # Keep originals alive across this yield, then remove both report and ZIP.
72
+ with tempfile.TemporaryDirectory(prefix="view-result-") as result_dir:
73
+ report = Path(result_dir) / "generation_metrics.json"
74
+ report.write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8")
75
+ archive = build_zip(images, keys, output_dir=result_dir)
76
+ yield gallery, archive, str(report), status, seed
77
+
78
+
79
+ with gr.Blocks(title="Character View Sheet (Qwen Image 2.1 turbo)", delete_cache=(3600, 3600)) as demo:
80
+ gr.Markdown("# Character View Sheet (Qwen Image 2.1 turbo)\n参照画像とポーズ人形から、学習用の11視点を生成します。")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  gr.Markdown(
82
+ "**研究用デモ** · 多視点生成の速度・人物同一性・ポーズ追従を評価するための試作です。"
83
+ "利用モデルのライセンスは "
84
+ "[Qwen Research License](https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/blob/main/LICENSE)です。"
85
+ "商用利用は別許諾が必要です。生成画像から学習したモデルを配布・提供する場合、"
86
+ "関連文書に「Built with Qwen」または「Improved using Qwen」の表示条件があります。"
 
 
87
  )
88
+ with gr.Row():
89
+ with gr.Column():
90
+ reference = gr.Image(label="キャラクターの参照画像", type="pil", height=380)
91
+ pose_enabled = gr.Checkbox(label="ポーズ人形を参照する", value=True)
92
+ crop_close_views = gr.Checkbox(label="白背景の直立全身画像:顔・上半身用に切り出す", value=False)
93
+ gr.Markdown("切り出しは実験機能です。構図が改善する一方、衣装の形が変わる場合があります。")
94
+ gr.Markdown("人形の姿勢・向き・構図を参考にします。関節位置を厳密に固定する機能ではありません。")
95
+ extra = gr.Textbox(label="追加の指示", placeholder="例: 元画像の線画と塗り方を維持", lines=2, max_lines=4)
96
+ with gr.Column():
97
+ keys = gr.CheckboxGroup(label="生成する視点", choices=[(v.label, v.key) for v in VIEWS], value=DEFAULT_KEYS)
98
+ resolution = gr.Radio(
99
+ label="画像サイズ", choices=[("高速: 全身512×768・寄り640×640", "fast"),
100
+ ("標準・速度優先: 全身832×1216・寄り1024×1024", "balanced"),
101
+ ("標準・細部優先: 全身832×1216・寄り1024×1024", "standard")],
102
+ value="balanced",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  )
104
+ allocation = gr.Markdown(allocation_message(DEFAULT_KEYS))
105
+ with gr.Accordion("ポーズ人形を確認", open=False):
106
+ previews = gr.Gallery(value=pose_preview(DEFAULT_KEYS), columns=4, height=430,
107
+ label="ポーズ参照", object_fit="contain", interactive=False)
108
+ with gr.Accordion("Seed設定", open=False):
109
+ seed = gr.Number(label="Seed", value=0, precision=0, minimum=0, maximum=2**31 - 1)
110
+ random_seed = gr.Checkbox(label="ランダムSeed", value=True)
111
+ run = gr.Button("選択した視点を生成", variant="primary")
112
+ status = gr.Textbox(label="進行状況", interactive=False)
113
+ results = gr.Gallery(label="生成した画像", columns=4, height=580, object_fit="contain")
114
+ download = gr.File(label="画像だけのZIP", interactive=False)
115
+ with gr.Accordion("実行時間の記録", open=False):
116
+ report = gr.File(label="計測結果 JSON", interactive=False)
117
+ gr.Markdown("高速化の確認用です。画像ZIPには画像だけが入ります。")
118
+ if USE_ANGLES:
119
+ gr.Markdown("2.1用角度LoRAを併用した実験設定です。")
120
+
121
+ keys.change(fn=allocation_message, inputs=keys, outputs=allocation, queue=False)
122
+ keys.change(fn=pose_preview, inputs=keys, outputs=previews, queue=False)
123
+ run.click(generate, [reference, keys, pose_enabled, resolution, extra, seed, random_seed, crop_close_views],
124
+ [results, download, report, status, seed], concurrency_limit=1, concurrency_id="generation")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  if __name__ == "__main__":
127
+ demo.queue(max_size=8, default_concurrency_limit=1).launch(max_file_size="20mb")
assets/cowboy_back.png ADDED

Git LFS Details

  • SHA256: f447664995513c7c96f2936d2825889cfa8f4524f79a2304f3a953a0f3365693
  • Pointer size: 131 Bytes
  • Size of remote file: 115 kB
assets/cowboy_front.png ADDED

Git LFS Details

  • SHA256: f13950fb8a82cf3b3ed3e1ded8d0bb25d6dc2d809da631e7d21cb37d4cce80c1
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
assets/face_front.png ADDED
assets/full_back.png ADDED

Git LFS Details

  • SHA256: bca9aafabd13e312dc0b0e75b24473823effb970e1dbe20776082fe43148e196
  • Pointer size: 131 Bytes
  • Size of remote file: 102 kB
assets/full_front.png ADDED

Git LFS Details

  • SHA256: 91891c7bbf20ecc6f82d5f3330e5f1b6ce3aa61ab82f6ff550fca1c8ff4d4059
  • Pointer size: 131 Bytes
  • Size of remote file: 104 kB
assets/full_left.png ADDED
assets/full_right.png ADDED
assets/knees_back.png ADDED

Git LFS Details

  • SHA256: 6f95ff33f58702cf6c98d90d222e5452d213d6c46cdf0f529d9d5d075700f9ec
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
assets/knees_front.png ADDED

Git LFS Details

  • SHA256: 645ef8f2ca2bbc3947c798abfef2fb5b3dc0b74689b2ad8c13e5f7b831770e77
  • Pointer size: 131 Bytes
  • Size of remote file: 132 kB
assets/pose_preview.png ADDED

Git LFS Details

  • SHA256: d04404b0b3d0f73d03f6b9e2157a96eb33e30c29c4ae94ab8b899cb04120b6c8
  • Pointer size: 131 Bytes
  • Size of remote file: 256 kB
assets/prone_back.png ADDED

Git LFS Details

  • SHA256: 456ce5836af1224fd50f621d0c5b9d43522c555670334f202d2130f61f526533
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
assets/upper_front.png ADDED
backend.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turbo workflow verified on local CUDA and Hugging Face ZeroGPU large."""
2
+ import os
3
+ import time
4
+
5
+ import spaces
6
+ import torch
7
+ from diffusers import QwenImage21Pipeline
8
+ from huggingface_hub import hf_hub_download
9
+
10
+ from core import requested_seconds, run_batch, rgb_on_white
11
+ from angles_adapter import load_angles_adapter
12
+ from views import REFERENCE_RESOLUTION
13
+
14
+ MODEL_ID = "Qwen/Qwen-Image-2.1-Turbo"
15
+ MODEL_REVISION = "d65dbc9a7e8f6b5479e33dee6030eaab2a906509"
16
+ GPU_SIZE = os.getenv("GPU_SIZE", "large")
17
+ if GPU_SIZE not in ("large", "xlarge"):
18
+ raise ValueError("GPU_SIZE must be large or xlarge")
19
+ QUOTA_MULTIPLIER = 2 if GPU_SIZE == "xlarge" else 1
20
+ MAX_GPU_SECONDS = int(os.getenv("MAX_GPU_SECONDS", "110"))
21
+ if not 45 <= MAX_GPU_SECONDS <= 110:
22
+ raise ValueError("MAX_GPU_SECONDS must be between 45 and 110")
23
+ STARTUP_RESERVE_SECONDS = 15
24
+ USE_ANGLES = os.getenv("ENABLE_ANGLES_LORA", "0") == "1"
25
+ ANGLES_REPO = "akhaliq/Qwen-Image-2.1-Multiple-Angles-LoRA"
26
+ ANGLES_WEIGHT = ("checkpoints_v2/angles_v2_full_qwen21_multiple_angles_v2_"
27
+ "qwen21_multiple_angles_v2_000001500.safetensors")
28
+
29
+ # ZeroGPU requires startup CUDA placement, not moving the model for each request.
30
+ # Do not reuse the old 2511 Lightning or angles adapters on this architecture.
31
+ pipe = QwenImage21Pipeline.from_pretrained(
32
+ MODEL_ID, revision=MODEL_REVISION, dtype=torch.bfloat16,
33
+ )
34
+ if USE_ANGLES:
35
+ checkpoint = hf_hub_download(ANGLES_REPO, filename=ANGLES_WEIGHT)
36
+ print(load_angles_adapter(pipe, checkpoint, adapter_name="angles", strength=0.8))
37
+ pipe.fuse_lora(adapter_names=["angles"])
38
+ pipe.unload_lora_weights()
39
+ pipe.vae.enable_tiling()
40
+ pipe.to("cuda")
41
+ if len(pipe.config.get("sample_sigmas", [])) != 8:
42
+ raise RuntimeError("Turboの8-stepスケジュールを読み込めていません。Diffusersのバージョンを確認してください。")
43
+
44
+
45
+ def duration(reference, keys, guides, resolution, seed, extra, crop_close_views=False):
46
+ return requested_seconds(keys, MAX_GPU_SECONDS)
47
+
48
+
49
+ @spaces.GPU(duration=duration, size=GPU_SIZE)
50
+ def run_gpu(reference, keys, guides, resolution, seed, extra, crop_close_views=False):
51
+ started = time.perf_counter()
52
+ torch.cuda.reset_peak_memory_stats()
53
+
54
+ def generate_one(*, image, prompt, width, height, seed, check_budget):
55
+ def callback(pipeline, step, timestep, callback_kwargs):
56
+ check_budget()
57
+ return callback_kwargs
58
+
59
+ output = pipe(
60
+ image=image,
61
+ prompt=prompt,
62
+ width=width,
63
+ height=height,
64
+ # Also bounds the area used to encode EACH reference; reducing only
65
+ # PIL input dimensions does not avoid the pipeline's internal upscaling.
66
+ output_resolution=REFERENCE_RESOLUTION[resolution],
67
+ true_cfg_scale=1.0,
68
+ use_kv_cache=True,
69
+ num_images_per_prompt=1,
70
+ generator=torch.Generator("cpu").manual_seed(seed),
71
+ callback_on_step_end=callback,
72
+ ).images[0]
73
+ return rgb_on_white(output)
74
+
75
+ allocation = duration(reference, keys, guides, resolution, seed, extra)
76
+ result = run_batch(
77
+ generate_one, reference, keys, guides, resolution, seed, extra, USE_ANGLES,
78
+ budget_seconds=allocation - STARTUP_RESERVE_SECONDS,
79
+ crop_close_views=crop_close_views,
80
+ )
81
+ metrics = {
82
+ "model": MODEL_ID, "revision": MODEL_REVISION, "gpu": torch.cuda.get_device_name(),
83
+ "gpu_size": GPU_SIZE, "reserved_seconds": allocation,
84
+ "quota_budget_seconds": allocation * QUOTA_MULTIPLIER,
85
+ "gpu_function_seconds": round(time.perf_counter() - started, 3),
86
+ "peak_allocated_gib": round(torch.cuda.max_memory_allocated() / 1024**3, 2),
87
+ "resolution": resolution, "pose_guides": guides is not None, "angles_lora": USE_ANGLES,
88
+ "crop_close_views": crop_close_views,
89
+ "seed": int(seed), "requested_views": keys, "completed_views": list(result.images),
90
+ "timings": result.timings, "stop_reason": result.stop_reason,
91
+ }
92
+ return result.images, result.stop_reason, metrics
bench/README.md DELETED
@@ -1,102 +0,0 @@
1
- ---
2
- title: Krea2 vs Qwen-Image-Edit Benchmark
3
- emoji: ⏱️
4
- colorFrom: yellow
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 5.49.1
8
- app_file: app.py
9
- pinned: false
10
- license: apache-2.0
11
- short_description: Krea 2 Turbo と Qwen-Image-Edit-2511 の生成時間・VRAM を実測比較
12
- ---
13
-
14
- # Krea 2 Turbo vs Qwen-Image-Edit-2511 ベンチマーク
15
-
16
- **これは別 Space として公開するためのディレクトリ。** メインの
17
- `Qwen-Image-Edit-2511-Lora-Sheet` とは独立した Space リポジトリに push すること。
18
-
19
- ```bash
20
- # 例
21
- huggingface-cli repo create Krea2-vs-Qwen-Edit-Bench --type space --sdk gradio
22
- cd bench && git init && git remote add origin https://huggingface.co/spaces/<user>/Krea2-vs-Qwen-Edit-Bench
23
- git add . && git commit -m "init" && git push -u origin main
24
- ```
25
-
26
- > ⚠️ **無料アカウントがホストできる ZeroGPU Space は最大 2 つ。**
27
- > メインの Space と合わせてちょうど上限に達する。
28
-
29
- ## 何を測るか
30
-
31
- - 1 枚あたりの生成時間 (cold = 初回 / warm = 2 回目以降の中央値)
32
- - ピーク VRAM (`torch.cuda.max_memory_allocated`)
33
- - 同一 seed・同一解像度での出力画像
34
-
35
- ## 公平性についての注記
36
-
37
- **同一ワークロードの比較ではない。**
38
-
39
- | | Krea 2 Turbo | Qwen-Image-Edit-2511 |
40
- |---|---|---|
41
- | stock diffusers での用途 | text-to-image のみ | 画像編集 (i2i, 多画像入力可) |
42
- | パイプライン | `Krea2Pipeline` | `QwenImageEditPlusPipeline` |
43
- | ステップ | 8 (TDM 蒸留), guidance 0.0 | 4 (Lightning LoRA), true_cfg 1.0 |
44
-
45
- Krea 2 には「参照画像 1 枚から同一キャラの多視点を派生させる」機能が公式に存在しない。
46
- そのため本ベンチは**各モデルが本来の用途を実行したときのコスト**を並べている。
47
-
48
- ## VRAM と ZeroGPU サイズ
49
-
50
- bf16 での重みは概算で
51
-
52
- - Qwen-Image-Edit-2511: transformer 40.9GB + text encoder (Qwen2.5-VL-7B) 16.6GB ≈ **57GB**
53
- - Krea 2 Turbo: transformer ~24GB + text encoder (Qwen3-VL-4B) ~8GB ≈ **32GB**
54
-
55
- 合計 89GB となり、ZeroGPU の `large` (48GB) はもちろん `xlarge` (96GB) でも
56
- 活性値を含めると危うい。既定では**両モデルとも fp8 量子化**し `size="xlarge"` で動かす。
57
-
58
- > `xlarge` はクォータを **2 倍**消費する。片方だけを正確に測るなら `BENCH_MODELS` で
59
- > 絞ること (1 モデルなら `large` に収まり消費も半分になる)。
60
-
61
- ## 環境変数
62
-
63
- | 変数 | 既定 | 意味 |
64
- |---|---|---|
65
- | `BENCH_MODELS` | `both` | `both` / `qwen` / `krea` |
66
- | `BENCH_FP8` | `1` | `0` で bf16 のまま |
67
- | `BENCH_SIZE` | `xlarge` | ZeroGPU の GPU サイズ |
68
- | `KREA_REPO` | (なし) | Krea 2 の取得元を明示指定 |
69
-
70
- ## Krea 2 の取得について
71
-
72
- 本家 [`krea/Krea-2-Turbo`](https://huggingface.co/krea/Krea-2-Turbo) は **gated (auto)**。
73
- Space の Settings で `HF_TOKEN` シークレットを設定し、モデルページで利用規約に同意しておく。
74
- それを避けたい場合は非 gated のミラー (`unsloth/Krea-2-Turbo` /
75
- `CalamitousFelicitousness/Krea-2-Turbo-Diffusers`) が自動でフォールバック候補になる。
76
-
77
- ### ライセンス上の注意
78
-
79
- Krea 2 Community License は、**モデルをホストする側に入出力のセーフティ分類器の実装**を
80
- 求めている (NCII / CSAM 等の生成防止)。また無償の商用利用は年商 $1M 未満かつ 50 席未満に限られる。
81
-
82
- このベンチ Space はユーザーの画像アップロードを受け付けるため、**公開運用するなら
83
- この要件を検討する必要がある**。計測目的に限るなら Space を private にするのが安全。
84
-
85
- Qwen-Image-Edit-2511 は Apache-2.0 で、この種の義務はない。
86
-
87
- ## Krea 2 の identity 編集を比較したい場合
88
-
89
- Krea 2 で「参照画像の同一性を保った編集」を試すには、以下のいずれかが必要:
90
-
91
- 1. ComfyUI + [`conradlocke/krea2-identity-edit`](https://huggingface.co/conradlocke/krea2-identity-edit)
92
- + 専用ノードパック `ComfyUI-Krea2Edit` (標準ノードでは扱えない dual conditioning を使うため)
93
- 2. 非公式の diffusers 移植
94
- [`huan-yin/Krea2-Identity-Edit-Diffusers`](https://github.com/huan-yin/Krea2-Identity-Edit-Diffusers)
95
- (`trust_remote_code=True`, 10 step, `ref_boost` 既定 4.0)
96
-
97
- いずれも公式サポート外で、元 LoRA のダウンロード実績もほぼ無い。モデルカード自身が
98
- 「特徴的な顔の骨格には弱い」「衣装の入れ替えは安定しない」と明記しており、
99
- 多視点にわたる同一性維持という用途では不利。
100
-
101
- Space のビルドを壊すリスクがあるため `requirements.txt` には含めていない。
102
- 試す場合は手動で追加すること。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bench/app.py DELETED
@@ -1,288 +0,0 @@
1
- """Krea 2 Turbo vs Qwen-Image-Edit-2511 の実測ベンチマーク Space.
2
-
3
- 測るもの:
4
- - 1 枚あたりの生成時間 (cold = 初回, warm = 2 回目以降の中央値)
5
- - ピーク VRAM (torch.cuda.max_memory_allocated)
6
- - 同一 seed / 同一解像度での出力画像
7
-
8
- **公平性についての注記**: この 2 つは同じ土俵に乗らない。
9
- Krea 2 Turbo は text-to-image 専用 (stock diffusers に編集パイプラインが無い)、
10
- Qwen-Image-Edit-2511 は画像編集モデルである。そのため
11
- 「それぞれが本来の用途を実行したときのコスト」を測る形にしてある。
12
- 同一ワークロードの比較ではないので、表を読むときは注意すること。
13
-
14
- VRAM について:
15
- bf16 では Qwen 約 57GB + Krea2 約 32GB = 89GB となり、ZeroGPU の
16
- `large` (48GB) はもちろん `xlarge` (96GB) でも活性値を含めると危うい。
17
- 既定では両モデルとも fp8 量子化し、`size="xlarge"` で動かす。
18
- 片方だけを正確に測りたい場合は環境変数 BENCH_MODELS で絞ること
19
- (1 モデルなら `large` に収まり、クォータ消費も半分になる)。
20
-
21
- 環境変数:
22
- BENCH_MODELS "both" (既定) | "qwen" | "krea"
23
- BENCH_FP8 "1" (既定) で fp8 量子化。"0" で bf16
24
- BENCH_SIZE "xlarge" (既定) | "large" ZeroGPU の GPU サイズ
25
- KREA_REPO Krea 2 の取得元を上書き
26
- """
27
-
28
- import os
29
- import statistics
30
- import time
31
- from typing import Dict, List, Optional
32
-
33
- import gradio as gr
34
- import spaces
35
- import torch
36
- from PIL import Image
37
-
38
- MODELS = os.environ.get("BENCH_MODELS", "both").lower()
39
- USE_FP8 = os.environ.get("BENCH_FP8", "1") not in ("0", "false", "False")
40
- GPU_SIZE = os.environ.get("BENCH_SIZE", "xlarge")
41
-
42
- DTYPE = torch.bfloat16
43
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
44
-
45
- QWEN_REPO = "Qwen/Qwen-Image-Edit-2511"
46
- QWEN_LIGHTNING_REPO = "lightx2v/Qwen-Image-Edit-2511-Lightning"
47
- QWEN_LIGHTNING_WEIGHT = "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors"
48
- QWEN_STEPS = 4
49
- QWEN_CFG = 1.0
50
-
51
- # 本家 krea/Krea-2-Turbo は gated (auto)。Space に HF_TOKEN シークレットを設定して
52
- # 利用規約に同意しておくか、非 gated のミラーを使う。
53
- KREA_REPO_CANDIDATES = [
54
- os.environ.get("KREA_REPO"),
55
- "krea/Krea-2-Turbo",
56
- "unsloth/Krea-2-Turbo",
57
- "CalamitousFelicitousness/Krea-2-Turbo-Diffusers",
58
- ]
59
- KREA_STEPS = 8
60
- KREA_GUIDANCE = 0.0
61
-
62
- LOAD_NOTES: List[str] = []
63
-
64
-
65
- def _quantize(module) -> bool:
66
- if not USE_FP8:
67
- return False
68
- try:
69
- from torchao.quantization import Float8DynamicActivationFloat8WeightConfig, quantize_
70
-
71
- quantize_(module, Float8DynamicActivationFloat8WeightConfig())
72
- return True
73
- except Exception as exc: # noqa: BLE001
74
- LOAD_NOTES.append(f"fp8 量子化に失敗 (bf16 で継続): {exc}")
75
- return False
76
-
77
-
78
- def _load_qwen():
79
- from diffusers import QwenImageEditPlusPipeline
80
-
81
- pipe = QwenImageEditPlusPipeline.from_pretrained(QWEN_REPO, torch_dtype=DTYPE).to(DEVICE)
82
- pipe.load_lora_weights(QWEN_LIGHTNING_REPO, weight_name=QWEN_LIGHTNING_WEIGHT)
83
- pipe.fuse_lora(lora_scale=1.0)
84
- pipe.unload_lora_weights()
85
- _quantize(pipe.transformer)
86
- return pipe
87
-
88
-
89
- def _load_krea():
90
- try:
91
- from diffusers import Krea2Pipeline
92
- except ImportError as exc:
93
- LOAD_NOTES.append(
94
- "Krea2Pipeline を import できませんでした。diffusers 0.39.0 以上が必要です "
95
- f"({exc})"
96
- )
97
- return None
98
-
99
- last_error: Optional[Exception] = None
100
- for repo in [r for r in KREA_REPO_CANDIDATES if r]:
101
- try:
102
- pipe = Krea2Pipeline.from_pretrained(repo, torch_dtype=DTYPE).to(DEVICE)
103
- _quantize(pipe.transformer)
104
- LOAD_NOTES.append(f"Krea 2 Turbo を `{repo}` から読み込み")
105
- return pipe
106
- except Exception as exc: # noqa: BLE001
107
- last_error = exc
108
- LOAD_NOTES.append(f"`{repo}` の読み込みに失敗: {type(exc).__name__}")
109
- LOAD_NOTES.append(
110
- "Krea 2 Turbo を読み込めませんでした。本家 `krea/Krea-2-Turbo` は gated のため、"
111
- "Space の Settings で HF_TOKEN シークレットを設定し、モデルページで利用規約に"
112
- f"同意してください。最後のエラー: {last_error}"
113
- )
114
- return None
115
-
116
-
117
- qwen_pipe = _load_qwen() if MODELS in ("both", "qwen") else None
118
- krea_pipe = _load_krea() if MODELS in ("both", "krea") else None
119
-
120
-
121
- # --- 計測 -------------------------------------------------------------------
122
-
123
-
124
- def _measure(fn, runs: int) -> Dict:
125
- """fn を runs 回実行し、時間とピーク VRAM を記録する。"""
126
- torch.cuda.reset_peak_memory_stats()
127
- timings: List[float] = []
128
- image = None
129
- for _ in range(runs):
130
- torch.cuda.synchronize()
131
- t0 = time.perf_counter()
132
- image = fn()
133
- torch.cuda.synchronize()
134
- timings.append(time.perf_counter() - t0)
135
- return {
136
- "image": image,
137
- "cold": timings[0],
138
- "warm": statistics.median(timings[1:]) if len(timings) > 1 else timings[0],
139
- "all": timings,
140
- "peak_vram_gb": torch.cuda.max_memory_allocated() / (1024**3),
141
- }
142
-
143
-
144
- def _bench_duration(reference, prompt, seed, width, height, runs, *args, **kwargs) -> int:
145
- """要求 duration。Krea 8 step + Qwen 4 step を runs 回ずつ。"""
146
- per_run = 12 # 1 枚あたりの余裕を見た秒数
147
- models = sum(1 for p in (qwen_pipe, krea_pipe) if p is not None)
148
- return int(30 + per_run * max(1, runs) * max(1, models))
149
-
150
-
151
- @spaces.GPU(duration=_bench_duration, size=GPU_SIZE)
152
- def run_benchmark(reference, prompt, seed, width, height, runs):
153
- results: Dict[str, Dict] = {}
154
-
155
- if qwen_pipe is not None:
156
- if reference is None:
157
- raise RuntimeError(
158
- "Qwen-Image-Edit-2511 は編集モデルなので参照画像が必要です。"
159
- "画像をアップロードするか BENCH_MODELS=krea にしてください。"
160
- )
161
- ref = reference.convert("RGB")
162
-
163
- def _qwen():
164
- gen = torch.Generator(device=DEVICE).manual_seed(int(seed))
165
- return qwen_pipe(
166
- image=[ref],
167
- prompt=prompt,
168
- negative_prompt=" ",
169
- height=int(height),
170
- width=int(width),
171
- num_inference_steps=QWEN_STEPS,
172
- generator=gen,
173
- true_cfg_scale=QWEN_CFG,
174
- ).images[0]
175
-
176
- results["qwen"] = _measure(_qwen, int(runs))
177
-
178
- if krea_pipe is not None:
179
-
180
- def _krea():
181
- gen = torch.Generator(device=DEVICE).manual_seed(int(seed))
182
- return krea_pipe(
183
- prompt,
184
- height=int(height),
185
- width=int(width),
186
- num_inference_steps=KREA_STEPS,
187
- guidance_scale=KREA_GUIDANCE,
188
- generator=gen,
189
- ).images[0]
190
-
191
- results["krea"] = _measure(_krea, int(runs))
192
-
193
- return results
194
-
195
-
196
- def _format_report(results: Dict[str, Dict], width: int, height: int, runs: int) -> str:
197
- precision = "fp8 (torchao dynamic)" if USE_FP8 else "bf16"
198
- lines = [
199
- "## 実測結果",
200
- "",
201
- f"解像度 {width}×{height} / 各 {runs} 回 / 精度 {precision} / ZeroGPU size `{GPU_SIZE}`",
202
- "",
203
- "| モデル | モード | ステップ | cold (初回) | warm (中央値) | ピーク VRAM |",
204
- "|---|---|:---:|---:|---:|---:|",
205
- ]
206
- labels = {
207
- "qwen": ("Qwen-Image-Edit-2511 + Lightning", "画像編集 (i2i)", QWEN_STEPS),
208
- "krea": ("Krea 2 Turbo", "text-to-image", KREA_STEPS),
209
- }
210
- for key, res in results.items():
211
- name, mode, steps = labels[key]
212
- lines.append(
213
- f"| {name} | {mode} | {steps} | {res['cold']:.2f} 秒 | "
214
- f"{res['warm']:.2f} 秒 | {res['peak_vram_gb']:.1f} GB |"
215
- )
216
-
217
- lines += [
218
- "",
219
- "### 読み方の注意",
220
- "",
221
- "- **同一ワークロードの比較ではない。** Krea 2 Turbo は stock diffusers では "
222
- "text-to-image 専用で、参照画像から視点を派生させる編集ができない。"
223
- "ここでは各モデルが本来の用途を実行したときのコストを並べている。",
224
- "- ピーク VRAM は同一プロセス内の累積値なので、2 モデルを同時に読み込んだ場合は"
225
- "分離できない。正確に測るには `BENCH_MODELS` で 1 モデルに絞ること。",
226
- "- cold は初回呼び出し (カーネル選択や遅延初期化を含む)、warm は 2 回目以降の中央値。",
227
- ]
228
- if LOAD_NOTES:
229
- lines += ["", "### 読み込み時のログ", ""] + [f"- {n}" for n in LOAD_NOTES]
230
- return "\n".join(lines)
231
-
232
-
233
- def on_run(reference, prompt, seed, width, height, runs):
234
- results = run_benchmark(reference, prompt, int(seed), int(width), int(height), int(runs))
235
- gallery = []
236
- if "qwen" in results:
237
- gallery.append((results["qwen"]["image"], "Qwen-Image-Edit-2511 (i2i, 4 step)"))
238
- if "krea" in results:
239
- gallery.append((results["krea"]["image"], "Krea 2 Turbo (t2i, 8 step)"))
240
- return gallery, _format_report(results, int(width), int(height), int(runs))
241
-
242
-
243
- # --- UI ---------------------------------------------------------------------
244
-
245
- with gr.Blocks() as demo:
246
- gr.Markdown("# Krea 2 Turbo vs Qwen-Image-Edit-2511 ベンチマーク")
247
- gr.Markdown(
248
- "生成時間とピーク VRAM を実測して比較します。\n\n"
249
- "⚠️ **同一ワークロードの比較ではありません。** Krea 2 Turbo は stock diffusers では "
250
- "text-to-image 専用で、参照画像から多視点を派生させる編集機能がありません。"
251
- "各モデルが本来の用途を実行したときのコストを並べたものです。\n\n"
252
- f"現在の設定: モデル `{MODELS}` / fp8 `{USE_FP8}` / GPU size `{GPU_SIZE}`"
253
- )
254
- if LOAD_NOTES:
255
- gr.Markdown("**読み込み時のログ**\n\n" + "\n".join(f"- {n}" for n in LOAD_NOTES))
256
-
257
- with gr.Row():
258
- with gr.Column():
259
- reference = gr.Image(label="参照画像 (Qwen の編集用)", type="pil", height=320)
260
- prompt = gr.Textbox(
261
- label="プロンプト (両モデル共通)",
262
- value="A full-body character standing in a neutral A-pose, plain white background.",
263
- lines=3,
264
- )
265
- with gr.Row():
266
- width = gr.Slider(label="幅", minimum=512, maximum=1536, step=64, value=1024)
267
- height = gr.Slider(label="高さ", minimum=512, maximum=1536, step=64, value=1024)
268
- with gr.Row():
269
- seed = gr.Number(label="Seed", value=42, precision=0)
270
- runs = gr.Slider(
271
- label="計測回数 (1回目は cold として別集計)",
272
- minimum=1, maximum=5, step=1, value=3,
273
- )
274
- run_button = gr.Button("⏱️ ベンチマーク実行", variant="primary")
275
-
276
- with gr.Column():
277
- gallery = gr.Gallery(label="出力", columns=2, height=420, object_fit="contain")
278
- report = gr.Markdown()
279
-
280
- run_button.click(
281
- fn=on_run,
282
- inputs=[reference, prompt, seed, width, height, runs],
283
- outputs=[gallery, report],
284
- )
285
-
286
-
287
- if __name__ == "__main__":
288
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bench/requirements.txt DELETED
@@ -1,20 +0,0 @@
1
- # Krea2Pipeline は diffusers 0.39.0 で追加された。それ未満だと import できない。
2
- --extra-index-url https://download.pytorch.org/whl/cu130
3
- torch==2.11.0
4
- torchvision==0.26.0
5
- transformers==5.14.1
6
- accelerate==1.14.0
7
- diffusers==0.39.0
8
- peft==0.19.1
9
- huggingface-hub==1.24.0
10
- spaces==0.51.1
11
- torchao==0.16.0
12
- safetensors
13
- sentencepiece
14
- pillow
15
-
16
- # 注記: Krea 2 の identity 編集を比較したい場合は、非公式の diffusers 移植
17
- # pip install git+https://github.com/huan-yin/Krea2-Identity-Edit-Diffusers
18
- # を追加する。ただし trust_remote_code が必要で、元になる LoRA
19
- # (conradlocke/krea2-identity-edit) はダウンロード実績がほぼ無く未検証。
20
- # Space のビルドを壊すおそれがあるため、既定では含めていない。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark_local.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibrate the eleven-view Turbo workflow on a local CUDA GPU.
2
+
3
+ No Spaces/Gradio import, quota timer, or captions are used. The angle LoRA is off
4
+ by default and can be enabled for a comparison using an existing --image. An omitted
5
+ --image uses the bundled mannequin for a speed smoke test only; --create-sample
6
+ first generates a synthetic character reference with the same loaded model. A memory limit
7
+ caps PyTorch's allocator; it does not emulate MIG, half a GPU, or ZeroGPU latency.
8
+ """
9
+ import argparse
10
+ from datetime import datetime, timezone
11
+ import importlib.metadata
12
+ import json
13
+ import math
14
+ from pathlib import Path
15
+ import sys
16
+ import time
17
+
18
+ from PIL import Image
19
+
20
+ from core import prepare_reference, conditioning_reference, rgb_on_white
21
+ from pose_guides import get_guide
22
+ from views import BY_KEY, DEFAULT_KEYS, SIZES, make_prompt, PROMPT_VERSION, REFERENCE_RESOLUTION
23
+
24
+ # Keep in sync with backend.py without importing its ZeroGPU startup code.
25
+ MODEL_ID = "Qwen/Qwen-Image-2.1-Turbo"
26
+ MODEL_REVISION = "d65dbc9a7e8f6b5479e33dee6030eaab2a906509"
27
+ ANGLES_REPO = "akhaliq/Qwen-Image-2.1-Multiple-Angles-LoRA"
28
+ ANGLES_WEIGHT = ("checkpoints_v2/angles_v2_full_qwen21_multiple_angles_v2_"
29
+ "qwen21_multiple_angles_v2_000001500.safetensors")
30
+ ANGLES_STRENGTH = 0.8
31
+ GIB = 1024**3
32
+ SAMPLE_SEED = 20261012
33
+ SAMPLE_PROMPT = (
34
+ "A single clean anime-style character illustration of a clearly adult woman in her late twenties, "
35
+ "full body, straight front view at eye level, standing in a relaxed neutral A-pose with her hands "
36
+ "lowered and legs narrowly spaced. Short teal bob haircut ending at her jaw, straight bangs, "
37
+ "amber eyes and a calm neutral expression. She wears a bright orange hip-length zip-front jacket "
38
+ "with a large cobalt-blue rectangular patch on the left chest and a yellow stripe around the "
39
+ "right forearm, a cream crew-neck shirt, straight dark navy trousers, and white ankle boots "
40
+ "with black soles. Opaque everyday clothing, natural adult proportions, clearly defined clothing "
41
+ "seams and consistent flat colors with restrained cel shading. Show the complete top of her "
42
+ "head and both boots with generous margins. Plain white background, soft even lighting. "
43
+ "One character only, no text, no letters, no logo, no reference panels, no collage."
44
+ )
45
+
46
+
47
+ def positive_int(value):
48
+ number = int(value)
49
+ if number < 1:
50
+ raise argparse.ArgumentTypeError("must be at least 1")
51
+ return number
52
+
53
+
54
+ def positive_float(value):
55
+ number = float(value)
56
+ if not math.isfinite(number) or number <= 0:
57
+ raise argparse.ArgumentTypeError("must be a finite number greater than 0")
58
+ return number
59
+
60
+
61
+ def parse_args(argv=None):
62
+ parser = argparse.ArgumentParser(description=__doc__)
63
+ source = parser.add_mutually_exclusive_group()
64
+ source.add_argument("--image", type=Path,
65
+ help="Character reference. Omit both source options for mannequin-only speed smoke test.")
66
+ source.add_argument("--create-sample", action="store_true",
67
+ help="Generate a synthetic adult anime character reference with the same loaded Turbo model.")
68
+ parser.add_argument("--output-dir", type=Path, required=True,
69
+ help="Save PNGs and metrics.json here; existing metrics are never overwritten.")
70
+ parser.add_argument("--resolution", choices=tuple(SIZES), default="fast")
71
+ parser.add_argument("--pose-guides", choices=("on", "off"), default="on")
72
+ parser.add_argument("--reference-crop", choices=("on", "off"), default="off",
73
+ help="Use close-view crops only for an upright full-body reference on white.")
74
+ parser.add_argument("--angles-lora", choices=("on", "off"), default="off",
75
+ help="Compare the optional Qwen2.1 angle adapter at strength0.8. Requires --image.")
76
+ parser.add_argument("--repeat", type=positive_int, default=1,
77
+ help="Number of complete eleven-view batches (default: 1).")
78
+ parser.add_argument("--warmup", type=int, choices=(0, 1), default=0,
79
+ help="Optionally generate one extra front view before the measured batches. "
80
+ "Its time, memory and image are also recorded (default: 0).")
81
+ parser.add_argument("--memory-limit-gib", type=positive_float,
82
+ help="PyTorch allocator cap in GiB, e.g. 48. This is not a MIG/compute cap.")
83
+ parser.add_argument("--seed", type=int, default=0,
84
+ help="Base seed; each view adds its fixed index, identical across repeats.")
85
+ args = parser.parse_args(argv)
86
+ if args.angles_lora == "on" and not args.image:
87
+ parser.error("--angles-lora on requires --image; reuse an existing reference "
88
+ "instead of --create-sample for a comparable source image")
89
+ return args
90
+
91
+
92
+ def write_metrics(path, metrics):
93
+ """Replace the JSON atomically, retaining completed results after a late error."""
94
+ temporary = path.with_suffix(".json.tmp")
95
+ temporary.write_text(json.dumps(metrics, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
96
+ temporary.replace(path)
97
+
98
+
99
+ def cuda_memory(torch):
100
+ return {
101
+ "allocated_gib": round(torch.cuda.memory_allocated() / GIB, 4),
102
+ "reserved_gib": round(torch.cuda.memory_reserved() / GIB, 4),
103
+ "peak_allocated_gib": round(torch.cuda.max_memory_allocated() / GIB, 4),
104
+ "peak_reserved_gib": round(torch.cuda.max_memory_reserved() / GIB, 4),
105
+ }
106
+
107
+
108
+ def package_versions():
109
+ versions = {}
110
+ for name in ("torch", "diffusers", "transformers", "accelerate", "peft", "pillow"):
111
+ try:
112
+ versions[name] = importlib.metadata.version(name)
113
+ except importlib.metadata.PackageNotFoundError:
114
+ versions[name] = None
115
+ return versions
116
+
117
+
118
+ def benchmark(args):
119
+ output_dir = args.output_dir.expanduser().resolve()
120
+ output_dir.mkdir(parents=True, exist_ok=True)
121
+ metrics_path = output_dir / "metrics.json"
122
+ if metrics_path.exists():
123
+ raise FileExistsError(f"{metrics_path} already exists; choose a new --output-dir")
124
+
125
+ pose_enabled = args.pose_guides == "on"
126
+ angles_enabled = args.angles_lora == "on"
127
+ metrics = {
128
+ "status": "running",
129
+ "started_at_utc": datetime.now(timezone.utc).isoformat(),
130
+ "model": MODEL_ID,
131
+ "revision": MODEL_REVISION,
132
+ "input": str(args.image.expanduser().resolve()) if args.image else None,
133
+ "input_kind": ("user_reference" if args.image else
134
+ "synthetic_character" if args.create_sample else "mannequin_speed_smoke_only"),
135
+ "quality_validation": "not performed by this benchmark",
136
+ "resolution": args.resolution,
137
+ "pose_guides": pose_enabled,
138
+ "angles_lora": angles_enabled,
139
+ "prompt_version": PROMPT_VERSION,
140
+ "conditioning": ("original reference with white-background close-view crops v1"
141
+ if args.reference_crop == "on" else "original reference unchanged"),
142
+ "crop_close_views": args.reference_crop == "on",
143
+ "angles_adapter": ({"repo": ANGLES_REPO, "weight": ANGLES_WEIGHT,
144
+ "strength": ANGLES_STRENGTH, "adapter_name": "angles",
145
+ "strategy": "fuse on CPU, unload adapter, then move pipeline to CUDA"}
146
+ if angles_enabled else None),
147
+ "base_seed": args.seed,
148
+ "requested_views": DEFAULT_KEYS,
149
+ "repeat": args.repeat,
150
+ "warmup": args.warmup,
151
+ "memory_limit_gib": args.memory_limit_gib,
152
+ "memory_limit_scope": "PyTorch allocator only; not MIG or a compute limit. "
153
+ "Non-PyTorch CUDA allocations can be outside this cap.",
154
+ "timing_scope": "Each inference uses CUDA synchronization before and after the pipeline call; "
155
+ "PNG writing is excluded. Batch wall time includes saving PNGs and metrics. "
156
+ "Cold means the first pipeline call in this process. A synthetic reference "
157
+ "or warmup prewarms the pipeline before the measured edit batches. "
158
+ "No ZeroGPU startup, queue, transport, or quota is simulated.",
159
+ "pipeline": {"true_cfg_scale": 1.0, "use_kv_cache": True,
160
+ "output_resolution": REFERENCE_RESOLUTION[args.resolution],
161
+ "num_images_per_prompt": 1, "sampling": "checkpoint-configured eight sigmas"},
162
+ "packages": package_versions(),
163
+ "generations": [],
164
+ "batches": [],
165
+ "completed_images": 0,
166
+ }
167
+ write_metrics(metrics_path, metrics)
168
+ process_started = time.perf_counter()
169
+ torch = None
170
+ stage = "input_preparation"
171
+ active_generation = None
172
+ active_batch = None
173
+ pipeline_call_count = 0
174
+ edit_call_count = 0
175
+ exit_code = 0
176
+ try:
177
+ max_edge = 768 if args.resolution == "fast" else 1216
178
+ if args.image:
179
+ with Image.open(args.image.expanduser()) as source:
180
+ reference = prepare_reference(source, max_edge)
181
+ elif not args.create_sample:
182
+ reference = prepare_reference(get_guide("full_front", 512, 768), max_edge)
183
+ print("No --image: using the mannequin for a SPEED SMOKE TEST ONLY; "
184
+ "these images do not validate character identity or pose quality.", flush=True)
185
+ else:
186
+ reference = None
187
+ if reference is not None:
188
+ metrics["reference_size"] = list(reference.size)
189
+ guides = None
190
+ if pose_enabled:
191
+ guides = {key: get_guide(key, *SIZES[args.resolution][BY_KEY[key].shape])
192
+ for key in DEFAULT_KEYS}
193
+
194
+ stage = "cuda_initialization"
195
+ import torch as torch_module
196
+ torch = torch_module
197
+ if not torch.cuda.is_available():
198
+ raise RuntimeError("CUDA is unavailable; this benchmark requires a local CUDA GPU")
199
+ device = torch.cuda.current_device()
200
+ properties = torch.cuda.get_device_properties(device)
201
+ metrics["gpu"] = {"name": torch.cuda.get_device_name(device),
202
+ "device_index": device,
203
+ "total_memory_gib": round(properties.total_memory / GIB, 4),
204
+ "compute_capability": f"{properties.major}.{properties.minor}",
205
+ "torch_cuda_version": torch.version.cuda}
206
+ if args.memory_limit_gib is not None:
207
+ fraction = args.memory_limit_gib * GIB / properties.total_memory
208
+ if fraction > 1:
209
+ raise ValueError("--memory-limit-gib exceeds the selected GPU's total memory")
210
+ # Set before model loading or CUDA model placement.
211
+ torch.cuda.set_per_process_memory_fraction(fraction, device)
212
+ metrics["allocator_fraction"] = fraction
213
+ print(f"PyTorch allocator cap: {args.memory_limit_gib:g} GiB. "
214
+ "The GPU still has its full compute capacity; this is not MIG or half-GPU emulation.",
215
+ flush=True)
216
+ torch.cuda.synchronize()
217
+ torch.cuda.reset_peak_memory_stats()
218
+ write_metrics(metrics_path, metrics)
219
+
220
+ stage = "model_load"
221
+ from diffusers import QwenImage21Pipeline
222
+ load_started = time.perf_counter()
223
+ pipe = QwenImage21Pipeline.from_pretrained(
224
+ MODEL_ID, revision=MODEL_REVISION, dtype=torch.bfloat16,
225
+ )
226
+ pipe.set_progress_bar_config(disable=True)
227
+ if angles_enabled:
228
+ from huggingface_hub import hf_hub_download
229
+ from angles_adapter import load_angles_adapter
230
+ checkpoint = hf_hub_download(ANGLES_REPO, filename=ANGLES_WEIGHT)
231
+ metrics["angles_adapter"]["conversion"] = load_angles_adapter(
232
+ pipe, checkpoint, adapter_name="angles", strength=ANGLES_STRENGTH,
233
+ )
234
+ pipe.fuse_lora(adapter_names=["angles"])
235
+ pipe.unload_lora_weights()
236
+ pipe.vae.enable_tiling()
237
+ pipe.to("cuda")
238
+ torch.cuda.synchronize()
239
+ metrics["model_load_seconds"] = round(time.perf_counter() - load_started, 4)
240
+ metrics["model_load_scope"] = ("from_pretrained through CUDA placement; includes optional "
241
+ "CPU adapter fusion and downloads if uncached")
242
+ metrics["model_load_memory"] = cuda_memory(torch)
243
+ sample_sigmas = pipe.config.get("sample_sigmas", [])
244
+ if len(sample_sigmas) != 8:
245
+ raise RuntimeError("The pinned Turbo checkpoint's eight-sigma sampling schedule was not loaded")
246
+ metrics["sample_sigmas"] = list(sample_sigmas)
247
+ metrics["peak_allocated_gib"] = metrics["model_load_memory"]["peak_allocated_gib"]
248
+ metrics["peak_reserved_gib"] = metrics["model_load_memory"]["peak_reserved_gib"]
249
+ print(f"Loaded {metrics['gpu']['name']} in {metrics['model_load_seconds']:.3f}s; "
250
+ f"model allocation {metrics['model_load_memory']['allocated_gib']:.2f} GiB.", flush=True)
251
+ write_metrics(metrics_path, metrics)
252
+
253
+ if args.create_sample:
254
+ stage = "sample_generation"
255
+ active_generation = {
256
+ "phase": "sample_reference", "key": "reference", "status": "running",
257
+ "seed": SAMPLE_SEED, "prompt": SAMPLE_PROMPT,
258
+ "width": 832, "height": 1216, "output_resolution": 1024,
259
+ "references": 0, "cache_state": "cold", "is_first_pipeline_call": True,
260
+ "is_first_edit": False, "pipeline_call_index": 1,
261
+ }
262
+ metrics["sample_generation"] = active_generation
263
+ metrics["active_generation"] = active_generation
264
+ write_metrics(metrics_path, metrics)
265
+ torch.cuda.synchronize()
266
+ torch.cuda.reset_peak_memory_stats()
267
+ sample_started = time.perf_counter()
268
+ pipeline_call_count += 1
269
+ sample_image = pipe(
270
+ prompt=SAMPLE_PROMPT, width=832, height=1216, output_resolution=1024,
271
+ true_cfg_scale=1.0, use_kv_cache=True, num_images_per_prompt=1,
272
+ generator=torch.Generator("cpu").manual_seed(SAMPLE_SEED),
273
+ ).images[0]
274
+ torch.cuda.synchronize()
275
+ sample_seconds = round(time.perf_counter() - sample_started, 4)
276
+ active_generation["seconds"] = sample_seconds
277
+ active_generation.update(cuda_memory(torch))
278
+ stage = "sample_save"
279
+ rgb_on_white(sample_image).save(output_dir / "reference.png", format="PNG")
280
+ active_generation["image"] = "reference.png"
281
+ active_generation["status"] = "complete"
282
+ metrics["sample_generation_seconds"] = sample_seconds
283
+ metrics["cold_generation"] = dict(active_generation)
284
+ metrics["first_pipeline_generation"] = dict(active_generation)
285
+ for field in ("peak_allocated_gib", "peak_reserved_gib"):
286
+ metrics[field] = max(metrics[field], active_generation[field])
287
+ reference = prepare_reference(sample_image, max_edge)
288
+ metrics["reference_size"] = list(reference.size)
289
+ metrics["input"] = "reference.png"
290
+ metrics.pop("active_generation", None)
291
+ active_generation = None
292
+ write_metrics(metrics_path, metrics)
293
+ print(f"Synthetic reference: {sample_seconds:.3f}s; saved reference.png. "
294
+ "This time is separate from edit batches; image quality has not been validated.", flush=True)
295
+
296
+ def generate(key, *, phase, repeat_index):
297
+ nonlocal stage, active_generation, pipeline_call_count, edit_call_count
298
+ view = BY_KEY[key]
299
+ width, height = SIZES[args.resolution][view.shape]
300
+ inputs = [conditioning_reference(reference, key, width, height)
301
+ if args.reference_crop == "on" else reference]
302
+ if guides is not None:
303
+ inputs.append(guides[key])
304
+ seed = (args.seed + DEFAULT_KEYS.index(key)) % (2**31)
305
+ directory = output_dir / ("warmup" if phase == "warmup" else f"run_{repeat_index:02d}")
306
+ directory.mkdir(exist_ok=True)
307
+ target = directory / f"{DEFAULT_KEYS.index(key) + 1:02d}_{key}.png"
308
+ active_generation = {"key": key, "phase": phase, "repeat_index": repeat_index,
309
+ "cache_state": "cold" if pipeline_call_count == 0 else "warm",
310
+ "is_first_pipeline_call": pipeline_call_count == 0,
311
+ "is_first_edit": edit_call_count == 0,
312
+ "pipeline_call_index": pipeline_call_count + 1,
313
+ "seed": seed, "width": width, "height": height,
314
+ "references": len(inputs), "status": "running"}
315
+ metrics["active_generation"] = active_generation
316
+ write_metrics(metrics_path, metrics)
317
+ stage = "generation"
318
+ torch.cuda.synchronize()
319
+ torch.cuda.reset_peak_memory_stats()
320
+ inference_started = time.perf_counter()
321
+ pipeline_call_count += 1
322
+ edit_call_count += 1
323
+ # Match backend.py. No budget callback: calibration must attempt all eleven views.
324
+ edit_prompt = make_prompt(view, "", pose_enabled, angles_enabled)
325
+ active_generation["prompt"] = edit_prompt
326
+ image = pipe(
327
+ image=inputs, prompt=edit_prompt,
328
+ width=width, height=height,
329
+ output_resolution=REFERENCE_RESOLUTION[args.resolution],
330
+ true_cfg_scale=1.0, use_kv_cache=True, num_images_per_prompt=1,
331
+ generator=torch.Generator("cpu").manual_seed(seed),
332
+ ).images[0]
333
+ torch.cuda.synchronize()
334
+ active_generation["seconds"] = round(time.perf_counter() - inference_started, 4)
335
+ active_generation.update(cuda_memory(torch))
336
+ stage = "image_save"
337
+ rgb_on_white(image).save(target, format="PNG")
338
+ active_generation["image"] = str(target.relative_to(output_dir))
339
+ active_generation["status"] = "complete"
340
+ metrics["generations"].append(active_generation)
341
+ if active_generation["cache_state"] == "cold":
342
+ metrics["cold_generation"] = dict(active_generation)
343
+ if active_generation["is_first_pipeline_call"]:
344
+ metrics["first_pipeline_generation"] = dict(active_generation)
345
+ if active_generation["is_first_edit"]:
346
+ metrics["first_edit_generation"] = dict(active_generation)
347
+ for field in ("peak_allocated_gib", "peak_reserved_gib"):
348
+ metrics[field] = max(metrics[field], active_generation[field])
349
+ metrics["completed_images"] += 1
350
+ if phase == "batch":
351
+ active_batch["completed_views"].append(key)
352
+ active_batch["inference_seconds"] = round(
353
+ active_batch["inference_seconds"] + active_generation["seconds"], 4)
354
+ metrics.pop("active_generation", None)
355
+ completed = active_generation
356
+ active_generation = None
357
+ write_metrics(metrics_path, metrics)
358
+ print(f"{phase} {repeat_index or '-'} / {key}: {completed['seconds']:.3f}s, "
359
+ f"peak allocated {completed['peak_allocated_gib']:.2f} GiB, "
360
+ f"peak reserved {completed['peak_reserved_gib']:.2f} GiB "
361
+ f"({completed['cache_state']})", flush=True)
362
+
363
+ if args.warmup:
364
+ generate("full_front", phase="warmup", repeat_index=0)
365
+ for repeat_index in range(1, args.repeat + 1):
366
+ active_batch = {"repeat_index": repeat_index, "status": "running",
367
+ "completed_views": [], "inference_seconds": 0.0,
368
+ "includes_cold_generation": pipeline_call_count == 0}
369
+ metrics["batches"].append(active_batch)
370
+ batch_started = time.perf_counter()
371
+ for key in DEFAULT_KEYS:
372
+ generate(key, phase="batch", repeat_index=repeat_index)
373
+ active_batch["wall_seconds"] = round(time.perf_counter() - batch_started, 4)
374
+ active_batch["status"] = "complete"
375
+ print(f"Batch {repeat_index}: 11/11 views, inference "
376
+ f"{active_batch['inference_seconds']:.3f}s, wall {active_batch['wall_seconds']:.3f}s.",
377
+ flush=True)
378
+ write_metrics(metrics_path, metrics)
379
+ active_batch = None
380
+ metrics["status"] = "complete"
381
+ except (Exception, KeyboardInterrupt) as exc:
382
+ exit_code = 130 if isinstance(exc, KeyboardInterrupt) else 1
383
+ metrics["status"] = "interrupted" if exit_code == 130 else "failed"
384
+ metrics["error"] = {"stage": stage, "type": type(exc).__name__, "message": str(exc)}
385
+ if active_generation is not None:
386
+ active_generation["status"] = metrics["status"]
387
+ if active_batch is not None:
388
+ active_batch["status"] = metrics["status"]
389
+ active_batch["wall_seconds"] = round(time.perf_counter() - batch_started, 4)
390
+ if torch is not None:
391
+ try:
392
+ metrics["memory_at_failure"] = cuda_memory(torch)
393
+ except Exception:
394
+ pass
395
+ print(f"Benchmark {metrics['status']} during {stage}: {type(exc).__name__}: {exc}\n"
396
+ "Completed PNGs and metrics are retained.", file=sys.stderr, flush=True)
397
+ finally:
398
+ metrics["process_seconds"] = round(time.perf_counter() - process_started, 4)
399
+ metrics["finished_at_utc"] = datetime.now(timezone.utc).isoformat()
400
+ write_metrics(metrics_path, metrics)
401
+ print(f"Metrics: {metrics_path}", flush=True)
402
+ return exit_code
403
+
404
+
405
+ def main(argv=None):
406
+ args = parse_args(argv)
407
+ try:
408
+ return benchmark(args)
409
+ except (OSError, ValueError) as exc:
410
+ print(f"Cannot start benchmark: {exc}", file=sys.stderr)
411
+ return 1
412
+
413
+
414
+ if __name__ == "__main__":
415
+ raise SystemExit(main())
core.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CPU-side dataset export and time-budget orchestration; independently testable."""
2
+ import io
3
+ import tempfile
4
+ import time
5
+ import zipfile
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from PIL import Image, ImageChops, ImageOps
10
+
11
+ from views import BY_KEY, DEFAULT_KEYS, SIZES, make_prompt, validate_keys
12
+
13
+
14
+ class BudgetExhausted(Exception):
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class BatchResult:
20
+ images: dict = field(default_factory=dict)
21
+ timings: list = field(default_factory=list)
22
+ stop_reason: str = ""
23
+
24
+
25
+ def rgb_on_white(image):
26
+ """Return an independent RGB image, compositing any transparency onto white."""
27
+ rgba = image.convert("RGBA")
28
+ white = Image.new("RGBA", rgba.size, "white")
29
+ white.alpha_composite(rgba)
30
+ return white.convert("RGB")
31
+
32
+
33
+ def prepare_reference(image, max_edge):
34
+ image = ImageOps.exif_transpose(image)
35
+ result = rgb_on_white(image)
36
+ result.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
37
+ return result
38
+
39
+
40
+ def conditioning_reference(reference, key, width, height):
41
+ """Crop close views from an upright full-body reference on a white background.
42
+
43
+ Fractions are measured from the detected foreground's head to feet. Crops are
44
+ derived independently from the original reference, with a small head margin,
45
+ and fitted proportionally onto a white canvas at the requested output size.
46
+ Full-body/pose views preserve the original reference object unchanged.
47
+ """
48
+ fraction = {"cowboy_front": 0.72, "cowboy_back": 0.72,
49
+ "upper_front": 0.45, "face_front": 0.20}.get(key)
50
+ if fraction is None:
51
+ return reference
52
+ if width <= 0 or height <= 0:
53
+ raise ValueError("Conditioning dimensions must be positive")
54
+ rgb = rgb_on_white(reference)
55
+ difference = ImageChops.difference(rgb, Image.new("RGB", rgb.size, "white"))
56
+ red, green, blue = difference.split()
57
+ distance = ImageChops.lighter(ImageChops.lighter(red, green), blue)
58
+ mask = distance.point(lambda value: 255 if value > 18 else 0)
59
+ bbox = mask.getbbox() or (0, 0, rgb.width, rgb.height)
60
+ subject_height = bbox[3] - bbox[1]
61
+ keep_height = max(1, round(subject_height * fraction))
62
+ top_padding = max(1, round(keep_height * 0.06))
63
+ top = max(0, bbox[1] - top_padding)
64
+ bottom = min(rgb.height, bbox[1] + keep_height)
65
+
66
+ # Use this band's foreground width so the full body's arms/legs do not force
67
+ # excess whitespace around a face crop. Preserve a small horizontal margin.
68
+ band_bbox = mask.crop((0, top, rgb.width, bottom)).getbbox()
69
+ left, right = (band_bbox[0], band_bbox[2]) if band_bbox else (bbox[0], bbox[2])
70
+ side_padding = max(1, round((right - left) * 0.06))
71
+ crop = rgb.crop((max(0, left - side_padding), top,
72
+ min(rgb.width, right + side_padding), bottom))
73
+ fitted = ImageOps.contain(crop, (width, height), Image.Resampling.LANCZOS)
74
+ canvas = Image.new("RGB", (width, height), "white")
75
+ canvas.paste(fitted, ((width - fitted.width) // 2, (height - fitted.height) // 2))
76
+ return canvas
77
+
78
+
79
+ def requested_seconds(keys, maximum=110):
80
+ """An execution budget, NOT a benchmark-based estimate or a guarantee."""
81
+ # Even a one-view smoke test needs startup and decode time in addition to
82
+ # the denoising budget. Keep a practical floor without claiming a speed.
83
+ return min(maximum, max(45, 15 + 10 * len(validate_keys(keys))))
84
+
85
+
86
+ def run_batch(generate_one, reference, keys, guide_images, resolution, seed, extra,
87
+ angles, budget_seconds, clock=time.monotonic, crop_close_views=False):
88
+ keys = validate_keys(keys)
89
+ result = BatchResult()
90
+ started = clock()
91
+ deadline = started + budget_seconds
92
+ longest = 0.0
93
+
94
+ def check_budget():
95
+ # Called at denoising step boundaries. Leave room for decode and result transfer.
96
+ if clock() >= deadline - 6:
97
+ raise BudgetExhausted()
98
+
99
+ for key in keys:
100
+ remaining = deadline - clock()
101
+ if remaining <= max(8.0, longest * 1.4 + 6):
102
+ result.stop_reason = "時間枠に達する前に終了しました。完成した画像を保存できます。"
103
+ break
104
+ view = BY_KEY[key]
105
+ width, height = SIZES[resolution][view.shape]
106
+ inputs = [conditioning_reference(reference, key, width, height)
107
+ if crop_close_views else reference]
108
+ if guide_images is not None:
109
+ inputs.append(guide_images[key])
110
+ # Stable per-view seed, unaffected by selecting or reordering other views.
111
+ view_seed = (int(seed) + DEFAULT_KEYS.index(key)) % (2**31)
112
+ t0 = clock()
113
+ try:
114
+ output = generate_one(
115
+ image=inputs, prompt=make_prompt(view, extra, guide_images is not None, angles),
116
+ width=width, height=height, seed=view_seed, check_budget=check_budget,
117
+ )
118
+ except BudgetExhausted:
119
+ result.stop_reason = "時間枠に達する前に終了しました。完成した画像を保存できます。"
120
+ break
121
+ except Exception as exc:
122
+ # A late view failure must not discard images already completed.
123
+ print(f"[generation] {key} failed: {type(exc).__name__}: {exc}", flush=True)
124
+ result.stop_reason = f"「{view.label}」の生成に失敗しました。完成済み画像は保存できます。"
125
+ break
126
+ elapsed = clock() - t0
127
+ longest = max(longest, elapsed)
128
+ result.images[key] = output
129
+ result.timings.append({"key": key, "seconds": round(elapsed, 3), "seed": view_seed,
130
+ "width": width, "height": height, "references": len(inputs)})
131
+ print(f"[generation] {key}: {elapsed:.2f}s", flush=True)
132
+ return result
133
+
134
+
135
+ def build_zip(images, selected_keys, output_dir=None):
136
+ """Export PNGs only; optionally write inside a caller-managed directory."""
137
+ keys = validate_keys(selected_keys)
138
+ if not any(key in images for key in keys):
139
+ return None
140
+ folder = Path(output_dir) if output_dir is not None else Path(tempfile.mkdtemp(prefix="character-views-"))
141
+ folder.mkdir(parents=True, exist_ok=True)
142
+ target = folder / "character_views.zip"
143
+ with zipfile.ZipFile(target, "w", zipfile.ZIP_STORED) as archive:
144
+ for key in keys:
145
+ if key not in images:
146
+ continue
147
+ buffer = io.BytesIO()
148
+ rgb_on_white(images[key]).save(buffer, format="PNG")
149
+ index = DEFAULT_KEYS.index(key) + 1
150
+ archive.writestr(f"dataset/{index:02d}_{key}.png", buffer.getvalue())
151
+ return str(target)
framing.py DELETED
@@ -1,59 +0,0 @@
1
- """寄り視点用に、全身画像から目的の構図を切り出すユーティリティ.
2
-
3
- なぜ必要か (実機検証の結果):
4
- 全身画像を条件にして「カウボーイショットにして」とプロンプトで頼んでも、
5
- Qwen-Image-Edit は入力のフレーミングを保とうとして全身のまま出力する。
6
- プロンプトの言い回しを 8 通り試しても直らなかった。
7
- あらかじめ目的の構図に切り出した画像を条件にすると、フレーミングが
8
- 決定的に決まり、モデルはディテール補完に専念できる。
9
- """
10
-
11
- from typing import Tuple
12
-
13
- import numpy as np
14
- from PIL import Image
15
-
16
- #: 被写体の全高を 1.0 としたときに、頭頂から何割を残すか
17
- CROP_FRACTION = {
18
- "face": 0.20,
19
- "upper": 0.45,
20
- "cowboy": 0.72,
21
- }
22
-
23
- #: 顔が切れないように上へ足す余白 (切り出し高さに対する割合)
24
- TOP_PAD_RATIO = 0.06
25
-
26
-
27
- def subject_bbox(img: Image.Image, tol: int = 18) -> Tuple[int, int, int, int]:
28
- """白背景を除いた被写体のバウンディングボックス (x0, y0, x1, y1)。
29
-
30
- 背景は全視点で plain white に固定しているので、白からの距離で判定できる。
31
- 被写体が見つからない場合は画像全体を返す。
32
- """
33
- arr = np.asarray(img.convert("RGB")).astype(np.int16)
34
- mask = (255 - arr).max(axis=2) > tol
35
- ys, xs = np.nonzero(mask)
36
- if len(ys) == 0:
37
- return (0, 0, img.width, img.height)
38
- return (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
39
-
40
-
41
- def crop_framing(img: Image.Image, kind: str, out_w: int, out_h: int) -> Image.Image:
42
- """被写体の上から一定割合を切り出し、出力アスペクト比に合わせて整える。"""
43
- if kind not in CROP_FRACTION:
44
- return img
45
-
46
- x0, y0, x1, y1 = subject_bbox(img)
47
- subject_h = max(1, y1 - y0)
48
- keep_h = max(8, int(subject_h * CROP_FRACTION[kind]))
49
-
50
- cy0 = max(0, y0 - int(keep_h * TOP_PAD_RATIO))
51
- cy1 = min(img.height, cy0 + keep_h)
52
-
53
- # 出力アスペクト比に合わせて横幅を決め、被写体の水平中心に寄せる
54
- need_w = max(8, int((cy1 - cy0) * (out_w / out_h)))
55
- cx = (x0 + x1) // 2
56
- cx0 = max(0, min(cx - need_w // 2, img.width - need_w)) if need_w <= img.width else 0
57
- cx1 = min(img.width, cx0 + need_w)
58
-
59
- return img.crop((cx0, cy0, cx1, cy1)).resize((out_w, out_h), Image.LANCZOS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
optimization.py DELETED
@@ -1,167 +0,0 @@
1
- """ZeroGPU 向け高速化 (fp8 量子化 + PyTorch ahead-of-time コンパイル).
2
-
3
- tori29umai/Qwen-Image-2509-CharacterSheet の optimization.py を土台に、
4
- fp8 量子化を有効化し、失敗時に素の bf16 へフォールバックする経路を足したもの。
5
-
6
- なぜ必要か:
7
- ZeroGPU は 2026/05 に H200 (~70GB) から RTX Pro 6000 Blackwell へ移行し、
8
- 既定の `large` サイズは 48GB になった。Qwen-Image-Edit-2511 は bf16 で
9
- transformer 40.9GB + text encoder 16.6GB ≈ 57GB あり、48GB 枠に収まらない。
10
- fp8 量子化で transformer を約半分に落とすことが実質的な前提条件になる。
11
-
12
- さらに ZeroGPU はクォータを「実行時間」ではなく「要求 duration」で消費するため、
13
- 1 視点あたりの生成時間を削ることがそのまま無料枠での生成可能枚数に効く。
14
-
15
- 環境変数:
16
- DISABLE_FP8=1 fp8 量子化を無効化 (bf16 のまま)
17
- DISABLE_AOTI=1 AoTI コンパイルを無効化
18
- DISABLE_FA3=1 FlashAttention-3 を無効化
19
- """
20
-
21
- import os
22
- from typing import Any, Callable, ParamSpec
23
-
24
- import spaces
25
- import torch
26
- from torch.utils._pytree import tree_map
27
-
28
- P = ParamSpec("P")
29
-
30
-
31
- TRANSFORMER_IMAGE_SEQ_LENGTH_DIM = torch.export.Dim("image_seq_length")
32
- TRANSFORMER_TEXT_SEQ_LENGTH_DIM = torch.export.Dim("text_seq_length")
33
-
34
- # 画像/テキストの系列長を動的次元にしておくことで、視点ごとに解像度が変わっても
35
- # (832x1216 / 1024x1024 / 1216x832) 再コンパイルが走らない。
36
- TRANSFORMER_DYNAMIC_SHAPES = {
37
- "hidden_states": {
38
- 1: TRANSFORMER_IMAGE_SEQ_LENGTH_DIM,
39
- },
40
- "encoder_hidden_states": {
41
- 1: TRANSFORMER_TEXT_SEQ_LENGTH_DIM,
42
- },
43
- "encoder_hidden_states_mask": {
44
- 1: TRANSFORMER_TEXT_SEQ_LENGTH_DIM,
45
- },
46
- "image_rotary_emb": (
47
- {0: TRANSFORMER_IMAGE_SEQ_LENGTH_DIM},
48
- {0: TRANSFORMER_TEXT_SEQ_LENGTH_DIM},
49
- ),
50
- }
51
-
52
-
53
- INDUCTOR_CONFIGS = {
54
- "conv_1x1_as_mm": True,
55
- "epilogue_fusion": False,
56
- "coordinate_descent_tuning": True,
57
- "coordinate_descent_check_all_directions": True,
58
- "max_autotune": True,
59
- "triton.cudagraphs": True,
60
- }
61
-
62
-
63
- def _env_off(name: str) -> bool:
64
- return os.environ.get(name, "").strip() not in ("", "0", "false", "False")
65
-
66
-
67
- def apply_fa3_(pipeline) -> bool:
68
- """FlashAttention-3 の attention processor を適用する。成功したら True。"""
69
- if _env_off("DISABLE_FA3"):
70
- print("[optimization] FA3 は DISABLE_FA3 により無効")
71
- return False
72
- try:
73
- from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
74
-
75
- pipeline.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
76
- print("[optimization] FlashAttention-3 を適用")
77
- return True
78
- except Exception as exc: # noqa: BLE001 - Space を落とさないことを優先
79
- print(f"[optimization] FA3 を適用できず既定の attention を使用: {exc}")
80
- return False
81
-
82
-
83
- #: 量子化は一度きり。AoTI が途中で失敗した場合にフォールバック側が二重適用しないよう記録する。
84
- _FP8_APPLIED = False
85
-
86
-
87
- def quantize_fp8_(module) -> bool:
88
- """transformer を fp8 動的量子化する。成功したら True。
89
-
90
- VRAM を約半減させるのが主目的で、副次的に ~1.2x の高速化が乗る。
91
- """
92
- global _FP8_APPLIED
93
- if _FP8_APPLIED:
94
- return True
95
- if _env_off("DISABLE_FP8"):
96
- print("[optimization] fp8 は DISABLE_FP8 により無効")
97
- return False
98
- try:
99
- from torchao.quantization import (
100
- Float8DynamicActivationFloat8WeightConfig,
101
- quantize_,
102
- )
103
-
104
- quantize_(module, Float8DynamicActivationFloat8WeightConfig())
105
- _FP8_APPLIED = True
106
- print("[optimization] fp8 動的量子化を適用")
107
- return True
108
- except Exception as exc: # noqa: BLE001
109
- print(f"[optimization] fp8 量子化に失敗、bf16 のまま継続: {exc}")
110
- return False
111
-
112
-
113
- def optimize_pipeline_(pipeline: Callable[P, Any], *args: P.args, **kwargs: P.kwargs) -> bool:
114
- """fp8 量子化 + AoTI コンパイルを適用する。AoTI まで通ったら True。
115
-
116
- 量子化と export は同一の GPU コンテキスト内で行う必要があるため、
117
- まとめて `@spaces.GPU` 関数の中で実行する。
118
- """
119
- if _env_off("DISABLE_AOTI"):
120
- print("[optimization] AoTI は DISABLE_AOTI により無効")
121
- if not _FP8_APPLIED:
122
- # 通常は app.py 側が CPU 上で量子化済み。未適用ならここで救済する。
123
- @spaces.GPU(duration=120)
124
- def quantize_only():
125
- return quantize_fp8_(pipeline.transformer)
126
-
127
- quantize_only()
128
- return False
129
-
130
- @spaces.GPU(duration=1500)
131
- def compile_transformer():
132
- # 実入力の形状を捕まえるためにパイプラインを 1 回だけ空回しする
133
- with spaces.aoti_capture(pipeline.transformer) as call:
134
- pipeline(*args, **kwargs)
135
-
136
- dynamic_shapes = tree_map(lambda t: None, call.kwargs)
137
- dynamic_shapes |= TRANSFORMER_DYNAMIC_SHAPES
138
-
139
- # 通常は app.py 側で CPU 転送前に量子化済み (_FP8_APPLIED が True なので no-op)。
140
- # export より前に済んでいれば、量子化後のカーネルがグラフに焼き込まれる。
141
- quantize_fp8_(pipeline.transformer)
142
-
143
- exported = torch.export.export(
144
- mod=pipeline.transformer,
145
- args=call.args,
146
- kwargs=call.kwargs,
147
- dynamic_shapes=dynamic_shapes,
148
- )
149
- return spaces.aoti_compile(exported, INDUCTOR_CONFIGS)
150
-
151
- try:
152
- spaces.aoti_apply(compile_transformer(), pipeline.transformer)
153
- print("[optimization] AoTI コンパイル済みの transformer を適用")
154
- return True
155
- except Exception as exc: # noqa: BLE001
156
- # コンパイルに失敗しても Space は動き続けるべき。量子化だけでも効果はある。
157
- print(f"[optimization] AoTI コンパイルに失敗、非コンパイル経路で継続: {exc}")
158
-
159
- @spaces.GPU(duration=120)
160
- def quantize_fallback():
161
- return quantize_fp8_(pipeline.transformer)
162
-
163
- try:
164
- quantize_fallback()
165
- except Exception as exc2: # noqa: BLE001
166
- print(f"[optimization] fp8 フォールバックも失敗: {exc2}")
167
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pose_guides.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, neutral pose mannequins for the eleven reference views.
2
+
3
+ These are ordinary RGB image references, not ControlNet or OpenPose maps. They
4
+ describe pose/camera/framing; a separate character image supplies identity.
5
+ No network, downloaded models, random sampling, or GPU is used. Pillow is the
6
+ only third-party dependency. Run ``python pose_guides.py --regenerate`` to
7
+ rebuild the bundled assets.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from pathlib import Path
14
+
15
+ from PIL import Image, ImageDraw, ImageFont
16
+
17
+
18
+ ASSET_DIR = Path(__file__).resolve().parent / "assets"
19
+ GUIDE_VERSION = 1
20
+ # key: (English preview label, pose, camera azimuth, elevation, crop, size)
21
+ GUIDE_SPECS = {
22
+ "full_front": ("01 Full / front", "standing", 0, 0, "full", (832, 1216)),
23
+ "full_left": ("02 Full / left", "standing", 270, 0, "full", (832, 1216)),
24
+ "full_right": ("03 Full / right", "standing", 90, 0, "full", (832, 1216)),
25
+ "full_back": ("04 Full / back", "standing", 180, 0, "full", (832, 1216)),
26
+ "cowboy_front": ("05 Mid-thigh / front", "standing", 0, 0, "cowboy", (1024, 1024)),
27
+ "cowboy_back": ("06 Mid-thigh / back", "standing", 180, 0, "cowboy", (1024, 1024)),
28
+ "upper_front": ("07 Upper body / front", "standing", 0, 0, "upper", (1024, 1024)),
29
+ "face_front": ("08 Face / front", "standing", 0, 0, "face", (1024, 1024)),
30
+ "knees_front": ("09 Hug knees / front", "knees", 0, 0, "full", (832, 1216)),
31
+ "knees_back": ("10 Hug knees / back", "knees", 180, 0, "full", (832, 1216)),
32
+ "prone_back": ("11 Prone / rear, elevated", "prone", 180, 60, "full", (1216, 832)),
33
+ }
34
+
35
+
36
+ def _add(a, b):
37
+ return tuple(x + y for x, y in zip(a, b))
38
+
39
+
40
+ def _sub(a, b):
41
+ return tuple(x - y for x, y in zip(a, b))
42
+
43
+
44
+ def _mul(a, scale):
45
+ return tuple(x * scale for x in a)
46
+
47
+
48
+ def _dot(a, b):
49
+ return sum(x * y for x, y in zip(a, b))
50
+
51
+
52
+ def _cross(a, b):
53
+ return (a[1] * b[2] - a[2] * b[1],
54
+ a[2] * b[0] - a[0] * b[2],
55
+ a[0] * b[1] - a[1] * b[0])
56
+
57
+
58
+ def _unit(a):
59
+ length = math.sqrt(_dot(a, a))
60
+ return _mul(a, 1 / length) if length > 1e-12 else (0.0, 1.0, 0.0)
61
+
62
+
63
+ class _Mannequin:
64
+ """Small ellipsoid mesh model with articulated limbs and subtle joints."""
65
+
66
+ def __init__(self):
67
+ self.faces = []
68
+
69
+ def ellipsoid(self, center, radii, tone=177, axis=(0, 1, 0), facing=None):
70
+ ey = _unit(axis)
71
+ if facing is None:
72
+ helper = (0, 0, 1) if abs(ey[2]) < 0.9 else (0, 1, 0)
73
+ ex = _unit(_cross(ey, helper))
74
+ ez = _unit(_cross(ex, ey))
75
+ else:
76
+ ez = _unit(facing)
77
+ ex = _unit(_cross(ey, ez))
78
+ ez = _unit(_cross(ex, ey))
79
+ rings, sectors = 40, 72
80
+ vertices = []
81
+ for ring in range(rings + 1):
82
+ latitude = math.pi * ring / rings
83
+ row = []
84
+ for sector in range(sectors):
85
+ longitude = 2 * math.pi * sector / sectors
86
+ local = (radii[0] * math.sin(latitude) * math.cos(longitude),
87
+ radii[1] * math.cos(latitude),
88
+ radii[2] * math.sin(latitude) * math.sin(longitude))
89
+ world = _add(center, _add(_mul(ex, local[0]),
90
+ _add(_mul(ey, local[1]), _mul(ez, local[2]))))
91
+ normal = _unit(_add(_mul(ex, local[0] / radii[0] ** 2),
92
+ _add(_mul(ey, local[1] / radii[1] ** 2),
93
+ _mul(ez, local[2] / radii[2] ** 2))))
94
+ row.append((world, normal))
95
+ vertices.append(row)
96
+ for ring in range(rings):
97
+ for sector in range(sectors):
98
+ next_sector = (sector + 1) % sectors
99
+ corners = [vertices[ring][sector], vertices[ring + 1][sector],
100
+ vertices[ring + 1][next_sector], vertices[ring][next_sector]]
101
+ points = tuple(v[0] for v in corners)
102
+ normal = _unit(tuple(sum(v[1][i] for v in corners) for i in range(3)))
103
+ self.faces.append((points, normal, tone))
104
+
105
+ def limb(self, start, end, radius, tone=178):
106
+ delta = _sub(end, start)
107
+ length = math.sqrt(_dot(delta, delta))
108
+ self.ellipsoid(_mul(_add(start, end), 0.5),
109
+ (radius, length / 2 + radius * 0.5, radius), tone, delta)
110
+
111
+ def joint(self, point, radius):
112
+ self.ellipsoid(point, (radius, radius, radius), 160)
113
+
114
+ def head(self, center, axis=(0, 1, 0), facing=(0, 0, 1)):
115
+ up, forward = _unit(axis), _unit(facing)
116
+ side = _unit(_cross(up, forward))
117
+ self.ellipsoid(center, (0.105, 0.145, 0.101), 187, up, forward)
118
+ # A short, blunt nose and small ears make front/back/side unambiguous.
119
+ nose = _add(center, _add(_mul(forward, 0.105), _mul(up, -0.01)))
120
+ self.ellipsoid(nose, (0.018, 0.029, 0.033), 179, up, forward)
121
+ for sign in (-1, 1):
122
+ ear = _add(center, _add(_mul(side, sign * 0.103), _mul(up, -0.005)))
123
+ self.ellipsoid(ear, (0.019, 0.032, 0.019), 176, up, forward)
124
+
125
+
126
+ def _standing():
127
+ model = _Mannequin()
128
+ model.ellipsoid((0, 1.26, 0), (0.189, 0.237, 0.103))
129
+ model.ellipsoid((0, 1.045, 0), (0.130, 0.129, 0.091), 172)
130
+ model.ellipsoid((0, 0.958, -0.002), (0.158, 0.125, 0.108), 180)
131
+ model.limb((0, 1.438, 0), (0, 1.536, 0), 0.052)
132
+ model.head((0, 1.665, 0))
133
+ for side in (-1, 1):
134
+ shoulder = (side * 0.196, 1.398, 0)
135
+ elbow = (side * 0.264, 1.130, 0)
136
+ wrist = (side * 0.309, 0.901, 0.016)
137
+ model.joint(shoulder, 0.071)
138
+ model.limb(shoulder, elbow, 0.063)
139
+ model.joint(elbow, 0.046)
140
+ model.limb(elbow, wrist, 0.047)
141
+ model.joint(wrist, 0.030)
142
+ model.ellipsoid((side * 0.319, 0.846, 0.02), (0.039, 0.069, 0.025), 183,
143
+ (side * 0.13, 1, 0))
144
+ hip = (side * 0.089, 0.920, 0)
145
+ knee = (side * 0.105, 0.516, 0.010)
146
+ ankle = (side * 0.111, 0.097, 0)
147
+ model.limb(hip, knee, 0.085)
148
+ model.joint(knee, 0.057)
149
+ model.limb(knee, ankle, 0.057)
150
+ model.joint(ankle, 0.039)
151
+ model.ellipsoid((side * 0.111, 0.049, 0.068), (0.059, 0.048, 0.125), 180)
152
+ return model
153
+
154
+
155
+ def _knees():
156
+ model = _Mannequin()
157
+ model.ellipsoid((0, 0.230, -0.111), (0.163, 0.135, 0.119), 180)
158
+ model.ellipsoid((0, 0.445, -0.148), (0.151, 0.190, 0.103), 173,
159
+ (0, 1, 0.10))
160
+ model.ellipsoid((0, 0.624, -0.096), (0.183, 0.177, 0.099), 177,
161
+ (0, 1, 0.24))
162
+ model.limb((0, 0.745, -0.065), (0, 0.823, -0.01), 0.050)
163
+ model.head((0, 0.939, 0.024), axis=(0, 0.99, 0.08), facing=(0, -0.08, 0.99))
164
+ for side in (-1, 1):
165
+ hip = (side * 0.105, 0.226, -0.080)
166
+ knee = (side * 0.116, 0.603, 0.248)
167
+ ankle = (side * 0.119, 0.097, 0.320)
168
+ model.limb(hip, knee, 0.086)
169
+ model.joint(knee, 0.062)
170
+ model.limb(knee, ankle, 0.056)
171
+ model.joint(ankle, 0.038)
172
+ model.ellipsoid((side * 0.120, 0.050, 0.383), (0.061, 0.050, 0.114), 180)
173
+ shoulder = (side * 0.186, 0.724, -0.064)
174
+ elbow = (side * 0.264, 0.465, 0.257)
175
+ wrist = (side * 0.085, 0.470, 0.356)
176
+ model.joint(shoulder, 0.063)
177
+ model.limb(shoulder, elbow, 0.058)
178
+ model.joint(elbow, 0.043)
179
+ model.limb(elbow, wrist, 0.043)
180
+ model.joint(wrist, 0.027)
181
+ model.ellipsoid((side * 0.037, 0.467 + side * 0.012, 0.359),
182
+ (0.030, 0.062, 0.023), 184, (1, side * 0.1, 0))
183
+ return model
184
+
185
+
186
+ def _prone():
187
+ model = _Mannequin()
188
+ model.ellipsoid((0, 0.178, 1.227), (0.189, 0.237, 0.103), axis=(0, 0, 1), facing=(0, -1, 0))
189
+ model.ellipsoid((0, 0.165, 1.021), (0.130, 0.130, 0.092), 172,
190
+ axis=(0, 0, 1), facing=(0, -1, 0))
191
+ model.ellipsoid((0, 0.182, 0.924), (0.158, 0.127, 0.111), 180,
192
+ axis=(0, 0, 1), facing=(0, -1, 0))
193
+ model.limb((0, 0.17, 1.418), (0, 0.16, 1.500), 0.052)
194
+ model.head((0, 0.162, 1.641), axis=(0, 0, 1), facing=(0, -1, 0))
195
+ for side in (-1, 1):
196
+ shoulder = (side * 0.195, 0.172, 1.372)
197
+ elbow = (side * 0.375, 0.083, 1.606)
198
+ wrist = (side * 0.244, 0.065, 1.797)
199
+ model.joint(shoulder, 0.068)
200
+ model.limb(shoulder, elbow, 0.060)
201
+ model.joint(elbow, 0.043)
202
+ model.limb(elbow, wrist, 0.045)
203
+ model.joint(wrist, 0.027)
204
+ model.ellipsoid((side * 0.188, 0.052, 1.823), (0.032, 0.066, 0.023), 183,
205
+ axis=(side, 0, -0.2), facing=(0, -1, 0))
206
+ hip = (side * 0.090, 0.177, 0.899)
207
+ knee = (side * 0.105, 0.116, 0.491)
208
+ ankle = (side * 0.113, 0.083, 0.098)
209
+ model.limb(hip, knee, 0.083)
210
+ model.joint(knee, 0.055)
211
+ model.limb(knee, ankle, 0.056)
212
+ model.joint(ankle, 0.036)
213
+ model.ellipsoid((side * 0.114, 0.058, 0.026), (0.058, 0.111, 0.047), 180,
214
+ axis=(0, -0.25, 1), facing=(0, -1, -0.25))
215
+ return model
216
+
217
+
218
+ def _render(model, azimuth, elevation, crop, size, pose):
219
+ azimuth, elevation = math.radians(azimuth), math.radians(elevation)
220
+ toward = (math.sin(azimuth) * math.cos(elevation), math.sin(elevation),
221
+ math.cos(azimuth) * math.cos(elevation))
222
+ right = (math.cos(azimuth), 0, -math.sin(azimuth))
223
+ up = _cross(toward, right)
224
+ if pose == "prone":
225
+ # Roll the overhead camera so the long body fits the landscape bucket.
226
+ right, up = up, _mul(right, -1)
227
+ projected = [(_dot(vertex, right), _dot(vertex, up))
228
+ for points, _, _ in model.faces for vertex in points]
229
+ min_x, max_x = min(v[0] for v in projected), max(v[0] for v in projected)
230
+ min_y, max_y = min(v[1] for v in projected), max(v[1] for v in projected)
231
+ if crop != "full":
232
+ # Absolute anatomical cut planes avoid uncertain image segmentation.
233
+ min_y = {"cowboy": 0.705, "upper": 1.160, "face": 1.493}[crop]
234
+ max_y += {"cowboy": 0.065, "upper": 0.047, "face": 0.025}[crop]
235
+ else:
236
+ height = max_y - min_y
237
+ min_y -= height * 0.055
238
+ max_y += height * 0.065
239
+ width = max_x - min_x
240
+ min_x -= width * 0.075
241
+ max_x += width * 0.075
242
+ width, height = size
243
+ scale = min(width / (max_x - min_x), height / (max_y - min_y)) if crop == "full" else height / (max_y - min_y)
244
+ center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2
245
+ supersample = 2
246
+ canvas = Image.new("RGB", (width * supersample, height * supersample), "white")
247
+ draw = ImageDraw.Draw(canvas)
248
+ # Camera-relative studio lighting makes shape readable at every azimuth.
249
+ light = _unit(_add(_mul(right, -0.40), _add(_mul(up, 0.68), _mul(toward, 0.80))))
250
+ faces = sorted(model.faces, key=lambda face: sum(_dot(p, toward) for p in face[0]))
251
+ for points, normal, tone in faces:
252
+ if _dot(normal, toward) < -0.08:
253
+ continue
254
+ coords = [(((_dot(p, right) - center_x) * scale + width / 2) * supersample,
255
+ (height / 2 - (_dot(p, up) - center_y) * scale) * supersample)
256
+ for p in points]
257
+ if max(p[1] for p in coords) < 0 or min(p[1] for p in coords) > height * supersample:
258
+ continue
259
+ diffuse = max(0, _dot(normal, light))
260
+ shade = round(tone * (0.54 + 0.55 * diffuse))
261
+ color = (shade, min(255, shade + 2), min(255, shade + 4))
262
+ draw.polygon(coords, fill=color)
263
+ return canvas.resize(size, Image.Resampling.LANCZOS)
264
+
265
+
266
+ def _preview(paths):
267
+ card_w, card_h, padding = 360, 420, 20
268
+ sheet = Image.new("RGB", (card_w * 3 + padding * 4, card_h * 4 + padding * 5), "#eef0f3")
269
+ draw = ImageDraw.Draw(sheet)
270
+ try:
271
+ font = ImageFont.truetype("DejaVuSans.ttf", 19)
272
+ except OSError:
273
+ font = ImageFont.load_default(size=19)
274
+ for index, (key, path) in enumerate(paths.items()):
275
+ x = padding + index % 3 * (card_w + padding)
276
+ y = padding + index // 3 * (card_h + padding)
277
+ draw.rounded_rectangle((x, y, x + card_w, y + card_h), radius=12, fill="white")
278
+ draw.text((x + 16, y + 17), GUIDE_SPECS[key][0], fill="#283444", font=font)
279
+ with Image.open(path) as source:
280
+ thumbnail = source.copy()
281
+ thumbnail.thumbnail((card_w - 24, card_h - 66), Image.Resampling.LANCZOS)
282
+ sheet.paste(thumbnail, (x + (card_w - thumbnail.width) // 2,
283
+ y + 52 + (card_h - 66 - thumbnail.height) // 2))
284
+ sheet.save(ASSET_DIR / "pose_preview.png", optimize=True)
285
+
286
+
287
+ def ensure_guides(*, regenerate: bool = False) -> dict[str, Path]:
288
+ """Return the eleven bundled PNG paths; create any missing assets locally."""
289
+ ASSET_DIR.mkdir(parents=True, exist_ok=True)
290
+ paths = {key: ASSET_DIR / f"{key}.png" for key in GUIDE_SPECS}
291
+ models = {}
292
+ changed = False
293
+ for key, path in paths.items():
294
+ if path.is_file() and not regenerate:
295
+ continue
296
+ _, pose, azimuth, elevation, crop, size = GUIDE_SPECS[key]
297
+ if pose not in models:
298
+ models[pose] = {"standing": _standing, "knees": _knees, "prone": _prone}[pose]()
299
+ _render(models[pose], azimuth, elevation, crop, size, pose).save(path, optimize=True)
300
+ changed = True
301
+ if changed or not (ASSET_DIR / "pose_preview.png").is_file():
302
+ _preview(paths)
303
+ return paths
304
+
305
+
306
+ def get_guide(key: str, width: int, height: int) -> Image.Image:
307
+ """Load one neutral RGB guide, resizing to the requested output dimensions."""
308
+ if key not in GUIDE_SPECS:
309
+ raise KeyError(f"Unknown pose guide: {key}")
310
+ if width <= 0 or height <= 0:
311
+ raise ValueError("Guide width and height must be positive")
312
+ path = ASSET_DIR / f"{key}.png"
313
+ if not path.is_file():
314
+ path = ensure_guides()[key]
315
+ with Image.open(path) as source:
316
+ result = source.convert("RGB")
317
+ if result.size != (width, height):
318
+ result = result.resize((width, height), Image.Resampling.LANCZOS)
319
+ return result
320
+
321
+
322
+ if __name__ == "__main__":
323
+ import argparse
324
+
325
+ parser = argparse.ArgumentParser(description=__doc__)
326
+ parser.add_argument("--regenerate", action="store_true", help="Rebuild all bundled PNGs")
327
+ args = parser.parse_args()
328
+ for guide_key, guide_path in ensure_guides(regenerate=args.regenerate).items():
329
+ print(f"{guide_key}: {guide_path}")
330
+ print(f"Preview: {ASSET_DIR / 'pose_preview.png'}")
qwenimage/__init__.py DELETED
File without changes
qwenimage/qwen_fa3_processor.py DELETED
@@ -1,142 +0,0 @@
1
- """
2
- Paired with a good language model. Thanks!
3
- """
4
-
5
- import torch
6
- from typing import Optional, Tuple
7
- from diffusers.models.transformers.transformer_qwenimage import apply_rotary_emb_qwen
8
-
9
- try:
10
- from kernels import get_kernel
11
- _k = get_kernel("kernels-community/vllm-flash-attn3")
12
- _flash_attn_func = _k.flash_attn_func
13
- except Exception as e:
14
- _flash_attn_func = None
15
- _kernels_err = e
16
-
17
-
18
- def _ensure_fa3_available():
19
- if _flash_attn_func is None:
20
- raise ImportError(
21
- "FlashAttention-3 via Hugging Face `kernels` is required. "
22
- "Tried `get_kernel('kernels-community/vllm-flash-attn3')` and failed with:\n"
23
- f"{_kernels_err}"
24
- )
25
-
26
- @torch.library.custom_op("flash::flash_attn_func", mutates_args=())
27
- def flash_attn_func(
28
- q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, causal: bool = False
29
- ) -> torch.Tensor:
30
- outputs, lse = _flash_attn_func(q, k, v, causal=causal)
31
- return outputs
32
-
33
- @flash_attn_func.register_fake
34
- def _(q, k, v, **kwargs):
35
- # two outputs:
36
- # 1. output: (batch, seq_len, num_heads, head_dim)
37
- # 2. softmax_lse: (batch, num_heads, seq_len) with dtype=torch.float32
38
- meta_q = torch.empty_like(q).contiguous()
39
- return meta_q #, q.new_empty((q.size(0), q.size(2), q.size(1)), dtype=torch.float32)
40
-
41
-
42
- class QwenDoubleStreamAttnProcessorFA3:
43
- """
44
- FA3-based attention processor for Qwen double-stream architecture.
45
- Computes joint attention over concatenated [text, image] streams using vLLM FlashAttention-3
46
- accessed via Hugging Face `kernels`.
47
-
48
- Notes / limitations:
49
- - General attention masks are not supported here (FA3 path). `is_causal=False` and no arbitrary mask.
50
- - Optional windowed attention / sink tokens / softcap can be plumbed through if you use those features.
51
- - Expects an available `apply_rotary_emb_qwen` in scope (same as your non-FA3 processor).
52
- """
53
-
54
- _attention_backend = "fa3" # for parity with your other processors, not used internally
55
-
56
- def __init__(self):
57
- _ensure_fa3_available()
58
-
59
- @torch.no_grad()
60
- def __call__(
61
- self,
62
- attn, # Attention module with to_q/to_k/to_v/add_*_proj, norms, to_out, to_add_out, and .heads
63
- hidden_states: torch.FloatTensor, # (B, S_img, D_model) image stream
64
- encoder_hidden_states: torch.FloatTensor = None, # (B, S_txt, D_model) text stream
65
- encoder_hidden_states_mask: torch.FloatTensor = None, # unused in FA3 path
66
- attention_mask: Optional[torch.FloatTensor] = None, # unused in FA3 path
67
- image_rotary_emb: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, # (img_freqs, txt_freqs)
68
- ) -> Tuple[torch.FloatTensor, torch.FloatTensor]:
69
- if encoder_hidden_states is None:
70
- raise ValueError("QwenDoubleStreamAttnProcessorFA3 requires encoder_hidden_states (text stream).")
71
- if attention_mask is not None:
72
- # FA3 kernel path here does not consume arbitrary masks; fail fast to avoid silent correctness issues.
73
- raise NotImplementedError("attention_mask is not supported in this FA3 implementation.")
74
-
75
- _ensure_fa3_available()
76
-
77
- B, S_img, _ = hidden_states.shape
78
- S_txt = encoder_hidden_states.shape[1]
79
-
80
- # ---- QKV projections (image/sample stream) ----
81
- img_q = attn.to_q(hidden_states) # (B, S_img, D)
82
- img_k = attn.to_k(hidden_states)
83
- img_v = attn.to_v(hidden_states)
84
-
85
- # ---- QKV projections (text/context stream) ----
86
- txt_q = attn.add_q_proj(encoder_hidden_states) # (B, S_txt, D)
87
- txt_k = attn.add_k_proj(encoder_hidden_states)
88
- txt_v = attn.add_v_proj(encoder_hidden_states)
89
-
90
- # ---- Reshape to (B, S, H, D_h) ----
91
- H = attn.heads
92
- img_q = img_q.unflatten(-1, (H, -1))
93
- img_k = img_k.unflatten(-1, (H, -1))
94
- img_v = img_v.unflatten(-1, (H, -1))
95
-
96
- txt_q = txt_q.unflatten(-1, (H, -1))
97
- txt_k = txt_k.unflatten(-1, (H, -1))
98
- txt_v = txt_v.unflatten(-1, (H, -1))
99
-
100
- # ---- Q/K normalization (per your module contract) ----
101
- if getattr(attn, "norm_q", None) is not None:
102
- img_q = attn.norm_q(img_q)
103
- if getattr(attn, "norm_k", None) is not None:
104
- img_k = attn.norm_k(img_k)
105
- if getattr(attn, "norm_added_q", None) is not None:
106
- txt_q = attn.norm_added_q(txt_q)
107
- if getattr(attn, "norm_added_k", None) is not None:
108
- txt_k = attn.norm_added_k(txt_k)
109
-
110
- # ---- RoPE (Qwen variant) ----
111
- if image_rotary_emb is not None:
112
- img_freqs, txt_freqs = image_rotary_emb
113
- # expects tensors shaped (B, S, H, D_h)
114
- img_q = apply_rotary_emb_qwen(img_q, img_freqs, use_real=False)
115
- img_k = apply_rotary_emb_qwen(img_k, img_freqs, use_real=False)
116
- txt_q = apply_rotary_emb_qwen(txt_q, txt_freqs, use_real=False)
117
- txt_k = apply_rotary_emb_qwen(txt_k, txt_freqs, use_real=False)
118
-
119
- # ---- Joint attention over [text, image] along sequence axis ----
120
- # Shapes: (B, S_total, H, D_h)
121
- q = torch.cat([txt_q, img_q], dim=1)
122
- k = torch.cat([txt_k, img_k], dim=1)
123
- v = torch.cat([txt_v, img_v], dim=1)
124
-
125
- # FlashAttention-3 path expects (B, S, H, D_h) and returns (out, softmax_lse)
126
- out = flash_attn_func(q, k, v, causal=False) # out: (B, S_total, H, D_h)
127
-
128
- # ---- Back to (B, S, D_model) ----
129
- out = out.flatten(2, 3).to(q.dtype)
130
-
131
- # Split back to text / image segments
132
- txt_attn_out = out[:, :S_txt, :]
133
- img_attn_out = out[:, S_txt:, :]
134
-
135
- # ---- Output projections ----
136
- img_attn_out = attn.to_out[0](img_attn_out)
137
- if len(attn.to_out) > 1:
138
- img_attn_out = attn.to_out[1](img_attn_out) # dropout if present
139
-
140
- txt_attn_out = attn.to_add_out(txt_attn_out)
141
-
142
- return img_attn_out, txt_attn_out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,35 +1,9 @@
1
- # torch は **絶対にピンしない**。
2
- #
3
- # 一度 `--extra-index-url .../cu130` + `torch==2.11.0` を指定したところ、ZeroGPU の
4
- # ベースイメージが用意する torch と食い違い、GPU 取得時に
5
- # RuntimeError: NVML_SUCCESS == r INTERNAL ASSERT FAILED at CUDACachingAllocator.cpp:1165
6
- # で必ず落ちた (spaces が pin_memory().cuda() でモデルを転送する箇所)。
7
- # ZeroGPU のベースイメージが入れる torch をそのまま使うこと。
8
- #
9
- # 依存の組み合わせは、同じ Qwen-Image-Edit-2511 + 同じ角度 LoRA で実際に稼働している
10
- # multimodalart/qwen-image-multiple-angles-3d-camera に合わせてある。
11
- diffusers==0.39.0
12
- transformers
13
  accelerate
14
  safetensors
15
- sentencepiece
16
  peft
17
  torchvision
18
-
19
- # FlashAttention-3 をカーネルとして取得する。
20
- # kernels 0.15 以降は get_kernel() に version=/revision= が必須になり、
21
- # vendoring している qwen_fa3_processor.py の呼び出しが通らなくなるため 0.15 未満に固定。
22
- kernels<0.15
23
-
24
- # fp8 動的量子化 (VRAM 削減 + 約 1.2 倍の高速化)
25
- torchao==0.16.0
26
-
27
- # タガーは CPU 実行 (ZeroGPU クォータを消費しない)
28
- onnxruntime
29
  pillow
30
- numpy
31
-
32
- # `spaces` と `gradio` は書かないこと。
33
- # HF Spaces はビルドコマンドで両方を強制注入する:
34
- # pip install -r requirements.txt "torch<=2.11.0" gradio[oauth,mcp]==5.49.1 ... spaces==0.51.1
35
- # requirements.txt 側で別バージョンを指定すると ResolutionImpossible でビルドが落ちる。
 
1
+ # HF Gradio/ZeroGPU supplies torch, spaces and gradio. Keep those environment versions.
2
+ # Pinned upstream implementation includes QwenImage21 + saved Turbo sigma schedules.
3
+ git+https://github.com/huggingface/diffusers.git@85c9fa4caee33d1c5b473395784471d2d7957c1b
4
+ transformers>=5.17.0,<6
 
 
 
 
 
 
 
 
5
  accelerate
6
  safetensors
 
7
  peft
8
  torchvision
 
 
 
 
 
 
 
 
 
 
 
9
  pillow
 
 
 
 
 
 
tagging.py DELETED
@@ -1,441 +0,0 @@
1
- """LoRA 学習用キャプション生成 (WD Tagger v3 / OppaiOracle の比較付き).
2
-
3
- どちらのタガーも ONNX なので **onnxruntime の CPU 実行**にしてある。
4
- ZeroGPU のクォータは要求 duration ぶんそのまま消費されるため、タグ付けを GPU 側で
5
- やると生成できる視点数が直接減る。CPU で回せばクォータ消費はゼロになる。
6
-
7
- キャプションの構成:
8
- {トリガーワード}, {構図の確定タグ}, {タガーが推定したタグ}
9
-
10
- 構図タグ (full body / from behind / from above / cowboy shot など) は
11
- views.py のカメラパラメータから決定的に注入する。タガーは構図を安定して当てられない
12
- ので、既知の情報を推論に任せない方が精度が高い。
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import csv
18
- import json
19
- import threading
20
- from dataclasses import dataclass
21
- from typing import Dict, List, Sequence, Tuple
22
-
23
- import numpy as np
24
- from huggingface_hub import hf_hub_download
25
- from PIL import Image
26
-
27
- # --- タガー定義 -------------------------------------------------------------
28
-
29
- WD_REPO = "SmilingWolf/wd-swinv2-tagger-v3"
30
- WD_DEFAULT_THRESHOLD = 0.35
31
-
32
- OPPAI_REPO = "Grio43/OppaiOracle"
33
- OPPAI_SUBDIR = "V1.1_onnx"
34
- # pr_thresholds.json の micro.f1_optimal.threshold。WD の 0.35 とは桁が違うので
35
- # 共通の既定値を使ってはいけない。
36
- OPPAI_DEFAULT_THRESHOLD = 0.805
37
- OPPAI_SKIP_INDICES = (0, 1) # <PAD>, <UNK>
38
-
39
- #: Danbooru の rating / character カテゴリ。キャラクター LoRA の学習では
40
- #: 通常どちらもキャプションから外す (rating はノイズ、character 名は固有名詞)。
41
- WD_KEEP_CATEGORIES = (0,)
42
-
43
- #: OppaiOracle は全タグが general カテゴリ扱いで、rating タグも同じ空間に入っている
44
- #: (実測で `rating:general` が確信度 1.00 で出る)。名前で落とす。
45
- META_TAG_PREFIXES = ("rating:",)
46
-
47
- #: 画像そのものの素性を表すメタタグ。学習キャプションに残すとノイズにしかならない。
48
- META_TAGS = frozenset(
49
- {
50
- "highres", "absurdres", "incredibly absurdres", "lowres", "hugefilesize",
51
- "commentary", "commentary request", "english commentary", "chinese commentary",
52
- "translated", "translation request", "check translation",
53
- "bad id", "bad pixiv id", "bad twitter id", "bad link",
54
- "jpeg artifacts", "scan", "official art", "artist request", "character request",
55
- "md5 mismatch", "resolution mismatch", "source larger", "non-web source",
56
- "third-party edit", "revision", "duplicate", "image sample",
57
- }
58
- )
59
-
60
-
61
- def is_meta_tag(tag: str) -> bool:
62
- norm = _normalize(tag)
63
- return norm in META_TAGS or any(norm.startswith(p) for p in META_TAG_PREFIXES)
64
-
65
-
66
- def format_tag(tag: str) -> str:
67
- """Danbooru タグを kohya-ss のキャプション表記に整える。"""
68
- if tag in ("^_^", ">_<", "o_o", "0_0", "|_|", "._.", "\\m/", "^^^"):
69
- return tag
70
- return tag.replace("_", " ").replace("(", r"\(").replace(")", r"\)")
71
-
72
-
73
- def _normalize(tag: str) -> str:
74
- """重複判定・除外判定に使う正規形。"""
75
- return tag.replace("_", " ").replace("\\", "").strip().lower()
76
-
77
-
78
- @dataclass
79
- class TagResult:
80
- tagger: str
81
- tags: List[Tuple[str, float]] #: (整形済みタグ, 確信度) を確信度降順で
82
-
83
- def names(self) -> List[str]:
84
- return [t for t, _ in self.tags]
85
-
86
-
87
- class _OnnxTagger:
88
- """ONNX タガーの共通土台。セッションはプロセス内で 1 回だけ作る。"""
89
-
90
- name = "base"
91
- default_threshold = 0.35
92
- #: UI の閾値スライダー範囲 (min, max, step)。両タガーはスコア分布が大きく違うため、
93
- #: 共通のレンジを使うと片方が実用外の値しか選べなくなる。
94
- threshold_range = (0.05, 0.95, 0.01)
95
-
96
- def __init__(self) -> None:
97
- self._session = None
98
- self._tags: List[str] = []
99
- self._categories: List[int] = []
100
- self._lock = threading.Lock()
101
-
102
- def _build(self) -> None: # pragma: no cover - サブクラスで実装
103
- raise NotImplementedError
104
-
105
- def ensure_loaded(self) -> None:
106
- if self._session is not None:
107
- return
108
- with self._lock:
109
- if self._session is None:
110
- self._build()
111
-
112
- def _preprocess(self, image: Image.Image) -> Dict[str, np.ndarray]: # pragma: no cover
113
- raise NotImplementedError
114
-
115
- def predict(
116
- self,
117
- image: Image.Image,
118
- threshold: float | None = None,
119
- drop_meta: bool = True,
120
- max_tags: int = 60,
121
- ) -> TagResult:
122
- self.ensure_loaded()
123
- thr = self.default_threshold if threshold is None else float(threshold)
124
- probs = self._session.run(None, self._preprocess(image))[0][0]
125
-
126
- picked: List[Tuple[str, float]] = []
127
- for idx in np.nonzero(probs >= thr)[0]:
128
- if idx >= len(self._tags):
129
- continue
130
- raw = self._tags[idx]
131
- if not raw:
132
- continue
133
- if self._categories and self._categories[idx] not in WD_KEEP_CATEGORIES:
134
- continue
135
- if drop_meta and is_meta_tag(raw):
136
- continue
137
- picked.append((format_tag(raw), float(probs[idx])))
138
- picked.sort(key=lambda x: x[1], reverse=True)
139
- # 閾値を下げすぎたときに数千タグが出てキャプションが壊れるのを防ぐ
140
- return TagResult(self.name, picked[:max_tags])
141
-
142
-
143
- class WDTagger(_OnnxTagger):
144
- """SmilingWolf/wd-*-tagger-v3.
145
-
146
- ONNX 入力は NHWC・BGR・0-255 のまま (正規化はグラフ内)。余白は白で
147
- 正方形にパディングしてからリサイズする、というのが SmilingWolf 系の作法。
148
- """
149
-
150
- name = "WD Tagger v3"
151
- default_threshold = WD_DEFAULT_THRESHOLD
152
- threshold_range = (0.10, 0.70, 0.01)
153
-
154
- def __init__(self, repo: str = WD_REPO) -> None:
155
- super().__init__()
156
- self.repo = repo
157
- self._size = 448
158
- self._channels_last = True
159
- self._input_name = "input"
160
-
161
- def _build(self) -> None:
162
- import onnxruntime as ort
163
-
164
- model_path = hf_hub_download(self.repo, "model.onnx")
165
- tags_path = hf_hub_download(self.repo, "selected_tags.csv")
166
-
167
- with open(tags_path, "r", encoding="utf-8") as fh:
168
- rows = list(csv.DictReader(fh))
169
- self._tags = [r["name"] for r in rows]
170
- self._categories = [int(r["category"]) for r in rows]
171
-
172
- session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
173
- inp = session.get_inputs()[0]
174
- self._input_name = inp.name
175
- shape = inp.shape
176
- # [N, H, W, 3] なら NHWC、[N, 3, H, W] なら NCHW
177
- self._channels_last = len(shape) == 4 and shape[-1] == 3
178
- dim = shape[1] if self._channels_last else shape[2]
179
- if isinstance(dim, int) and dim > 0:
180
- self._size = dim
181
- self._session = session
182
- print(f"[tagging] {self.name} loaded: {len(self._tags)} tags, input={inp.name}{shape}")
183
-
184
- def _preprocess(self, image: Image.Image) -> Dict[str, np.ndarray]:
185
- img = image.convert("RGBA")
186
- canvas = Image.new("RGBA", img.size, (255, 255, 255))
187
- canvas.alpha_composite(img)
188
- img = canvas.convert("RGB")
189
-
190
- side = max(img.size)
191
- square = Image.new("RGB", (side, side), (255, 255, 255))
192
- square.paste(img, ((side - img.size[0]) // 2, (side - img.size[1]) // 2))
193
- square = square.resize((self._size, self._size), Image.BICUBIC)
194
-
195
- arr = np.asarray(square, dtype=np.float32)[:, :, ::-1] # RGB -> BGR
196
- if not self._channels_last:
197
- arr = arr.transpose(2, 0, 1)
198
- return {self._input_name: np.expand_dims(arr, axis=0)}
199
-
200
-
201
- class OppaiOracleTagger(_OnnxTagger):
202
- """Grio43/OppaiOracle V1.1.
203
-
204
- 前処理はグラフ外。preprocessing.json の指定どおり
205
- letterbox (アスペクト比維持 + (114,114,114) パディング) → (x/255 - 0.5) / 0.5。
206
- `padding_mask` (True = パディング画素) を第 2 入力として渡す必要がある。
207
- """
208
-
209
- name = "OppaiOracle V1.1"
210
- default_threshold = OPPAI_DEFAULT_THRESHOLD
211
- # 実測: 0.805 で 21 タグ / 0.5 で 445 タグ / 0.35 で 2768 タグ。
212
- # WD と同じレンジにすると実用外の領域しか触れなくなるので分けてある。
213
- threshold_range = (0.60, 0.97, 0.005)
214
-
215
- def __init__(self) -> None:
216
- super().__init__()
217
- self._size = 448
218
- self._pad = (114, 114, 114)
219
- self._mean = 0.5
220
- self._std = 0.5
221
-
222
- def _build(self) -> None:
223
- import onnxruntime as ort
224
-
225
- model_path = hf_hub_download(OPPAI_REPO, f"{OPPAI_SUBDIR}/model.onnx")
226
- tags_path = hf_hub_download(OPPAI_REPO, f"{OPPAI_SUBDIR}/selected_tags.csv")
227
- prep_path = hf_hub_download(OPPAI_REPO, f"{OPPAI_SUBDIR}/preprocessing.json")
228
-
229
- with open(prep_path, "r", encoding="utf-8") as fh:
230
- prep = json.load(fh)
231
- self._size = int(prep.get("image_size", 448))
232
- self._pad = tuple(prep.get("pad_color_rgb", [114, 114, 114]))
233
- self._mean = float(prep.get("normalize_mean", [0.5])[0])
234
- self._std = float(prep.get("normalize_std", [0.5])[0])
235
-
236
- with open(tags_path, "r", encoding="utf-8") as fh:
237
- rows = list(csv.DictReader(fh))
238
- self._tags = [r["name"] for r in rows]
239
- # 全タグが general カテゴリなのでカテゴリ絞り込みはしない
240
- self._categories = []
241
- for idx in OPPAI_SKIP_INDICES:
242
- if idx < len(self._tags):
243
- self._tags[idx] = "" # <PAD> / <UNK> は出力しない
244
-
245
- self._session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
246
- names = [i.name for i in self._session.get_inputs()]
247
- print(f"[tagging] {self.name} loaded: {len(self._tags)} tags, inputs={names}")
248
-
249
- def _preprocess(self, image: Image.Image) -> Dict[str, np.ndarray]:
250
- img = image.convert("RGB")
251
- w, h = img.size
252
- scale = self._size / max(w, h)
253
- nw, nh = max(1, round(w * scale)), max(1, round(h * scale))
254
- resized = img.resize((nw, nh), Image.BICUBIC)
255
-
256
- canvas = Image.new("RGB", (self._size, self._size), self._pad)
257
- off_x, off_y = (self._size - nw) // 2, (self._size - nh) // 2
258
- canvas.paste(resized, (off_x, off_y))
259
-
260
- arr = np.asarray(canvas, dtype=np.float32) / 255.0
261
- arr = (arr - self._mean) / self._std
262
- arr = arr.transpose(2, 0, 1)[None] # NCHW
263
-
264
- mask = np.ones((self._size, self._size), dtype=bool) # True = パディング
265
- mask[off_y : off_y + nh, off_x : off_x + nw] = False
266
- return {"pixel_values": arr, "padding_mask": mask[None]}
267
-
268
-
269
- _WD = WDTagger()
270
- _OPPAI = OppaiOracleTagger()
271
-
272
- TAGGERS: Dict[str, _OnnxTagger] = {
273
- "wd": _WD,
274
- "oppai": _OPPAI,
275
- }
276
- TAGGER_LABELS = {
277
- "wd": "WD Tagger v3 (SmilingWolf)",
278
- "oppai": "OppaiOracle V1.1 (Grio43)",
279
- }
280
-
281
-
282
- # --- キャプション組み立て ---------------------------------------------------
283
-
284
-
285
- #: 互いに排他的なタグ群。タガーは「うつ伏せの視点」を条件付け元の立ち絵から
286
- #: 推定してしまい `standing` を返すことがある (実測)。確定タグ側で構図が決まって
287
- #: いる群については、タガー由来の同じ群のタグを落として矛盾を防ぐ。
288
- CONFLICT_GROUPS: Tuple[frozenset, ...] = (
289
- # フレーミング
290
- frozenset(
291
- {
292
- "full body", "upper body", "portrait", "close-up", "cowboy shot",
293
- "lower body", "feet out of frame", "head out of frame",
294
- }
295
- ),
296
- # カメラ方向
297
- frozenset(
298
- {
299
- "from behind", "from side", "from above", "from below", "from front",
300
- "profile", "facing away", "facing viewer", "dutch angle",
301
- }
302
- ),
303
- # ポーズ
304
- frozenset(
305
- {
306
- "standing", "sitting", "lying", "on stomach", "on back", "on side",
307
- "squatting", "kneeling", "walking", "running", "all fours",
308
- "hugging own legs", "knees up", "crossed legs", "wariza", "yokozuwari",
309
- "seiza", "arms up", "leaning forward",
310
- }
311
- ),
312
- )
313
-
314
-
315
- def _conflicting_keys(fixed_norm: set) -> set:
316
- """確定タグが触れている群のうち、確定タグ自身には無いタグの正規形。"""
317
- blocked: set = set()
318
- for group in CONFLICT_GROUPS:
319
- if fixed_norm & group:
320
- blocked |= group - fixed_norm
321
- return blocked
322
-
323
-
324
- def build_caption(
325
- fixed_tags: Sequence[str],
326
- predicted: Sequence[str],
327
- trigger: str = "",
328
- exclude: Sequence[str] | str = (),
329
- ) -> str:
330
- """確定タグとタガー出力をマージして 1 行のキャプションにする。
331
-
332
- 確定タグを先に置くのは、kohya-ss の caption dropout / shuffle を使わない
333
- 運用で先頭ほど強く効くため。
334
- """
335
- if isinstance(exclude, str):
336
- exclude = parse_exclude(exclude)
337
- # ユーザー指定の除外は確定タグにも効かせる (白背景を外したい等のため)
338
- user_excluded = {_normalize(e) for e in exclude if e.strip()}
339
- # 矛盾による除外はタガー出力にだけ効かせる
340
- conflict_blocked = _conflicting_keys({_normalize(t) for t in fixed_tags})
341
-
342
- seen: set = set()
343
- out: List[str] = []
344
-
345
- trigger = (trigger or "").strip()
346
- if trigger:
347
- out.append(trigger)
348
- seen.add(_normalize(trigger))
349
-
350
- for tag in fixed_tags:
351
- key = _normalize(tag)
352
- if not key or key in seen or key in user_excluded:
353
- continue
354
- seen.add(key)
355
- out.append(tag)
356
-
357
- for tag in predicted:
358
- key = _normalize(tag)
359
- if not key or key in seen or key in user_excluded or key in conflict_blocked:
360
- continue
361
- seen.add(key)
362
- out.append(tag)
363
-
364
- return ", ".join(out)
365
-
366
-
367
- def parse_exclude(text: str) -> List[str]:
368
- return [t.strip() for t in (text or "").replace("\n", ",").split(",") if t.strip()]
369
-
370
-
371
- def compare(results: Dict[str, TagResult]) -> Dict[str, List[str]]:
372
- """タガー間の共通タグ / 片方だけのタグを求める。"""
373
- if len(results) < 2:
374
- only = next(iter(results.values())).names() if results else []
375
- return {"common": only, "wd_only": [], "oppai_only": []}
376
-
377
- wd = results.get("wd")
378
- op = results.get("oppai")
379
- wd_set = {_normalize(t): t for t in (wd.names() if wd else [])}
380
- op_set = {_normalize(t): t for t in (op.names() if op else [])}
381
- common = [wd_set[k] for k in wd_set if k in op_set]
382
- return {
383
- "common": common,
384
- "wd_only": [wd_set[k] for k in wd_set if k not in op_set],
385
- "oppai_only": [op_set[k] for k in op_set if k not in wd_set],
386
- }
387
-
388
-
389
- def comparison_markdown(per_view: Dict[str, Dict[str, TagResult]]) -> str:
390
- """ZIP に同梱する比較レポート。"""
391
- lines = [
392
- "# キャプション比較: WD Tagger v3 vs OppaiOracle V1.1",
393
- "",
394
- "同一画像に両タガーを適用した結果。`共通` が多いほど両者の判断が一致している。",
395
- "どちらも Danbooru 系のアニメタガーなので、実写寄りの参照画像ではタグ品質が落ちる。",
396
- "",
397
- "| 視点 | 共通 | WD のみ | OppaiOracle のみ | WD 総数 | Oppai 総数 |",
398
- "|---|---|---|---|:---:|:---:|",
399
- ]
400
- totals = {"common": 0, "wd_only": 0, "oppai_only": 0, "wd": 0, "oppai": 0}
401
- for view_key, results in per_view.items():
402
- cmp = compare(results)
403
- wd_n = len(results["wd"].tags) if "wd" in results else 0
404
- op_n = len(results["oppai"].tags) if "oppai" in results else 0
405
- totals["common"] += len(cmp["common"])
406
- totals["wd_only"] += len(cmp["wd_only"])
407
- totals["oppai_only"] += len(cmp["oppai_only"])
408
- totals["wd"] += wd_n
409
- totals["oppai"] += op_n
410
- lines.append(
411
- f"| {view_key} | {', '.join(cmp['common']) or '-'} | "
412
- f"{', '.join(cmp['wd_only']) or '-'} | {', '.join(cmp['oppai_only']) or '-'} | "
413
- f"{wd_n} | {op_n} |"
414
- )
415
-
416
- lines += [
417
- "",
418
- "## 合計",
419
- "",
420
- f"- 共通タグ: {totals['common']}",
421
- f"- WD のみ: {totals['wd_only']}",
422
- f"- OppaiOracle のみ: {totals['oppai_only']}",
423
- f"- 総タグ数: WD {totals['wd']} / OppaiOracle {totals['oppai']}",
424
- "",
425
- "## 閾値について",
426
- "",
427
- f"- WD Tagger v3 の既定閾値は {WD_DEFAULT_THRESHOLD}",
428
- f"- OppaiOracle の既定閾値は {OPPAI_DEFAULT_THRESHOLD} "
429
- "(pr_thresholds.json の micro.f1_optimal)",
430
- "",
431
- "両者はスコアの分布が大きく違うため、同じ閾値で比べると不公平になる。",
432
- "タグ数を揃えて比べたい場合は UI の閾値スライダーで調整すること。",
433
- "",
434
- "## 既知の傾向",
435
- "",
436
- "- OppaiOracle はモデルカード自身が「色名タグはソース由来のノイズが多い」"
437
- "「本来 50+ 付くべきところ平均 28 タグしか付いていない」と明記している。",
438
- "- 構図タグ (from behind / from above / cowboy shot など) は"
439
- "どちらのタガーも安定しないため、本 Space ではカメラパラメータから決定的に注入している。",
440
- ]
441
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_core.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CPU-only behavioral checks. These do not validate model speed or image quality."""
2
+ import contextlib
3
+ import io
4
+ from pathlib import Path
5
+ import shutil
6
+ import sys
7
+ import unittest
8
+ import zipfile
9
+
10
+ from PIL import Image, ImageDraw
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
13
+
14
+ from core import (build_zip, conditioning_reference, prepare_reference,
15
+ requested_seconds, rgb_on_white, run_batch)
16
+ from views import BY_KEY, DEFAULT_KEYS, SIZES, VIEWS, validate_keys
17
+
18
+
19
+ class FakeClock:
20
+ def __init__(self):
21
+ self.now = 0.0
22
+
23
+ def __call__(self):
24
+ return self.now
25
+
26
+ def advance(self, seconds):
27
+ self.now += seconds
28
+
29
+
30
+ class CoreTests(unittest.TestCase):
31
+ def setUp(self):
32
+ self.reference = Image.new("RGB", (24, 32), "red")
33
+ self.guides = {key: Image.new("RGB", (12, 16), (index, 0, 0))
34
+ for index, key in enumerate(DEFAULT_KEYS)}
35
+ self.clock = FakeClock()
36
+
37
+ def run_generation(self, generate_one, keys=None, **overrides):
38
+ options = dict(generate_one=generate_one, reference=self.reference,
39
+ keys=DEFAULT_KEYS if keys is None else keys,
40
+ guide_images=self.guides, resolution="fast", seed=123,
41
+ extra="", angles=False, budget_seconds=100,
42
+ clock=self.clock)
43
+ options.update(overrides)
44
+ with contextlib.redirect_stdout(io.StringIO()):
45
+ return run_batch(**options)
46
+
47
+ def test_exactly_the_eleven_requested_views(self):
48
+ expected = ["full_front", "full_left", "full_right", "full_back",
49
+ "cowboy_front", "cowboy_back", "upper_front", "face_front",
50
+ "knees_front", "knees_back", "prone_back"]
51
+ self.assertEqual(DEFAULT_KEYS, expected)
52
+ self.assertEqual(len(VIEWS), 11)
53
+ self.assertEqual(len(BY_KEY), 11)
54
+ self.assertEqual(validate_keys(["full_front", "face_front", "full_front"]),
55
+ ["full_front", "face_front"])
56
+ for invalid in ([], ["unknown"], ["full_front", "unknown"]):
57
+ with self.subTest(invalid=invalid), self.assertRaises(ValueError):
58
+ validate_keys(invalid)
59
+
60
+ def test_each_view_uses_original_identity_and_its_own_guide(self):
61
+ calls = []
62
+ generated = []
63
+
64
+ def generate(**kwargs):
65
+ calls.append(kwargs)
66
+ output = Image.new("RGB", (8, 8), (0, len(calls), 0))
67
+ generated.append(output)
68
+ self.clock.advance(0.25)
69
+ return output
70
+
71
+ result = self.run_generation(generate, crop_close_views=True)
72
+ self.assertEqual(len(calls), 11, "No extra anchor generations are permitted")
73
+ self.assertEqual(list(result.images), DEFAULT_KEYS)
74
+ self.assertEqual(len(result.timings), 11)
75
+ self.assertEqual(result.stop_reason, "")
76
+ for index, (key, call) in enumerate(zip(DEFAULT_KEYS, calls)):
77
+ with self.subTest(key=key):
78
+ self.assertEqual(len(call["image"]), 2)
79
+ if key in ("cowboy_front", "cowboy_back", "upper_front", "face_front"):
80
+ source = call["image"][0]
81
+ self.assertIsNot(source, self.reference)
82
+ self.assertEqual(source.size, (call["width"], call["height"]))
83
+ self.assertEqual(source.getpixel((source.width // 2, source.height // 2)),
84
+ (255, 0, 0), "Crop must retain original red identity reference")
85
+ else:
86
+ self.assertIs(call["image"][0], self.reference)
87
+ self.assertTrue(all(call["image"][0] is not output for output in generated))
88
+ self.assertIs(call["image"][1], self.guides[key])
89
+ self.assertIs(result.images[key], generated[index])
90
+ self.assertEqual((call["width"], call["height"]),
91
+ SIZES["fast"][BY_KEY[key].shape])
92
+ self.assertIn("Image 2", call["prompt"])
93
+ self.assertEqual(result.timings[index]["references"], 2)
94
+
95
+ def test_guides_can_be_disabled_without_an_extra_generation(self):
96
+ calls = []
97
+
98
+ def generate(**kwargs):
99
+ calls.append(kwargs)
100
+ return Image.new("RGB", (8, 8))
101
+
102
+ result = self.run_generation(generate, keys=["full_front"], guide_images=None)
103
+ self.assertEqual(len(calls), 1)
104
+ self.assertEqual(calls[0]["image"], [self.reference])
105
+ self.assertNotIn("Image 2", calls[0]["prompt"])
106
+ self.assertEqual(result.timings[0]["references"], 1)
107
+
108
+ def test_crop_is_disabled_by_default_for_all_views(self):
109
+ calls = []
110
+
111
+ def generate(**kwargs):
112
+ calls.append(kwargs)
113
+ return Image.new("RGB", (8, 8))
114
+
115
+ self.run_generation(generate)
116
+ self.assertEqual(len(calls), 11)
117
+ self.assertTrue(all(call["image"][0] is self.reference for call in calls))
118
+
119
+ def test_per_view_seed_stays_the_same_for_subsets_and_reordering(self):
120
+ def seeds_for(keys):
121
+ received = []
122
+
123
+ def generate(**kwargs):
124
+ received.append(kwargs["seed"])
125
+ return Image.new("RGB", (8, 8))
126
+
127
+ self.run_generation(generate, keys=keys, seed=2**31 - 3)
128
+ return dict(zip(keys, received))
129
+
130
+ all_seeds = seeds_for(DEFAULT_KEYS)
131
+ subset = ["prone_back", "full_front", "face_front"]
132
+ self.assertEqual(seeds_for(subset), {key: all_seeds[key] for key in subset})
133
+ self.assertEqual(all_seeds["full_front"], 2**31 - 3)
134
+ self.assertEqual(all_seeds["full_back"], 0)
135
+ self.assertEqual(len(set(all_seeds.values())), 11)
136
+
137
+ def test_generation_failure_keeps_completed_results(self):
138
+ completed = Image.new("RGB", (8, 8), "blue")
139
+ calls = []
140
+
141
+ def generate(**kwargs):
142
+ calls.append(kwargs)
143
+ if len(calls) == 2:
144
+ raise RuntimeError("simulated backend error")
145
+ return completed
146
+
147
+ result = self.run_generation(generate)
148
+ self.assertEqual(len(calls), 2)
149
+ self.assertEqual(list(result.images), ["full_front"])
150
+ self.assertIs(result.images["full_front"], completed)
151
+ self.assertEqual(len(result.timings), 1)
152
+ self.assertIn(BY_KEY["full_left"].label, result.stop_reason)
153
+ self.assertTrue(result.stop_reason)
154
+
155
+ def test_denoising_budget_expiry_keeps_previous_result(self):
156
+ calls = []
157
+
158
+ def generate(**kwargs):
159
+ calls.append(kwargs)
160
+ self.clock.advance(1 if len(calls) == 1 else 13)
161
+ kwargs["check_budget"]()
162
+ return Image.new("RGB", (8, 8))
163
+
164
+ result = self.run_generation(generate, budget_seconds=20)
165
+ self.assertEqual(len(calls), 2)
166
+ self.assertEqual(list(result.images), ["full_front"])
167
+ self.assertEqual(len(result.timings), 1)
168
+ self.assertTrue(result.stop_reason)
169
+
170
+ def test_insufficient_remaining_time_stops_before_starting_next_view(self):
171
+ calls = []
172
+
173
+ def generate(**kwargs):
174
+ calls.append(kwargs)
175
+ self.clock.advance(6)
176
+ return Image.new("RGB", (8, 8))
177
+
178
+ result = self.run_generation(generate, budget_seconds=20)
179
+ self.assertEqual(len(calls), 1)
180
+ self.assertEqual(list(result.images), ["full_front"])
181
+ self.assertTrue(result.stop_reason)
182
+
183
+ def test_empty_budget_does_not_start_generation(self):
184
+ def never_called(**kwargs):
185
+ self.fail("Generation must not start when the budget is exhausted")
186
+
187
+ result = self.run_generation(never_called, budget_seconds=0)
188
+ self.assertEqual(result.images, {})
189
+ self.assertTrue(result.stop_reason)
190
+
191
+ def test_requested_budget_obeys_configured_ceiling(self):
192
+ self.assertEqual(requested_seconds(DEFAULT_KEYS), 110)
193
+ self.assertEqual(requested_seconds(DEFAULT_KEYS, maximum=60), 60)
194
+ self.assertEqual(requested_seconds(["face_front"]), 45)
195
+ self.assertEqual(requested_seconds(["face_front", "face_front"]), 45)
196
+
197
+ def test_single_view_budget_allows_work_after_startup_and_decode_reserves(self):
198
+ # Artificial 20-second generation: a regression check, not a speed estimate.
199
+ # The backend reserves 15 seconds for ZeroGPU startup/result transport.
200
+ def generate(**kwargs):
201
+ self.clock.advance(20)
202
+ kwargs["check_budget"]()
203
+ return Image.new("RGB", (8, 8))
204
+
205
+ keys = ["face_front"]
206
+ result = self.run_generation(generate, keys=keys,
207
+ budget_seconds=requested_seconds(keys) - 15)
208
+ self.assertEqual(list(result.images), keys)
209
+ self.assertEqual(result.stop_reason, "")
210
+
211
+ def test_zip_contains_only_selected_completed_pngs_no_captions(self):
212
+ completed = {"full_front": Image.new("RGBA", (12, 16), "red"),
213
+ "face_front": Image.new("RGB", (16, 16), "blue"),
214
+ "prone_back": Image.new("RGB", (16, 12), "green")}
215
+ # One selected image has not been generated; one completed image is unselected.
216
+ path = build_zip(completed, ["face_front", "full_left", "full_front", "face_front"])
217
+ self.assertIsNotNone(path)
218
+ self.addCleanup(shutil.rmtree, Path(path).parent)
219
+ with zipfile.ZipFile(path) as archive:
220
+ self.assertEqual(archive.namelist(),
221
+ ["dataset/08_face_front.png", "dataset/01_full_front.png"])
222
+ for name in archive.namelist():
223
+ with self.subTest(name=name), Image.open(io.BytesIO(archive.read(name))) as image:
224
+ image.load()
225
+ self.assertEqual(image.format, "PNG")
226
+ self.assertEqual(image.mode, "RGB")
227
+ self.assertEqual(image.getpixel((0, 0)),
228
+ (0, 0, 255) if "face_front" in name else (255, 0, 0))
229
+
230
+ def test_zip_has_no_file_when_selection_has_no_completed_images(self):
231
+ self.assertIsNone(build_zip({}, DEFAULT_KEYS))
232
+ self.assertIsNone(build_zip({"full_front": self.reference}, ["face_front"]))
233
+
234
+ def test_zip_composites_transparent_generated_pixels_on_white(self):
235
+ source = Image.new("RGBA", (2, 1), (255, 0, 0, 128))
236
+ source.putpixel((1, 0), (0, 0, 0, 0))
237
+ path = build_zip({"full_front": source}, ["full_front"])
238
+ self.addCleanup(shutil.rmtree, Path(path).parent)
239
+ with zipfile.ZipFile(path) as archive:
240
+ with Image.open(io.BytesIO(archive.read("dataset/01_full_front.png"))) as image:
241
+ self.assertEqual(image.mode, "RGB")
242
+ self.assertEqual(image.getpixel((0, 0)), (255, 127, 127))
243
+ self.assertEqual(image.getpixel((1, 0)), (255, 255, 255))
244
+ self.assertEqual(source.getpixel((1, 0)), (0, 0, 0, 0))
245
+
246
+ def test_close_view_crops_use_foreground_height_and_exclude_lower_stripes(self):
247
+ source = Image.new("RGB", (200, 400), (245, 245, 245))
248
+ draw = ImageDraw.Draw(source)
249
+ draw.rectangle((60, 40, 139, 359), fill=(128, 128, 128))
250
+ colors = {"head": (255, 0, 0), "chest": (0, 255, 0),
251
+ "thigh": (0, 0, 255), "feet": (255, 0, 255)}
252
+ for name, top, bottom in (("head", 40, 71), ("chest", 128, 143),
253
+ ("thigh", 210, 225), ("feet", 335, 359)):
254
+ draw.rectangle((60, top, 139, bottom), fill=colors[name])
255
+ original = source.tobytes()
256
+ expected = {"cowboy_front": {"head", "chest", "thigh"},
257
+ "cowboy_back": {"head", "chest", "thigh"},
258
+ "upper_front": {"head", "chest"}, "face_front": {"head"}}
259
+ for key, included in expected.items():
260
+ with self.subTest(key=key):
261
+ cropped = conditioning_reference(source, key, 256, 256)
262
+ self.assertEqual(cropped.mode, "RGB")
263
+ self.assertEqual(cropped.size, (256, 256))
264
+ palette = {color for count, color in cropped.getcolors(256 * 256)}
265
+ for name, color in colors.items():
266
+ self.assertEqual(color in palette, name in included,
267
+ f"Unexpected {name} stripe content in {key}")
268
+ self.assertEqual(source.tobytes(), original)
269
+ self.assertEqual(source.size, (200, 400))
270
+
271
+ def test_close_view_letterboxing_preserves_shape_without_stretching(self):
272
+ source = Image.new("RGB", (200, 400), "white")
273
+ draw = ImageDraw.Draw(source)
274
+ draw.rectangle((80, 60, 119, 359), fill=(160, 160, 160))
275
+ draw.rectangle((90, 40, 109, 59), fill="black")
276
+ cropped = conditioning_reference(source, "face_front", 200, 100)
277
+ dark = cropped.convert("L").point(lambda value: 255 if value < 20 else 0)
278
+ left, top, right, bottom = dark.getbbox()
279
+ self.assertLessEqual(abs((right - left) - (bottom - top)), 1,
280
+ "A square marker must remain square after fitting")
281
+ self.assertEqual(cropped.size, (200, 100))
282
+ self.assertEqual(cropped.getpixel((0, 50)), (255, 255, 255))
283
+ self.assertGreater(top, 0, "The top of the head should retain a safe margin")
284
+
285
+ def test_other_views_keep_the_original_reference_object(self):
286
+ for key in ("full_front", "full_left", "full_right", "full_back",
287
+ "knees_front", "knees_back", "prone_back"):
288
+ with self.subTest(key=key):
289
+ self.assertIs(conditioning_reference(self.reference, key, 512, 768), self.reference)
290
+
291
+ def test_white_or_transparent_reference_can_be_cropped(self):
292
+ source = Image.new("RGBA", (40, 80), (0, 0, 0, 0))
293
+ cropped = conditioning_reference(source, "face_front", 120, 100)
294
+ self.assertEqual(cropped.size, (120, 100))
295
+ self.assertEqual(cropped.getextrema(), ((255, 255), (255, 255), (255, 255)))
296
+ composite = rgb_on_white(source)
297
+ self.assertEqual(composite.mode, "RGB")
298
+ self.assertEqual(source.mode, "RGBA")
299
+
300
+ def test_reference_alpha_is_composited_on_white_and_downsized(self):
301
+ source = Image.new("RGBA", (400, 200), (255, 0, 0, 128))
302
+ prepared = prepare_reference(source, 100)
303
+ self.assertEqual(prepared.mode, "RGB")
304
+ self.assertEqual(prepared.size, (100, 50))
305
+ self.assertEqual(prepared.getpixel((50, 25)), (255, 127, 127))
306
+ self.assertEqual(source.mode, "RGBA")
307
+ self.assertEqual(source.size, (400, 200))
308
+ transparent = prepare_reference(Image.new("RGBA", (2, 2), (0, 0, 0, 0)), 100)
309
+ self.assertEqual(transparent.getpixel((0, 0)), (255, 255, 255))
310
+ self.assertEqual(transparent.size, (2, 2), "Small input should not be enlarged")
311
+
312
+ def test_reference_exif_orientation_is_applied(self):
313
+ source = Image.new("RGB", (40, 20), "red")
314
+ source.getexif()[274] = 6
315
+ prepared = prepare_reference(source, 100)
316
+ self.assertEqual(prepared.size, (20, 40))
317
+ self.assertNotIn(274, prepared.getexif())
318
+
319
+
320
+ if __name__ == "__main__":
321
+ unittest.main()
views.py CHANGED
@@ -1,447 +1,93 @@
1
- """視点カタログ.
 
2
 
3
- 視点を 1 行のレコードとして定義し、UI のチェックボックス・ZeroGPU の duration 見積り・
4
- キャプションへの確定タグ注入を **すべてこのテーブルから導出** する。
5
- 視点を増やすときは VIEWS に行を足すだけでよい。
6
-
7
- カメラ指定は fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA のトリガ語彙に従う:
8
- <sks> {azimuth} {elevation} {distance}
9
- LoRA は 4 仰角 × 8 方位 × 3 距離 = 96 カメラ位置で学習されているため、
10
- 下の定数以外の文字列を入れても効かない。
11
- """
12
-
13
- from dataclasses import dataclass, field
14
- from typing import Dict, List, Tuple
15
-
16
- # --- 角度 LoRA の語彙 (この 3 つの辞書の値以外は使わないこと) ---------------
17
-
18
- AZIMUTH = {
19
- 0: "front view",
20
- 45: "front-right quarter view",
21
- 90: "right side view",
22
- 135: "back-right quarter view",
23
- 180: "back view",
24
- 225: "back-left quarter view",
25
- 270: "left side view",
26
- 315: "front-left quarter view",
27
- }
28
- ELEVATION = {
29
- -30: "low-angle shot",
30
- 0: "eye-level shot",
31
- 30: "elevated shot",
32
- 60: "high-angle shot",
33
- }
34
- DISTANCE = {
35
- 0.6: "close-up",
36
- 1.0: "medium shot",
37
- 1.8: "wide shot",
38
- }
39
-
40
- TRIGGER = "<sks>"
41
-
42
- # 全視点に共通で入れる背景指定。背景をプロンプトで固定しつつ、キャプション側にも
43
- # white background / simple background を明示的に入れることで、LoRA に背景が
44
- # 「無名の前提」として焼き付くのを避ける (タグとして学習させて後で外せるようにする)。
45
- BACKGROUND_PROMPT = "The background is plain white."
46
- GLOBAL_TAGS = ("solo", "simple background", "white background")
47
-
48
- # 学習バケット (kohya-ss 標準、いずれも約 1MP)
49
- PORTRAIT = (832, 1216)
50
- SQUARE = (1024, 1024)
51
- LANDSCAPE = (1216, 832)
52
-
53
-
54
- @dataclass(frozen=True)
55
- class Camera:
56
- azimuth: int
57
- elevation: int
58
- distance: float
59
-
60
- def phrase(self) -> str:
61
- return f"{TRIGGER} {AZIMUTH[self.azimuth]} {ELEVATION[self.elevation]} {DISTANCE[self.distance]}"
62
-
63
-
64
- @dataclass(frozen=True)
65
- class Anchor:
66
- """ポーズの基準画像。ここから各視点をカメラ移動で派生させる。"""
67
-
68
- key: str
69
- label_ja: str
70
- pose_prompt: str
71
- camera: Camera
72
- size: Tuple[int, int]
73
- # 条件付けに使う画像。"reference" はユーザーのアップロード画像、
74
- # それ以外は別アンカーの key を指す。
75
- source: str
76
 
77
 
78
  @dataclass(frozen=True)
79
  class View:
80
  key: str
81
- label_ja: str
82
- group_ja: str
83
- anchor: str
84
- camera: Camera
85
- size: Tuple[int, int]
86
- #: フレーミングなど、カメラ語彙では表せない指示
87
- extra_prompt: str = ""
88
- #: キャプションに必ず入れる Danbooru 系タグ。タガーは構図を当てにいけないため、
89
- #: 既知のカメラパラメータから決定的に注入する。
90
- tags: Tuple[str, ...] = field(default_factory=tuple)
91
- #: 全身のポーズ説明を入れるか。実機検証の結果、寄り (close-up / medium) の視点に
92
- #: 「両腕を腿の脇に下ろし、脚は左右対称に…」といった全身の描写を混ぜると、
93
- #: モデルが全身を収めようとして寄りが効かなくなる。寄り視点では False にする。
94
- use_pose_prompt: bool = True
95
- #: 寄り視点で使う切り出し種別 ("face" / "upper" / "cowboy")。空なら切り出さない。
96
- #: framing.CROP_FRACTION のキーに対応する。
97
- crop_kind: str = ""
98
- #: 切り出し元。空ならアンカー画像、それ以外は別の視点 key。
99
- #: 例: 背面のカウボーイショットは背面の全身画像から切り出す必要がある。
100
- crop_from: str = ""
101
-
102
-
103
- # --- ポーズアンカー ---------------------------------------------------------
104
-
105
- POSE_STANDING = (
106
- "The character stands upright in a neutral A-pose, with both arms extended slightly "
107
- "downward and close to the thighs, keeping the body evenly balanced on both sides. "
108
- "The legs are positioned symmetrically with a narrow stance."
109
- )
110
- POSE_HUG_KNEES = (
111
- "The character sits on the ground hugging both knees against the chest, with the arms "
112
- "wrapped around the shins and the back slightly rounded. The feet are flat on the ground."
113
- )
114
- POSE_PRONE = (
115
- "The character lies prone on the ground, face down on their stomach, with the legs "
116
- "extended straight behind and the arms resting near the head."
117
- )
118
-
119
- # 寄り視点の指示文。実機検証で有効だった言い回しを採用している。
120
- # 「何が見えるか」だけでなく「何がフレーム外か」を明示すると効きがよい。
121
- FACE_TEXT = "A close-up portrait showing only the character's head and face."
122
- UPPER_TEXT = (
123
- "An upper body shot. Only the head, shoulders and chest are visible; "
124
- "the waist and legs are out of frame."
125
  )
126
- COWBOY_TEXT = (
127
- "The character is framed from the mid-thigh up; "
128
- "the feet and lower legs are out of frame."
129
- )
130
-
131
- ANCHORS: Dict[str, Anchor] = {
132
- "standing": Anchor(
133
- key="standing",
134
- label_ja="立ち (基準)",
135
- pose_prompt=POSE_STANDING,
136
- camera=Camera(0, 0, 1.8),
137
- size=PORTRAIT,
138
- source="reference",
139
- ),
140
- "hug_knees": Anchor(
141
- key="hug_knees",
142
- label_ja="膝抱え座り (基準)",
143
- pose_prompt=POSE_HUG_KNEES,
144
- camera=Camera(0, 0, 1.8),
145
- size=PORTRAIT,
146
- source="standing",
147
- ),
148
- # うつ伏せは Tier1 では「後方・高所から」の 1 視点しか使わないため、アンカー自身を
149
- # その構図で作って生成 1 回ぶんを節約する。ポーズ変更とカメラ移動を同時に要求する
150
- # ぶん破綻しやすいので、app.py の詳細設定で 2 段階生成に切り替えられるようにしてある。
151
- "prone": Anchor(
152
- key="prone",
153
- label_ja="うつ伏せ (基準)",
154
- pose_prompt=POSE_PRONE,
155
- camera=Camera(180, 60, 1.8),
156
- size=LANDSCAPE,
157
- source="standing",
158
- ),
159
  }
160
-
161
- #: 詳細設定で「うつ伏せを2段階生成」を選んだときに使うアンカーのカメラ。
162
- #: いったん正面やや俯瞰でポーズだけ作り、そのあとカメラを後方・高所へ動かす。
163
- PRONE_TWO_STAGE_CAMERA = Camera(0, 30, 1.8)
164
-
165
-
166
- # --- 視点カタログ (Tier1: ご指定の 11 視点) ---------------------------------
167
-
168
- VIEWS: List[View] = [
169
- View(
170
- key="full_front",
171
- label_ja="全身 正面",
172
- group_ja="全身 (三面図)",
173
- anchor="standing",
174
- camera=Camera(0, 0, 1.8),
175
- size=PORTRAIT,
176
- tags=("full body", "standing", "looking at viewer"),
177
- ),
178
- View(
179
- key="full_left",
180
- label_ja="全身 左側面",
181
- group_ja="全身 (三面図)",
182
- anchor="standing",
183
- camera=Camera(270, 0, 1.8),
184
- size=PORTRAIT,
185
- tags=("full body", "standing", "from side", "profile"),
186
- ),
187
- View(
188
- key="full_right",
189
- label_ja="全身 右側面",
190
- group_ja="全身 (三面図)",
191
- anchor="standing",
192
- camera=Camera(90, 0, 1.8),
193
- size=PORTRAIT,
194
- tags=("full body", "standing", "from side", "profile"),
195
- ),
196
- View(
197
- key="full_back",
198
- label_ja="全身 背面",
199
- group_ja="全身 (三面図)",
200
- anchor="standing",
201
- camera=Camera(180, 0, 1.8),
202
- size=PORTRAIT,
203
- tags=("full body", "standing", "from behind"),
204
- ),
205
- View(
206
- key="cowboy_front",
207
- label_ja="カウボーイショット 正面",
208
- group_ja="カウボーイショット",
209
- anchor="standing",
210
- camera=Camera(0, 0, 1.0),
211
- size=SQUARE,
212
- extra_prompt=COWBOY_TEXT,
213
- tags=("cowboy shot", "standing", "looking at viewer"),
214
- use_pose_prompt=False,
215
- crop_kind="cowboy",
216
- crop_from="full_front",
217
- ),
218
- View(
219
- key="cowboy_back",
220
- label_ja="カウボーイショット 背面",
221
- group_ja="カウボーイショット",
222
- anchor="standing",
223
- camera=Camera(180, 0, 1.0),
224
- size=SQUARE,
225
- extra_prompt=COWBOY_TEXT,
226
- tags=("cowboy shot", "standing", "from behind"),
227
- use_pose_prompt=False,
228
- crop_kind="cowboy",
229
- # 背面の寄りは背面の全身画像から切り出す必要がある
230
- crop_from="full_back",
231
- ),
232
- View(
233
- key="upper_front",
234
- label_ja="上半身 正面",
235
- group_ja="寄り",
236
- anchor="standing",
237
- camera=Camera(0, 0, 0.6),
238
- size=SQUARE,
239
- extra_prompt=UPPER_TEXT,
240
- tags=("upper body", "looking at viewer"),
241
- use_pose_prompt=False,
242
- crop_kind="upper",
243
- crop_from="full_front",
244
- ),
245
- View(
246
- key="face_front",
247
- label_ja="顔 正面",
248
- group_ja="寄り",
249
- anchor="standing",
250
- camera=Camera(0, 0, 0.6),
251
- size=SQUARE,
252
- extra_prompt=FACE_TEXT,
253
- tags=("portrait", "close-up", "looking at viewer"),
254
- use_pose_prompt=False,
255
- crop_kind="face",
256
- crop_from="full_front",
257
- ),
258
- View(
259
- key="knees_front",
260
- label_ja="膝抱え 正面",
261
- group_ja="膝抱え座り",
262
- anchor="hug_knees",
263
- camera=Camera(0, 0, 1.8),
264
- size=PORTRAIT,
265
- tags=("full body", "sitting", "hugging own legs", "knees up"),
266
- ),
267
- View(
268
- key="knees_back",
269
- label_ja="膝抱え 背面",
270
- group_ja="膝抱え座り",
271
- anchor="hug_knees",
272
- camera=Camera(180, 0, 1.8),
273
- size=PORTRAIT,
274
- tags=("full body", "sitting", "hugging own legs", "knees up", "from behind"),
275
- ),
276
- View(
277
- key="prone_back",
278
- label_ja="うつ伏せ 後方から",
279
- group_ja="うつ伏せ",
280
- anchor="prone",
281
- camera=Camera(180, 60, 1.8),
282
- size=LANDSCAPE,
283
- tags=("full body", "lying", "on stomach", "from behind", "from above"),
284
- ),
285
- ]
286
-
287
- VIEW_BY_KEY: Dict[str, View] = {v.key: v for v in VIEWS}
288
- DEFAULT_SELECTION: List[str] = [v.key for v in VIEWS]
289
-
290
- #: UI のグループ見出し順 (VIEWS の出現順を保つ)
291
- GROUP_ORDER: List[str] = list(dict.fromkeys(v.group_ja for v in VIEWS))
292
-
293
- PRESETS: Dict[str, List[str]] = {
294
- "全選択 (11視点)": [v.key for v in VIEWS],
295
- "三面図のみ (4視点)": ["full_front", "full_left", "full_right", "full_back"],
296
- "最小構成 (2視点)": ["full_front", "face_front"],
297
- }
298
-
299
-
300
- # --- プロンプト組み立て -----------------------------------------------------
301
-
302
-
303
- def _compose(camera: Camera, pose_prompt: str, extra_prompt: str, user_extra: str) -> str:
304
- parts = [camera.phrase(), pose_prompt]
305
- if extra_prompt:
306
- parts.append(extra_prompt)
307
- parts.append(BACKGROUND_PROMPT)
308
- user_extra = (user_extra or "").strip()
309
- if user_extra:
310
- parts.append(user_extra)
311
- return " ".join(p.strip() for p in parts if p and p.strip())
312
-
313
-
314
- def anchor_prompt(anchor: Anchor, user_extra: str = "", camera: Camera = None) -> str:
315
- return _compose(camera or anchor.camera, anchor.pose_prompt, "", user_extra)
316
-
317
-
318
- def view_prompt(view: View, user_extra: str = "") -> str:
319
- pose = ANCHORS[view.anchor].pose_prompt if view.use_pose_prompt else ""
320
- return _compose(view.camera, pose, view.extra_prompt, user_extra)
321
-
322
-
323
- def caption_tags(view: View) -> Tuple[str, ...]:
324
- """タガー出力とマージする前の、構図に由来する確定タグ。"""
325
- return tuple(GLOBAL_TAGS) + view.tags
326
-
327
-
328
- # --- 依存解決 / 生成計画 ----------------------------------------------------
329
-
330
-
331
- @dataclass(frozen=True)
332
- class Step:
333
- """1 回の pipe() 呼び出し。"""
334
-
335
- key: str #: 生成物の識別子 (視点 key か "anchor:<name>")
336
- kind: str #: "anchor" | "view"
337
- label_ja: str
338
- prompt_of: object #: Anchor または View
339
- source: str #: 条件付け画像の識別子 ("reference" | "anchor:<name>" | 視点 key)
340
- size: Tuple[int, int]
341
- view_key: str = "" #: このステップが直接生み出す視点 (あれば)
342
- crop_kind: str = "" #: 条件付け画像を切り出す種別 (framing.CROP_FRACTION のキー)
343
- #: ユーザーが選んだ視点ではなく、寄りの切り出し元として必要になっただけの中間生成か
344
- intermediate: bool = False
345
-
346
-
347
- def _anchor_id(key: str) -> str:
348
- return f"anchor:{key}"
349
-
350
-
351
- def build_plan(selected_keys: List[str], two_stage_prone: bool = False) -> List[Step]:
352
- """選択された視点を生成するために必要な pipe() 呼び出しを、依存順に並べて返す。
353
-
354
- 依存は 2 種類ある:
355
- - ポーズアンカー (立ち → 膝抱え / うつ伏せ)
356
- - 寄りの切り出し元となる別の視点 (背面カウボーイ ← 背面全身)
357
- アンカーのカメラと一致し追加指示のない視点は、アンカー生成で兼ねて 1 回省く。
358
- """
359
- requested = [k for k in selected_keys if k in VIEW_BY_KEY]
360
- if not requested:
361
- return []
362
-
363
- # --- 1. 必要な視点の閉包を取る (寄りの切り出し元をたどる) ---
364
- needed_views: List[str] = []
365
-
366
- def require_view(key: str) -> None:
367
- if key in needed_views:
368
- return
369
- view = VIEW_BY_KEY[key]
370
- if view.crop_from:
371
- require_view(view.crop_from)
372
- needed_views.append(key)
373
-
374
- for k in requested:
375
- require_view(k)
376
-
377
- # --- 2. 必要なアンカーの閉包を取る ---
378
- anchors_needed: List[str] = []
379
-
380
- def require_anchor(name: str) -> None:
381
- if name in anchors_needed:
382
- return
383
- src = ANCHORS[name].source
384
- if src != "reference":
385
- require_anchor(src)
386
- anchors_needed.append(name)
387
-
388
- for k in needed_views:
389
- require_anchor(VIEW_BY_KEY[k].anchor)
390
-
391
- # --- 3. アンカーを依存順に並べる ---
392
- steps: List[Step] = []
393
- anchor_serves: Dict[str, str] = {}
394
-
395
- for name in anchors_needed:
396
- base = ANCHORS[name]
397
- camera = PRONE_TWO_STAGE_CAMERA if (name == "prone" and two_stage_prone) else base.camera
398
- anchor = Anchor(
399
- key=base.key,
400
- label_ja=base.label_ja,
401
- pose_prompt=base.pose_prompt,
402
- camera=camera,
403
- size=base.size,
404
- source=base.source,
405
  )
406
- served = ""
407
- for k in needed_views:
408
- v = VIEW_BY_KEY[k]
409
- if v.anchor == name and v.camera == camera and not v.extra_prompt:
410
- served = v.key
411
- break
412
- if served:
413
- anchor_serves[name] = served
414
- steps.append(
415
- Step(
416
- key=_anchor_id(name),
417
- kind="anchor",
418
- label_ja=base.label_ja,
419
- prompt_of=anchor,
420
- source="reference" if base.source == "reference" else _anchor_id(base.source),
421
- size=VIEW_BY_KEY[served].size if served else anchor.size,
422
- view_key=served,
423
- intermediate=bool(served) and served not in requested,
424
- )
425
  )
426
-
427
- # --- 4. 視点を依存順に並べる (needed_views は既に依存順) ---
428
- for k in needed_views:
429
- v = VIEW_BY_KEY[k]
430
- if anchor_serves.get(v.anchor) == v.key:
431
- continue # アンカー生成で兼ねた
432
- source = v.crop_from if v.crop_from else _anchor_id(v.anchor)
433
- steps.append(
434
- Step(
435
- key=v.key,
436
- kind="view",
437
- label_ja=v.label_ja,
438
- prompt_of=v,
439
- source=source,
440
- size=v.size,
441
- view_key=v.key,
442
- crop_kind=v.crop_kind,
443
- intermediate=k not in requested,
444
- )
445
  )
446
-
447
- return steps
 
 
 
1
+ """Eleven views; no taggers or training-caption generation."""
2
+ from dataclasses import dataclass
3
 
4
+ PROMPT_VERSION = "pose-transfer-v2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  @dataclass(frozen=True)
8
  class View:
9
  key: str
10
+ label: str
11
+ camera: str
12
+ pose: str
13
+ frame: str
14
+ shape: str = "portrait"
15
+
16
+
17
+ STAND = "Stand upright in a neutral relaxed A-pose, arms lowered, legs narrowly spaced."
18
+ SIT = "Sit on the ground hugging both knees against the chest, arms around the shins, feet on the floor."
19
+ PRONE = "Lie face down on the stomach, legs straight, arms bent resting near the head."
20
+ FULL = "Show the entire body including the top of the head and both feet."
21
+ COWBOY = "Frame from the top of the head to mid-thigh; the knees and feet are outside the frame."
22
+
23
+ VIEWS = (
24
+ View("full_front", "全身 正面", "front view, eye-level shot", STAND, FULL),
25
+ View("full_left", "全身 左側面", "left side view, eye-level shot", STAND, FULL),
26
+ View("full_right", "全身 右側面", "right side view, eye-level shot", STAND, FULL),
27
+ View("full_back", "全身 背面", "back view, eye-level shot", STAND, FULL),
28
+ View("cowboy_front", "腿上 正面", "front view, eye-level shot", "", COWBOY, "square"),
29
+ View("cowboy_back", "腿上 背面", "back view, eye-level shot", "", COWBOY, "square"),
30
+ View("upper_front", "上半身 正面", "front view, eye-level shot", "",
31
+ "Show only the head, shoulders and chest; waist and legs are outside the frame.", "square"),
32
+ View("face_front", "顔 正面", "front view, eye-level shot", "",
33
+ "Close-up portrait showing the head and face, with only the top of the shoulders visible.", "square"),
34
+ View("knees_front", "膝抱え 正面", "front view, eye-level shot", SIT, FULL),
35
+ View("knees_back", "膝抱え 背面", "back view, eye-level shot", SIT, FULL),
36
+ View("prone_back", "うつ伏せ 後方・俯瞰", "back view, high-angle shot", PRONE, FULL, "landscape"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  )
38
+ BY_KEY = {view.key: view for view in VIEWS}
39
+ DEFAULT_KEYS = list(BY_KEY)
40
+ SIZES = {
41
+ "fast": {"portrait": (512, 768), "square": (640, 640), "landscape": (768, 512)},
42
+ "standard": {"portrait": (832, 1216), "square": (1024, 1024), "landscape": (1216, 832)},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  }
44
+ SIZES["balanced"] = SIZES["standard"]
45
+ REFERENCE_RESOLUTION = {"fast": 640, "balanced": 640, "standard": 1024}
46
+
47
+
48
+ def validate_keys(keys):
49
+ if not keys:
50
+ raise ValueError("視点を1つ以上選択してください。")
51
+ if any(key not in BY_KEY for key in keys):
52
+ raise ValueError("不明な視点が含まれています。")
53
+ return list(dict.fromkeys(keys))
54
+
55
+
56
+ def make_prompt(view, extra="", pose_guide=True, angles=False):
57
+ parts = []
58
+ if angles:
59
+ # Qwen 2.1 adapter grammar; the 2511 <sks> grammar is NOT used.
60
+ parts.append("<mva> " + view.camera + (" close-up" if view.key in ("face_front", "upper_front") else ""))
61
+ if pose_guide:
62
+ parts.append(
63
+ "Edit Image 2: replace its grey mannequin with the character from Image 1. "
64
+ "Keep Image 2's pose, body orientation, camera, crop and placement in the frame. "
65
+ "Image 1 supplies only the character design, outfit, hair and rendering style; "
66
+ "do not preserve Image 1's pose, camera direction or framing. "
67
+ "Render the character's normal skin and clothing, with no mannequin joints or grey material."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  )
69
+ else:
70
+ parts.append("Reshoot the character in Image 1 from the requested camera and framing.")
71
+ if view.key in ("full_back", "cowboy_back", "knees_back"):
72
+ parts.append(
73
+ "Strict rear view. The character faces away from the viewer. "
74
+ "Show the back of the head, back of the hair and back of the clothing. "
75
+ "The face, eyes and front of the chest are hidden."
 
 
 
 
 
 
 
 
 
 
 
 
76
  )
77
+ elif view.key == "full_left":
78
+ parts.append("Strict profile, with the nose pointing to the RIGHT edge of the image.")
79
+ elif view.key == "full_right":
80
+ parts.append("Strict profile, with the nose pointing to the LEFT edge of the image.")
81
+ elif view.key == "prone_back":
82
+ parts.append(
83
+ "Look down at the character's back while they lie face down on the floor. "
84
+ "Their chest and face point toward the floor, not toward the camera. "
85
+ "Place the head at the RIGHT of the image and feet at the LEFT, matching Image 2."
86
+ if pose_guide else
87
+ "Look down at the character's back while they lie face down on the floor. "
88
+ "Their chest and face point toward the floor, not toward the camera."
 
 
 
 
 
 
 
89
  )
90
+ parts.extend(["Camera: " + view.camera + ".", view.pose, view.frame,
91
+ "Preserve the identity, hair, outfit details, colors and visual style from Image 1. "
92
+ "Use a plain white background. One character, no text, no collage.", extra.strip()])
93
+ return " ".join(part for part in parts if part)