🎨

透過画像(mask)を作成してOpenAI APIで画像編集する機能を作ってみた

2024/04/07に公開

こんにちは!@Ryo54388667です!☺️

普段は都内でフロントエンドエンジニアとして業務をしてます!
主にTypeScriptやNext.jsといった技術を触っています。

透過画像(mask)を作成してOpenAI APIでオリジナルの画像を編集する一連の機能について紹介したいと思います!

📌 はじめに

4月の頭にOpen AI社からChatGPTのDALL.Eアップデート情報があり、生成された画像の一部分をGUIで編集をリクエストする機能が追加されました!

自由にお絵描きするような感覚で対象箇所を選択し、プロンプトを入力すると画像の一部を編集できるようです。 直感的なインターフェースで、めちゃくちゃ良いな!と思いました。

自分も内部の仕組みを勉強したいので、一連の機能を実装してみました!
「ChatGPTの画像編集機能を真似た」というにはあまりに些末な仕上がりですが。。。😇

今回作ったものはこんな感じです!
編集箇所への指示は 「赤色のリボンをつけて」 というものです。

📌 実装方針と準備

実装方針

まず実装の大枠を説明します。

  1. 透過画像(mask)を作成する
  2. リクエスト可能な形式にデータをフォーマットする

OpenAI APIの仕様を見ると、リクエストボディにはオリジナルの画像のデータimageとプロンプトpromptは必須で、透過画像maskはオプショナルのようです。今回は画像の一部を編集するのが目的なのでmaskもリクエストボディに含めます。

// 仕様
const image = await openai.images.edit({
    image: fs.createReadStream("otter.png"),
    mask: fs.createReadStream("mask.png"),
    prompt: "A cute baby sea otter wearing a beret",
  });

参考:OpenAI 画像編集APIの仕様

透過画像とは、よく目にするのは一部が白と灰色のチェック模様のエリアが画像の中に存在するものですね。(下記の画像参照)透過エリアの模様に関してはアプリ依存なので、一概に白と灰色のチェック模様になるわけではないようです。

また、リクエスト可能なファイル形式はpngのみ(MINEタイプ: image/png)なので、こちらに準拠する必要があります。
参考:OpenAI 画像編集APIのモデルの仕様

準備

実はこの準備がけっこう面倒です。。詰まった箇所もいくつかありました😇

Package

name version 備考
Next 14.0.0
React 18.2.0
openai ^4.32.1
react-konva ^18.2.10 canvasの制御のため
use-image ^1.1.1 画像描画のイベント制御(上と提供元は同じ)
(sharp) ^0.33.3 (任意) webp形式をpng形式に変換する

OpenAI APIの準備

OpenAIのAPIの課金形態は、利用したぶんだけ課金される形態(従量課金制)から事前にクレジットを購入して利用するような形態になりました。個人的には制限をつけた従量課金形式の方が使い勝手が良かったのですが。。ビジネスなので仕方がありませんね😅事前にこちらを購入する必要があります。


対象ページ

add to credit balanceを押下すると、購入額を自由に決めることができて、任意の量のクレジットを購入できます。これを行わないとAPIのリクエスト時にリミットエラーがレスポンスされます。

react-konva を Next.js (App Router) で利用できるようにする

今回は外部にOpenAI APIのキーが漏れないようにサーバーサイドでリクエストするのでNext.jsを採用しました。

react-konva をインストールしてコンポーネントを配置するだけではNext.jsだとエラー祭りになってしまいます😇一つずつ潰していきます。

importの部分でdynamic関数を利用する必要があるようです。

create a component that handles all the canvas logic and import that dynamically without SSR.

import dynamic from 'next/dynamic';
const MyComponent = dynamic(() => import('../components/MyComponent'), { ssr: false });

https://github.com/vercel/next.js/issues/25454#issuecomment-862571514

canvasの描画ライブラリはDOMやブラウザ固有のAPIに依存しているというのが理由ですかね。
これで良いかと思いきや、まだエラーが出ます。。😇

Module not found: Can't resolve 'canvas'

next.config.jsのwebpackの設定を下記のように変更します。

// next.config.js
const nextConfig = {
  ...
  webpack: (config) => {
    config.externals = {
      canvas: "canvas",
    };
    return config;
  },
};

参考issue

Next.jsのビルドプロセスでcanvasモジュールをバンドルから除外しています。
以上の設定を行うとreact-konva を Next.js (App Router)で利用できるようになります👌
こちらの調査していたら時間が溶けました。。。

📌 コードについて

1.透過画像(mask)を作成する

線を描画の関数
MaskEditor.tsx

