5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事ではdiffusers(Stable Diffusion)とFastAPI(Python)とvuetify(TypeScript/vue.js)を使って画像が生成できるためのウェブアプリを作成する例を説明します。

前置き

私は以前からvue.jsをフロントエンドとしてウェブサイトを作っていました。主に使ったライブラリーはvuetifyで、2020年にQiitaで記事を書いたこともあります。

バックエンドの方は色々試したことがあります。Pythonでは以前responderというフレームワークを使っていたのですが、今年に入ってからFastAPIを使い始めました。記事も書きました。

だからこれからウェブサイトを作るならFastAPI+vue.jsの組み合わせが一番だと思っています。

そして最近生成AIに興味があってStable Diffusionを始めました。diffusersというモジュールを使ったら簡単にStable Diffusionモデルを扱って画像を生成できます。

diffusersとStable Diffusionの基本に関してはこの記事に纏めて入門を書きました。

ということでvue.js(vuetify)+FastAPI+diffusersで生成AIのウェブアプリを作ってみることにしました。

これは本格的なウェブアプリではなく、ただ簡単な機能だけ搭載して、初心者の勉強の参考にする例として書いたものです。

vue.jsは今までJavaScriptで書いていましたが、最近TypeScriptという言語を知って、vue.jsではJavaScriptの代わりにTypeScriptを使うもできるとわかったから今回ちょっとTypeScriptに挑戦してみることにしました。とはいっても殆どJavaScriptと変わらないから新しく勉強することは少しだけです。

作るもの

作る説明に入る前にとりあえず今回どんなウェブアプリを作るか見ておきましょう。

まずブラウザで作成完了でデプロイしウェブアプリにアクセスしたらこんな画面になります。
stadifapp01.png

左にラジオボタンで、入力した注文だけ画像を生成するか、元画像を使って新しい画像を生成するか、という選択肢があります。まずは言葉だけ生成してみましょう。

例えば試しに「水遊びしている和服の水色三つ編み少女」と入力して、画像サイズは既定値のままにして「生成開始」ボタンを押したら生成が始まって、この画面に入って暫く待つことになります。
stadifapp02.png

そして生成が終わったら結果は下に出てきます。ちゃんと書いた通りの画像が出てきますね。
stadifapp03.jpg

左の「保存」ボタンを押したら名前を付けて画像を保存することができます。
stadifapp04.jpg

次は「元画像から生成する」を選択してみます。そうしたら右の方が変わって、画像ファイルの選択が現れます。「変化の強さ」はどれくらい元画像から変わるかを示すパラメータであり0(そのまま)から1(完全に変わる)まで設定できます。
stadifapp05.png

もしファイル選択をしていないまま生成を開始しようとしたら忠告が出てきます。
stadifapp06.png

試しに先程生成された画像を選んで、「ゴッホ風」と入力します。
stadifapp07.png

そうしたら微妙だけどゴッホが描いたような絵(多分ね)に変換された画像ができます。
stadifapp08.jpg

はい、終わりです。これくらいしかない小さく地味なウェブアプリですね。

このような簡単なウェブアプリを今回のテュートリアルにします。

使うライブラリーとフレームワーク

ウェブアプリ開発は普段一つの言語か一つのフレームワークで作るものではないのですね。色んな組み合わせで作れますが、今回使うのは主この3つに分けられます。

役目 ライブラリー 言語/フレームワーク
メイン その他
画像生成AI diffusers pytorch
transformers
translate
PIL
Python
バックエンド fastapi uvicorn
フロントエンド vuetify axios TypeScript/vue.js

環境設定

インストール

まずは必要なPythonライブラリーのインストールです。バックエンド関連のライブラリーはfastapiがメインだが、その他にuvicornとmultipartも必要なので、全部一緒にインストールします。どれもpipで簡単にインストールできます。

pip install fastapi uvicorn python-multipart

