Replicate・Next.js・Upstashで作る写真復元アプリ開発チュートリアル
この記事では、機械学習モデルを活用した写真復元アプリの開発方法を解説します。進める前に、以下の環境が整っていることを前提とします。
- Redisインスタンスを作成済みのUpstashアカウント
- APIトークンを取得済みのReplicateアカウント
- 機能を実装するためのNext.jsプロジェクト
- プロジェクトをデプロイするためのVercelアカウント
このチュートリアルについて
Replicateで提供されているモデルを使って、機械学習による画像生成を始めてみたいと思ったことはありませんか?本チュートリアルでは、Replicateがホストする多彩なモデルとUpstashのRedisを組み合わせた実装方法を紹介します。単にモデルを紹介するだけでなく、実際にセットアップを行う手順を一から丁寧に説明し、さらに別のモデルへ簡単に差し替える方法についても触れていきます。
今回はMicrosoftが公開している「Bringing Old Photos Back to Life」というモデルを使用します。このモデルは古い写真を入力として受け取り、モデルを通すことで編集された、より美しく改善された画像を出力します。

アプリケーションのアーキテクチャ
Reactの経験がある方なら、コードベースを読み進めるだけでアーキテクチャ全体を把握できるでしょう。しかし初めての方や全体像を先に確認したい方のために、以下に概要図を用意しました。

