Tech Waves

produced by Hakuhodo DY ONE

AIによる自律的改善ループ ~ gemini-2.5-flash-image(Nano Banana)を使ったA2Aアプリ

生成AIを活用したアプリケーションにおける課題の一つとして、出力品質のバラツキがあります。

特に画像生成AIにおいては、プロンプトの微調整次第で成果物が大きく異なってくるため、プロンプトエンジニアリングに多くの時間を費やすことが多いです。

本記事では、この課題に対する1つの解法として、「AIが生成し、AIが評価し、AIが修正する」という自律的なフィードバックループ(Agent to Agent Architecture)を実装検証してみました。

 

アプリケーションの概要

AIを活用する際、実行フローの中で人間がチェックするHITL(Human In The Loop)は必要ですが、一部エージェントに移管することでその負担をなるべく減らしたいと思います。

今回は画像の認識、生成ループの検証を行います。

具体的には以下のステップを実装します。

観察: AIが画像を認識し、描写する。

生成: ①から画像を生成する。

評価: 原画像と生成画像を比較しスコアリング(0-100点評価)と問題点の指摘を行う。

修正:スコアが60未満であれば③のフィードバックから①を再度行う。

無限ループを避けるため、ループは3回までを上限としています。

 

登場するAI

  • ReadImgAgent (観察係)gemini-2.5-flash を使用。入力画像を詳細にテキスト化します。(ステップ①、④)
  • ImageGeneratorAgent (画像作成係): gemini-2.5-flash-image を使用。テキストから画像を生成します。(ステップ②)
  • AdviserAgent (評価者):gemini-2.5-flash を使用。2枚の画像を比較し、0-100点のスコアと具体的な改善案を出力します。(ステップ③)

 

実装コード


import sys
import io
from dotenv import load_dotenv
import base64
from langchain_core.messages import HumanMessage, AIMessage
from IPython.display import Image, display

load_dotenv()

from langchain_google_genai import ChatGoogleGenerativeAI

class ReadImgAgent:

    def __init__(self, llm=None):
        self.llm = llm
        pass

    def encode_image(self, image_path):
        with open(image_path, "rb") as f:
            return base64.b64encode(f.read()).decode()

    def read_image(self, image_path):
        # 画像をbase64エンコード
        image_b64 = self.encode_image(image_path)

        # 画像形式を判定
        import os
        ext = os.path.splitext(image_path)[1].lower()
        mime_type_map = {
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.gif': 'image/gif',
            '.webp': 'image/webp',
            '.bmp': 'image/bmp'
        }
        mime_type = mime_type_map.get(ext, 'image/png')  # デフォルトはpng

        # マルチモーダルメッセージを作成
        message = HumanMessage(
            content=[
                { "type": "text", "text": """"以下の画像の内容を説明してください。""" },
                { "type": "image_url", "image_url": f"data:{mime_type};base64,{image_b64}" }
            ]
        )

        # LLMに問い合わせ
        response = self.llm.invoke([message])

        return response.content

class ImageGeneratorAgent:

    def __init__(self, llm=None):
        self.llm = llm
        pass

    def generate_image(self, prompt, save_path=None):
        message = HumanMessage(
            content=[
                {"type": "text", "text": f"次の文言から画像を生成してください: {prompt}"}
            ]
        )

        response = self.llm.invoke(
            [message],
            generation_config=dict(response_modalities=["TEXT", "IMAGE"]),
        )

        # 画像をBase64から取得
        image_base64 = self._get_image_base64(response)

        # 画像を保存する場合
        if save_path:
            image_data = base64.b64decode(image_base64)
            with open(save_path, "wb") as f:
                f.write(image_data)
            print(f"画像を保存しました: {save_path}")

        # Jupyter環境なら画像を表示
        try:
            image_data = base64.b64decode(image_base64)
            display(Image(data=image_data))
        except:
            pass

        return response 
    
    def _get_image_base64(self, response: AIMessage) -> None:
        # デバッグ: レスポンスの構造を確認
        print(f"DEBUG: response type: {type(response)}")
        print(f"DEBUG: response.content type: {type(response.content)}")
        print(f"DEBUG: response.content: {response.content}")

        # 画像ブロックを安全に検索
        image_block = next(
            (block
             for block in response.content
             if isinstance(block, dict) and block.get("image_url")),
            None  # デフォルト値を設定
        )

        if image_block is None:
            raise ValueError("レスポンスに画像データが含まれていません。response.contentを確認してください。")

        return image_block["image_url"].get("url").split(",")[-1]
    