…中略…
const handleMouseDown = (e: KonvaEventObject<MouseEvent>) => {
    isDrawing.current = true;
    const stage = e.target.getStage();
    const pos = stage.getPointerPosition();
    setLines([...lines, { points: [pos.x, pos.y] }]);
  };

  const handleMouseMove = (e: KonvaEventObject<MouseEvent>) => {

    const stage = e.target.getStage();
    const point = stage.getPointerPosition();
    const lastLine = lines[lines.length - 1];
    lastLine.points = lastLine.points.concat([point?.x ?? 0, point?.y ?? 0]);

    lines.splice(lines.length - 1, 1, lastLine);
    setLines(lines.concat());
  };

  const handleMouseUp = () => {
    isDrawing.current = false;
  };

react-konva の使い方なので詳しくは割愛します。
マウスの一挙手一投足を制御するための関数です!canvasに線を描画しています。

線を描画する箇所
MaskEditor.tsx

       <Stage
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
          ref={stageRef}
        >
          <Layer>
            <KImage // <== konvaのImageコンポーネント
              image={image}
              width={image.width} 
              height={image.height}
            />
            {lines.map((line, i) => (
              <Line
                key={i}
                points={line.points}
                stroke="#ffffff”
                strokeWidth={40}
                lineCap="round"
                globalCompositeOperation="destination-out” //<== 透過画像なのでこれが必須。
              />
            ))}
          </Layer>
        </Stage>

Stage, Layer, Lineコンポーネントはreact-konvaの仕様なので詳しくは割愛します。透過画像に関係するのは、globalCompositeOperationdestination-outの部分です。これが必須です。他のアプリではよく 「消しゴム機能」 で利用されるものですね!これによって線の軌跡が透過状態になります。

描画したCanvasを画像データ化する箇所
MaskEditor.tsx

…中略…
const handleSubmit = async () => {
    // toDataURLで画像のデータ化(URI data:)
    const dataURL = stageRef.current.toDataURL({
      pixelRatio: 1
    });

    const data = await fetch("/api/canvas", { method: "POST", body: JSON.stringify({ originalImgPath: `http://localhost:3000${imageUrl}`, maskImgPath: dataURL }) }).then((res) => res.json());

  };

…中略…

toDataURLでcanvasの状態を画像データ化します。
これによって線の描画を含む画像データとして出力できます。

MaskEditor.tsxの全コード
"use client"
import { KonvaEventObject } from 'konva/lib/Node';
import { useState, useRef } from 'react';
import { Layer, Image as KImage, Line, Stage } from 'react-konva';
import useImage from 'use-image';
import Konva from 'konva';

type Props = {
  imageUrl: string;
};

type LineType = {
  points: number[];
}

const MaskEditor = ({ imageUrl }: Props) => {
  const [lines, setLines] = useState<LineType[]>([]);
  const [updatedImg, setUpdatedImg] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  const isDrawing = useRef(false);
  const stageRef = useRef<Konva.Stage | null>(null);

  const [image, status] = useImage(imageUrl, 'anonymous');

  const handleMouseDown = (e: KonvaEventObject<MouseEvent>) => {
    isDrawing.current = true;
    const stage = e.target.getStage();
    if (!stage) return

    const pos = stage.getPointerPosition();
    if (!pos) return

    setLines([...lines, { points: [pos.x, pos.y] }]);
  };

  const handleMouseMove = (e: KonvaEventObject<MouseEvent>) => {
    if (!isDrawing.current) return

    const stage = e.target.getStage();
    if (!stage) return;

    const point = stage.getPointerPosition();
    const lastLine = lines[lines.length - 1];
    lastLine.points = lastLine.points.concat([point?.x ?? 0, point?.y ?? 0]);

    lines.splice(lines.length - 1, 1, lastLine);
    setLines(lines.concat());
  };

  const handleMouseUp = () => {
    isDrawing.current = false;
  };

  const handleSubmit = async () => {
    setIsLoading(true);
    if (!stageRef.current) return;

    const dataURL = stageRef.current.toDataURL({
      pixelRatio: 1
    });

    const data = await fetch("/api/canvas", { method: "POST", body: JSON.stringify({ originalImgPath: `http://localhost:3000${imageUrl}`, maskImgPath: dataURL }) }).then((res) => res.json());

    setUpdatedImg(data.url);
    setIsLoading(false);
  };

  return (
    <>
      {!isLoading && (
        <button className='text-white m-4 px-4 py-2 bg-green-700 rounded shadow-md' onClick={handleSubmit}>マスク画像をもとにオリジナル画像を変更</button>
      )}
      {isLoading && <div className="text-2xl text-slate-500 font-bold m-4" >ローディング中...</div>}
      {updatedImg && <div className="absolute inset-0 flex justify-center items-center bg-gray-900 bg-opacity-50 z-50">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img src={updatedImg} alt='' width={512} height={512} />
      </div>}
      {status === 'loaded' && image && !updatedImg && (
        <Stage
          width={image.width} // イメージのロード後に設定された幅
          height={image.height} // イメージのロード後に設定された高さ
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
          ref={stageRef}
        >
          <Layer>
            <KImage
              image={image}
              width={image.width} // イメージのロード後に設定された幅
              height={image.height} // イメージのロード後に設定された高さ
            />
            {lines.map((line, i) => (
              <Line
                key={i}
                points={line.points}
                stroke="#ffffff"
                strokeWidth={40}
                lineCap="round"
                globalCompositeOperation="destination-out"
              />
            ))}
          </Layer>
        </Stage>
      )}
    </>
  );
};
export default MaskEditor;