開発を始めるために必要なもの
まず当然ながらNext.jsプロジェクトが必要です。公式のセットアップガイドに従って新規作成してもよいですし、既存のプロジェクトを使っても問題ありません。また、本チュートリアルではTailwind CSSを使用していますが、お好みのスタイリング手法に置き換えても構いません。
基本的なNext.jsプロジェクトの準備ができたら、次のコマンドでUpstashのRedisライブラリをインストールしましょう。
npm install @upstash/redis
続いて、.env.localファイルに以下の環境変数を設定します。RedisのトークンはUpstashコンソールから、Replicate APIトークンはアカウントページから取得できます。サイトURLにはデプロイ先、つまり今回の場合はVercelのデプロイエンドポイントを指定します。
SITE_URL=https://your-project-url.vercel.app
REPLICATE_API_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
フロントエンドフォームの構築
まず、画像の入力を受け付け、処理状況をポーリングし、完成した画像を表示するためのフォームを作成します。
画像復元フォームの作成
ファイル:pages/index.tsx
import { MouseEvent, RefObject, useRef, useState } from "react";
import Head from "next/head";
import useInterval from "../hooks/useInterval";
export default function Home() {
const [restoring, setRestoring] = useState<boolean>(false);
const [messageId, setMessageId] = useState<string | null>(null);
const [prediction, setPrediction] = useState<any>({});
const [outputImageUrl, setOutputImageUrl] = useState<string | null>(null);
const imageUrlRef: RefObject<HTMLInputElement> = useRef(null);
const hrRef: RefObject<HTMLInputElement> = useRef(null);
const scratchRef: RefObject<HTMLInputElement> = useRef(null);
useInterval(
async () => {
await fetch(`/api/poll?id=${messageId}`)
.then((res: any) => res.json())
.then((data: any) => {
if (!data.output) {
return;
}
setRestoring(false);
setMessageId(null);
setOutputImageUrl(data.output);
})
.catch((err: any) => console.error(err));
},
messageId ? 1000 : null,
);
async function restoreImage(e: any) {
e.preventDefault();
setRestoring(true);
await fetch("/api/create", {
method: "POST",
body: JSON.stringify({
image_url: imageUrlRef.current?.value,
is_hr: hrRef.current?.value,
has_scratches: scratchRef.current?.value,
}),
headers: { "Content-Type": "application/json" },
})
.then((res: Response) => res.json())
.then((data: any) => {
setMessageId(data.data.id);
setPrediction(data.data);
})
.catch((err: Error) => console.error(err));
}
async function cancel(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault();
await fetch("/api/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cancel_url: prediction.urls.cancel }),
})
.then((res: Response) => res.json())
.then((data: any) => {
setMessageId(null);
setPrediction({});
setRestoring(false);
})
.catch((err: Error) => console.error(err));
}
return (
<>
<Head>
<title>PhotoRescue</title>
<meta
name="description"
content="A simple Next.js application that utilizes Replicate to restore old photos."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="my-16 flex flex-col items-center justify-center md:my-32">
<h1 className="text-5xl font-black">PhotoRescue</h1>
<p className="mt-4">Restore your old photos to their former glory.</p>
{outputImageUrl && (
<div className="flex flex-col items-center justify-center">
<img
src={outputImageUrl}
alt="Restored Image"
className="mt-8 h-auto w-72"
/>
<button
type="button"
onClick={() => setOutputImageUrl(null)}
className="mt-8 inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 disabled:opacity-50"
>
Start Again
</button>
</div>
)}
{!outputImageUrl && (
<form
onSubmit={restoreImage}
className="mt-10 flex w-full max-w-lg flex-col items-center"
>
<div className="w-full space-y-4">
<div>
<label htmlFor="image_url" className="text-sm font-semibold">
Image URL
</label>
<input
name="image_url"
id="image_url"
type="text"
defaultValue="https://replicate.delivery/mgxm/b033ff07-1d2e-4768-a137-6c16b5ed4bed/d_1.png"
placeholder="https://example.com/image.png"
className="mt-0.5 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-gray-500 focus:ring-gray-500"
ref={imageUrlRef}
required
/>
</div>
<div className="max-w-lg space-y-4">
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
name="is_hr"
id="is_hr"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500"
ref={hrRef}
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="is_hr"
className="font-medium text-gray-900"
>
Is High Resolution?
</label>
<p className="text-gray-500">
Check this if the input image is a high resolution
photo.
</p>
</div>
</div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
name="is_scratched"
id="is_scratched"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500"
ref={scratchRef}
defaultChecked={true}
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="is_scratched"
className="font-medium text-gray-900"
>
Has Scratches?
</label>
<p className="text-gray-500">
Check this if the input image has visible scratches over
it.
</p>
</div>
</div>
</div>
</div>
<div className="mt-6 flex gap-2">
<button
type="submit"
disabled={restoring}
className="inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 disabled:opacity-50"
>
{restoring ? "Restoring..." : "Restore"}
</button>
{restoring && prediction && (
<button
type="button"
onClick={cancel}
className="inline-flex items-center rounded-full border border-gray-900 bg-white px-6 py-2.5 text-sm font-medium text-gray-900 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2"
>
Cancel
</button>
)}
</div>
</form>
)}
</div>
</main>
</>
);
}
デフォルトでは、このコンポーネントはユーザーが復元したい画像のURLを入力できるフォームを表示します。加えて、「画像が高解像度かどうか」「傷(キズ)が写り込んでいるかどうか」といったオプションも選択できます。ユーザーが必要な情報を入力してフォームを送信すると、フォームデータとともにPOSTリクエストが/api/createへ送られます。
APIへリクエストが送信され、予測(prediction)情報を含むレスポンスを受け取ると、コンポーネントはポーリング状態に入ります。具体的には、予測が完了したかどうかを確認するため、/api/pollに対して1秒に1回GETリクエストを送信します。ポーリングリクエストが成功レスポンスを返せば、それはReplicateからコールバックエンドポイントへ通知が届いたことを意味し、予測結果の出力を取得できるようになります。
ポーリング中は、予測処理をキャンセルするためのボタンも表示されます。このボタンが押されると、初期作成時に受け取った予測データ内のcancel_urlを使ってPOSTリクエストが/api/cancelへ送信されます。
ポーリングの実装には、hooks/useInterval.tsに配置するカスタムフックを使用します。これによりReactのコンポーネントライフサイクルとシームレスに連携でき、任意のReactコンポーネント内でコールバック付きのインターバル処理を簡潔に扱えます。フックの詳細については、関連ドキュメントを参照してください。
import { useEffect, useRef } from "react";
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
export default useInterval;
APIのセットアップ
API側は複数のファイルで構成されており、予測の作成・キャンセル、完了状況のポーリング、そしてReplicate側で予測が完了した際に呼び出されるコールバックの指定といった機能を担います。
画像予測の作成
ファイル:pages/api/create.ts
import type { NextApiRequest, NextApiResponse } from "next";
import fetch, { Response } from "node-fetch";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(400).json({
message: `Invalid request method: ${req.method}.`,
});
}
const { image_url, is_hr, has_scratches }: any = req.body;
await fetch("https://api.replicate.com/v1/predictions", {
method: "POST",
headers: {
Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
version:
"c75db81db6cbd809d93cc3b7e7a088a351a3349c9fa02b6d393e35e0d51ba799",
input: {
image: image_url,
HR: is_hr,
with_scratch: has_scratches,
},
webhook_completed: `${process.env.SITE_URL}/api/callback`,
}),
})
.then((res: Response) => res.json())
.then(async (data: any) => {
await redis.set(data.id, data);
return res.status(202).json({ data: data });
})
.catch((error: Error) => {
return res.status(500).json({ message: error.message });
});
}
予測作成用のAPIエンドポイントでは、まず受信したリクエストメソッドがPOSTであることをシンプルにチェックし、異なる場合は400レスポンスを返します。その後、Replicate APIトークンを使ってReplicateへPOSTリクエストを送信します。リクエストボディには、どのモデルへリクエストを送るかを示すモデルのversionパラメータ(利用したいモデルページの「API」タブで確認できます)と、フロントエンドのフォームから渡されたデータをモデルのパラメータとして含めます。
リクエスト送信後は、返却された予測のidをキーとしてRedisに保存し、予測データをフロントエンドへ返します。フロントエンドはこのデータをもとにポーリングを行い、予測が完了状態になるまで待ち続けます。
コールバック
ファイル:pages/api/callback.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { body }: any = req;
try {
await redis.set(body.id, body);
return res.status(200).send(body);
} catch (error) {
return res.status(500).json({ error });
}
}
コールバックエンドポイントは、特定の予測処理が完了したことをReplicateが通知するためにPOSTリクエストを送ってくる先です。このリクエストを受信すると、リクエストボディから予測データを取り出し、該当するRedisのアイテムを完了済みの予測データで更新します。
ポーリング
ファイル:pages/api/poll.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { id }: any = req.query;
try {
const data = await redis.get(id);
if (!data) {
return res
.status(404)
.json({ message: "Data for supplied ID not found" });
}
return res.status(200).json(data);
} catch (error: any) {
return res.status(500).json({ message: error.message });
}
}
ポーリング用のエンドポイントでは、リクエストからidを抽出し、その識別子でRedisに保存されているデータの取得を試みます。データが見つからない場合は404レスポンスを返し、見つかった場合はそのデータを200レスポンスとして返します。
キャンセル
ファイル:pages/api/cancel.tsx
import type { NextApiRequest, NextApiResponse } from "next";
import fetch, { Response } from "node-fetch";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(400).json({
message: `Invalid request method: ${req.method}.`,
});
}
const { cancel_url }: any = req.body;
await fetch(cancel_url, {
method: "POST",
headers: {
Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
"Content-Type": "application/json",
},
})
.then((res: Response) => res.json())
.then((data: any) => {
return res.status(202).json({ data: data });
})
.catch((error: Error) => {
return res.status(500).json({ message: error.message });
});
}
開始済みの予測をキャンセルするAPIエンドポイントは非常にシンプルです。作成リクエスト時に保存された予測データ由来のcancel_urlをフロントエンドから受け取り、そのエンドポイントに対してReplicate APIトークンとともにPOSTリクエストを送るだけです。
ライブラリ(Libs)
ライブラリ部分では、データ追跡に使用するRedisクライアントを作成します。
ファイル:lib/redis.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL as string,
token: process.env.UPSTASH_REDIS_REST_TOKEN as string,
});
export default redis;
このオブジェクトはアプリケーション内でデータの保存・取得に使われ、ReplicateからのWebhook完了通知がいつ届いたかを把握できるようにします。
まとめ
ReplicateにはAPI経由で利用できる多様なモデルが揃っています。VercelとUpstashを組み合わせれば、機械学習モデルを活用した実用的なWebアプリケーションをかつてないほど手軽に構築・デプロイできます。
完全なコードリポジトリを確認したい方は、公開されているリポジトリをご覧ください。
さらなる発展に向けて
ここまで紹介したのは、比較的シンプルなモデルをReplicateで活用する一例にすぎません。フォームのパラメータとAPI内のversionを入れ替えるだけで別のモデルへ簡単に切り替えられます。Replicate APIトークンさえ連携していれば、利用可能なすべてのモデルを活用できます。
Replicateで利用可能なモデルの一覧は公式サイトで探索できます。気になるモデルを見つけたら「API」タブをクリックして使い方を確認しましょう。そこにはPython、cURL、Cog、Docker用のボタンがあり、モデルをその場でテストできるだけでなく、必須パラメータの種類や送信方法を把握するのにも役立ちます。
-
Redis初のアンバサダー制度「Redis Insiders」プログラム発表
Redisはこのたび、初となるアンバサダープログラム「Redis Insiders(レディス・インサイダーズ)」を立ち上げました。 Redisはオープンソースとして世界中で何百万人ものユーザーに利用されており、開発者コミュニティは私たちのプロダクトの中心的存在です。最も熱心で情熱的なコミュニティメンバーの皆さまと協力し、Redisについて世界へ発信しながら、皆さんのプレゼンス向上も後押ししていきたいと考えています。 Redisを活用している開発者で、コードについて書いたり話したりするのが好きな方なら、Redis Insiderになれるチャンスがあります。特に、テック業界において少数派(アンダー
-
Redis ZINCRBYコマンドの使い方 – Redisでソート済みセットの要素スコアをインクリメントする方法
このチュートリアルでは、Redisデータストアに保存されたソート済みセット(Sorted Set)型の値について、特定の要素のスコアをインクリメントする方法を学びます。この操作には、redis-cliでZINCRBYコマンドを使用します。 ZINCRBYコマンドは、キーに保存されているソート済みセットの値の中から指定した要素を探し、そのスコアを指定した値(increment)だけ増加させるために使用します。もし指定した要素がソート済みセット内に存在しない場合は、その要素が新しく追加され、指定したincrement値がスコアとして設定されます。また、キー自体が存在しない場合には、指定した要素のみ