class AdviserAgent:
    """助言エージェント 生成された画像を確認し、評価(0~100点)し、改善点を提案する"""
    def __init__(self, llm=None):
        self.llm = llm

    def advise(self, original_image_path, generated_image_path):
        """元画像と生成画像を比較して評価し、改善アドバイスを提供"""
        import os

        # 元画像をBase64エンコード
        with open(original_image_path, "rb") as f:
            original_image_b64 = base64.b64encode(f.read()).decode()

        # 生成された画像をBase64エンコード
        with open(generated_image_path, "rb") as f:
            generated_image_b64 = base64.b64encode(f.read()).decode()

        # 画像形式を判定
        mime_type_map = {
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.gif': 'image/gif',
            '.webp': 'image/webp',
            '.bmp': 'image/bmp'
        }

        original_ext = os.path.splitext(original_image_path)[1].lower()
        generated_ext = os.path.splitext(generated_image_path)[1].lower()
        original_mime = mime_type_map.get(original_ext, 'image/png')
        generated_mime = mime_type_map.get(generated_ext, 'image/png')

        # 評価メッセージを作成(両方の画像を送信)
        message = HumanMessage(
            content=[
                {
                    "type": "text",
                    "text": """以下の2つの画像を比較して評価してください。

                    【1枚目】元画像(オリジナル)
                    【2枚目】生成画像(再現したもの)

                    評価基準:
                    1. 元画像との類似度・再現度(0-100点で評価)
                    2. 生成画像の品質(解像度、鮮明さなど)
                    3. 元画像と異なる点・改善が必要な点

                    最後に、総合評価として0~100点のスコアを「スコア: XX点」の形式で必ず記載してください。
                    60点以上であれば合格、60点未満であれば不合格とします。"""
                },
                {"type": "image_url", "image_url": f"data:{original_mime};base64,{original_image_b64}"},
                {"type": "image_url", "image_url": f"data:{generated_mime};base64,{generated_image_b64}"}
            ]
        )

        # LLMに問い合わせ
        response = self.llm.invoke([message])

        return response.content


from langgraph.graph import StateGraph, END
from typing import TypedDict


class State(TypedDict):
    input_image_path: str
    image_description: str
    save_path: str
    advice: str
    retry_count: int
    improvement_feedback: str  # Adviserからの改善提案を蓄積



def read_image_node(state: State):
    llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0)
    read_img_agent = ReadImgAgent(llm=llm)
    state["image_description"] = read_img_agent.read_image(state["input_image_path"])
    return state

def generate_image_node(state: State):
    # 画像生成エージェントの利用
    image_llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash-image", temperature=0)
    image_agent = ImageGeneratorAgent(llm=image_llm)

    retry_count = state.get("retry_count", 0)
    improvement_feedback = state.get("improvement_feedback", "")

    # プロンプトの構築(Adviserのフィードバックを反映)
    if retry_count > 0 and improvement_feedback:
        prompt = f"""
        {state['image_description']}

        【前回の問題点と改善指示】
        {improvement_feedback}

        上記の改善点を踏まえて、より正確に再現してください。
        
        """
        print(f"\n[再生成 {retry_count}回目] Adviserのフィードバックを反映して画像を生成中...")
        print(f"改善指示:\n{improvement_feedback}\n")
    else:
        prompt = state["image_description"]
        print(f"\n[初回生成] 画像を生成中...")
        print(f"画像の説明: {state['image_description']}\n")

    # ユニークなファイル名を生成(retry_countで番号を振る)
    base_name = "generated_image"
    save_path = f"img/{base_name}_{retry_count}.png"

    state["save_path"] = save_path
    image_agent.generate_image(prompt=prompt, save_path=save_path)
    print(f"保存先: {save_path}")

    return state

def advise_image_node(state: State):
    # 助言エージェントの利用
    llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0)
    adviser_agent = AdviserAgent(llm=llm)

    print("元画像と生成画像を比較評価中...")
    advice = adviser_agent.advise(
        original_image_path=state["input_image_path"],
        generated_image_path=state["save_path"]
    )

    print(f"\n評価結果:\n{advice}\n")
    state["advice"] = advice
    return state

def should_retry(state: State) -> str:
    """評価結果に基づいて再生成するか判断"""
    advice = state.get("advice", "")
    retry_count = state.get("retry_count", 0)

    # スコアを抽出(例: "スコア: 75点" から 75 を取得)
    import re
    score_match = re.search(r'スコア:\s*(\d+)点', advice)

    if score_match:
        score = int(score_match.group(1))
        print(f"スコア: {score}点")

        # 60点未満で、かつリトライ回数が3回未満なら再生成
        if score < 60 and retry_count < 3:
            print(f"不合格(リトライ {retry_count + 1}/3)")
            return "regenerate"
        elif score >= 60:
            print("合格")
            return "end"
        else:
            print(f"最大リトライ回数に達しました")
            return "end"
    else:
        print("スコアを検出できませんでした")
        return "end"