Editorコンポーネントの宣言箇所

page.tsx

"use client"
import dynamic from "next/dynamic"

const MaskEditor = dynamic(() => import('../../../components/MaskEditor'), {
  ssr: false,
});

const Page = () => {
  return (
    <div>
      <MaskEditor imageUrl="/cat.webp" />
    </div>
  )
}
export default Page

2.リクエスト可能な形式にデータをフォーマットする

/api/canvas/route.ts

…中略…
const editImage = async (originalImgPath: string, maskImgPath: string) => {
  const [imageContent, maskContent] = await Promise.all([
    fetchImageBinaryData(originalImgPath),
    fetchImageBinaryData(maskImgPath),
  ])

  if (!isUploadable(imageContent) || !isUploadable(maskContent)) return
  const response = await openai.images.edit({
    model: MODEL,
    image: imageContent,
    mask: maskContent,
    prompt: PROMPT,  // 今回は固定値を入れています。
    response_format: "url",
  })

  return response.data[0].url
}

async function fetchImageBinaryData(imageUrl: string) {
  const response = await fetch(imageUrl, { cache: "no-store" })
  let buffer = await response.arrayBuffer()
  const contentType = response.headers.get("content-type");
  // webp画像対応するため。pngに変換する。
  if (contentType !== "image/png") {
    buffer = await sharp(Buffer.from(buffer)).png().toBuffer();
  }

  // 変換後のバイナリデータからFileオブジェクトを作成
  return new File([buffer], "image.png", { type: "image/png" });
}

export async function POST(request: NextRequest) {
  const { originalImgPath, maskImgPath } = await request.json()

  const url = await editImage(originalImgPath, maskImgPath)
  return new Response(JSON.stringify({ url: url }), { headers: { "Content-Type": "application/json" } })
}
/api/canvas/route.tsの全コード
import { NextRequest } from "next/server";
import OpenAI from "openai";
import { isUploadable } from "openai/uploads.mjs";
import sharp from "sharp";

const openai = new OpenAI({
  apiKey: process.env.OPEN_AI_API_KEY,
});

const MODEL = "dall-e-2";
const PROMPT = "wear a red butterfly bow.";

const editImage = async (originalImgPath: string, maskImgPath: string) => {
  const [imageContent, maskContent] = await Promise.all([
    fetchImageBinaryData(originalImgPath),
    fetchImageBinaryData(maskImgPath),
  ])

  if (!isUploadable(imageContent) || !isUploadable(maskContent)) return
  const response = await openai.images.edit({
    model: MODEL,
    image: imageContent,
    mask: maskContent,
    prompt: PROMPT,
    response_format: "url",
  })

  return response.data[0].url
}

async function fetchImageBinaryData(imageUrl: string) {
  const response = await fetch(imageUrl, { cache: "no-store" })
  let buffer = await response.arrayBuffer()
  const contentType = response.headers.get("content-type");

  if (contentType !== "image/png") {
    buffer = await sharp(Buffer.from(buffer)).png().toBuffer();
  }

  // 変換後のバイナリデータからFileオブジェクトを作成
  return new File([buffer], "image.png", { type: "image/png" });
}

export async function POST(request: NextRequest) {
  const { originalImgPath, maskImgPath } = await request.json()

  const url = await editImage(originalImgPath, maskImgPath)
  return new Response(JSON.stringify({ url: url }), { headers: { "Content-Type": "application/json" } })
}

ここで注意したいのはOpenAI APIのリクエストボディのimagemaskの型です。
ライブラリ内の型を見ると以下のようなものになってます。

// open AI 内部
export type Uploadable = FileLike | ResponseLike | FsReadStream;

こちらに合わせるため、pngのFileオブジェクトに変換しています。
isUploadable関数はライブラリから提供されています。

また、2024年4月初旬現在では、画像編集APIで利用できるモデルはDALL.E-2のみです。これがDALL.E-3対応になると、またクオリティが上がりそうですね!

以上です!

📌 まとめ

  1. canvasの軌跡で透過画像(mask)を作成する
  2. リクエスト可能な形式(Uploadable)にデータをフォーマットする

より良い方法があれば教えてください〜

最後まで読んでいただきありがとうございます!
気ままにつぶやいているので、気軽にフォローをお願いします!🥺
https://twitter.com/Ryo54388667/status/1733434994016862256

Discussion