File size: 18,076 Bytes
73a5aa8
 
bfea3f5
73a5aa8
 
83f12a5
73a5aa8
5a4293a
73a5aa8
c7396ab
 
ca28d65
f989429
5a4293a
73a5aa8
83f12a5
73a5aa8
c7396ab
 
73a5aa8
83f12a5
8df58f5
 
 
73a5aa8
 
 
 
4ac4284
73a5aa8
 
 
 
 
83f12a5
73a5aa8
 
ca28d65
73a5aa8
ca28d65
aba93f6
ca28d65
 
afcd408
8df58f5
 
 
 
 
 
 
 
 
 
aba93f6
8df58f5
 
 
73a5aa8
8df58f5
73a5aa8
 
83f12a5
 
 
ca28d65
 
 
 
 
 
 
 
 
 
 
83f12a5
73a5aa8
 
 
 
 
 
 
 
ca28d65
 
73a5aa8
ca28d65
e736798
afcd408
c7396ab
ca28d65
 
e736798
ca28d65
afcd408
ca28d65
 
 
 
 
 
 
 
 
 
73a5aa8
ca28d65
 
 
 
 
 
 
e736798
 
73a5aa8
ca28d65
 
e736798
ca28d65
 
 
0914dea
e736798
 
afcd408
ca28d65
0914dea
ca28d65
73a5aa8
ca28d65
73a5aa8
 
 
 
bfea3f5
73a5aa8
afcd408
 
544f302
73a5aa8
544f302
bfea3f5
0914dea
544f302
bfea3f5
4ac4284
544f302
 
 
 
aba93f6
 
0914dea
544f302
aba93f6
 
 
 
 
 
 
0914dea
 
 
 
 
 
 
 
 
 
544f302
 
 
73a5aa8
afcd408
4ac4284
 
bfea3f5
0914dea
 
bfea3f5
0914dea
afcd408
aba93f6
544f302
 
 
afcd408
 
 
c7396ab
afcd408
c7396ab
 
73a5aa8
c7396ab
73a5aa8
 
afcd408
 
c7396ab
73a5aa8
bfea3f5
73a5aa8
 
bfea3f5
aba93f6
 
 
 
 
 
 
bfea3f5
544f302
 
 
bfea3f5
544f302
 
 
bfea3f5
 
73a5aa8
 
bfea3f5
73a5aa8
 
 
bfea3f5
73a5aa8
 
 
 
c7396ab
afcd408
73a5aa8
83f12a5
bfea3f5
73a5aa8
 
 
 
544f302
73a5aa8
 
 
 
 
bfea3f5
73a5aa8
 
bfea3f5
73a5aa8
 
 
 
 
 
544f302
 
bfea3f5
 
 
 
 
 
73a5aa8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afcd408
1d33fdb
 
73a5aa8
 
 
 
bfea3f5
 
73a5aa8
 
bfea3f5
4ac4284
 
bfea3f5
4ac4284
 
0914dea
4ac4284
 
afcd408
bfea3f5
0914dea
4ac4284
 
73a5aa8
4ac4284
73a5aa8
 
 
4ac4284
73a5aa8
 
 
afcd408
544f302
 
73a5aa8
 
 
 
 
afcd408
73a5aa8
 
 
 
 
afcd408
 
73a5aa8
 
c7396ab
73a5aa8
 
 
 
 
bfea3f5
 
73a5aa8
 
 
 
 
bfea3f5
73a5aa8
 
 
 
0914dea
bfea3f5
0914dea
aba93f6
 
 
73a5aa8
83f12a5
73a5aa8
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# coding: utf-8
import gradio as gr
from PIL import Image
import numpy as np
import io
import base64
import zipfile
import json
import time
import asyncio 
import os 
import cv2 
import httpx 

# --- 定数とAPI設定 (Constants and API Configuration) ---

# Nano Banana API (gemini-2.5-flash-image-preview) の設定
# 環境変数からAPIキーを安全に読み込む
API_KEY = os.environ.get("NANO_BANANA_API", "") # Secret名 "NANO_BANANA_API" を使用
API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent?key="

