はじめに
みなさん、こんにちは。株式会社インプルのyotaです。
前回は、ローカル環境で動くVLM(視覚言語モデル)であるLlama 3.2-Visionを使って、1枚のスケッチ画像から一気にHTML/CSSを生成する検証を行いました。まだご覧になられていない方がいらっしゃいましたら、ぜひ前回の記事もご覧ください!
今回は、そこから一歩踏み込んで、AIの「役割分担」による精度向上に挑戦したお話です。
前回の振り返り
前回のプロトタイプでは、画像全体をAIに読み込ませる「一発変換(丸投げ)」を試みました。単純なレイアウトならうまくいくのですが、要素が複雑になると途端に精度が落ち、レイアウトが破綻してしまう結果に終わりました。
ここで私は立ち止まりました。
「そもそも、この精度の低下は『文字や要素の認識(OCR)』がうまくいっていないからなのか?それとも『HTMLのコーディング』でつまずいているのか?」
全プロセスを一気に1つのAIに任せてしまうと、どこが原因で失敗しているのかが完全にブラックボックス化してしまうのです。
「処理の段階(フェーズ)を分割して一つずつ検証すれば、ボトルネックを特定でき、結果的にシステム全体の精度が上がるのではないか?」
これが、今回の改善の出発点です。
今回の方針:AIの「適材適所」と、Florence-2との出会い
処理を分割するにあたり、「UIの配置を認識するプロセス」と「HTMLをコーディングするプロセス」を切り離すというアプローチを採用しました。
実は当初、配置の認識(OCR)もLlama 3.2-Visionにやらせようとしていました。しかし、「ヘッダーの座標を教えて」と聞いても、具体的なX/Y座標をピンポイントで特定するのが非常に苦手で、出鱈目な数値を返してくるばかりだったのです。
そこで、Geminiに「高度なコーディングは不要だから、とにかく『どこに何があるか』を正確に教えてくれるモデルはないか?」と聞いてみました。そうして出会ったのが、Microsoftが開発した軽量ビジョンモデル「Florence-2」でした。
Florence-2とは?
Florence-2は、一言で言えば「画像解析に特化した職人肌のAI」です。
Llamaのような「汎用的な会話や複雑な推論」はできませんが、代わりに「OCR(文字認識)」や「オブジェクト検出(物体認識)」といった特定の視覚タスクにおいて、モデルサイズが小さいにも関わらず高い精度を誇ります。
最大の特徴は、画像内のテキストや物体の位置を「正確なバウンディングボックス(四角い枠の絶対座標)」として出力してくれる点です。
Florence-2にOCRと位置特定を任せ、コーディングはLlama 3.2-Visionに任せる。この適材適所のアプローチこそが、今回のシステムにおける要となります。
実装編
環境準備
まずは、今回必要なライブラリをインストールします(仮想環境の構築やLlama 3.2-Visionを動かすためのOllama等は既にセットアップ済みとします)。
pip install torch torchvision transformers einops timm Pillow
今回使用するスケッチは前回に引き続き、次の2枚を使用します(基本的に左のテスト1を使っていきます)。
フェーズ1:Florence-2による要素の特定と「水平スライス」
Florenceへの指示
Florence-2の面白いところは、モデルに渡す「タスクプロンプト(特殊な文字列)」を変えることで、様々な視覚タスクをこなしてくれる点です。代表的なものには以下のようなタスクがあります。
<OCR>:画像内の文字を読み取る<OD>(Object Detection):画像内の物体を検出し、その座標と名前を出力する<REGION_PROPOSAL>:名前がわからない物体でも「ここに何かある」と枠で囲む<CAPTION>:画像の説明文を生成する
余談:<OD>を使ってみる
開発当初、私は「手書きのUI要素なんだから、普通に物体検出<OD>でいけるだろう」と容易に考えていました。そこで、テスト用のスケッチ画像(テスト1)を渡し、<OD>タスクを実行してみたのです。
するとFlorence-2は、画像全体を一つの大きなバウンディングボックスで囲み、こう出力しました。
「whiteboard(ホワイトボード)」
…確かに白い背景に何かが書いてあるからそう見えるのですが、私たちが知りたいのはそこではありません。汎用的な物体検出モデルは、抽象的な「手書きのUI」を認識するように訓練されていないため、このような結果になってしまったのです。
OCRを基準にした「水平スライス」アルゴリズム
そこで私は方針を転換し、「文字(テキスト)」を基準とすることにしました。本来は「UI(図形)」を基準にするのが理想ですが、今回は「役割分担による精度向上」を目標としていますので、スピード重視で簡単な方で進めます。<OCR_WITH_REGION>(文字認識+座標取得)というタスクを使い、文字のY座標をもとに画像を水平に切り刻むアルゴリズムを実装しました(一部省略しています)。
import torch
from transformers import AutoProcessor, AutoModelForCausalLM
from PIL import Image
# OCRデータを扱いやすい[x, y, w, h]に変換する関数
def clean_ocr_data(ocr_result):
(中略)
# 1. デバイスの設定
device = "mps" if torch.backends.mps.is_available() else "cpu"
# 2. モデルの読み込み
model_id = "microsoft/Florence-2-base"
model = AutoModelForCausalLM.from_pretrained(
model_id,
trust_remote_code=True,
attn_implementation="eager"
).to(device).eval()
processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
# 3. 画像の読み込み
image_path = "./test_images/test1.jpg"
try:
image = Image.open(image_path).convert("RGB")
except FileNotFoundError:
exit()
# 4. 指示
task_prompt = "<OCR_WITH_REGION>"
prompt = task_prompt
# 5. 推論
inputs = processor(text=prompt, images=image, return_tensors="pt").to(device)
with torch.no_grad():
generated_ids = model.generate(
input_ids=inputs["input_ids"],
pixel_values=inputs["pixel_values"],
max_new_tokens=1024,
do_sample=False,
num_beams=3,
use_cache=False
)
# 6. 解析
generated_text = processor.batch_decode(generated_ids, skip_special_tokens=False)[0]
parsed_answer = processor.post_process_generation(
generated_text,
task=task_prompt,
image_size=(image.width, image.height)
)
# 7. データの整形と出力
print("\n=== 整形後のデータ ===")
clean_boxes = clean_ocr_data(parsed_answer)
for box in clean_boxes:
print(f"[{box['label']}], X: {box['x']}, Y: {box['y']}, W: {box['w']}, H: {box['h']}")
# 8. 画像の切り抜きを実行
print("\n=== 画像の切り抜きを実行 ===")
if not clean_boxes:
print("領域のアンカーとなるテキストが検出されなかったため、分割をスキップします。")
else:
# 1. 検出された全ての要素を「Y座標が小さい(上にある)順」に並び替える
sorted_boxes = sorted(clean_boxes, key=lambda b: b['y'])
# 2. 分割線(Y座標)のリストを生成する
# 最初の境界線は「画像の一番上(Y=0)」
boundaries = [0]
# 各要素の間の中間地点を計算して境界線に追加していく
for i in range(len(sorted_boxes) - 1):
current_box = sorted_boxes[i]
next_box = sorted_boxes[i + 1]
# 現在の要素の下端と次の要素の上端の中間
mid_y = (current_box['y'] + current_box['h'] + next_box['y']) / 2
boundaries.append(mid_y)
# 最後の境界線は「画像の一番下(Y=最大の高さ)」
boundaries.append(image.height)
# 3. ループ処理で画像を順番に切り抜き保存する
img_w = image.width
for i, box in enumerate(sorted_boxes):
# 切り抜く座標(左, 上, 右, 下)
crop_area = (0, boundaries[i], img_w, boundaries[i + 1])
region_img = image.crop(crop_area)
# ファイル名に連番とラベル名を含める(例:region_00_logo.jpeg)
# 空白や記号を_に置換(ファイル名エラー対策)
safe_label = box['label'].replace(" ", "_").replace("/", "_")
file_name = f"region_{i:02d}_{safe_label}.jpg"
region_img.save(file_name)
これを実行すると次のような整形後のデータ(文字の座標)をもとに画像が水平スライスされます。
=== 整形後のデータ ===
[Header], X: 1520.8, Y: 376.4, W: 288.5, H: 111.1
[Main], X: 1406.0, Y: 1040.3, W: 493.6, H: 165.4
[Footer], X: 1548.6, Y: 1832.5, W: 309.4, H: 138.2