生成AIのライブラリーはpytorchが基本ですが、これはGPUを使うかどうか違うので、まずはこのリンクを参考にしてpytorchをインストールします。
https://pytorch.org/get-started/locally/

そしてdiffusersとその他の関連のライブラリーは簡単にpipでインストールできます。

pip install diffusers transformers translate

vue.jsの方はyarnをインストールしていれば十分です。

今回使っている環境

バージョンが違うと書き方や作動は大きく違うことがあります。特にvue.js関連は違いが大きすぎて古い知識はすぐ使えなくなって面倒です。私が2020年に書いたvuetifyの記事も今回の書き方とは大分違って、今になってほぼ使い物になっていません。

だから今回使う言語や関連のライブラリーのバージョンをここに書いておきます。殆ど今2024年7月時点での最新のものです。

python 3.12.2
diffusers 0.29.2
pytorch 2.3.1
transformers 4.41.2
translate 3.6.1
PIL 10.3.0
fastapi 0.111.0
uvicorn 0.30.1
multipart 0.0.9
vue 3.4.21
vuetify 3.5.8
axios 1.7.2
vite 5.1.5
yarn 1.22.22
typescript 5.4.2

使うパソコン

画像生成AIはGPUがあった方が一番ですが、もしなければCPUのままで遅いだけで作れないわけではありません。ただウェブアプリに作るとしたら遅すぎるときついかもですね。メモリーも十分大きくないと無理です。

今回私はメモリー24GBのMacBook Air M3を使っています。macbookにはmpsというGPUがあります。CUDAほど速くないが、512×512サイズの画像を生成する程度は十分だと思います。

ただ本格的にウェブアプリを作る時はCUDAのGPUを搭載したコンピュータでないと話にならないかもしれません。

全体の構成

作成するプロジェクトは2つのフォルダに分けられます。ここではstadifwebstadifvueという名前を付けます。stadifwebはウェブアプリのメインフォルダで、stadifvueはプリコンパイルのファイルを作るためのvue.jsプロジェクトフォルダ。

├──stadifweb
│   ├── web.py
│   ├── seisei.py
│   ├── cuteyukimixAdorable_kemiaomiao.safetensors
│   ├── index.html
│   ├── favicon.ico
│   ├── assets
│   └── img
├──stadifvue
│   ├── src
│   │   ├── App.vue
│   │   └── (その他)
│   └── (その他)

各ファイルを纏めたらこのようになります。

名前 種類 説明
stadifweb フォルダ ウェブアプリのメインフォルダ
├ web.py Python FastAPIで書いたウェブアプリの核心
├ seisei.py Python diffusersで書いた生成AI。web.pyから呼び出される
cuteyukimixAdorable_kemiaomiao.safetensors バイナリ 生成AIのStable Diffusionのチェックポイントモデル
├ index.html
├ favicon.ico
├ assets
プリコンパイル vue.jsによってプリコンパイルされたもの。直接手を出す必要がない
├ img フォルダ 生成された画像を保存するフォルダ。最初は空っぽ
stadifvue フォルダ vue.jsプロジェクトのフォルダ
 ├ App.vue vue.js ウェブ画面を作るhtmlとTypeScript

ここで書き換えるのはただ3つだけ。

  • web.py
  • seisei.py
  • App.vue

cuteyukimixAdorable_kemiaomiao.safetensorsは「CuteYukiMix」というStable Diffusionのチェックポイントモデルのファイルです。Stable Diffusionで画像を生成するといっても使うモデルによって違う絵ができます。可愛いアニメ風の絵を作るためのモデルが色々ありますが、今回はこのモデルを選びました。このリンクからダウンロードできます。

ファイルサイズは2GBもあるので、今回プロジェクトで一番大きいファイルです。

画像生成AI(diffusers/Stable Diffusion)

Stable Diffusionで画像を生成する部分のPythonコードです。書き方などここでは詳しく説明しませんが、私が以前書いた入門を参考に。

