PythonでOpenCVを使い、スキャンしたArUcoマーカー付き画像の自動処理をするアプリをつくった

blog

背景:活動の中で生まれたニーズ

とぅいんくるん♪ との活動の中で、子どもたちが描いた絵をスキャンし、映像を伴うミュージックシアターへすぐに登場させる仕組みを運用しています。
(映像は、OpenFrameworksを使って制作したインタラクティブな映像コンテンツです。)

この仕組みの中では、絵をスキャンした後に以下の処理が必要でした。
※絵は、クレヨンや色鉛筆で描かれています。

  • コントラストや明るさを適切に調整
  • 閉じられている領域以外を透過
  • PNG形式で書き出し
  • イラスト用紙の枠線内のみを正確にトリミング

これらを効率的に行う必要がありました。


これまでの処理フローと課題

当初は以下のような方法をとっていました。

  1. スキャナ:ScanSnap iX1600(いわゆる高速ドキュメントスキャナ)に手差しでスキャン
  2. 補正処理:XnConvertを使って明るさ・コントラスト補正、トリミング、サイズ変更
  3. 透過処理:AI生成の独自JavaScriptアプリで自動透過

課題点

  • クレヨンの特性:クレヨンで描かれた場合、ラミネートフィルムで挟んでスキャンしないとスキャナが汚れる
    ※ラミネートフィルムは使い捨てになってしまう。挟む時間もかかる。
  • 二度手間:複数アプリを経由必要していて効率が悪い
  • トリミングの精度:スキャン位置のずれによって、自動トリミングの際にイラスト用紙の枠線が残ってしまうことがある。トリミングを範囲を狭くすると、イラストが切れてしまう。

解決策:ハードとソフトの両面から改善

この課題を解決するために、次の2つのアプローチを取りました。

  1. ハードウェア改善
     非接触スキャナ ScanSnap SV600 を導入 → フィルム不要に!
  2. ソフトウェア改善
     Python + OpenCVで完全自動処理アプリを開発

※参考 ScanSnap SV600の活用動画


OpenCVとArUcoマーカーによる自動処理

非接触スキャナは便利ですが、傾きや枠のズレが生じやすい課題があります。
そこで採用したのが ArUco(アルコ)マーカー です。

こちらがArUcoマーカー を使ったイラスト用紙の例。四隅に、QRコードのような2次元コードを配置しています。

実装した処理

Python(OpenCV)で、以下の自動処理を実装しました。

  • ArUcoマーカーを検出 → 4点を基準に傾き補正
  • マーカー内で正確にトリミング
  • 閉じられていない領域を透過処理
  • PNGファイルとして自動書き出し

スキャナとカメラでの違い

実はArUcoマーカーを使えば、スマホカメラで撮った写真を使用しても、悪くないクオリティでの自動処理が可能です。
しかし、高指向性LEDでのライン照射スキャンを行うScanSnap SV600 には次の強みがあります。

  • 会場の照明(色温度、明るさ)に影響されにくい
  • 手や影が写り込まない


最近は、上部に書画カメラのようなものが付いているタイプの安価な非接触スキャナが増えてきているのですが、そちらとは違い、会場の照明(色温度、明るさ)に影響されにくいことはかなり大きなメリットです!


ワークフローの自動化

さらに、SV600でスキャンが完了したタイミングでPythonスクリプトが自動起動する仕組みを構築。
結果、スキャナについている、スキャンボタンを押す以外の操作は全く不要になりました。
SV600側では、スキャン完了後に何の画面や通知も出さず、指定したフォルダにjpg画像として出力するよう設定。Macに標準搭載されているAutomatorを活用し、その指定したフォルダにファイルが追加されたら、Pythonスクリプト(pyファイル)を自動実行するように設定しました。

  • スキャン → 自動補正・透過 → PNG出力
    この一連の流れがスキャンボタンを押すだけで完了します。

実際の出力例

実際のワークフローでは、

  • スキャンしたイラストが
  • このように背景透過処理済みPNGとして出力されます!


まとめ:PythonとAIで「ちょっと便利」をつくる

今回のアプリは GitHub Copilot を活用し、AIコーディングで大部分を仕上げました。
Pythonは、こうした「ちょっとした自動化アプリ」を素早く作るのに本当に便利だと感じます。

今後も、現場の課題を解決する小さな仕組みをどんどんつくっていきたいです!

実際のコード(参考までに)

import cv2
import numpy as np
from PIL import Image
import os

