IT

【Python】特定の色を抜くアプリを作った話

概要

アップロードした画像の色を分析し、特定の色を除去し透明にするアプリを作りました。

Render.comのFreeプランにデプロイしようと思ったのですが、流石にメモリ容量が足りずに画像処理ができませんでした・・・。

有料のサービスにデプロイするようなものでもないかと思ったので、とりあえずここに書いてみます。

動作イメージ

  • 選択した色を除去した段階でDLされますが、一旦プレビュー見てからでよかったかもしれません。

コード群

  • ディレクトリ構造
.
├── Dockerfile
├── README.txt
├── app.py
├── requirements.txt
└── templates
    └── upload.html
  • Dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--timeout", "120", "--workers", "3", "--threads", "3", "--worker-class", "gthread", "app:app"]
  • requirements.txt
Flask==2.3.2
Werkzeug==2.3.6
Pillow==9.5.0
numpy==1.24.3
opencv-python-headless==4.7.0.72
scikit-learn==1.2.2
gunicorn==20.1.0
  • app.py
import os 
import io
import base64
from typing import List, Dict
from flask import Flask, request, jsonify, render_template
from PIL import Image
from sklearn.cluster import KMeans
import cv2
import numpy as np

app = Flask(__name__)

class ImageProcessor:
    @staticmethod
    def analyze_colors(image_data: bytes, n_colors: int = 5) -> List[Dict[str, int]]:
        nparr = np.frombuffer(image_data, np.uint8)
        image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
        image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        
        pixels_rgb = image_rgb.reshape(-1, 3)
        
        kmeans = KMeans(n_clusters=n_colors, random_state=42)
        kmeans.fit(pixels_rgb)
        
        colors_rgb = kmeans.cluster_centers_.astype(int)
        colors_hsv = [cv2.cvtColor(np.uint8([[color]]), cv2.COLOR_RGB2HSV)[0][0] for color in colors_rgb]
        
        result = [
            {
                'h': int(hsv[0]), 's': int(hsv[1]), 'v': int(hsv[2]),
                'r': int(rgb[0]), 'g': int(rgb[1]), 'b': int(rgb[2])
            }
            for hsv, rgb in zip(colors_hsv, colors_rgb)
        ]
        
        app.logger.info(f"Detected colors: {result}")
        
        return result

    @staticmethod
    def remove_color(image_data: bytes, color: Dict[str, int],
                     h_threshold: int = 10, s_threshold: int = 40, v_threshold: int = 40) -> Image.Image:
        nparr = np.frombuffer(image_data, np.uint8)
        image = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED)
        
        if image.shape[2] == 3:
            image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
        
        hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
        h, s, v = color['h'], color['s'], color['v']
        
        lower_bound = np.array([max(0, h - h_threshold), max(0, s - s_threshold), max(0, v - v_threshold)])
        upper_bound = np.array([min(180, h + h_threshold), min(255, s + s_threshold), min(255, v + v_threshold)])
        
        mask = cv2.inRange(hsv_image, lower_bound, upper_bound)
        mask = cv2.GaussianBlur(mask, (5, 5), 0)
        
        alpha = cv2.bitwise_not(mask)
        result = cv2.merge((image[:,:,0], image[:,:,1], image[:,:,2], alpha))
        result_pil = Image.fromarray(cv2.cvtColor(result, cv2.COLOR_BGRA2RGBA))        
        return result_pil

@app.route('/', methods=['GET'])
def index():
    return render_template('upload.html')

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        return jsonify({'error': 'ファイルがありません'}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({'error': 'ファイルが選択されていません'}), 400
    
    try:
        image_data = file.read()
        colors = ImageProcessor.analyze_colors(image_data)
        
        encoded_image = base64.b64encode(image_data).decode('utf-8')
        
        return jsonify({
            'colors': colors,
            'filename': file.filename,
            'image': encoded_image
        })
    except Exception as e:
        app.logger.error(f"Error processing file: {str(e)}")
        return jsonify({'error': 'ファイル処理中にエラーが発生しました'}), 500

@app.route('/remove_background', methods=['POST'])
def remove_background():
    try:
        data = request.json
        color = data['color']
        image_data = base64.b64decode(data['image'].split(',')[1])
        original_filename = data['filename']
        
        app.logger.info(f"Removing color: {color}")
        
        processed_image = ImageProcessor.remove_color(image_data, color)
        
        buffered = io.BytesIO()
        processed_image.save(buffered, format="PNG")
        img_str = base64.b64encode(buffered.getvalue()).decode()
        
        filename_without_ext = os.path.splitext(original_filename)[0]
        new_filename = f"{filename_without_ext}_processed.png"
        
        return jsonify({
            'image': img_str,
            'filename': new_filename,
            'mime': 'image/png'
        })
    except Exception as e:
        app.logger.error(f"Error removing background: {str(e)}")
        return jsonify({'error': '背景除去中にエラーが発生しました'}), 500

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0')
  • templates/upload.html
<!DOCTYPE html>
<html lang="ja" class="h-full bg-gray-100">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>特定の色ブッコ抜くツール</title>
  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    .max-w-full {
      max-width: 100%;
    }

    .h-auto {
      height: auto;
    }

    .object-contain {
      object-fit: contain;
    }

    .loading {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.5);
      z-index: 9999;
      justify-content: center;
      align-items: center;
      color: white;
      font-size: 24px;
    }
  </style>