# API送信用の最大画像サイズを定義 (ほとんどのAIモデルで推奨される最大値に近い)
MAX_API_SIZE = 1024 

# --- ヘルパー関数 (Helper Functions) ---

def pil_to_base64(img: Image.Image) -> str:
    """PIL画像をBase64エンコードされたPNGデータに変換します。"""
    buffered = io.BytesIo()
    # アルファチャンネル(PNG)を維持
    if img.mode != 'RGBA':
        img = img.convert('RGBA')
    img.save(buffered, format="PNG")
    return base64.b64encode(buffered.getvalue()).decode("utf-8")

async def nano_banana_completion_api(base_image: Image.Image, prompt: str) -> Image.Image:
    """
    Nano Banana (gemini-2.5-flash-image-preview) APIを呼び出し、画像補完を実行します。
    """
    if not API_KEY:
        # APIキーが空の場合はエラーを発生させて、ユーザーに設定を促す
        raise gr.Error("エラー: APIキーが設定されていません。Hugging Face Secretsで 'NANO_BANANA_API' を設定してください。")

    # --- API送信前に画像サイズを調整 ---
    resized_image = base_image.copy()
    w, h = resized_image.size
    
    if max(w, h) > MAX_API_SIZE:
        if w > h:
            new_w = MAX_API_SIZE
            new_h = int(h * (MAX_API_SIZE / w))
        else:
            new_h = MAX_API_SIZE
            new_w = int(w * (MAX_API_SIZE / h))
        # アスペクト比を維持しつつリサイズ
        resized_image = resized_image.resize((new_w, new_h), Image.Resampling.LANCZOS)
        print(f"--- 画像をAPI送信のためリサイズしました: {w}x{h} -> {new_w}x{new_h} ---")
    
    # Base64エンコード
    base64_image = pil_to_base64(resized_image)
    
    # プロンプトと画像を含むペイロードの構築(Image-to-Image用)
    payload = {
        "contents": [
            {
                "parts": [
                    {
                        "text": f"画像を編集・補完してください。この画像はLive2D素材の一部です。マスクされた(透明な)領域にプロンプトに従った内容を生成し、画像を自然に完成させてください。プロンプト: {prompt}"
                    },
                    {
                        "inlineData": {
                            "mimeType": "image/png",
                            "data": base64_image
                        }
                    }
                ]
            }
        ],
        "generationConfig": {
            "responseModalities": ["TEXT", "IMAGE"]
        },
    }

    print(f"--- API呼び出しペイロードを構築しました。プロンプト: {prompt} ---")
    
    # --- 実際のAPI呼び出しロジック (httpxを使用) ---
    final_api_url = API_URL + API_KEY
    
    # 指数バックオフ付きリトライロジック
    max_retries = 5 
    delay = 2 
    
    for attempt in range(max_retries):
        try:
            async with httpx.AsyncClient(timeout=60.0) as client:
                response = await client.post(final_api_url, json=payload)
                response.raise_for_status() 
            
            result = response.json()
            candidate = result.get('candidates', [{}])[0]
            
            # Base64画像データの抽出
            base64_data = None
            for part in candidate.get('content', {}).get('parts', []):
                if 'inlineData' in part and part['inlineData']['mimeType'].startswith('image/'):
                    base64_data = part['inlineData']['data']
                    break

            if base64_data:
                # Base64データをデコードしてPIL Imageに変換
                image_data = base64.b64decode(base64_data)
                completed_image = Image.open(io.BytesIO(image_data)).convert('RGBA')
                print("--- AI補完処理を完了しました (実API) ---")
                return completed_image
            else:
                error_detail = result.get('candidates', [{}])[0].get('finishReason', 'UNKNOWN')
                raise ValueError(f"APIレスポンスから画像データが抽出できませんでした。Finish Reason: {error_detail}")

        except httpx.HTTPStatusError as e:
            if e.response.status_code in [429, 503] and attempt < max_retries - 1:
                print(f"APIレート制限/サーバーエラー ({e.response.status_code})。{delay}秒待機後にリトライします...")
                await asyncio.sleep(delay)
                delay *= 2  # 指数バックオフ
            else:
                # ユーザーへの表示用にgr.Errorを再raise
                raise gr.Error(f"API呼び出しエラー: Client error '{e.response.status_code} {e.response.reason_phrase}' for url '{e.request.url}'")
        except httpx.RequestError as e:
             raise gr.Error(f"APIリクエストエラー: ネットワークまたはタイムアウトエラーが発生しました。詳細: {e}")
        except Exception as e:
            # その他の予期せぬエラー
            raise gr.Error(f"予期せぬエラーが発生しました: {e}")
    
    raise gr.Error("APIリクエストが最大リトライ回数を超えて失敗しました。")


