AIが和歌を作ってくれて、さらにこんな画像で出してくれたらいいな、という発想でアプリを考えました。
この絵、気に入ってます。
前回からの宿題
前回の記事です。
以下、作りたいプロダクトの概要です。
タイトル: 今の気持ちを伝えたい
-
今の気持ち、見たい風景、動物、植物、地名などを入力する。選択肢を表示しておく。
-
LLMが入力に対して、適切な和歌をいくつか提示してくる。
-
そのうちの一つを選ぶと、その和歌に応じた風景画が4つ描かれる。
-
4つのうち1つを選択すると、その画像と和歌が合成され、作品が完成する。
完成?
苦労しました。APIを使って思うような出力がなかなか得られなかったです。今もほんとに得られているのかは謎です。
制作したもの
Webアプリとして制作しました。
実行環境はローカルのLinux環境(WSL)を使い、Webサーバーを立てる形を取りました。

外部とのやりとりは全てサーバー側(server.js)で実行します。
サーバー上の実行環境はNode.jsで作ります。
ファイル構造はこんな感じ
my-project/
├── .env
├── package.json
├── server.js
├── public/
│ ├── index.html
│ └── script.js
├── img/
│ ├── 生成された画像ファイル
.envにはAPI KEYなどを入れておきます。
API_KEY=MY_MIIBO_API_KEY
AGENT_ID=MY_MIIBO_AGENT_ID
UID=MY_MIIBO_UID
DEEPL_API_KEY=MY_DEEPL_API_KEY
HUGGINGFACE_API_KEY=MY_HUGGINGFACE_API_KEY
package.jsonは、Node.jsを初期化すると生成されますが、ESモジュールを使用するために、"type": "module"
の設定を追加しています。
{
"name": "waka",
"version": "1.0.0",
"description": "",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.7.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"node-fetch": "^3.3.2"
}
}
以下、ソースコードです。
クリックしてコードを表示
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>和歌画像生成アプリ</title>
<style>
.selected {
border: 5px solid red;
}
.generated-image {
max-width: 200px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>和歌画像生成アプリ</h1>
<input type="text" id="utterance" placeholder="キーワードを入力">
<button id="getWakaButton" onclick="getWaka()">和歌を取得</button>
<div id="wakaDisplay"></div>
<div id="translation"></div>
<div id="images"></div>
<button id="generateImageButton" style="display:none;" onclick="generateImages()">画像生成</button>
<button id="combineButton" style="display:none;" onclick="combineImageAndWaka()">画像と和歌を合成</button>
<div id="status"></div>
<div id="result"></div>
<script src="script.js"></script>
</body>
</html>
script.js
async function getWaka() {
const utterance = document.getElementById('utterance').value;
const response = await fetch('/get-waka', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ utterance })
});
const data = await response.json();
displayWaka(data);
}
function displayWaka(data) {
const wakaDisplay = document.getElementById('wakaDisplay');
wakaDisplay.innerHTML = ''; // Clear previous waka display
const wakaOptions = data.bestResponse.utterance.split('\n').filter(waka => waka.trim());
wakaOptions.forEach((waka, index) => {
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'waka';
radio.value = waka;
radio.id = `waka${index}`;
const label = document.createElement('label');
label.htmlFor = `waka${index}`;
label.textContent = waka;
wakaDisplay.appendChild(radio);
wakaDisplay.appendChild(label);
wakaDisplay.appendChild(document.createElement('br'));
});
document.getElementById('generateImageButton').style.display = 'inline';
}
async function generateImages() {
const selectedWaka = document.querySelector('input[name="waka"]:checked').value;
const translation = await translateWaka(selectedWaka);
displayTranslation(translation);
const models = [
'stabilityai/stable-diffusion-xl-base-1.0',
'stabilityai/stable-diffusion-2-1-base',
'stabilityai/stable-diffusion-3-medium-diffusers',
'sd-community/sdxl-flash'
];
const images = [];
const statusDiv = document.getElementById('status');
for (const model of models) {
try {
statusDiv.innerHTML = `Generating image with model: ${model}`;
const imagePath = await generateImage(model, translation);
images.push(imagePath);
} catch (error) {
console.error(`Error generating image with model ${model}:`, error);
statusDiv.innerHTML = `Error generating image with model: ${model}. Continuing with next model.`;
}
}
statusDiv.innerHTML = 'All images generated!';
displayImages(images);
}
async function translateWaka(waka) {
const response = await fetch('/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text: waka })
});
const data = await response.json();
return data.translations[0].text;
}
function displayTranslation(translation) {
const translationDiv = document.getElementById('translation');
translationDiv.innerHTML = `<p>Translation: ${translation}</p>`;
}
async function generateImage(model, prompt) {
const response = await fetch('/generate-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ model, prompt })
});
const data = await response.json();
if (data.imagePath) {
return data.imagePath;
} else {
throw new Error('Image generation failed');
}
}
function displayImages(images) {
const imagesDiv = document.getElementById('images');
imagesDiv.innerHTML = ''; // Clear previous images
images.forEach((imagePath, index) => {
const img = document.createElement('img');
img.src = imagePath;
img.id = `image${index}`;
img.classList.add('generated-image');
imagesDiv.appendChild(img);
});
document.getElementById('combineButton').style.display = 'inline';
}
function combineImageAndWaka() {
const selectedWaka = document.querySelector('input[name="waka"]:checked').value;
const formattedWaka = formatWaka(selectedWaka);
const selectedImage = document.querySelector('img.selected').src;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = selectedImage;
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
drawVerticalText(ctx, formattedWaka, canvas.width - 50, 50);
const result = canvas.toDataURL('image/png');
displayResult(result);
}
}
function formatWaka(waka) {
// 番号を削除し、「」を削除し、半角スペースで改行、作者の上にスペース
let formattedWaka = waka.match(/「(.*)」/);
formattedWaka[1] = formattedWaka[1].replace(/\s+/g, '\n');
const authorMatch = waka.match(/- (.*)/);
const author = authorMatch ? authorMatch[1] : '';
return `${formattedWaka[1]}\n ${author}`;
}
function drawVerticalText(ctx, text, x, y) {
const lines = text.split('\n');
ctx.font = '30px Arial';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
lines.forEach((line, i) => {
const chars = line.split('');
chars.forEach((char, j) => {
ctx.fillText(char, x - (i * 30), y + (j * 30));
});
});
}
function displayResult(result) {
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = ''; // Clear previous result
const img = document.createElement('img');
img.src = result;
resultDiv.appendChild(img);
}
document.addEventListener('click', (event) => {
if (event.target.tagName === 'IMG') {
document.querySelectorAll('img').forEach(img => img.classList.remove('selected'));
event.target.classList.add('selected');
}
});
server.js
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import express from 'express';
import fetch from 'node-fetch';
import { fileURLToPath } from 'url';
const app = express();
const PORT = process.env.PORT || 3000;
// ESモジュール環境で__dirnameを使用するための設定
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static('public'));
app.use(express.json());
// imgディレクトリを静的ファイルとして提供
app.use('/img', express.static(path.join(__dirname, 'img')));
app.post('/get-waka', async (req, res) => {
const { utterance } = req.body;
try {
const response = await fetch('https://api-mebo.dev/api', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_key: process.env.API_KEY,
agent_id: process.env.AGENT_ID,
utterance: utterance,
uid: process.env.UID
})
});
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch waka.' });
}
});
app.post('/translate', async (req, res) => {
const { text } = req.body;
const params = new URLSearchParams();
params.append('text', text);
params.append('target_lang', 'EN');
try {
const response = await fetch('https://api-free.deepl.com/v2/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `DeepL-Auth-Key ${process.env.DEEPL_API_KEY}`
},
body: params.toString()
});
const data = await response.json();
console.log(data);
res.json(data);
} catch (error) {
console.error('Error:', error);
res.status(500).json({ error: 'Translation failed' });
}
});
async function generateImage(model, prompt) {
try {
const response = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.HUGGINGFACE_API_KEY}`
},
body: JSON.stringify({
inputs: prompt,
options: {
wait_for_model: true
}
}),
});
if (response.ok) {
const buffer = await response.arrayBuffer();
const imagePath = path.join(__dirname, 'img', `${model.replace('/', '-')}.png`);
fs.writeFileSync(imagePath, Buffer.from(buffer));
return `/img/${model.replace('/', '-')}.png`;
} else {
throw new Error('Failed to generate image.');
}
} catch (error) {
throw new Error('Failed to return.');
}
}
app.post('/generate-image', async (req, res) => {
const { model, prompt } = req.body;
try {
const imagePath = await generateImage(model, prompt);
res.json({ imagePath });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
制作過程
前回の記事では、今の気持ちからMiiboを使って和歌を選び出すAPIを実装し、テストするところまでを作ったので、残る課題に取り組みました。
今回の課題
- 和歌から画像生成を行う部分を実装する
- 画像に和歌を合成する
- UIを作って、全体を繋げる
- ローカルの画像生成AIを使ってみる
- デモ動画の撮影
和歌から画像生成を行う部分を実装
和歌をプロンプトとして、Hugging Face Inference APIを使って、4種類の画像を生成します。このモデルを決める所から始めました。
それぞれ違うモデルを使うのですが、それを選択するのに時間が掛かりました。
というのは、多くのモデルがAPIを使ってアクセスできないし、出来ると書いていても、やってみると上手くいかない、というものが多いからです。
また、動作確認をしても、時々意味不明のエラーが出るため、エラーが出ることを前提に作る必要があります。
そういったテストを繰り返して、4つを選択しました。
'stabilityai/stable-diffusion-xl-base-1.0',
'stabilityai/stable-diffusion-2-1-base',
'stabilityai/stable-diffusion-3-medium-diffusers',
'sd-community/sdxl-flash'
結局、3つはStable Diffusionのバージョン違いということになりました。
一気に作る
課題には個別に書いていましたが、全体を繋げる所まで一気にプロンプトを作ってChatGPTに作ってもらいました。
プロンプト:
以下、API_KEYなどは全て、.envに設定する。
Webサーバーにhttp-serverを使う。
1.和歌を取得する。
呼び出し(サンプル)
curl -H "Content-Type: application/json" -X POST -d '{"api_key":"MY_API_KEY","agent_id":"MY_AGENT_ID","utterance":"高揚している、紅葉、川が流れる","uid":"MY_UID"}' https://api-mebo.dev/api
戻り値(サンプル)
{"utterance":"高揚している、紅葉、川が流れる",
"bestResponse":{
"utterance":"以下の和歌をご紹介します:\n\n
1. 山川 に 風のかけたる しがらみは 流れもあへぬ 紅葉なりけり(藤原定家)\n
2. 紅葉散る 滝の白糸 たえだえに しきてぞまさる 水の玉川(藤原公任)\n
3. たま川の 水のかさふり まどひつつ 紅葉流れて うきたつを見る(藤原俊成)",
"score":1100,
"options":["他にも紅葉に関する和歌を教えてください","春に関する和歌も知りたいです","恋の和歌を紹介してください"],
"topic":"",
"imageUrl":"",
"url":"",
"isAutoResponse":true,
"extensions":null,
"shouldSelectOption":false,
"state":"",
"embededHtml":""
},
"avatarIconUrl":"https://firebasestorage.googleapis.com/v0/b/mabo-f1cc7.appspot.com/o/images%2FjrKpyim7GmZ8WW8NVXzECeSA0im1%2F81f54908-6f12-41cd-a388-03dcc962ddf9190b9fb1c6d2bd%2Fwaka.jpg?alt=media&token=9f5205ab-6785-4673-a886-a31dd9e410f0",
"userState":{},
"isError":false}
実装はJavaScriptをクライアントサイドで動かす。
2.和歌を選択して、画像生成する。
1.の戻り値 utterance の3つの和歌からWebUIにて1つ選択する。
その和歌をDeepLを使って英語に翻訳する。
動画生成ボタンを押すと、翻訳した和歌をプロンプトとして、Hugging Face Inference APIを使って、4つのモデルを使ってそれぞれ1つづつ画像を生成する。
モデルは、
stabilityai/stable-diffusion-xl-base-1.0
stabilityai/stable-diffusion-2-1-base
stabilityai/stable-diffusion-3-medium-diffusers
sd-community/sdxl-flash
生成した画像をWebUIに表示して、選択することができる。
3.画像と和歌を合成する。
次にボタンを押すと、生成した画像に、日本語の和歌を合成して、目的の画像を生成して、表示する。
出力されたコードは、概ね問題なさそうだったのですが、APIの呼び出しでエラーが出るので、そのあたりを順番に修正していきます。
DeepLでの問題
まず、エンドポイントが間違っていました。
ChatGPTは、https://api.deepl.com/v2/translate
を指定してきましたが、自分はフリーなので、
https://api-free.deepl.com/v2/translate
に変更しました。
これはなかなか分からなかったのですが、curlでテストをして戻り値にこのURLに変更せよとあったので、分かりました。APIは必ずcurlでテストして、戻り値等を確認するのがよさそうです。
次に、Content-Typeはjsonではなく、x-www-form-urlencodedに設定する必要がある、ということでした。これはGETで引数を渡すフォーマットと同じなのですが、POSTでBodyにxxxx=yyyy? のように記述する必要があります。
この2点が修正箇所になります。
Hugging Faceの画像生成での問題
戻り値での画像データの取得方法など、細かい問題があって、その修正は行ないました。
それよりも、本質的な部分は以下の点になります。
- 同時に1つないし2つ程度しか実行できない。
- 処理に時間が掛かり、いつ戻ってくるか分からない。
- 同じ処理をしても、戻ってこない場合やエラーが返ってくる場合が頻発する。
この点に対処しておく必要があります。
最初にChatGPTが出してきたコードは、4つのAPIコールを同時に行って並列処理させるようなものでした。この方が勿論効率はいいのですが、API側がそれを受け付けない仕様なので、順次結果が出てから次の呼び出しを行うようにしました。
呼び出しの結果エラーになったりタイムアウトになった場合は、それを無視して次のモデルに移る、という形にしました。
エラーやタイムアウトの際、リトライするようにしたかったのですが、ChatGPTはその答えを出してくれず、自分で考えるしかなさそうだったので、次回の課題とします。
テストを繰り返すうちに別の問題が発生
テストを行っていると、ずっと問題なかったはずの和歌の生成がおかしなものになってきました。
こんな和歌あったかな、というようなものになっており、明らかにAIが作ったような拙い和歌が出るようになってきました。
また、DeepLの翻訳も、おかしなものになってきました。
例えば、
「春過ぎて 夏来にけらし 白妙の 衣ほすてふ 天の香具山」 - 持統天皇
の翻訳が、
- "Spring has passed and summer has come.
春が過ぎて夏が来た、だけじゃなくて、もう少し頑張れ、と思いました。
中には、”May,May,May,May..." とだけ繰り返すような翻訳になったり。どうも、短時間に呼び出しを繰り返すと、手抜きをし始めるような気がします。
フリーだからでしょうか。
そうはいっても、ちゃんと翻訳してくれることが多いので、この問題には目をつぶります。
和歌の問題は放っては置けないので、プロンプトを変更することにしました。
あなたは和歌選定AIです。ユーザーが入力したキーワードに基づいて、関連するものを過去の和歌から3つ選択してください。
作者不詳は禁止する。勝手に作るのも禁止、必ず実在の和歌にすること。
2行目を追加しました。
これで、一旦は問題解決しました。
さらに、いつの間にか和歌の戻り値のフォーマットが変わっていました。具体的には、和歌が(春過ぎて 夏来にけらし 白妙の 衣ほすてふ 天の香具山)で括弧に括られて帰ってきたものが、「春過ぎて 夏来にけらし 白妙の 衣ほすてふ 天の香具山」と鍵括弧に括られているのです。
このあたりも揺らぎがあるようなので、どちらにも対応する必要があるのかも知れませんし、フォーマットをきっちりと指定した方がよいのかも知れません。
とりあえず、鍵括弧に対応する形としました。
とりあえず、完成形
ということで、最初の動画を撮影して完成としました。
このキーワードで、この和歌で良いのか、という所は目をつぶります。
反省
残った課題として、
- ローカルの画像生成AIを使ってみる
というのがあります。
動作環境をローカルにしたのは、じつはこの課題を想定していたものでしたが、ちょっと時間が掛かり過ぎてタイムアップとなってしましました。
新しい課題としては、
- Hugging Faceの呼び出しで、エラーの場合にリトライをする
- 和歌の戻り値のフォーマットのゆらぎを無くす
- AIが馬鹿になっていく原因を突き止める
があります。
1回の試行では問題は無くとも、繰り返していくと馬鹿になっていくものだと実用には堪えないので、この辺りがAIを使って実用的なアプリを作る上での重要は課題のように思えます。
あと、見た目がAIが作ったデフォルトなので、もうちょっと見栄えのするものにしたかったです。
この辺りは、CSSファイルを作らせる、UIを絵で描いて指定する、とか工夫の余地は大きかったです。
まずは見た目から入る、という作り方の方がよかったかも知れません。
自分の興味として、見た目よりもロジックの方に目が行ってしまうので、そのあたりは要改善ですね。
皆さんに見てもらうには、まずは目につかないと。
以上