【Unity + OpenCV】めんこを読み取った位置にエフェクトを出す　後編

## はじめに
今回は以下の記事の続きとなります！
めんこの中心点が分かったのでその位置にエフェクトを出す作業をまとめていきます！

https://qiita.com/DI_198/private/bbbcd760d9ee41580df2

## 概要
Webカメラ内にカード（めんこ使用）が投げ入れられたとき、リアルタイムで矩形と認識し中心点を特定、その位置にランダムでエフェクトを出すというものを目指します！
![Videotogif (4).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584542/09f4a9fa-f3b2-95a5-08a4-6343955d0d8a.gif)

## 目次
[1.環境](#環境)
[2.スクリーン座標をワールド座標に変換する](#スクリーン座標をワールド座標に変換する)
[3.エフェクトの選定](#エフェクトの選定)
[4.エフェクトの生成と制御](#エフェクトの生成と制御)
[5.参考](#参考)
## 環境
* Windows11
* Unity 2022.3.10f1
・OpenCVforUnity 1.7.1
* Spedal  60fps 1080P HD Webカメラ

## スクリーン座標をワールド座標に変換する
![Videotogif (6).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584542/adf040b1-78cc-2a5e-7532-0877338a0e93.gif)
前の記事で上記のように中心点を特定できたと思いますが
エフェクトをリアルタイムに出すためにワールド座標へ変換しなければなりません
スクリーン座標とワールドワールド座標の関係性については以下の記事を参考にしてみてください

https://shibuya24.info/entry/unity-screen-to-world

:::note info
結論：CameraコンポーネントのScreenToWorldPointを使う
:::
__1.__　中心点の座標をワールド座標へ変換し、エフェクトを制御するスクリプトを作成します 
新たに`ScreenToWorld`というスクリプトを作成し、以下のコードを記入してください
```ScreenToWorld.cs
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class ScreenToWorld : MonoBehaviour
{
    
    [SerializeField] private List<GameObject> effectPrefabs;
    

    public GameObject TransformToScreen(int cx, int cy)
    {
        // ランダムにエフェクトを選択
        GameObject effectPrefab = effectPrefabs[UnityEngine.Random.Range(0, effectPrefabs.Count)];
        // スクリーン座標をVector3に変換
        Vector3 vec = new Vector3(cx, -cy + 440f, 3.3f);
        // ワールド座標に変換
        var worldPos = Camera.main.ScreenToWorldPoint(vec);
        // エフェクトのインスタンスを生成
        GameObject effectInstance = Instantiate(effectPrefab);

        effectInstance.transform.position = worldPos;
        // エフェクトが終了したら削除するコルーチンを開始
        StartCoroutine(DestroyEffectWhenFinished(effectInstance));

        return effectInstance;
    }

    /// エフェクトが終了したらそれを削除する
    private IEnumerator DestroyEffectWhenFinished(GameObject effectInstance)
    {
        ParticleSystem ps = effectInstance.GetComponent<ParticleSystem>();
          // パーティクルシステムがアクティブな間は待機
        while (ps != null && ps.IsAlive(true))
        {
            yield return null;
        }
        Destroy(effectInstance);
    }

}
```
## エフェクトの選定
 続いて空のオブジェクトを作成し`Effect`と名前を変更し`ScreenToWorld`をアタッチしてください
 
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584542/941d8863-8ca6-e80b-56c4-ce2fdc1e9add.png)

＋を押すと格納場所が出てくるのでこちらに好きなエフェクトを入れます
今回はUnity Asset Storeにある無料のエフェクトを使用しました

https://assetstore.unity.com/packages/vfx/particles/cartoon-fx-remaster-free-109565

今回は４つほど入れておきます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584542/2f65c55e-9e2b-8d08-1ba2-2705a31c74ec.png)

## エフェクトの生成と制御
 `CountorFinder`を以下のように書き換えます
```CountorFinder.cs
using OpenCvSharp;
using OpenCvSharp.Demo;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class CountorF : WebCamera
{
    [SerializeField] private FlipMode ImageFlip;  // 画像の反転モード
    [SerializeField] private float Treshold = 96.4f;  // 画像処理の閾値
    [SerializeField] private bool ShowProcessingImage = true;  // 処理後の画像を表示するか
    [SerializeField] private float MinArea = 7000f;  // カードとして認識する領域の最小面積
    [SerializeField] private int ShadeThickness = 5;  // 輪郭線の太さ
    [SerializeField] private ScreenToWorld screenToWorld;  // スクリーン座標をワールド座標に変換するクラス
    [SerializeField] private int EffectArea = 35;

    //[SerializeField] private GameObject objectPrefab;  // エフェクトのプレハブ
    //[SerializeField] private List<GameObject> effectPrefabs; // 複数のエフェクトプレハブを管理するリスト

    private Mat image;  // ウェブカメラからの入力画像
    private Mat processImage = new Mat();  // 画像処理後の画像
    private bool isCardDetected = false;  // カードが検出されたかどうか
    private Point[][] countours;  // 画像から検出される輪郭
    private HierarchyIndex[] hierarchy;  // 輪郭の階層情報
    private List<Point> cardCenters = new List<Point>();  // 現在のフレームで検出されたカードの中心
    private List<ActiveEffect> activeEffects = new List<ActiveEffect>();  // アクティブなエフェクトのリスト
    // ウェブカメラからの画像を処理するメソッド
    protected override bool ProcessTexture(WebCamTexture input, ref Texture2D output)
    {
        // ウェブカメラのテクスチャをMatに変換
        image = OpenCvSharp.Unity.TextureToMat(input);
        // 画像処理開始
        Cv2.Flip(image, image, ImageFlip);  // 画像を反転
        Cv2.CvtColor(image, processImage, ColorConversionCodes.BGR2GRAY);  // グレースケールに変換
        Cv2.Threshold(processImage, processImage, Treshold, 255, ThresholdTypes.BinaryInv);  // 画像の二値化
        Cv2.FindContours(processImage, out countours, out hierarchy, RetrievalModes.Tree, ContourApproximationModes.ApproxSimple, null);  // 輪郭を検出
        cardCenters.Clear();  // 現在のフレームのカードの中心リストをリセット

        // 各輪郭に対してカードかどうか判断
        foreach (Point[] countour in countours)
        {
            if (IsValidCardContour(countour))
            {
                drawContour(processImage, new Scalar(127, 127, 127), ShadeThickness, countour);
                isCardDetected = true;
                Moments m = Cv2.Moments(countour);
                cardCenters.Add(new Point((int)(m.M10 / m.M00), (int)(m.M01 / m.M00)));
            }
        }
        // 新しいカードが検出された場所にエフェクトを生成
        SpawnEffectsForNewCards();
        // 出力画像を更新
        output = output ?? OpenCvSharp.Unity.MatToTexture(ShowProcessingImage ? processImage : image);
        OpenCvSharp.Unity.MatToTexture(ShowProcessingImage ? processImage : image, output);
        return true;
    }
    // 渡された輪郭がカードかどうかを判断するメソッド
    private bool IsValidCardContour(Point[] contour)
    {
        double length = Cv2.ArcLength(contour, true);
        Point[] points = Cv2.ApproxPolyDP(contour, length * 0.01, true);
        var area = Cv2.ContourArea(contour);
        return area > MinArea && points.Length == 4;
    }
    // 新しいカードの位置にエフェクトを生成するメソッド
    private void SpawnEffectsForNewCards()
    {
        foreach (Point center in cardCenters)
        {
            if (!IsEffectAlreadyAt(center) && isCardDetected) 
            {
                GameObject newEffect = screenToWorld.TransformToScreen(center.X, center.Y);
                activeEffects.Add(new ActiveEffect { EffectObject = newEffect, Center = center });
            }
        }
    }
    // 指定された位置に既にエフェクトが存在するかを判断するメソッド
    private bool IsEffectAlreadyAt(Point center)
    {
        foreach (ActiveEffect activeEffect in activeEffects)
        {
            double distance = center.DistanceTo(activeEffect.Center);
            if (distance < EffectArea)
                return true;
        }

        return false;
    }
    // 画像に輪郭を描画するメソッド
    private void drawContour(Mat Image, Scalar Color, int Thickness, Point[] Points)
    {
        for (int i = 1; i < Points.Length; i++)
        {
            Cv2.Line(Image, Points[i - 1], Points[i], Color, Thickness);
        }
        Cv2.Line(Image, Points[Points.Length - 1], Points[0], Color, Thickness);
    }
}
```
__4.__ 新たに`ActiveEffect`というスクリプトを作り以下のコードを記入します
```ActiveEffect.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using OpenCvSharp;

// アクティブなエフェクトに関連する情報を格納するクラス
public class ActiveEffect
{
    public GameObject EffectObject { get; set; }  // エフェクトのオブジェクト

    public Point Center { get; set; }  // エフェクトが発生する中心位置

}
```
エフェクトがでた部分を記憶しその周囲にエフェクトが出ないようにしています

## 結果
 それではWebカメラを固定し実行してみましょう

カードを投げ入れた位置にエフェクトが出ました！
![Videotogif (4).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584542/66b9f53a-b4b4-1184-ebba-cb7a73365d36.gif)

:::note warn
何度もエフェクトが出てしまう場合は`Effect Area`の値を大きくしてみると良いかもしれません！
:::

## まとめ
今回はOpenCVforUnityを使ってリアルタイムでめんこを読み取りエフェクトを出すというのを作ってみました！今回はWebカメラ上でしたが、プレイヤー同士がQuest3などのHMDをかぶって立体的にエフェクトが出たら面白そうだなと思い作成してみました！

## 参考
https://blog.kaorun55.com/entry/2014/10/16/071023

https://www.youtube.com/watch?v=ZV5eejYG6NI

https://qiita.com/takaya901/items/daf777d0dc290b6ead4f