水平スライスされた画像は、それぞれ上から順にregion_00_Header.jpg, region_01_Main.jpg, region_02_Footer.jpgという名前で自動的に保存されました。
フェーズ2:LlamaによるHTML生成
次に切り出した部品画像をLlama 3.2-Visionに渡し、HTMLコンポーネントを生成させます。ここでのポイントは、「ファイル名から単語を抽出し、プロンプトにカンペとして埋め込む」という処理です。これをせずに、画像のみを与えてしまうと、「役割分担」をした意味がなくなってしまいます。
import ollama
import os
import glob
# 1. フォルダ内の全ての"region_*.jpg"という名前のファイルを全て取得し、名前順(00, 01, 02, ...)に並べる
slice_files = sorted(glob.glob("region_*.jpg"))
if not slice_files:
print("スライス画像が見つかりません。先にtest_florence.pyを実行してください。")
exit()
print(f"{len(slice_files)}枚のスライス画像を処理します...\n")
# 2. 取得した画像を1枚ずつループ
for image_path in slice_files:
# ファイル名からFlorence-2が検出した単語(ラベル)を自動抽出
filename = os.path.basename(image_path)
detected_word = filename.split('_', 2)[-1].replace('.jpg', '')
# 出力用ファイルの作成(例: draft_00_Header.md)
output_filename = filename.replace("region_", "draft_").replace(".jpg", ".md")
print(f"処理中: {filename}(確定要素: {detected_word})")
# スライス画像専用のプロンプト設計
prompt_text = f"""
あなたは優秀なフロントエンドエンジニアです。
提供された画像は、Webサイトのワイヤーフレームの一部(コンポーネント)を切り抜いたものです。
【事前情報(重要)】
前段の画像解析AIにより、この画像には以下の要素(テキストまたは役割)**のみ**が含まれていることが確定しています。
確定要素:「{detected_word}」
【指示】
上記の確定要素「{detected_word}」のためのUIコンポーネントをHTMLとTailwind CSSを用いてコーディングしてください。
【厳格な制約事項】
- 画像のレイアウトを参考にしますが、テキストの内容や役割は【事前情報】として渡された「{detected_word}」を絶対の正とします。
- html, head, bodyタグなどの全体レイアウトは不要です。コンポーネントのコードのみを出力してください。
- 事前情報にない要素や存在しないダミーテキストは**絶対に**生成しないでください。
- 出力はマークダウンのコードブロック(```html ... ```)のみとしてください。
- この出力は後段の統合AIに渡される「部品」であるため、コードブロック(```html ... ```)以外の挨拶や解説は一切含めないでください。
"""
# Ollama APIの呼び出し
try:
response = ollama.chat(
model='llama3.2-vision',
messages=[{
'role': 'user',
'content': prompt_text,
'images': [image_path]
}],
)
# 生成されたテキスト(ドラフト)を取得
raw_output = response['message']['content']
# ドラフトを個別のMarkdownファイルとして保存
with open(output_filename, "w", encoding="utf-8") as f:
f.write(f"\n")
f.write(raw_output)
print(f"=== 抽出完了! -> {output_filename} に保存しました===\n")
except Exception as e:
print("\n=== エラーが発生しました: {e} ===")
print("Ollamaアプリがバックグラウンドで起動しているか、モデル名が正しいか確認してください。")
画像と一緒に、「この画像は『Header』という要素しか入っていないから、絶対にそれだけを作れ」という強い制約をシステム的に注入することで、Llamaのハルシネーションを封じ込める作戦です。
結果:一見大成功したように見えたが…?
希望
この「役割分担」を実行した結果、テスト1では、余計なタグが含まれない、非常にクリーンなコンポーネント単位のコードが出力されました。
`` draft_00_Header.md(テスト1)
```html
<div class="flex flex-wrap justify-center items-center gap-4">
<h1 class="text-3xl font-bold text-gray-800">Header</h1>
</div>
```
「おっ、これは大成功か!?」と私は喜びました。
さらに!前回、ループに陥り結果すら出力されなかったテスト2でも…
`` draft_00_Header.md(テスト2)
```html
<div class="flex flex-col gap-4">
<h1 class="text-3xl font-bold">Header</h1>
<p class="text-lg">Header description</p>
</div>
```
`` draft_01_Logo.md(テスト2)
```html
<div class="logo-container">
<img class="logo" src="https://example.com/logo.png" alt="Logo">
</div>
```
```css
.logo-container {
@apply flex justify-center;
}
.logo {
@apply w-40 h-40 object-cover;
@apply md:w-80 md:h-80;
}
```
のように、概ね正しいHTMLが出力されるという「希望の光」が見えました。3枚のカードは無視されていますが、「確定要素として与えた要素以外は無視する」ように指示しているので、正当な反応です。
現実
しかし、テストを繰り返す中で、「残酷な現実」が時折牙を向くことになります。先ほどのような成功例もありますが、次のような失敗例もあるのです。
<div class="w-full h-full flex justify-center items-center">
...(中略/20回程度の繰り返し)
<div class="w-full h-full flex justify-center items-center">
</div>
...(中略/20回程度の繰り返し)
</div>
前回と違い、「結果が出力される」という点では改善していますが、やはり「画像内の図形」に惑わされているようです。
考察
成功する時もあるのに、なぜ突然このような暴走を起こすのか?
この不安定さの原因は、VLMやMLLM(マルチモーダル大規模言語モデル)が持つ「Vision Bias」や「Modality Bias」といったバイアス(偏り)によるもののようです。
Modality Bias(モダリティ・バイアス)とは?
VLMのようなマルチモーダルAIは、テキストや画像など複数の入力(モダリティ)を受け取りますが、状況によって「特定の情報に極端に依存(偏重)してしまい、他の指示を無視してしまう」という性質があります。これがModality Biasです。
AIの画像解析の研究では、私の今回の結果とは違い、Language Bias(言語バイアス)が強い傾向にあるようです(https://arxiv.org/abs/2508.02419)。例えば、「まだ緑色のバナナの画像」を見せながら「このバナナは何色?」と質問すると、画像を無視して「バナナ=黄色」というテキスト側の強力な事前知識に引っ張られ、「黄色」と答えてしまう現象です。
なぜテキスト指示は「画像」に負けたのか?
テキストが強いはずのVLMで、なぜ今回は画像が勝ってしまったのでしょうか?
それは、「AIが否定的な指示に弱いから」です。
厳密には、AIの「アテンション機構(Attention mechanism)」の構造に原因があります。LLMは「カード」や「枠線」といった強い意味を持つ情報に対して、強い注意(Attention)を向けるように学習されています。そのため、「〜するな」という1トークンの否定的な制約は、文脈や視覚情報の強さに押し負け、かき消されてしまうのです。
実際、2025年には「Negation: A Pink Elephant in the Large Language Models’ Room?(否定:LLMの部屋にいるピンクの象?)」というタイトルの論文(https://arxiv.org/abs/2503.22395)が公開されています。人間が「ピンクの象を想像するな」と言われると逆に想像してしまうように、LLMが否定を正しく処理できずハルシネーションを起こす問題は、最新のAI研究でも無視できない課題として指摘されています。
まとめ
いかがだったでしょうか?
今回は、Florence-2(目)とLlama 3.2-Vision(脳)の役割を分担し、「水平スライス」を行うアプローチを検証しました。
適材適所でAIを連携させる方針自体は非常に効果的でしたが、この分割方法ではどうしても他の要素のノイズが混入しやすく、VLMの構造的な特性(アテンション機構による視覚バイアス)に引っかかってしまうことがわかりました。
AIに「ノイズを無視して」とテキストで頼むのには限界があります。「ピンクの象」を思い浮かべてしまうLLMのハルシネーションを防ぐには、プロンプトで無理やり押さえつけるのではなく、前処理の段階で「そもそもAIに余計なものを見せない」といった根本的な工夫が不可欠です。
今回の検証を通して、AIへの指示の出し方や、システムとしてどう前提条件を整えるべきかという点で、非常に多くの学びを得ることができました。今後のLLM活用においても、この「AIの特性(得意・不得意)」をしっかりと意識しながら、より適切なアプローチを探っていきたいと思います!


