前回作成した画像認識システムのコード詳細
今回は前回作成したコードの詳しい説明を書いていく。
ビュー部分
まずはビュー部分から。
<form id="imageAnalysisForm" data-analyze-url="{{ route('image.analyze') }}" class="space-y-4">
@csrf
<input type="hidden" name="post_id" value="{{ $post->id }}">
<div class="space-y-2">
<label for="ai_image" class="text-sm font-medium">分析する画像</label>
<input id="ai_image" type="file" name="image" class="w-full border border-gray-300 dark:border-gray-600 rounded-md text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<button type="submit" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md text-sm font-medium transition-colors">
分析する
</button>
</form>
<div id="analysisResult" class="mt-6 p-4 border border-gray-200 dark:border-gray-700 rounded-md hidden bg-gray-50 dark:bg-gray-900">
<h4 class="font-medium text-lg mb-2">分析結果</h4>
<div id="resultContent" class="text-sm"></div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@3.18.0"></script>
<script src="{{ asset('js/tensorflow-classifier.js') }}"></script>
以下説明
<form id="imageAnalysisForm" data-analyze-url="{{ route('image.analyze') }}" class="space-y-4">
フォームタグ。
id="imageAnalysisForm"
→ JavaScript でフォームを操作するためのID
data-analyze-url="{{ route('image.analyze') }}"
→ Laravel の route('image.analyze') で指定されたURLにデータを送る
<input type="hidden" name="post_id" value="{{ $post->id }}">
type="hidden"
→ 画面には表示されないけど、データを送るための入力欄
name="post_id" → この名前で post_id を送る
value="{{ $post->id }} → 投稿IDをフォームに埋め込む
<input id="ai_image" type="file" name="image" class="w-full border border-gray-300 dark:border-gray-600 rounded-md text-sm">
type="file" → 画像やファイルを選択するための入力欄
name="image" → Laravel に送るときのデータ名(image)
id="ai_image" → JavaScript で操作するためのID
<div id="analysisResult" class="mt-6 p-4 border border-gray-200 dark:border-gray-700 rounded-md hidden bg-gray-50 dark:bg-gray-900">
<h4 class="font-medium text-lg mb-2">分析結果</h4>
<div id="resultContent" class="text-sm"></div>
</div>
document.getElementById('analysisResult').classList.remove('hidden');
document.getElementById('resultContent').innerText = "これは猫の画像です!";
id="analysisResult"
→ 画像分析の結果を表示するエリア
hidden クラスがある!
→ 最初は非表示になっていて、後で表示される
id="resultContent"
→ 結果のテキストがここに入る
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@3.18.0"></script>
この行は TensorFlow.js(テンソルフローJS) というライブラリを CDN から読み込むためのスクリプトです。
TensorFlow.js:Googleが開発した、JavaScriptで動く 機械学習ライブラリ
CDN(Content Delivery Network):インターネット上のサーバーから 直接スクリプトをダウンロードして使う 方法
これを使うと、ブラウザ上でAI(機械学習モデル)を動かせるようになる
<script src="{{ asset('js/tensorflow-classifier.js') }}"></script>
asset('js/tensorflow-classifier.js') は Laravel の public/js/tensorflow-classifier.js を参照する
このスクリプトで、画像を送信したり、分析結果を表示したりする
ルーター部分
Route::post('/image/analyze', [ImageAnalysisController::class, 'analyze'])->name('image.analyze');
画像分析のフォーム data-analyze-url="{{ route('image.analyze') }}" に対応
✅ POST メソッドで 画像を送信 すると、このルートが実行される
✅ 対応するコントローラー: ImageAnalysisController@analyze
コントローラー部分
まずはanalyze関数について
public function analyze(Request $request)
{
// 基本的な画像分析のロジック
$validated = $request->validate([
'image' => 'required|file|mimes:jpeg,png,jpg,gif|max:2048',
'post_id' => 'required|exists:posts,id'
]);
$image = $request->file('image');
$imageInfo = getimagesize($image->path());
$width = $imageInfo[0];
$height = $imageInfo[1];
// 画像の分析結果
$result = [
'width' => $width,
'height' => $height,
'dominant_tone' => $this->getDominantTone($image->path()),
'avg_color' => $this->getAverageColor($image->path()),
'brightness' => $this->getBrightness($image->path()),
];
return response()->json([
'success' => true,
'result' => $result
]);
}
主要な色調 (dominant_tone) → getDominantTone()
平均色 (avg_color) → getAverageColor()
明るさ (brightness) → getBrightness()
また
success: true で成功を示す
result に分析結果を含める。
response()->json([...])
JSON(JavaScript Object Notation)というデータ形式でレスポンスを返す。
JSON はフロントエンドとサーバー間のデータのやり取りによく使われる形式
// 主要な色調を取得
private function getDominantTone($imagePath)
{
// 簡略化した実装
$img = imagecreatefromstring(file_get_contents($imagePath));
$w = imagesx($img);
$h = imagesy($img);
$rTotal = $gTotal = $bTotal = 0;
$total = 0;
for ($x = 0; $x < $w; $x += 10) {
for ($y = 0; $y < $h; $y += 10) {
$rgb = imagecolorat($img, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$rTotal += $r;
$gTotal += $g;
$bTotal += $b;
$total++;
}
}
$rAvg = $rTotal / $total;
$gAvg = $gTotal / $total;
$bAvg = $bTotal / $total;
if ($rAvg > $gAvg && $rAvg > $bAvg) {
return '赤系';
} elseif ($gAvg > $rAvg && $gAvg > $bAvg) {
return '緑系';
} elseif ($bAvg > $rAvg && $bAvg > $gAvg) {
return '青系';
} else {
return 'モノクロ系';
}
}
関数の中身についてはあまり触れませんがみたとおりです(笑)
// 平均色を取得
private function getAverageColor($imagePath)
{
$img = imagecreatefromstring(file_get_contents($imagePath));
$w = imagesx($img);
$h = imagesy($img);
$rTotal = $gTotal = $bTotal = 0;
$total = 0;
for ($x = 0; $x < $w; $x += 10) {
for ($y = 0; $y < $h; $y += 10) {
$rgb = imagecolorat($img, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$rTotal += $r;
$gTotal += $g;
$bTotal += $b;
$total++;
}
}
$r = round($rTotal / $total);
$g = round($gTotal / $total);
$b = round($bTotal / $total);
$hex = sprintf("#%02x%02x%02x", $r, $g, $b);
return [
'r' => $r,
'g' => $g,
'b' => $b,
'hex' => $hex
];
}
// 明るさを取得
private function getBrightness($imagePath)
{
$img = imagecreatefromstring(file_get_contents($imagePath));
$w = imagesx($img);
$h = imagesy($img);
$brightnessTotal = 0;
$total = 0;
for ($x = 0; $x < $w; $x += 10) {
for ($y = 0; $y < $h; $y += 10) {
$rgb = imagecolorat($img, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
// 明るさの計算 (0-255)
$brightness = (0.299 * $r + 0.587 * $g + 0.114 * $b);
$brightnessTotal += $brightness;
$total++;
}
}
$avgBrightness = $brightnessTotal / $total;
$category = '';
if ($avgBrightness < 64) {
$category = '暗い';
} elseif ($avgBrightness < 128) {
$category = 'やや暗い';
} elseif ($avgBrightness < 192) {
$category = 'やや明るい';
} else {
$category = '明るい';
}
return [
'value' => round($avgBrightness, 2),
'category' => $category
];
}
}
tensorflow-classifier.js
まず中身は
// TensorFlow.js Image Classifier for Laravel application
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('imageAnalysisForm');
const resultDiv = document.getElementById('analysisResult');
const resultContent = document.getElementById('resultContent');
// Model loading status
let modelLoaded = false;
let model;
// Load the MobileNet model (pre-trained on ImageNet)
async function loadModel() {
try {
// Load the model
model = await tf.loadLayersModel('https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json');
modelLoaded = true;
console.log('MobileNet model loaded successfully');
// Add a notification that the model is ready
const modelStatus = document.createElement('div');
modelStatus.className = 'text-green-600 mb-2';
modelStatus.textContent = 'AI モデルの準備完了';
form.insertBefore(modelStatus, form.firstChild);
} catch (error) {
console.error('Error loading model:', error);
}
}
// Call model loading when page loads
loadModel();
// Process image and make prediction
async function classifyImage(imgElement) {
if (!modelLoaded) {
return { error: 'モデルがまだ読み込まれていません。少々お待ちください。' };
}
try {
// Preprocess the image to match MobileNet's input requirements
const tfImg = tf.browser.fromPixels(imgElement)
.resizeNearestNeighbor([224, 224]) // Resize to 224x224
.toFloat()
.div(tf.scalar(255.0))
.expandDims();
// Make prediction
const predictions = await model.predict(tfImg).data();
// Get the top 5 predictions
const topPredictions = Array.from(predictions)
.map((prob, index) => ({ probability: prob, className: IMAGENET_CLASSES[index] }))
.sort((a, b) => b.probability - a.probability)
.slice(0, 5);
return {
success: true,
predictions: topPredictions
};
} catch (error) {
console.error('Error classifying image:', error);
return { error: '画像分類エラー: ' + error.message };
} finally {
// Clean up tensors
tf.dispose();
}
}
// Handle form submit
form.addEventListener('submit', async function(e) {
e.preventDefault();
const imageInput = document.getElementById('ai_image');
if (!imageInput.files || imageInput.files.length === 0) {
resultContent.innerHTML = '<div class="text-red-600">画像を選択してください。</div>';
resultDiv.classList.remove('hidden');
return;
}
const file = imageInput.files[0];
const formData = new FormData(form);
formData.append('post_id', formData.get('post_id'));
// Show loading state
resultContent.innerHTML = '<div>分析中...</div>';
resultDiv.classList.remove('hidden');
// First, display the image
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.maxWidth = '300px';
img.style.maxHeight = '300px';
img.style.marginBottom = '10px';
// When image is loaded, classify it
img.onload = async function() {
// Perform the existing image analysis
try {
const response = await fetch(form.getAttribute('data-analyze-url') || '{{ route("image.analyze") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
});
const data = await response.json();
// Now perform TensorFlow.js classification
const tfResult = await classifyImage(img);
// Combine the results
let htmlContent = '';
if (data.success) {
// Display standard image analysis results
let colorDisplay = '';
if (data.result.avg_color && data.result.avg_color.hex) {
colorDisplay =
<div class="flex items-center mt-2">
<div style="background-color: ${data.result.avg_color.hex}; width: 50px; height: 50px; border: 1px solid #ccc;"></div>
<div class="ml-2">
<div>色コード: ${data.result.avg_color.hex}</div>
<div>RGB値: R=${data.result.avg_color.r}, G=${data.result.avg_color.g}, B=${data.result.avg_color.b}</div>
</div>
</div>
;
}
htmlContent +=
<div class="text-green-600">分析完了!</div>
<div class="mt-3">
<div><strong>画像サイズ:</strong> ${data.result.width} × ${data.result.height} ピクセル</div>
<div><strong>優勢な色調:</strong> ${data.result.dominant_tone}</div>
${data.result.brightness ? <div><strong>明るさ:</strong> ${data.result.brightness.value} (${data.result.brightness.category})</div> : ''}
</div>
<div class="mt-3">
<strong>平均色:</strong>
${colorDisplay}
</div>
;
}
// Add TensorFlow.js classification results
if (tfResult.success) {
htmlContent +=
<div class="mt-4">
<strong>AI画像分類結果:</strong>
<ul class="mt-2 list-disc pl-5">
${tfResult.predictions.map(pred =>
<li>${pred.className}: ${(pred.probability * 100).toFixed(2)}%</li>
).join('')}
</ul>
</div>
;
} else if (tfResult.error) {
htmlContent += <div class="mt-4 text-red-600">AI分類エラー: ${tfResult.error}</div>;
}
// Display the image and results
resultContent.innerHTML =
<div class="mb-4">
<strong>分析した画像:</strong><br>
${img.outerHTML}
</div>
${htmlContent}
;
// Save the analysis results if needed
if (data.success && tfResult.success) {
const combinedResult = {
standardAnalysis: data.result,
aiClassification: tfResult.predictions
};
saveAnalysisResults(combinedResult);
}
} catch (error) {
resultContent.innerHTML =
<div class="mb-4">
<strong>分析した画像:</strong><br>
${img.outerHTML}
</div>
<div class="text-red-600">エラー: ${error.message}</div>
;
}
};
});
// Save analysis results to the server
function saveAnalysisResults(combinedResult) {
fetch('/image/save-analysis', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
post_id: document.querySelector('input[name="post_id"]').value,
analysis_data: JSON.stringify(combinedResult)
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('分析結果が保存されました');
}
})
.catch(error => {
console.error('分析結果の保存中にエラーが発生しました:', error);
});
}
});
//This is a simplified version of ImageNet classes
//In a real app, you'd want to include all 1000 classes
const IMAGENET_CLASSES = [
'テレビ', '冷蔵庫', '本', 'タオル', '車', 'バス', '人', '犬', '猫', '鳥',
'花', '木', '海', '山', '川', '空', '雲', '食べ物', '果物', '野菜',
'コンピューター', 'カメラ', '時計', '電話', '椅子', 'テーブル', 'ベッド', 'ソファ', 'キッチン用品', '建物',
'信号機', '道路', '橋', '船', '飛行機', '電車', '自転車', 'バイク', 'スポーツ用品', '楽器'];
以下説明
async function loadModel() {
try {
model = await tf.loadLayersModel('https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json');
modelLoaded = true;
console.log('MobileNet model loaded successfully');
// モデルがロードされたことをユーザーに通知
const modelStatus = document.createElement('div');
modelStatus.className = 'text-green-600 mb-2';
modelStatus.textContent = 'AI モデルの準備完了';
form.insertBefore(modelStatus, form.firstChild);
} catch (error) {
console.error('Error loading model:', error);
}
}
tf.loadLayersModel() を使い、事前学習済みの MobileNet モデル をダウンロード
モデルがロードされたら modelLoaded = true; に設定
ロード完了後、「AIモデルの準備完了」メッセージを表示。
MobileNet とは事前学習済みの 画像認識モデル。
画像を入力すると、「何の画像か」を 確率付きで分類 できる
1000種類のカテゴリに分類可能(犬、猫、車、本 など)
async function classifyImage(imgElement) {
if (!modelLoaded) {
return { error: 'モデルがまだ読み込まれていません。少々お待ちください。' };
}
try {
const tfImg = tf.browser.fromPixels(imgElement)
.resizeNearestNeighbor([224, 224])
.toFloat()
.div(tf.scalar(255.0))
.expandDims();
const predictions = await model.predict(tfImg).data();
const topPredictions = Array.from(predictions)
.map((prob, index) => ({ probability: prob, className: IMAGENET_CLASSES[index] }))
.sort((a, b) => b.probability - a.probability)
.slice(0, 5);
return { success: true, predictions: topPredictions };
} catch (error) {
console.error('Error classifying image:', error);
return { error: '画像分類エラー: ' + error.message };
} finally {
tf.dispose();
}
}
画像を前処理
resizeNearestNeighbor([224, 224]) で MobileNetの入力サイズ(224×224)にリサイズ
toFloat().div(tf.scalar(255.0)) で 0~1の範囲に正規化
.expandDims() で モデルの入力形状 (batch, height, width, channels) に合わせる
モデルに画像を渡して予測 (model.predict(tfImg))
確率が高い順に上位5つを取得 (.slice(0, 5))
エラーが発生したら catch で処理
メモリ解放 (tf.dispose())
form.addEventListener('submit', async function(e) {
e.preventDefault();
const imageInput = document.getElementById('ai_image');
if (!imageInput.files || imageInput.files.length === 0) {
resultContent.innerHTML = '<div class="text-red-600">画像を選択してください。</div>';
resultDiv.classList.remove('hidden');
return;
}
const file = imageInput.files[0];
const formData = new FormData(form);
formData.append('post_id', formData.get('post_id'));
resultContent.innerHTML = '<div>分析中...</div>';
resultDiv.classList.remove('hidden');
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.maxWidth = '300px';
img.style.maxHeight = '300px';
img.onload = async function() {
try {
const response = await fetch(form.getAttribute('data-analyze-url') || '{{ route("image.analyze") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
});
const data = await response.json();
const tfResult = await classifyImage(img);
let htmlContent = '';
if (data.success) {
htmlContent += `<div><strong>画像サイズ:</strong> ${data.result.width} × ${data.result.height} ピクセル</div>`;
htmlContent += `<div><strong>明るさ:</strong> ${data.result.brightness.value} (${data.result.brightness.category})</div>`;
}
if (tfResult.success) {
htmlContent += `<div><strong>AI画像分類結果:</strong><ul>`;
tfResult.predictions.forEach(pred => {
htmlContent += `<li>${pred.className}: ${(pred.probability * 100).toFixed(2)}%</li>`;
});
htmlContent += `</ul></div>`;
}
resultContent.innerHTML = htmlContent;
} catch (error) {
resultContent.innerHTML = `<div class="text-red-600">エラー: ${error.message}</div>`;
}
};
});
フォーム送信を無効化 (e.preventDefault())
画像ファイルが選択されているかチェック
選択した画像をサーバー (image.analyze) に送信
サーバーの分析結果を取得
JavaScript(TensorFlow.js)でも分類
両方の結果を合成して表示
function saveAnalysisResults(combinedResult) {
fetch('/image/save-analysis', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
post_id: document.querySelector('input[name="post_id"]').value,
analysis_data: JSON.stringify(combinedResult)
})
});
}
MobileNet を使って 画像分類
Laravel で 画像のサイズ・明るさを分析
両方の結果を統合して表示
サーバーに保存する機能付き