# === ArUco関連の設定 ===
aruco_dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50)
parameters = cv2.aruco.DetectorParameters()
detector = cv2.aruco.ArucoDetector(aruco_dict, parameters)

class ImageProcessor:
    def __init__(self):
        self.scan_dir = "/****/****/****/scan"
        self.output_dir = "/****/****/****/images"

    def process_images(self):
        for i in range(100):
            scan_filename = f"scan{i:02d}.jpg"
            output_filename = f"star{i:02d}.png"
            
            scan_path = os.path.join(self.scan_dir, scan_filename)
            output_path = os.path.join(self.output_dir, output_filename)

            if not os.path.exists(scan_path):
                continue

            if os.path.exists(output_path):
                print(f"スキップ: {output_filename} は既に存在します")
                continue

            try:
                self.process_single_image(scan_path, output_filename)
                print(f"処理完了: {scan_filename} -> {output_filename}")
            except Exception as e:
                print(f"エラー: {scan_filename} の処理に失敗しました - {str(e)}")

    def process_single_image(self, input_path, output_filename):
        try:
            # 画像処理のコア部分
            img = cv2.imread(input_path)
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            corners, ids, _ = detector.detectMarkers(gray)

            if ids is None or len(ids) < 4:
                raise ValueError("4つのArUcoマーカーが検出できません")

            ids = ids.flatten()
            sorted_corners = [None]*4
            for corner, id in zip(corners, ids):
                sorted_corners[id] = corner[0]

            width, height = 400, 400
            dst_points = np.array([
                [0, 0],
                [width - 1, 0],
                [0, height - 1],
                [width - 1, height - 1]
            ], dtype=np.float32)

            # 各マーカーの四隅
            corner0 = sorted_corners[0]
            corner1 = sorted_corners[1]
            corner2 = sorted_corners[2]
            corner3 = sorted_corners[3]

            # 中心点(重心)を計算
            def get_center(corner):
                return np.mean(corner, axis=0)

            # 四隅の1点から、中心点方向に向かって少し内側へオフセット
            # ratio=-0.1だと枠線が入りやすいので少し大きめに
            def inset_to_center(corner, idx, ratio=-0.2):
                pt = corner[idx]
                center = get_center(corner)
                return pt + (center - pt) * ratio

            # マーカーの内側の点を使用してトリミング領域を設定
            src_points = np.array([
                inset_to_center(sorted_corners[0], 2),  # 左上マーカーの右下
                inset_to_center(sorted_corners[1], 3),  # 右上マーカーの左下
                inset_to_center(sorted_corners[2], 1),  # 左下マーカーの右上
                inset_to_center(sorted_corners[3], 0),  # 右下マーカーの左上
            ], dtype=np.float32)


            M = cv2.getPerspectiveTransform(src_points, dst_points)
            warped = cv2.warpPerspective(img, M, (width, height))

            # トリミング後の画像で透過処理
            gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
            _, thresh = cv2.threshold(gray, 240, 255, cv2.THRESH_BINARY)

            h, w = thresh.shape[:2]
            floodfilled = thresh.copy()
            mask = np.zeros((h + 2, w + 2), np.uint8)

            # 端から20ピクセル内側の点からもfloodFillを開始
            inset = 20
            flood_points = [
                (0, 0), (w-1, 0), (0, h-1), (w-1, h-1),  # 四隅
                (inset, inset), (w-1-inset, inset),      # 内側の点
                (inset, h-1-inset), (w-1-inset, h-1-inset)
            ]
            
            for pt in flood_points:
                cv2.floodFill(floodfilled, mask, pt, 0, loDiff=10, upDiff=10)

            # 透明化マスク: 元画像が白(245以上)で、かつ塗りつぶされた部分のみ透明化
            white_mask = gray > 245
            transparent_mask = white_mask & (floodfilled == 0)

            # BGR→RGBA変換
            warped_rgba = cv2.cvtColor(warped, cv2.COLOR_BGR2RGBA)
            warped_rgba[..., 3] = np.where(transparent_mask, 0, 255).astype(np.uint8)

            result_img = Image.fromarray(warped_rgba)

            # 保存処理
            save_path = os.path.join(self.output_dir, output_filename)
            result_img.save(save_path)

        except Exception as e:
            raise


# === コマンドライン実行 ===
if __name__ == "__main__":
    processor = ImageProcessor()
    processor.process_images()
タイトルとURLをコピーしました