# --- メイン処理ロジック (Main Processing Logic) ---

async def segment_and_inpaint(original_image_np: np.ndarray, sketchpad_image_np: np.ndarray, inpaint_prompt: str):
    """
    アップロードされた一枚絵とブラシで描かれたマスクを受け取り、
    マスクされた領域を分割し、欠損部分をAI補完する関数。
    (この関数はasync generatorとして動作し、yieldで結果を返します)
    """
    # 失敗時の統一リターン用タプルを生成
    def fail(message):
        # 画像出力は全てNone, JSONとZIPは空文字列/None, ステータスにエラーメッセージ
        return (None, None, None, "{}", None, f"❌ 処理失敗: {message}")

    if original_image_np is None:
        # 修正: return -> yield
        yield fail("元の画像がアップロードされていません。")
        return # Generatorを終了

    # 元画像の形状を取得(SketchpadがNoneだった場合の代替用)
    h, w, _ = original_image_np.shape

    # ★★★ Sketchpad入力の型チェックとデータ形式の強制 ★★★
    
    if not isinstance(sketchpad_image_np, np.ndarray) or sketchpad_image_np is None:
        # Sketchpadの入力がNumPy配列ではないか、Noneであった場合、
        # 全て透明なダミーのNumPy配列を作成し、「描画なし」として処理を続行
        print("--- Sketchpad入力が不正またはNoneでした。全て透明なダミーデータを作成します。 ---")
        sketchpad_image_np = np.zeros((h, w, 4), dtype=np.uint8) # RGBA
    
    if sketchpad_image_np.ndim == 3 and sketchpad_image_np.shape[2] == 3:
        # 3次元配列(RGB)の場合、透明なアルファチャンネルを追加して4次元(RGBA)に変換
        print("--- SketchpadデータがRGB (3次元) で渡されました。RGBA (4次元) に変換します。 ---")
        # 全て透明(0)のアルファチャンネルを作成
        alpha_channel = np.zeros((h, w, 1), dtype=np.uint8) 
        # RGB配列とアルファチャンネルを結合
        sketchpad_image_np = np.concatenate((sketchpad_image_np, alpha_channel), axis=2)
        
    elif sketchpad_image_np.ndim != 4 or sketchpad_image_np.shape[2] != 4:
        # 4次元配列ではない、または4チャンネルではない場合は不正と判断
        # 修正: return -> yield
        yield fail(f"描画データ形式が不正です。(次元: {sketchpad_image_np.ndim}, チャンネル: {sketchpad_image_np.shape[2] if sketchpad_image_np.ndim >= 3 else 'N/A'})。")
        return # Generatorを終了

    # PIL Image (元の画像) を取得
    original_image = Image.fromarray(original_image_np).convert("RGBA")
    
    # Sketchpadからの入力は、元の画像の上にブラシで描画されたRGBA画像
    # アルファチャンネルからマスクを抽出
    sketch_mask_alpha = sketchpad_image_np[:, :, 3] 
    
    # ユーザーが何も描画しなかった場合のチェック (アルファチャンネルが全てゼロ)
    if np.all(sketch_mask_alpha == 0):
        # SketchpadがNoneの場合もここに来る
        # 修正: return -> yield
        yield fail("分割したいパーツをブラシで描画してください。(描画が検出されません)")
        return # Generatorを終了

    # NumPy配列からPIL Imageのマスクに変換 (Lモード)
    hair_mask = Image.fromarray(sketch_mask_alpha, mode='L')

    # --- Part A: 分割パーツを作成 (ユーザーが描いた部分) ---
    hair_part = original_image.copy().convert('RGBA')
    hair_part.putalpha(hair_mask)
    
    # --- Part B: 欠損穴のある体 (Body with Hole) を作成 (AI補完用入力) ---
    body_with_hole = original_image.copy().convert("RGBA")
    
    # マスクを反転 (描画した部分を透明な穴にするため)
    inverted_mask_np = cv2.bitwise_not(sketch_mask_alpha)
    body_with_hole.putalpha(Image.fromarray(inverted_mask_np, mode='L'))
    
    print("--- 1. 手動パーツ分割 完了 ---")

    # 2. 欠損領域のAI補完 (AI Inpainting)
    try:
        # 処理ステータスを一時的に更新してフィードバックを提供
        yield (None, None, None, "{}", None, "⏳ AI補完処理を開始しました... (完了まで最大1分程度かかる場合があります)")

        completed_body_part = await nano_banana_completion_api(
            body_with_hole, 
            inpaint_prompt or "マスクされた領域のキャラクターの顔と身体の肌、及び下に着ている服を、元のイラストのテイストに合わせて自然に補完してください。"
        )
    except gr.Error as e:
        # 修正: return -> yield
        yield fail(f"AI補完APIエラー: {e}")
        return # Generatorを終了
    except Exception as e:
        # 修正: return -> yield
        yield fail(f"AI補完処理中に予期せぬエラー: {e}")
        return # Generatorを終了

    # 3. 出力ファイルの準備とZIPファイルの作成
    output_parts = {
        "body_completed": completed_body_part,
        "hair_front": hair_part, 
    }
    
    # JSON構造の作成
    output_json_data = {
        "parts": {
            "body_completed": "body_completed.png",
            "hair_front": "hair_front.png"
        },
        "inpaint_prompt_used": inpaint_prompt,
        "note": "hair_frontはユーザーがブラシで描画したマスクに基づいて分割されました。",
        "timestamp": time.strftime("%Y%m%d_%H%M%S")
    }
    json_data_str = json.dumps(output_json_data, indent=2, ensure_ascii=False)
    
    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
        for name, img in output_parts.items():
            img_buffer = io.BytesIO()
            if img.mode != 'RGBA':
                img = img.convert('RGBA')
            img.save(img_buffer, format="PNG")
            zipf.writestr(f"live2d_parts/{name}.png", img_buffer.getvalue())
        
        zipf.writestr("live2d_parts/parts_structure.json", json_data_str.encode('utf-8'))
        
    zip_buffer.seek(0)
    zip_file_path = f"live2d_parts_{output_json_data['timestamp']}.zip"
    
    with open(zip_file_path, "wb") as f:
        f.write(zip_buffer.read())

    print(f"--- 4. 全パーツZIPファイル {zip_file_path} の作成完了 ---")
    
    # 成功時の最終結果をyield (6つの出力)
    yield (
        completed_body_part, 
        hair_part,           
        body_with_hole,      
        json_data_str,       # JSON文字列をそのまま渡す
        zip_file_path,       
        "✅ 処理完了!結果を確認し、ZIPファイルをダウンロードしてください。" # ステータス
    )