</head>

<body class="h-full flex flex-col">
  <div class="flex-grow">
    <nav class="bg-gray-800">
      <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
        <div class="flex h-16 items-center justify-between">
          <div class="flex items-center">
            <div class="flex-shrink-0">
              <svg class="h-8 w-8 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round"
                  d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
              </svg>
            </div>
            <div class="ml-10 flex items-baseline space-x-4">
              <a href="#" class="bg-gray-900 text-white rounded-md px-3 py-2 text-sm font-medium"
                aria-current="page">特定の色ブッコ抜きツール!!!!!</a>
            </div>
          </div>
        </div>
      </div>
    </nav>
    <main>
      <div class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
        <div class="px-4 py-6 sm:px-0">
          <div class="rounded-lg border-4 border-dashed border-gray-200 p-4">
            <form id="upload-form" enctype="multipart/form-data" class="mb-4">
              <div class="flex items-center justify-center w-full">
                <label for="file-upload"
                  class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:hover:bg-bray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600">
                  <div class="flex flex-col items-center justify-center pt-5 pb-6">
                    <svg aria-hidden="true" class="w-10 h-10 mb-3 text-gray-400" fill="none" stroke="currentColor"
                      viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                        d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12">
                      </path>
                    </svg>
                    <p class="mb-2 text-sm text-gray-500 dark:text-gray-400"><span
                        class="font-semibold">クリックしてアップロード</span> または画像をドラッグ&ドロップ</p>
                    <p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG or JPEG (MAX. 800x400px)</p>
                    <p class="text-xs text-gray-500 dark:text-gray-400">20MBまでです。</p>
                  </div>
                  <input id="file-upload" type="file" name="file" accept="image/*" required class="hidden" />
                </label>
              </div>
            </form>

            <div id="color-swatches" class="flex flex-wrap justify-center gap-2 mb-4"></div>

            <div class="flex justify-center">
              <button id="remove-background"
                class="hidden px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
                選択した色を除去
              </button>
            </div>

            <div class="flex flex-col md:flex-row justify-center items-center gap-4 mb-4">
              <div class="w-full md:w-1/2">
                <img id="preview" class="max-w-full h-auto object-contain hidden rounded-lg shadow-lg py-2" alt="プレビュー">
              </div>
              <div class="w-full md:w-1/2">
                <img id="result-preview" class="max-w-full h-auto object-contain hidden rounded-lg shadow-lg py-2"
                  alt="処理結果">
              </div>
            </div>
          </div>
        </div>

        <div class="mt-8 px-4 py-6 sm:px-0">
          <div class="p-4 bg-yellow-50 border-l-4 border-yellow-400 text-yellow-800">
            <p class="font-bold">README</p>
            <ul class="list-disc list-inside mt-2">
              <li>このツールは画像から取得した数種の色素を取得し、それをぶっこ抜く(除去)するジョークツールです。</li>
              <li>AIで生成された画像や色彩が淡い画像は、色の検知精度が低くなる可能性があります。</li>
              <li>最良の結果を得るには、色彩がはっきりとした画像を使用することが望ましいです。</li>
              <li>除去した後はPNGファイルがダウンロードされます。</li>
              <li>画像を保持したくないので、フロントで処理しているので少し挙動が遅いことがあります。</li>
              <li>当アプリの利用により生じた如何なる事項に関しても、運営側は一切の責任を負いません。</li>
            </ul>
          </div>
        </div>
      </div>
    </main>
  </div>
  <footer class="bg-gray-800 text-white py-4 mt-auto">
    <div class="container mx-auto text-center">
      <a href="https://enishiblog.org/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-300">
        2024 緑川縁. All rights reserved.
      </a>
    </div>
  </footer>

  <div id="loading" class="loading">
    <div>処理中...</div>
  </div>

  <script>
    const form = document.getElementById('upload-form');
    const fileUpload = document.getElementById('file-upload');
    const colorSwatches = document.getElementById('color-swatches');
    const preview = document.getElementById('preview');
    const resultPreview = document.getElementById('result-preview');
    const removeBackgroundButton = document.getElementById('remove-background');
    const loading = document.getElementById('loading');
    let selectedColor = null;
    let currentImageData = null;

    const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB

    fileUpload.addEventListener('change', (e) => {
      if (e.target.files.length > 0) {
        const file = e.target.files[0];
        if (file.size > MAX_FILE_SIZE) {
          alert('ファイルサイズが大きすぎます。20MB以下の画像を選択してください。');
          e.target.value = '';
          return;
        }
        form.dispatchEvent(new Event('submit'));
      }
    });

    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      const formData = new FormData(form);
      try {
        loading.style.display = 'flex';
        const response = await axios.post('/upload', formData);
        const { colors, filename, image } = response.data;
        currentFilename = filename;
        displayColorSwatches(colors);

        preview.src = `data:image/jpeg;base64,${image}`;
        preview.classList.remove('hidden');
        currentImageData = `data:image/jpeg;base64,${image}`;

        removeBackgroundButton.classList.remove('hidden');
        resultPreview.classList.add('hidden');
      } catch (error) {
        console.error('Error:', error.response ? error.response.data : error.message);
        alert('画像のアップロード中にエラーが発生しました。');
      } finally {
        loading.style.display = 'none';
      }
    });

    function displayColorSwatches(colors) {
      colorSwatches.innerHTML = '';
      colors.forEach(color => {
        const swatch = document.createElement('div');
        swatch.className = 'w-12 h-12 rounded-full cursor-pointer transition-transform hover:scale-110';
        swatch.style.backgroundColor = `rgb(${color.r}, ${color.g}, ${color.b})`;
        swatch.setAttribute('aria-label', `Color: R${color.r}, G${color.g}, B${color.b}`);
        swatch.addEventListener('click', () => selectColor(swatch, color));
        colorSwatches.appendChild(swatch);
      });
    }

    function selectColor(swatch, color) {
      document.querySelectorAll('#color-swatches > div').forEach(s => s.classList.remove('ring-2', 'ring-offset-2', 'ring-indigo-500'));
      swatch.classList.add('ring-2', 'ring-offset-2', 'ring-indigo-500');
      selectedColor = color;
    }

    removeBackgroundButton.addEventListener('click', async () => {
      if (!selectedColor || !currentImageData) {
        alert('色を選択し、画像をアップロードしてください。');
        return;
      }
      try {
        loading.style.display = 'flex'; 
        const response = await axios.post('/remove_background', {
          color: selectedColor,
          image: currentImageData,
          filename: currentFilename
        });

        resultPreview.src = `data:${response.data.mime};base64,${response.data.image}`;
        resultPreview.classList.remove('hidden');

        // ダウンロードリンクの作成
        const downloadLink = document.createElement('a');
        downloadLink.href = `data:${response.data.mime};base64,${response.data.image}`;
        downloadLink.download = response.data.filename;
        document.body.appendChild(downloadLink);
        downloadLink.click();
        document.body.removeChild(downloadLink);
      } catch (error) {
        console.error('Error:', error.response ? error.response.data : error.message);
        alert('色の除去中にエラーが発生しました。');
      } finally {
        loading.style.display = 'none';
      }
    });

  </script>
</body>

</html>

簡単な処理説明

基本的な処理はapp.py で実施しています。

  • remove_color で、画像データと削除する色を受け取り、指定された色の範囲内のピクセルを透明にすることで、画像の背景を除去してます。
  • ImageProcessor.analyze_colors で、画像の主要な色を抽出し、抽出された色の情報を返しています。
  • remove_backgroundで、フロントで選択された除去する色を受け取っています。
  • ImageProcessor.remove_color で、指定された色を透明にし、背景を除去した画像を返しています。

最後に

Pillow、sklearn、OpenCV、NumPyを利用することで簡単に画像処理を実装することができてやっぱりPythonは便利ですね。

今回はAIで生成したイラストの背景色を抜こうと思って作成しましたが、Canvaの背景リムーバで事足りたので、ジョークツールとして作ってみました。

いつかブラッシュアップしてサービス化できたらいいなと思います。

以上、お付き合いいただきありがとうございました。

  • この記事を書いた人

緑川縁

ニートからシステムエンジニアになった人
クラウド案件をメインにやっています。
保持資格:CCNA,AWS SAA&SAP,秘書検定2級
趣味でボカロ曲作り始めました。

-IT
-,