NextJSとPythonで作る画像モノクロ変換ウェブアプリケーション

画像処理機能を持つウェブアプリケーションの開発は、フロントエンドとバックエンドの技術を組み合わせる絶好の機会です。この記事では、React/NextJSをフロントエンドに、Pythonをバックエンドに使用した画像のモノクロ変換アプリケーションの実装方法について解説します。実際に[image to black and white](https://imagetoblackandwhite.org) の経験をもとに、技術的なポイントを共有します。

## 技術スタックの概要

このプロジェクトでは以下の技術スタックを採用しています：

- **フロントエンド**: NextJS 14 (React)
- **バックエンド**: Python 3.10 + FastAPI
- **画像処理**: Python PIL (Pillow)
- **デプロイ**: Vercel (フロントエンド) + AWS Lambda (バックエンド)

## バックエンド: Pythonによる画像処理API

まず、Pythonを使った画像処理APIの核となる部分を実装していきます。

### FastAPIによるAPI設計

```python
from fastapi import FastAPI, File, UploadFile, Form
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
import uuid
import os
from PIL import Image, ImageEnhance

app = FastAPI()

# CORS設定
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 本番環境では適切に制限すること
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.post("/convert")
async def convert_image(
    file: UploadFile = File(...),
    contrast: float = Form(1.0),
    brightness: float = Form(1.0)
):
    # 一時ファイル名の生成
    temp_file = f"/tmp/{uuid.uuid4()}.jpg"
    output_file = f"/tmp/{uuid.uuid4()}_bw.jpg"
    
    # アップロードされたファイルを一時保存
    with open(temp_file, "wb") as buffer:
        buffer.write(await file.read())
    
    # 画像処理
    img = Image.open(temp_file)
    
    # グレースケール変換
    img = img.convert('L')
    
    # コントラスト調整
    if contrast != 1.0:
        enhancer = ImageEnhance.Contrast(img)
        img = enhancer.enhance(contrast)
    
    # 明るさ調整
    if brightness != 1.0:
        enhancer = ImageEnhance.Brightness(img)
        img = enhancer.enhance(brightness)
    
    # 変換後の画像を保存
    img.save(output_file, "JPEG")
    
    # 一時ファイルを返却
    return FileResponse(output_file, media_type="image/jpeg", filename="converted.jpg")

@app.on_event("shutdown")
def cleanup():
    # 一時ファイルのクリーンアップ処理
    import glob
    for f in glob.glob("/tmp/*.jpg"):
        try:
            os.remove(f)
        except:
            pass
```

このAPIは画像ファイルを受け取り、コントラストと明るさのパラメータに基づいてモノクロ変換を行います。PILライブラリを使用して、画像をグレースケールに変換し、必要に応じて調整を加えています。

### 高度な画像処理機能の追加

より高度なモノクロ変換のために、カラーフィルターの効果をシミュレートする機能を追加しましょう：

```python
@app.post("/convert/advanced")
async def convert_image_advanced(
    file: UploadFile = File(...),
    red_filter: float = Form(0.299),  # デフォルトは標準的なRGB->グレースケール変換の重み
    green_filter: float = Form(0.587),
    blue_filter: float = Form(0.114),
    contrast: float = Form(1.0),
    brightness: float = Form(1.0)
):
    # 一時ファイル名の生成
    temp_file = f"/tmp/{uuid.uuid4()}.jpg"
    output_file = f"/tmp/{uuid.uuid4()}_bw.jpg"
    
    # アップロードされたファイルを一時保存
    with open(temp_file, "wb") as buffer:
        buffer.write(await file.read())
    
    # 画像処理
    img = Image.open(temp_file)
    
    # RGBデータを取得
    img_rgb = img.convert('RGB')
    width, height = img.size
    
    # カスタムグレースケール変換
    img_gray = Image.new('L', (width, height))
    
    for x in range(width):
        for y in range(height):
            r, g, b = img_rgb.getpixel((x, y))
            gray_value = int(r * red_filter + g * green_filter + b * blue_filter)
            img_gray.putpixel((x, y), gray_value)
    
    # コントラスト調整
    if contrast != 1.0:
        enhancer = ImageEnhance.Contrast(img_gray)
        img_gray = enhancer.enhance(contrast)
    
    # 明るさ調整
    if brightness != 1.0:
        enhancer = ImageEnhance.Brightness(img_gray)
        img_gray = enhancer.enhance(brightness)
    
    # 変換後の画像を保存
    img_gray.save(output_file, "JPEG")
    
    # 一時ファイルを返却
    return FileResponse(output_file, media_type="image/jpeg", filename="converted.jpg")
```

このコードでは、RGBチャンネルに対する重みを個別に指定できるようにして、フィルター効果をシミュレートしています。例えば、赤フィルターの効果を得るには赤チャンネルの重みを高く設定します。

## フロントエンド: NextJSによるUI実装

次に、NextJS 14を使用したフロントエンドの実装に移ります。

### 基本的なページ構造

```jsx
// pages/index.js
import { useState, useRef } from 'react';
import Head from 'next/head';
import Image from 'next/image';
import styles from '../styles/Home.module.css';

export default function Home() {
  const [originalImage, setOriginalImage] = useState(null);
  const [convertedImage, setConvertedImage] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [contrast, setContrast] = useState(1.0);
  const [brightness, setBrightness] = useState(1.0);
  const fileInputRef = useRef(null);

  const handleImageUpload = (event) => {
    const file = event.target.files[0];
    if (file) {
      const reader = new FileReader();
      reader.onloadend = () => {
        setOriginalImage(reader.result);
        setConvertedImage(null);
      };
      reader.readAsDataURL(file);
    }
  };

  const convertImage = async () => {
    if (!originalImage) return;
    
    setIsLoading(true);
    
    try {
      const file = fileInputRef.current.files[0];
      const formData = new FormData();
      formData.append('file', file);
      formData.append('contrast', contrast);
      formData.append('brightness', brightness);
      
      const response = await fetch('https://api.yourbackend.com/convert', {
        method: 'POST',
        body: formData,
      });
      
      if (!response.ok) {
        throw new Error('API request failed');
      }
      
      const blob = await response.blob();
      setConvertedImage(URL.createObjectURL(blob));
    } catch (error) {
      console.error('Error converting image:', error);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className={styles.container}>
      <Head>
        <title>モノクロ変換アプリ</title>
        <meta name="description" content="NextJSとPythonで作ったモノクロ変換アプリ" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>
          モノクロ変換アプリ
        </h1>

        <div className={styles.uploadSection}>
          <input
            type="file"
            accept="image/*"
            onChange={handleImageUpload}
            ref={fileInputRef}
            style={{ display: 'none' }}
            id="imageUpload"
          />
          <label htmlFor="imageUpload" className={styles.uploadButton}>
            画像をアップロード
          </label>
        </div>

        {originalImage && (
          <div className={styles.controlsSection}>
            <div className={styles.slider}>
              <label>コントラスト: {contrast.toFixed(1)}</label>
              <input
                type="range"
                min="0.5"
                max="2.0"
                step="0.1"
                value={contrast}
                onChange={(e) => setContrast(parseFloat(e.target.value))}
              />
            </div>
            
            <div className={styles.slider}>
              <label>明るさ: {brightness.toFixed(1)}</label>
              <input
                type="range"
                min="0.5"
                max="2.0"
                step="0.1"
                value={brightness}
                onChange={(e) => setBrightness(parseFloat(e.target.value))}
              />
            </div>
            
            <button 
              className={styles.convertButton}
              onClick={convertImage}
              disabled={isLoading}
            >
              {isLoading ? '変換中...' : '変換する'}
            </button>
          </div>
        )}

        <div className={styles.imageContainer}>
          {originalImage && (
            <div className={styles.imageBox}>
              <h3>元の画像</h3>
              <img 
                src={originalImage} 
                alt="Original" 
                className={styles.image}
              />
            </div>
          )}
          
          {convertedImage && (
            <div className={styles.imageBox}>
              <h3>変換後</h3>
              <img 
                src={convertedImage} 
                alt="Converted" 
                className={styles.image}
              />
              <a 
                href={convertedImage} 
                download="monochrome.jpg"
                className={styles.downloadButton}
              >
                ダウンロード
              </a>
            </div>
          )}
        </div>
      </main>
    </div>
  );
}
```

### ドラッグ&ドロップ機能の実装

ユーザー体験を向上させるために、ドラッグ&ドロップによるアップロード機能を追加します：

```jsx
// components/DragAndDrop.js
import { useState, useRef } from 'react';
import styles from '../styles/DragAndDrop.module.css';

export default function DragAndDrop({ onFileUpload }) {
  const [isDragging, setIsDragging] = useState(false);
  const dropRef = useRef(null);

  const handleDragIn = (e) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
      setIsDragging(true);
    }
  };

  const handleDragOut = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
  };

  const handleDragOver = (e) => {
    e.preventDefault();
    e.stopPropagation();
  };

  const handleDrop = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
    
    if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
      const file = e.dataTransfer.files[0];
      // 画像ファイルかどうかチェック
      if (file.type.startsWith('image/')) {
        onFileUpload(file);
        e.dataTransfer.clearData();
      }
    }
  };

  return (
    <div
      className={`${styles.dropzone} ${isDragging ? styles.dragging : ''}`}
      ref={dropRef}
      onDragEnter={handleDragIn}
      onDragLeave={handleDragOut}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
    >
      <div className={styles.dropzoneContent}>
        <p>ここに画像をドラッグ&ドロップ</p>
        <p>または</p>
        <label className={styles.fileButton}>
          ファイルを選択
          <input
            type="file"
            accept="image/*"
            onChange={(e) => {
              if (e.target.files[0]) {
                onFileUpload(e.target.files[0]);
              }
            }}
            style={{ display: 'none' }}
          />
        </label>
      </div>
    </div>
  );
}
```

このコンポーネントを`index.js`に統合するには、既存のファイル選択UIを置き換えます。

## 高度な機能: WebWorkerによる処理の最適化

画像処理は重い処理になる可能性があるため、WebWorkerを使ってメインスレッドをブロックしないようにしましょう：

```javascript
// public/imageWorker.js
self.onmessage = async function(e) {
  const { imageData, contrast, brightness } = e.data;
  
  try {
    // 簡易的なクライアントサイド処理（プレビュー用）
    const canvas = new OffscreenCanvas(imageData.width, imageData.height);
    const ctx = canvas.getContext('2d');
    ctx.putImageData(imageData, 0, 0);
    
    // グレースケール変換（シンプルな実装）
    const imageDataCopy = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const data = imageDataCopy.data;
    
    for (let i = 0; i < data.length; i += 4) {
      const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
      
      // コントラスト適用
      let adjustedValue = avg;
      if (contrast !== 1) {
        adjustedValue = 128 + (adjustedValue - 128) * contrast;
      }
      
      // 明るさ適用
      adjustedValue *= brightness;
      
      // 値の範囲を0-255に制限
      adjustedValue = Math.max(0, Math.min(255, adjustedValue));
      
      data[i] = data[i + 1] = data[i + 2] = adjustedValue;
    }
    
    ctx.putImageData(imageDataCopy, 0, 0);
    
    // 結果を返す
    const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
    self.postMessage({ status: 'success', result: blob });
  } catch (error) {
    self.postMessage({ status: 'error', error: error.message });
  }
};
```

## デプロイ戦略

このアプリケーションを効率的にデプロイするための戦略を考えましょう：

### フロントエンド: Vercel

NextJSアプリケーションはVercelにデプロイするのが最も簡単です：

```bash
# Vercelへのデプロイ
npm install -g vercel
vercel
```

### バックエンド: AWS Lambda + API Gateway

PythonバックエンドはAWS Lambdaにデプロイし、API Gatewayを通じて公開します：

```bash
# Serverless Frameworkを使った場合の設定例
# serverless.yml
service: image-converter-api

provider:
  name: aws
  runtime: python3.10
  region: ap-northeast-1  # 東京リージョン
  memorySize: 1024
  timeout: 30

functions:
  api:
    handler: handler.handler
    events:
      - http:
          path: /{proxy+}
          method: any
          cors: true
```

handler.pyでは、FastAPIアプリケーションをLambdaハンドラーでラップします：

```python
from mangum import Mangum
from app import app  # FastAPIアプリをインポート

handler = Mangum(app)
```

## パフォーマンス最適化

画像処理アプリケーションを最適化するいくつかのテクニックを紹介します：

1. **画像リサイズ**: 大きな画像を処理前に適切なサイズにリサイズする
2. **キャッシュの活用**: 同じパラメータでの変換結果をキャッシュする
3. **プログレッシブ処理**: 最初に低解像度のプレビューを表示し、その後高解像度処理を行う
4. **バックエンドスケーリング**: 処理需要に応じてバックエンドをスケールする

## まとめ

NextJSとPythonを組み合わせることで、高機能な画像処理ウェブアプリケーションを構築できることがわかりました。フロントエンドではReactの柔軟性を活かしたUIを、バックエンドではPythonの強力な画像処理ライブラリを使うことで、それぞれの技術の強みを最大限に活用できます。

この記事で紹介したコードをベースに、独自の画像処理機能を追加してみてください。例えば、セピア調変換、ノイズ追加、ビネット効果などを実装することで、より高度な画像編集ツールになるでしょう。

---

*本記事のコードは一例であり、実際の実装では適切なエラーハンドリングやセキュリティ対策を行ってください。*