# --- Gradioインターフェース定義 (Gradio Interface Definition) ---

# Gradioテーマ定義
theme = gr.themes.Soft(
    primary_hue="blue", 
    secondary_hue="blue", 
    neutral_hue="gray",
)

with gr.Blocks(theme=theme, title="Live2D素材自動分割・補完アプリ") as demo:
    gr.Markdown(
        """
        <div style='text-align: center; margin-bottom: 20px; padding: 10px; background: #E0F7FA; border-radius: 12px;'>
            <h1 style='color: #00796B; font-size: 2.5em; font-weight: 700;'>🎨 Live2D 素材 自動分割・補完アプリ 🤖</h1>
            <p style='color: #004D40; font-size: 1.1em;'>一枚絵をアップロードし、**ブラシでパーツを指定**することで、AIによる欠損部分の自動補完(Nano Banana API利用)を行います。</p>
            <p style='color: #004D40; font-size: 1.0em;'>💡 **操作方法:** ①に元画像をアップロードし、②のキャンバスに**描画ブラシ**を使って、前髪など**分割したいパーツ**を塗りつぶしてください。塗った部分がパーツとして分離され、AIがその下の欠損を補完します。</p>
            <p style='color: #D32F2F; font-size: 0.9em;'>⚠ 注意: 現在、描画キャンバスの背景にアップロード画像を動的に表示できません。アップロード画像を確認しながら、**描画キャンバス(②)の白いエリアに**ブラシでパーツの輪郭を塗ってください。</p>
        </div>
        """
    )

    status_message = gr.Textbox(label="処理ステータス", interactive=False, value="処理を開始するには、画像アップロードと描画を行い、実行ボタンを押してください。", elem_classes="status-box")
    
    with gr.Row():
        with gr.Column(scale=1):
            # 元画像をアップロードするためのコンポーネント
            original_image_upload = gr.Image(
                label="① 元画像のアップロード", 
                type="numpy",
                height=200,
            )
            # 描画専用のSketchpadコンポーネント (type="numpy")
            input_sketchpad = gr.Sketchpad(
                label="② 分割したいパーツをブラシで塗る (例: 前髪)", 
                height=400,
                brush={"color": "#FF0000", "size": 20}, 
                type="numpy"
            )
            
            inpaint_prompt = gr.Textbox(
                label="③ AI補完プロンプト(オプション)", 
                value="マスクされた領域のキャラクターの顔と身体の肌、及び下に着ている服を、元のイラストのテイストに合わせて自然に補完してください。",
                placeholder="例: マスクされた領域を元の絵柄で自然に描き足す"
            )
            process_button = gr.Button("④ 手動分割・AI補完を実行", variant="primary", scale=0)
            
            gr.Markdown(
                """
                ### 📌 開発のポイント
                * **エラー解消:** `async def` 関数内で `yield` を使用する場合の `SyntaxError` を解消しました。これにより、処理ステータスが正しくフィードバックされるようになります。
                * **安定性の再強化:** Sketchpadの入力データ型チェックを引き続き強化しています。
                """
            )

        with gr.Column(scale=2):
            # --- 出力エリア ---
            gr.Markdown("## 💡 処理結果 (手動分割・補完済パーツ)")
            
            with gr.Tabs():
                with gr.TabItem("メインパーツ (AI補完結果)"):
                    completed_body_output = gr.Image(label="補完済みメインボディパーツ (AI Inpainting)", height=300)
                    
                with gr.TabItem("分割パーツ例 (ブラシ指定部分)"):
                    hair_part_output = gr.Image(label="ユーザーがブラシで指定したパーツ", height=300)
                    
                with gr.TabItem("欠損体 (補完AI入力)"):
                    body_with_hole_output = gr.Image(label="補完前の欠損体 (AIが補完する穴)", height=300)

            with gr.Row():
                download_zip = gr.File(label="全パーツZIPダウンロード", file_count="single")
            
            gr.Markdown("## 📋 出力レイヤー構造 (JSON)")
            # JSONDecodeErrorを避けるため、JSONコンポーネントではなくテキストボックスを使用
            output_json_text = gr.Textbox(label="Live2Dパーツ構造 JSON (確認用)", lines=10, interactive=False)


    # --- イベントリスナー ---
    process_button.click(
        fn=segment_and_inpaint,
        inputs=[original_image_upload, input_sketchpad, inpaint_prompt],
        outputs=[
            completed_body_output, 
            hair_part_output, 
            body_with_hole_output, 
            output_json_text,   
            download_zip,
            status_message      
        ],
        # AI補完処理中はUIをロック
        api_name="process_inpaint"
    )

# デモの起動 (Hugging Face Spacesでの実行を想定)
if __name__ == "__main__":
    demo.launch(share=False)