def retry_node(state: State):
    """再生成のためにリトライカウントを増やし、改善フィードバックを準備"""
    retry_count = state.get("retry_count", 0)
    state["retry_count"] = retry_count + 1

    # Adviserのアドバイスから改善提案を抽出
    advice = state.get("advice", "")

    # 改善が必要な点を抽出(簡易的な処理)
    import re

    # "改善が必要な点" や "異なる点" のセクションを探す
    improvement_section = ""
    if "改善が必要な点" in advice:
        match = re.search(r'改善が必要な点[::]\s*(.+?)(?:スコア|$)', advice, re.DOTALL)
        if match:
            improvement_section = match.group(1).strip()
    elif "異なる点" in advice:
        match = re.search(r'異なる点[::]\s*(.+?)(?:スコア|$)', advice, re.DOTALL)
        if match:
            improvement_section = match.group(1).strip()

    # 改善提案がない場合は、アドバイス全体から抽出
    if not improvement_section:
        improvement_section = advice.split("スコア:")[0].strip()

    # 既存のフィードバックに追記(履歴を保持)
    existing_feedback = state.get("improvement_feedback", "")
    if existing_feedback:
        state["improvement_feedback"] = f"{existing_feedback}\n\n[再生成 {retry_count}回目の指摘]\n{improvement_section}"
    else:
        state["improvement_feedback"] = improvement_section

    print(f"Adviserのフィードバックを反映して再生成します({state['retry_count']}回目)\n")

    return state


workflow = StateGraph(State)
workflow.add_node("ReadImage", read_image_node)
workflow.add_node("GenerateImage", generate_image_node)
workflow.add_node("AdviseImage", advise_image_node)
workflow.add_node("Retry", retry_node)

workflow.set_entry_point("ReadImage")
workflow.add_edge("ReadImage", "GenerateImage")
workflow.add_edge("GenerateImage", "AdviseImage")
workflow.add_conditional_edges(
    "AdviseImage",
    should_retry,
    {
        "regenerate": "Retry",
        "end": END
    }
)
workflow.add_edge("Retry", "GenerateImage")

app = workflow.compile()

print("=" * 60)
print("画像生成ワークフロー開始(A2A: Agent-to-Agent評価システム)")
print("=" * 60)

result = app.invoke({
    "input_image_path": "img/item1.png",
    "image_description": "",
    "save_path": "",
    "advice": "",
    "retry_count": 0,
    "improvement_feedback": ""
})

print("\n" + "=" * 60)
print("ワークフロー完了")
print("=" * 60)
  

 

 

ステート管理

3つのエージェント間で情報を共有するステートを以下のように定義します。

特に、improvement_feedback(改善指示)を定義することで、ループごとに精度向上を図っています。


    class State(TypedDict):
        input_image_path: str
        image_description: str
        save_path: str
        advice: str 
        retry_count: int # 0から始まり、リトライ毎に数を増やす。
        improvement_feedback: str  # Adviserからの改善提案を蓄積

 

 

評価と条件分岐

AdviserAgentの回答結果から スコアを抽出し、ループするかどうかを判定します。


    def should_retry(state: State) -> str:
        # ... (スコア抽出処理) ...
        if score < 60 and retry_count < 3:
            return "regenerate"  # 再生成へ
        return "end"             # 終了

 

フィードバックの反映


    if retry_count > 0:
        prompt = f"""{state['image_description']}
    
        【前回の問題点と改善指示】
        {improvement_feedback}
    
        上記の改善点を踏まえて、より正確に再現してください。"""

 

実行結果

1回目のトライ

フリー素材の猫の画像を使用しました。

 

① この画像を ReadImgAgent (観察係)に読み込ませ、描写させた文章が以下になります。

 

 

ImageGeneratorAgent (画像作成係)によって上記の文章から画像を生成しました。

 

③ ①と②の画像を比較し、AdviserAgent (評価者)に評価してもらいます。

