🐢

【完全無料】VercelでOpenCVを動かそう~タートルグラフィックスを添えて~

2024/12/03に公開

これは2024team411アドベントカレンダー3日目の記事です。
昨日はかなるさんの「【初心者向け】かわいい・かっこいい開発環境の作り方」でした。開発者にとって、普段使いする開発環境にこだわるのってとても良いですね。自分はVSCodeをダークテーマにしたっきりカスタマイズしてないです...

はじめに

突然ですが、みなさんはタートルグラフィックスというものを知っていますか?
タートルグラフィックスとは、GUIプログラミングの入門のようなもので、LOGOというプログラミング言語によって1967年に初めて実装されました。その後、さまざまな言語でも実装され、初心者向けとして受け継がれてきました。以下のgifを見ればわかると思いますが、文字通り亀が移動することで線が描かれるというものです。

この記事は、そんなタートルグラフィックスがきっかけで得られた知見を紹介していきたいと思います!

きっかけ

上にある動きは、以下のコードによって動作しています。

class Star {
    public static void main(String[] args) throws InterruptedException {
        TurtleFrame f;
        f = new TurtleFrame(400, 400);
        Turtle m = new Turtle();
        f.add(m);
        m.up(); // 筆を上げる
        m.lt(90); // 左に90度回転
        m.fd(50); // 前に50px進む
        m.lt(90);
        m.fd(90);
        m.down(); // 筆を下ろす
        int k = 11;
        int n = 30;
        for (int i = 0; i < n; i++) {
            m.lt(360 * k / n);
            m.fd(200);
        }
        m.up();
    }
}

このように、1つ1つの動きを記述して亀を動かしています。ですが、これでは描きたい絵に対してすべてを直線に分解し、そしてそれに対応するコードを手書きしなければなりません。これは楽しくありません。そこで、すべてを自動化したいと思い、このプロジェクト(?)に取り掛かりました。

...タートルグラフィックスで絵、描きたくないですか?

OpenCVと馴れ合う

まず、目標を達成するためには、おおまかに以下のステップで進める必要があります。

・画像から線画に変換
・線画から動きのデータに変換
・データから亀を動かすためのコードに変換

そのため、まずは画像を線画にするためのツールを調べました。すると、pythonのOpenCVを使えばできるという記述を見つけ、早速試してみました。

import cv2
import numpy as np
import base64
...
image_data = base64.b64decode(image_base64)
np_arr = np.frombuffer(image_data, np.uint8)
image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)

# OpenCVの処理
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
blurred = cv2.GaussianBlur(enhanced, (5, 5), 0)
edges = cv2.Canny(blurred, 30, 100)
kernel = np.ones((3, 3), np.uint8)
dilated = cv2.dilate(edges, kernel, iterations=0)
turned = cv2.bitwise_not(dilated)
...

上記の処理により、実際にこのような変換結果が得られます。

変換前

出典: https://sonochiyushi.com/special/index00300000.html

変換後

アップロード時の圧縮でわかりにくくなってますが、すべて1ピクセルの線で描かれていることがわかります!
これで線画のタスクはクリアしました!(後ほどこの実装のせいで痛い目を見るのは別の話)

TypeScriptと馴れ合う

次のステップに移ります。

・画像から線画に変換
・線画から動きのデータに変換
・データから亀を動かすためのコードに変換

今回、誰でもツールを使用できるように、webでの実装を目標としていたため、TypeScript, Reactを用いて作ろうと決めました。ここの部分には結構苦戦しましたが、やっていることは画像データを読み込んで、黒色のピクセルたちを線(ここでは島と呼ぶ)に分解して、それらの島を巡回して亀が移動するようなコードを生成するだけです。
コードは全部このリポジトリにあります。
https://github.com/AkaakuHub/turtle-graphics-art/blob/main/lib/generateTurtleCommands.ts

実装が長いので一部のみ掲載しますが、基本的には上記の操作を行っています。

generateTurtleCommands.ts
type Position = [number, number];
type Island = Position[];

let imageData: ImageData;
let blackPixels: Position[] = [];

import { TurtleCommand, TurtleCommands, TurtleJsonType } from '../types';

...

function findBlackPixels(): void {
  const { width, height, data } = imageData;
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      if (data[(y * width + x) * 4] === 0) {
        blackPixels.push([x, y]);
      }
    }
  }
}
...
function findNearestIsland(currentPos: Position, islands: Island[]): Island | null {
  let minDistance = Infinity;
  let nearestIsland: Island | null = null;
  for (const island of islands) {
    const distance = calculateDistance(currentPos, island[0]);
    if (distance < minDistance) {
      minDistance = distance;
      nearestIsland = island;
    }
  }
  return nearestIsland;
}
...

