Slicelayers's picture
Update app.py
544f302 verified
# 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)