seisei.py
import torch
from diffusers import StableDiffusionPipeline,StableDiffusionImg2ImgPipeline,DPMSolverMultistepScheduler
from translate import Translator

honyaku = Translator('en','ja').translate # プロンプトを日本語から英語に翻訳するための関数
model_file = 'cuteyukimixAdorable_kemiaomiao.safetensors'

dtype = torch.float16
if(torch.cuda.is_available()):
    device = 'cuda'
elif(torch.backends.mps.is_available()):
    device = 'mps'
else:
    device = 'cpu'
    dtype = torch.float32

# 言葉だけで画像を生成する(txt2img)関数
def seisei_txt2img(prompt,hirosa,takasa):
    pipe = StableDiffusionPipeline.from_single_file(model_file,torch_dtype=dtype).to(device)
    pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
    return pipe(
        honyaku(prompt),
        width=hirosa,
        height=takasa,
        num_inference_steps=20,
        clip_skip=2
    ).images[0]

# 元画像から画像を生成する(img2img)関数
def seisei_img2img(prompt,motogazou,tsuyosa):
    pipe = StableDiffusionImg2ImgPipeline.from_single_file(model_file,torch_dtype=dtype).to(device)
    pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
    return pipe(
        honyaku(prompt),
        motogazou,
        strength=tsuyosa,
        num_inference_steps=30,
        clip_skip=2
    ).images[0]

deviceのところはCUDAのGPUを搭載している場合はCUDAを、macの場合はMPSを、それ以外はGPUを使わずただCPUで実行することになります。

バックエンド(FastAPI)

ウェブアプリを起動して管理するコードです。ここでフロントエンドからの注文を受け取って、seisei.pyで書いた画像生成関数が呼び出されます。

書き方についてここでは詳しく説明しませんが私が以前書いたFastAPIの記事など他の基本の記事が沢山あるので参考に。

web.py
import os,io,datetime
from PIL import Image
from fastapi import FastAPI,Request,HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from seisei import seisei_txt2img,seisei_img2img

# 画像を保存するためのフォルダ
if(not os.path.exists('img')):
    os.makedirs('img')