島の移動を正しくコントロールするのに苦戦しました。しかし、このコードが完成したことにより、見事線画から亀の動きを記述したjsonファイルを作成できました!
あとはこのjsonをJavaで読み込むだけ、そう思っていた時期が私にもありました。

Javaと馴れ合う

いよいよ最後のステップです!

・画像から線画に変換
・線画から動きのデータに変換
・データから亀を動かすためのコードに変換

ここは単純で、jsonをパースして読み込むだけで完成しました。

import java.io.FileReader;
import org.json.simple.JSONObject;
import org.json.simple.JSONArray;
import org.json.simple.parser.JSONParser;
class Oekaki {
  public static void main(String[] args) {
    try {
      JSONObject jsonObject = (JSONObject) new JSONParser().parse(new FileReader("turtle.json"));
      JSONArray sizeArray = (JSONArray) jsonObject.get("size");
      int width = ((Number) sizeArray.get(0)).intValue();
...
      // 初期化のため、左上(0, 0)に移動させる
      m.up();
      m.fd(200);
      m.lt(90);
      m.fd(200);
      m.lt(180);

      for (Object commandObj : dataArray) {
        String command = (String) commandObj;
        if (command.equals("u")) {
          m.up();
        } else if (command.equals("d")) {
...
        }
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

このコードをコンパイル、そして実行!
そして画面に現れた亀はイラストを見事描画しました。

というわけで今回の記事は以上です!ここまで読んでいただきありがとうございました!!!!




...とはいかず、ここで大きな問題が発生しました。

各ステップのコードが記述されている言語をよーく見てください。
・画像から線画に変換→Python
・線画から動きのデータに変換→TypeScript

そう、肝心の線画変換部分がPythonで書かれており、webで動かせないのです。一応、nodejs環境で動作するOpenCVはありましたが、通常のweb環境ではnodejsが使えるわけもなく...
バックエンドをAWSなどでホストすれば楽にPythonが動かせるのですが完全無料!のまま貫きたかったのでその選択肢は選びません。

普段からVercelにデプロイしているため、VercelのServerless Functionsについて調べたところ、なんとバックエンドとしてPythonも使えることがわかりました!早速さっきのコードをAPIとしてデプロイ、これで万事休す、と思いきや...

Vercelの制約のせいでそのままではできないじゃん!!

ログを見ると、

Deploying outputs...
Error: A Serverless Function has exceeded the unzipped maximum size of 250 MB. : https://vercel.link/serverless-function-size
▲ Build system report
• No memory or disk space problems detected
• Folder sizes on disk:
  ‣ Input source code:     1 MB
  ‣ Build cache:          <1 MB
  ‣ Output files:          5 MB
  ‣ Node modules:        354 MB
• 1 files larger than 100 MB detected on disk:
  ‣ 118 MB : node_modules/@next/swc-linux-x64-gnu/next-swc.linux-x64-gnu.node

サーバーレス関数だけでなくnode_modulesの中にも怪しいものがあることがわかりました。
でもこれ自体が原因ではなさそうです...

ここから地獄の作業が始まりました。どうにかしてサイズを削減するため、ドキュメントを読んだりissueを漁ったりして色々な手段を試しました。

・余分なコードを削除する
・opencv以外で代用する
・vercelのconfigを弄る
・stack overflowの怪しい記述を試す
・etc...

しかしどの場合も失敗しました。一旦opencvをimportせずにデプロイしてみたらもちろんデプロイ自体はできますが肝心の機能が動くはずありません。諦めかけたその時、ヘッドレス版OpenCV(GUIがない分容量が小さい)の存在を発見しました。opencv-python-headless==4.8.0.74はだめ、しかし、opencv-contrib-python-headlessを‎requirements.txtに記述したところ...

デプロイできた!!!以上!!!!

というわけで、今回の目標である、タートルグラフィックスでお絵かきをするツールが完成しました!
線画抽出は以下のサイトから試すことができます!!ぜひ遊んでみてください!!
https://turtle-graphics-art.vercel.app/?turtle
(turtleをクエリに付けると亀機能が有効化されます)

今度こそ、今回の記事は以上です!!ここまで読んでいただきありがとうございました!!!!!!

明日の記事はみみさんの「GitHub Copilotのすゝめ」です。自分もよく使っていますが一体どんな使い方をしているんでしょうかね...?

Discussion