1. 元画像との類似度・再現度(65点) 生成画像は、元画像と同じくグレーと白のバイカラーの猫を、白い背景でクローズアップした構図で再現しています。全体的な毛色や顔の輪郭、耳の形などはよく似ており、一見すると同じ猫のように見えます。 しかし、以下の点で再現度が低いと感じられます。

  • 目の色: 元画像は鮮やかなオレンジがかった黄色(琥珀色)ですが、生成画像は黄緑色です。これは猫の印象を大きく左右する要素であり、大きな違いです。
  • 表情とアングル: 元画像は首をやや傾げ、好奇心旺盛な表情で、少し斜めからのアングルです。対して生成画像は完全に正面を向いており、表情もやや硬く、無表情に見えます。
  • 毛並みの質感: 元画像の方が毛並みがより柔らかく、ふわふわとした質感が感じられますが、生成画像はやや滑らかで、ふわふわ感が薄いです。
  • 顔の模様の比率: 目の周りのグレーの部分と鼻から口にかけての白い部分の比率が、元画像と生成画像でわずかに異なります。
  • ヒゲ: 元画像の方がヒゲが長く、より自然に見えます。生成画像はヒゲが短く、やや不自然です。

これらの違いから、全体的な雰囲気は似ているものの、細部の再現性、特に目の色や表情といった個性を決定づける要素の再現度が低いため、65点と評価します。

2. 生成画像の品質 生成画像の品質は非常に高いです。

  • 解像度: 高く、細部まで鮮明に描写されています。
  • 鮮明さ: ピントがしっかりと合っており、非常に鮮明です。毛並みや目のディテールもクリアに見えます。
  • ノイズ・アーティファクト: 目立ったノイズや不自然なアーティファクトは見られません。
  • ライティング: 均一で明るいライティングで、全体的に清潔感のある印象です。

単体の画像として見れば、プロフェッショナルな写真と遜色ない高品質な画像と言えます。

3. 元画像と異なる点・改善が必要な点

  • 目の色の再現: 元画像の鮮やかな琥珀色を正確に再現できていません。生成画像の黄緑色の目は、元画像とは異なる印象を与えます。
  • 表情とアングルの再現: 元画像の首を傾げた好奇心旺盛な表情と、やや斜めからのアングルを再現できていません。生成画像は正面向きで、表情が硬いです。
  • 毛並みの質感の向上: 元画像のような、より柔らかくふわふわとした毛並みの質感を表現できると、さらに類似度が高まります。
  • ライティングの立体感: 生成画像のライティングは均一で美しいですが、元画像のような自然な光による立体感や奥行きがもう少しあると良いでしょう。
  • ヒゲの自然さ: ヒゲの長さや配置が、元画像に比べてやや不自然に見えるため、より自然な描写が望まれます。

スコア: 70点 この画像は、白い背景をバックにした、可愛らしい猫のクローズアップ写真です。

猫は、グレーと白の毛色を持つバイカラーで、カメラをまっすぐに見つめています。頭部と耳、目の周りはグレーの毛で覆われ、額から鼻筋、口元、胸にかけては白い毛が特徴的です。

特に印象的なのは、大きく見開かれた瞳です。瞳孔は黒く、その周りの虹彩は鮮やかな金色から緑色へとグラデーションしており、光を反射してキラキラと輝いています。鼻は小さく、可愛らしいピンク色をしています。口元からは長く白いひげが伸びています。耳はピンと立っており、内側には薄いピンク色が見えます。

猫は好奇心旺盛で、少し首を傾げているようにも見え、見る人に穏やかで愛らしい印象を与えます。背景はシンプルで明るい白一色で、猫の表情や毛並みが際立つように撮影されています。

いきなり70点をだしてしまったため、フィードバックもされずに、処理フローが完了してしまいました・・。

 

 

合格点を80点以上として再トライ

ちょっとハードルを上げました。

 

【1回目】

20点

再度リトライしたところ、急に採点が厳しくなりました。(何故?)

目の色が琥珀色ではなく、緑掛かっていることや、ポーズが異なっていることでかなり低くスコアリングされています。

 

【アドバイス

1. 元画像との類似度・再現度(0-100点で評価) 猫の配色(グレーと白のバイカラー)は一致しているものの、最も特徴的な目の色(元画像は鮮やかな琥珀色、生成画像は緑色)が全く異なり、別の猫のように見えます。元画像は首を傾げた可愛らしい表情ですが、生成画像は正面を向いており、表情が乏しいです。毛並みの質感も異なり、元画像の柔らかくふわふわした印象に対し、生成画像はやや硬く、のっぺりしています。ポーズや構図も異なるため、全体的な類似度・再現度は非常に低いと言えます。 評価点: 20点

2. 生成画像の品質(解像度、鮮明さなど) 生成画像は解像度が高く、全体的に鮮明でピントも合っています。画像としての基本的な品質は良好です。しかし、毛並みの柔らかさや立体感、生命感といった細部の表現は元画像に劣ります。特に白い毛の部分の質感が単調に見えます。また、全体的な色味が元画像より彩度が低く、鮮やかさに欠けます。