app = FastAPI()
# CORSを設定して開発中のvueから接続できるようにする
app.add_middleware(
    CORSMiddleware,
    allow_origins=['http://localhost:3000'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

# 生成の注文を受け取って処理する
@app.post('/seisei_suru')
async def seisei_suru(request: Request):
    try:
        form = await request.form() # フォームデータを受け取る
        prompt = form['prompt']
        # 言葉だけによる生成(txt2img)の場合
        if(form['seisei_mode']=='txt2img'):
            hirosa = int(form['hirosa'])
            takasa = int(form['takasa'])
            gazou = seisei_txt2img(prompt,hirosa,takasa)
            gazou_file = 't2i_'
        # 元画像からの生成(img2img)の場合
        else:
            motogazou = Image.open(io.BytesIO(await form['motogazou'].read())).convert('RGB')
            tsuyosa = float(form['tsuyosa'])
            gazou = seisei_img2img(prompt,motogazou,tsuyosa)
            gazou_file = 'i2i_'
        
        # 画像ファイルの名前に日付と時間を付ける
        gazou_file += datetime.datetime.now().strftime('%Y-%m-%d_%H:%M:%S')+'.jpg'
        gazou.save(os.path.join('img',gazou_file)) # 生成された画像を保存する
        return {'gazou_file': gazou_file}
    except Exception as err: # エラーが出てきたらその内容を返す
        raise HTTPException(status_code=500,detail='エラー:'+str(err))

# 静的ファイルの置く場所を直接このプロジェクトフォルダにする
app.mount(path="/",app=StaticFiles(directory='.',html=True))

準備が終わったらこのプロジェクトフォルダに入ってこのコマンドで実行します。

uvicorn web:app --reload
結果
INFO:     Will watch for changes in these directories: ['/Users/phyblas/stadifweb']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [93860] using WatchFiles
INFO:     Started server process [93862]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

これでhttp://127.0.0.1:8000からアクセスできるようになります。でもまだフロントエンドの方を使っていないから今のままでは入っても何もないはずです。

フロントエンド(vuetify)

プロジェクト作成

コマンドラインでこれを打ってvuetifyを中心とするvue.jsプロジェクトを作成します。

yarn create vuetify
結果
yarn create v1.22.22
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Installed "create-vuetify@2.2.5" with binaries:
      - create-vuetify
[##########] 10/10
Vuetify.js - Material Component Framework for Vue

✔ Project name: … stadifvue
✔ Which preset would you like to install? › Default (Vuetify)
✔ Use TypeScript? … No / Yes
✔ Would you like to install dependencies with yarn, npm, pnpm, or bun? › yarn
✔ Install Dependencies? … No / Yes

◌ Generating scaffold...
◌ Installing dependencies with yarn...

yarn install v1.22.22
info No lockfile found.
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
✨  Done in 20.14s.

stadifvue has been generated at /Users/phyblas/Library/CloudStorage/Dropbox/web/stadifvue

Discord community: https://community.vuetifyjs.com
Github: https://github.com/vuetifyjs/vuetify
Support Vuetify: https://github.com/sponsors/johnleider
✨  Done in 136.04s.

プロジェクト名を入れた後選択は4つ出てきました。ここで選んだのは:
✔ Default (Vuetify)
✔ Yes
✔ yarn
✔ Yes

作成した後フォルダの中に入って次に行きます。

cd stadifvue

ライブラリー追加

プロジェクトを作成したら次はvuetify以外使いたいライブラリーのインストールです。今回複雑なことはしていないので使うのはaxiosだけです。

yarn add axios
結果
yarn add v1.22.22
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...

success Saved lockfile.
success Saved 9 new dependencies.
info Direct dependencies
└─ axios@1.7.2
info All dependencies
├─ asynckit@0.4.0
├─ axios@1.7.2
├─ combined-stream@1.0.8
├─ delayed-stream@1.0.0
├─ follow-redirects@1.15.6
├─ form-data@4.0.0
├─ mime-db@1.52.0
├─ mime-types@2.1.35
└─ proxy-from-env@1.1.0
✨  Done in 1.09s.

作成されたプロジェクトフォルダにはいっぱいファイルやフォルダが入っているが、ここではそのままにしておいてよくて、書き換えるのはsrcサブフォルダの中にあるApp.vueというファイルだけです。

App.vue

App.vueはウェブアプリを織りなすメインファイルです。

大きいアプリならコンポネントごとファイルに分けられてApp.vueの中からコンポネントを呼び出すことになりますが、今回は小さくて簡単なアプリなので全部このまま直接App.vueの中で書くことにします。

一応こうしたらsrcの中のcomponentsフォルダはもう要らないので消してもいいです。

App.vue
<template>
  <v-app>
    <v-main>
      <v-container>
        <v-sheet class="px-3 py-2">
          <v-row class="text-h4 my-2">
            <v-col class="mx-auto" style="text-align: center; color: aquamarine;">
              生成AIで画像を作りしましょう!
            </v-col>
          </v-row>
          <v-row>
            <v-col cols="1" class="d-flex"
              style="align-items: center; text-align: right; min-width: 120px;">
              どんな画像が<br />欲しい?
            </v-col>
            <v-col>
              <v-textarea v-model="prompt" rows="2" bg-color="#eef" hide-details />
            </v-col>
          </v-row>
          <v-row>
            <v-col cols="3" class="my-1 py-1"
              style="align-items: center; max-height: 90px; min-width: 220px;">
              <v-radio-group v-model="seisei_mode">
                <v-radio label="言葉だけで生成する" value="txt2img" />
                <v-radio label="元画像から生成する" value="img2img" />
              </v-radio-group>
            </v-col>
            <v-col v-if="seisei_mode == 'txt2img'" class="d-flex my-1 py-1"
              style="align-items: center; max-height: 90px; min-width: 400px;">
              <div class="mx-2">画像サイズ</div>
              <v-text-field v-model="hirosa" type="number"
                variant="outlined" label="広さ" max-width="120"
                bg-color="#133" suffix="px" hide-details />
              <v-text-field v-model="takasa" type="number"
                variant="outlined" label="高さ" max-width="120"
                bg-color="#133" suffix="px" hide-details />
            </v-col>
            <v-col v-else class="d-flex" style="min-width: 400px;">

              <div>
                <v-label style="width: 120px;">
                  変化の強さ {{ tsuyosa }}
                </v-label>
                <v-slider v-model="tsuyosa"
                  min="0" max="1" step="0.01" style="width: 120px;" />
              </div>

              <v-file-input label="元画像にするファイルを選択"
                v-model="motogazou" accept=".jpg, .jpeg, .png, .gif, .webp"
                hide-details />
            </v-col>
          </v-row>
          <v-row>
            <v-col>
              <v-btn @click="seisei_suru" class="text-h6"
                color="primary" width="300" elevation="3">
                生成開始
              </v-btn>
              <v-dialog v-model="seisei_shiteru" persistent style="text-align: center;">
                <v-sheet class="py-4 px-4" style="max-width: 600; align-items: center;">
                  <div class="text-h6">
                    今生成している。少々お待ちください<br />... ... ...<br />... ... ...
                  </div>
                </v-sheet>
              </v-dialog>
            </v-col>
          </v-row>
          <v-row v-if="gazou_url" style="background-color: #444;">
            <v-col style="max-width: 100px;">
              <v-btn class="text-h6" color="secondary" elevation="3">
                保存
                <v-dialog v-model="hozon_shitai" activator="parent" max-width="400">
                  <v-sheet class="py-4 px-4">
                    <v-text-field v-model="hozon_file"
                      label="保存するファイルの名前" max-width="300" hide-details />
                    <v-btn @click="gazou_hozon()" class="text-h6 my-2" color="secondary" elevation="3">
                      保存する
                    </v-btn>
                  </v-sheet>
                </v-dialog>
              </v-btn>
            </v-col>
            <v-col>
              <img :src="gazou_url" />
            </v-col>
          </v-row>
        </v-sheet>
      </v-container>
    </v-main>
  </v-app>
</template>

<script lang="ts">
import axios from "axios";
// ルートのURL。ここではFastAPIのデフォルトのローカルだが、本番のデプロイでは実際に使うURLにする
const root_url = "http://127.0.0.1:8000";

export default {
  data() {
    return {
      prompt: "", // 注文
      seisei_mode: "txt2img", // txt2imgかimg2imgか
      hirosa: 512, // 生成する画像の広さ
      takasa: 512, // 生成する画像の高さ
      tsuyosa: 0.5, // img2imgの変化の強さ
      motogazou: null as any, // 元画像のファイル
      hozon_file: "", // 保存するファイルの名前
      hozon_shitai: false, // 保存のダイアログの表示
      seisei_shiteru: false, // 生成している途中に出てくるダイアログの表示
      gazou_url: "", // 生成された画像のURL
    };
  },
  methods: {
    // 「生成開始」が押された時の処理
    seisei_suru() {
      // img2imgを選んだのに元画像のファイルがまだ選択されていない場合
      if (this.seisei_mode == "img2img" && !this.motogazou) {
        alert("まず元画像のファイルを選択してください");
        return;
      }

      // 生成するための準備
      this.seisei_shiteru = true; // 生成しているという状態にしてダイアログを表示される
      let form = new FormData(); // フォームデータを作成
      form.append("prompt", this.prompt);
      form.append("seisei_mode", this.seisei_mode);
      if (this.seisei_mode == "txt2img") {
        // 広さと高さは8で割り切れる必要がある
        this.hirosa -= this.hirosa % 8;
        this.takasa -= this.takasa % 8;
        // 大きさの限界を制限する
        if (this.hirosa < 8) this.hirosa = 8;
        else if (this.hirosa > 1024) this.hirosa = 1024;
        if (this.takasa < 8) this.takasa = 8;
        else if (this.takasa > 1024) this.takasa = 1024;
        form.append("hirosa", String(this.hirosa));
        form.append("takasa", String(this.takasa));
      }
      else { // img2imgの場合
        form.append("tsuyosa", String(this.tsuyosa));
        form.append("motogazou", this.motogazou);
      }

      // バックエンドの方に接続して生成の情報を送る
      axios.post(root_url + "/seisei_suru", form)
        .then((res) => {
          // 間違いがなければ暫く待った後生成された画像のURLが返されてここに表示させる
          this.gazou_url = root_url + '/img/' + res.data.gazou_file;
          this.hozon_file = res.data.gazou_file;
        })
        .catch((err) => {
          // 何か間違いがあればエラーの内容を表示する
          alert(err.response.data.detail);
        })
        .finally(() => {
          // 生成完了後(又はエラーが出たら)生成しているという状態が終わって、ダイアログを閉じる
          this.seisei_shiteru = false;
        });
    },
    // 「保存する」ボタンが押された時の処理
    gazou_hozon() {
      axios.get(this.gazou_url, { responseType: 'blob' })
        .then(response => {
          const blob = new Blob([response.data]);
          const link = document.createElement('a');
          link.href = URL.createObjectURL(blob);
          link.download = this.hozon_file;
          link.click();
          URL.revokeObjectURL(link.href);
          this.hozon_shitai = false;
        })
        .catch((err) => {
          alert(err)
        });
    }
  },
  watch: {
    // 広さと高さが整数にしかなれないようにする
    hirosa(b, a) {
      let intb = parseInt(b.toString());
      if (intb != b) this.hirosa = a;
    },
    takasa(b, a) {
      let intb = parseInt(b.toString());
      if (intb != b) this.takasa = a;
    },
  },
};
</script>

開発中の実行

ファイルが整ったら次は実行して正常に動くか試します。

yarn run dev
結果
yarn run v1.22.22
$ vite

  VITE v5.3.3  ready in 399 ms

  ➜  Local:   http://localhost:3000/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

そしてブラウザでhttp://localhost:3000/に入ったらウェブアプリの画面が表示されて使えるはずです。

プリコンパイル

ちゃんと思った通り動くようになっていると確認できたら開発が終わって次は本番のためコンパイルします。

yarn run build
結果
yarn run v1.22.22
$ vue-tsc --noEmit && vite build
vite v5.3.3 building for production...
✓ 246 modules transformed.
dist/index.html                                             1.20 kB │ gzip:  0.57 kB
dist/assets/materialdesignicons-webfont-CveiMJFx.woff2    343.66 kB
dist/assets/materialdesignicons-webfont-BbcIfeS3.woff     493.82 kB
dist/assets/materialdesignicons-webfont-Df9Q6YdI.ttf    1,089.80 kB
dist/assets/materialdesignicons-webfont-YdEl25M1.eot    1,090.02 kB
dist/assets/index-KtCkQxIU.css                            588.67 kB │ gzip: 85.99 kB
dist/assets/index-BGeibSey.js                             275.77 kB │ gzip: 95.81 kB
✓ built in 725ms
✨  Done in 2.42s.

そうしたらindex.htmlassetsフォルダとfavicon.icodistというフォルダに現れて、これら全部ウェブアプリプロジェクトのメインフォルダのstadifwebに置きます。

stadifapp09.png

これで整いました。

stadifapp10.png

その後ブラウザでhttp://127.0.0.1:8000にアクセスして正常に使えるようになったら完成です。

はい、これでお終い!

参考

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?