3. 元画像と異なる点・改善が必要な点

  • 目の色: 最も大きな違いであり、改善が必須です。元画像の鮮やかな琥珀色を正確に再現する必要があります。
  • 表情とポーズ: 元画像の首を傾げた可愛らしい表情とポーズを再現することで、類似度が格段に向上します。
  • 毛並みの質感: 特に白い毛の部分のふわふわ感や柔らかさを再現し、より自然な毛並みにする必要があります。
  • 全体的な色味と明るさ: 元画像の明るく鮮やかな色調に近づけるべきです。生成画像は全体的に彩度が低く、落ち着いた印象です。
  • 背景: 元画像のように完全に均一な白い背景にする必要があります。生成画像では背景の下部にグレーの物体が写り込んでいます。
  • ひげ: 生成画像のひげは元画像よりも長く、強調されているため、元画像に合わせるべきです。

 

【2回目】

①への入力文書が以下のようにリッチ化されます。

次の文言から画像を生成してください: この画像は、白い背景を背にした、好奇心旺盛な表情の猫のクローズアップ写真です。

猫は、頭部や耳、背中がグレーで、顔の中央(鼻筋から目の間)、胸元、そしておそらくお腹にかけては白い毛色をしています。特に印象的なのは、大きく丸い瞳です。瞳孔の周りは鮮やかな緑色で、その外側は金色から琥珀色に輝いており、非常に魅力的です。鼻は小さく、可愛らしいピンク色をしています。長く白いひげが顔の両側から伸びています。

カメラをまっすぐ見つめており、頭をわずかに傾けているため、非常に愛らしく、何かを問いかけているような、あるいは興味津々といった表情に見えます。背景は完全にぼかされた真っ白で、猫の姿が際立っています。

全体として、この写真は猫の美しさと愛らしさを捉えた、明るく魅力的な一枚です。

【前回の問題点と改善指示】
2つの画像を比較し、以下の通り評価します。

1. 元画像との類似度・再現度(0-100点で評価) 猫の配色(グレーと白のバイカラー)は一致しているものの、最も特徴的な目の色(元画像は鮮やかな琥珀色、生成画像は緑色)が全く異なり、別の猫のように見えます。元画像は首を傾げた可愛らしい表情ですが、生成画像は正面を向いており、表情が乏しいです。毛並みの質感も異なり、元画像の柔らかくふわふわした印象に対し、生成画像はやや硬く、のっぺりしています。ポーズや構図も異なるため、全体的な類似度・再現度は非常に低いと言えます。 評価点: 20点

2. 生成画像の品質(解像度、鮮明さなど) 生成画像は解像度が高く、全体的に鮮明でピントも合っています。画像としての基本的な品質は良好です。しかし、毛並みの柔らかさや立体感、生命感といった細部の表現は元画像に劣ります。特に白い毛の部分の質感が単調に見えます。また、全体的な色味が元画像より彩度が低く、鮮やかさに欠けます。

3. 元画像と異なる点・改善が必要な点

  • 目の色: 最も大きな違いであり、改善が必須です。元画像の鮮やかな琥珀色を正確に再現する必要があります。

  • 表情とポーズ: 元画像の首を傾げた可愛らしい表情とポーズを再現することで、類似度が格段に向上します。

  • 毛並みの質感: 特に白い毛の部分のふわふわ感や柔らかさを再現し、より自然な毛並みにする必要があります。

  • 全体的な色味と明るさ: 元画像の明るく鮮やかな色調に近づけるべきです。生成画像は全体的に彩度が低く、落ち着いた印象です。

  • 背景: 元画像のように完全に均一な白い背景にする必要があります。生成画像では背景の下部にグレーの物体が写り込んでいます。

  • ひげ: 生成画像のひげは元画像よりも長く、強調されているため、元画像に合わせるべきです。

    上記の改善点を踏まえて、より正確に再現してください。

 

 

78点

【3回目】

68点

3回目は逆にスコアが下がってしまいました・・

その後も数回ループを繰り返しましたが、スコアは70点台前後で推移しました。

 

 

まとめ

LangGraphとGemini 2.5を組み合わせることで、人の手を介さずに、役割を分担させたAIのみで、品質向上ループを実装することができました。これは、単独のAIではその都度人間が指示をしなくてはいけない部分も自動化できたことになります。

今回はNano Bananaを使ってみたいと思い、画像の生成ループを作成しましたが、コードの生成や文章作成など、他の生成AIタスクに応用可能だと思います。

ただし、評価基準は客観的にわかるようなスコアリングを設け、最終的に人が判断、承認する必要はあります。