Closed59

HoloLens2のEdgeからImmersalによる位置合わせのテスト(できるのか?)

目的

HoloLens2のchromium Edgeで実行中のWebARアプリで
Immersalによる(server-side)Localizationを実行し、
スキャンマップとリアル空間の位置合わせを行う

正直できるかわからない、できそうだけど
できるような気はするけどどこかで躓きそうな予感がする、とも書いておく

技術スタック

  • Platform: (Chromium-based)Edge on HoloLens2
  • WebGL: Babylon.js (Babylon.js Editor)
  • Localization: Immersal REST API

Babylon,js Editorに関して、先日のWebXRTechTokyoでLimesさんが発表していらっしゃった
WebXRデバイスでローカルLAN内でデバッグができるプレビュー機能が追加されたんだとか
是非使ってみたいが、今回は必須ではないのであとで使ってみる

Immersalについて、
ImmersalはARCloudにおける空間スキャン、位置合わせ、コンテンツ配置(の一部の機能)
などを提供するサービスである

https://immersal.com/

これを使うことによって、現実の空間をスキャンして、
そのスキャンデータにコンテンツを配置して、
AR/MRアプリでインタラクションする、といったことが可能になる

Immersalの位置合わせは、事前にローカルにDLしたマップデータと
オフラインで行うモードと

サーバにキャプチャ画像を送って位置合わせの結果を取得する
サーバサイドモードがある。
今回はImmersal REST APIを用いて鯖サイドの位置合わせをしたいと考えている

https://immersal.gitbook.io/sdk/cloud-service/rest-api

Immersal REST APIにある通り、鯖サイドで位置合わせを行う場合に鯖にPOSTするデータは

  • token
  • base64エンコードされた画像データ
  • 対象のmapID
  • Camera Intrinsics
    • Focal Length
    • Principal Offset

が必要になる

現状の問題点・懸念点

Camera Intrinsicsについて

Web API(getUserMedia)からはCamera Intrinsicsは取得できないっぽいので、
今回は事前に調べたHoloLens2のRGB CameraのIntrinsicsを使用する必要がある
しかしネット上にはそのような情報(焦点距離すらも)出ていないので、自分でプログラムを組んで調べる必要がある→たぶんこれはできそう

getUserMediaについて

そもそもWebフロントエンドからHoloLensのRGBカメラのキャプチャができるのかわかっていない
そしてWebAR実行中にcameraのストリームを奪い合うことにならないのか?とかもよくわかっていない

ちなみに以下はjsからwebcamのメディアソースをキャプチャして
base64エンコードするサンプル

https://jsfiddle.net/qfaeb3tL/1/

別にどうというわけではないんだけど、こういうの重畳表示させたいよね......させたくない......?

HoloLens2のEdgeからgetUserMedia()を読んでみた結果、普通に前面のRGBカメラ(1270x720)が取得できた
immersive-arでも問題なく動作

コードはこんな感じ。
videoElementはvideo、videoCanvasはcanvas要素です。
ブラウザでやる、streamをvideoに流しておいて、canvasにどれをキャプチャして
やっとそのcanvasからtoDataURLで取り出せるというやつだ

    navigator.mediaDevices
      .getUserMedia({
        audio: false,
        video: true,
      })
      .then((stream) => {
        videoElement.srcObject = stream;
        setTimeout(() => {
          videoCanvas.width = 1270;
          videoCanvas.height = 720;
          button.imageUrl = videoCanvas.toDataURL();
        }, 2000);
      });

    navigator.mediaDevices.enumerateDevices().then((infos) => {
      console.log(infos);
    });

    button.onPointerDownObservable.add(() => {
      videoCanvas.getContext("2d")!.drawImage(videoElement, 0, 0, 1270, 720);
      button.imageUrl = videoCanvas.toDataURL();
    });

明日はHoloLensの前面RGBカメラのIntrinsicsを調べて
その値を直打ちでImmersal REST APIに投げて位置合わせまでしたい

スキャンしたメッシュデータもBabylon.js Sandboxでかっこよく装飾して.babylonファイルに出力して
位置合わせの結果に合わせて動かしたいな

https://sandbox.babylonjs.com/

cameraのIntrinsicsを取得するには、VideoMediaFrame.CameraIntrinscsを取得すれば良さそう

https://docs.microsoft.com/en-us/uwp/api/windows.media.capture.frames.videomediaframe.cameraintrinsics?view=winrt-20348#Windows_Media_Capture_Frames_VideoMediaFrame_CameraIntrinsics

この機能はUWPの機能であるため、Unityで使うためにはifdefマクロでかっ込まなくちゃいけない気がする

こちらのドキュメントが参考になりそう

https://docs.microsoft.com/ja-jp/windows/uwp/audio-video-camera/capture-photos-and-video-with-cameracaptureui

HoloLensのCamera Intrinsicsを調べるためにUnityプロジェクトを作成した
Unity久しぶり過ぎてプロジェクト作るの怖い......MRTKもいつの間にかUPMじゃなくてMR Feature Tool使うのが推奨になってて怖い......。

たるこすさんからアドバイスをいただいたい
自分もこの方法のほうが好きですね......次回はそうしよ......

UnityでWSA固有のメソッドを呼べる環境にするまでちょっと時間がかかったが、
Unityビルドで出力された.slnファイルをVSでビルドした結果、エラーはなさそうになった
今回Windows.Media.Capture名前空間は使いそうなので、それらをusingするために
#ifディレクティブで囲めば良さそう

#if !UNITY_EDITOR && UNITY_WSA
using Windows.Media.Capture.Frames;
using Windows.Devices.Enumeration;
using Windows.Media.Capture;
using Windows.UI.Xaml.Media.Imaging;
using Windows.Media.MediaProperties;
using Windows.Graphics.Imaging;
using System.Threading;
using Windows.UI.Core;
using System.Threading.Tasks;
using Windows.Media.Core;
using System.Diagnostics;
using Windows.Media;
using Windows.Media.Devices;
using Windows.Media.Audio;
#endif

よくわからなかったのでサンプルにあるヤツ全部usingしてみる()
あと、#ifで囲むとUnityで補完が効かなくなるからコーディングしづらい......
ScriptBackendがMonoだった時はVSでコーディングすればよかったんだけど、IL2CPPになってからそれができなのでツライ、なんとかならないかな

あとMRTLでカメラのアクセスを有効にしなくちゃいけないかも

サンプルを見た感じだと、

まずはFrameSourceGroup

var frameSourceGroups = await MediaFrameSourceGroup.FindAllAsync();

var selectedGroupObjects = frameSourceGroups.Select(group =>
   new
   {
       sourceGroup = group,
       colorSourceInfo = group.SourceInfos.FirstOrDefault((sourceInfo) =>
       {
           // On Xbox/Kinect, omit the MediaStreamType and EnclosureLocation tests
           return sourceInfo.MediaStreamType == MediaStreamType.VideoPreview
           && sourceInfo.SourceKind == MediaFrameSourceKind.Color
           && sourceInfo.DeviceInformation?.EnclosureLocation.Panel == Windows.Devices.Enumeration.Panel.Front;
       })

   }).Where(t => t.colorSourceInfo != null)
   .FirstOrDefault();

MediaFrameSourceGroup selectedGroup = selectedGroupObjects?.sourceGroup;
MediaFrameSourceInfo colorSourceInfo = selectedGroupObjects?.colorSourceInfo;

if (selectedGroup == null)
{
    return;
}

で作成し、

var mediaCapture = new MediaCapture();

var settings = new MediaCaptureInitializationSettings()
{
    SourceGroup = selectedGroup,
    SharingMode = MediaCaptureSharingMode.ExclusiveControl,
    MemoryPreference = MediaCaptureMemoryPreference.Cpu,
    StreamingCaptureMode = StreamingCaptureMode.Video
};
try
{
    await mediaCapture.InitializeAsync(settings);
}
catch (Exception ex)
{
    System.Diagnostics.Debug.WriteLine("MediaCapture initialization failed: " + ex.Message);
    return;
}

でMediaCaptureを作成、初期化して、

var colorFrameSource = mediaCapture.FrameSources[colorSourceInfo.Id];
var preferredFormat = colorFrameSource.SupportedFormats.Where(format =>
{
    return format.VideoFormat.Width >= 1080
    && format.Subtype == MediaEncodingSubtypes.Argb32;

}).FirstOrDefault();

if (preferredFormat == null)
{
    // Our desired format is not supported
    return;
}

await colorFrameSource.SetFormatAsync(preferredFormat);

これによってcolorFrameSourceというものを作って
これによってフレームソースの優先度、なるものが設定され、

MediaFrameReader mediaFrameReader= await mediaCapture.CreateFrameReaderAsync(colorFrameSource, MediaEncodingSubtypes.Argb32);
mediaFrameReader.FrameArrived += ColorFrameReader_FrameArrived;
await mediaFrameReader.StartAsync();

によってMediaFrameReaderオブジェクトが作成、初期化され
フレームが取得できた時のコールバック関数が指定される。

コールバック関数は以下の形式になっている。

private void ColorFrameReader_FrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
{
    var mediaFrameReference = sender.TryAcquireLatestFrame();
    var videoMediaFrame = mediaFrameReference?.VideoMediaFrame;
    // ...
}

最後のコールバック関数で無事VideoMediaFrameオブジェクトが取得出来ていれば

videoMediaFrame.CameraIntrinsics;

によってIntrinscsが取得できるはずである

昨日はなぜかHoloLens2アプリの調子がおかしくなってしまったり、
IL2CPPビルドでUWPのIntelliSenseが効かない問題に直面したりで結構沼ってしまったんだけど、
気持ちを切り替えて再挑戦したい

HoloLensのビルドは無事確認
ここで躓いているようだからまだまだですな......
MRTK2.7.0+OpenXRで動いているみたい

現状、まだCameraIntinsicsは取得できず、
以下のコードを書いている

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using TMPro;
using System;

#if !UNITY_EDITOR && UNITY_WSA
using Windows.Media.Capture.Frames;
using Windows.Devices.Enumeration;
using Windows.Media.Capture;
using Windows.UI.Xaml.Media.Imaging;
using Windows.Media.MediaProperties;
using Windows.Graphics.Imaging;
using System.Threading;
using Windows.UI.Core;
using System.Threading.Tasks;
using Windows.Media.Core;
using System.Diagnostics;
using Windows.Media;
using Windows.Media.Devices;
using Windows.Media.Audio;
using System.Linq;
#endif

namespace CameraIntrinsics
{
    public class CameraIntrinsicsProvidor : MonoBehaviour
    {
        [SerializeField]
        private TextMeshPro _logText;

        [SerializeField]
        private TextMeshPro _logText2;

        // Start is called before the first frame update
        void Start()
        {
            if (_logText != null && _logText2 != null)
            {
                _logText.text = "Hello!";

#if !UNITY_EDITOR && UNITY_WSA
                _ = MediaCaptureInitializeAsync();
#endif
            }
        }

#if !UNITY_EDITOR && UNITY_WSA
        private async Task MediaCaptureInitializeAsync()
        {
            var frameSourceGroups = await MediaFrameSourceGroup.FindAllAsync();

            //var selectedGroupObjects = frameSourceGroups.Select(group =>
            //   new
            //   {
            //       sourceGroup = group,
            //       colorSourceInfo = group.SourceInfos.FirstOrDefault((sourceInfo) =>
            //       {
            //           return sourceInfo.MediaStreamType == MediaStreamType.VideoPreview
            //                       && sourceInfo.SourceKind == MediaFrameSourceKind.Color
            //                       && sourceInfo.DeviceInformation?.EnclosureLocation.Panel == Windows.Devices.Enumeration.Panel.Front;
            //       })

            //   }).Where(t => t.colorSourceInfo != null)
            //   .FirstOrDefault();

            MediaFrameSourceGroup selectedGroup = frameSourceGroups.FirstOrDefault();
            MediaFrameSourceInfo colorSourceInfo = selectedGroup?.SourceInfos?.FirstOrDefault();

            if (selectedGroup == null)
            {
                _logText.text = "Selected Group is Null!!";
                return;
            }
            else
            {
                foreach (var source in frameSourceGroups)
                {
                    _logText.text += $"name: {selectedGroup.DisplayName}\n";
                    foreach (var info in selectedGroup.SourceInfos)
                    {
                        _logText.text += $"streamType: {info.MediaStreamType}\n";
                    }
                }
                _logText2.text = frameSourceGroups.Count.ToString();
            }

            // ===========media capture config===============

            var mediaCapture = new MediaCapture();

            var settings = new MediaCaptureInitializationSettings()
            {
                SourceGroup = selectedGroup,
                SharingMode = MediaCaptureSharingMode.ExclusiveControl,
                MemoryPreference = MediaCaptureMemoryPreference.Cpu,
                StreamingCaptureMode = StreamingCaptureMode.Video
            };
            try
            {
                await mediaCapture.InitializeAsync(settings);
            }
            catch (Exception ex)
            {
                _logText.text = "MediaCapture initialization failed: " + ex.Message;
                System.Diagnostics.Debug.WriteLine("MediaCapture initialization failed: " + ex.Message);
                return;
            }
            _logText.text = "Media Capture Init!";

            // ============color frame config ===============

            var colorFrameSource = mediaCapture.FrameSources[colorSourceInfo.Id];
            var preferredFormat = colorFrameSource.SupportedFormats.Where(format =>
            {
                return format.VideoFormat.Width >= 1080
                && format.Subtype == MediaEncodingSubtypes.Argb32;

            });

            if (preferredFormat == null)
            {
                _logText.text = "Our desired format is not supported";
                return;
            }
            else
            {
                _logText.text = "format supported <<OK>>";
                //foreach(var format in preferredFormat)
                //{
                //    _logText.text += $"{format.VideoFormat.Width}:{format.VideoFormat.Height}";
                //}
            }

            await colorFrameSource.SetFormatAsync(preferredFormat.FirstOrDefault());
            _logText.text += "colorFrameSource Seted!";

            // ========media frame reader config =================

            MediaFrameReader mediaFrameReader = await mediaCapture.CreateFrameReaderAsync(colorFrameSource, MediaEncodingSubtypes.Argb32);

            _logText.text = "media frame reader init!";

            mediaFrameReader.FrameArrived += ColorFrameReader_FrameArrived;

            await mediaFrameReader.StartAsync();

        }

        private void ColorFrameReader_FrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
        {
            var mediaFrameReference = sender.TryAcquireLatestFrame();
            var videoMediaFrame = mediaFrameReference?.VideoMediaFrame;

            if (videoMediaFrame != null)
            {
                var width = videoMediaFrame.CameraIntrinsics.ImageWidth;
                var height = videoMediaFrame.CameraIntrinsics.ImageHeight;
                var focal = videoMediaFrame.CameraIntrinsics.FocalLength;
                var principal = videoMediaFrame.CameraIntrinsics.PrincipalPoint;

                _logText.text = $"width: {width}, height: {height}\n";
                _logText.text += $"focal:{focal.X},{focal.Y}\n";
                _logText.text += $"principal: {principal.X}, {principal.Y}";
            }
            else
            {
                _logText.text = "video media frame is NULL";
            }

            sender.StopAsync().AsTask().Wait();
            sender.Dispose();
        }
#endif

    }
}

なぜかformat supported <<OK>>からログテキストの更新がされない
なんだろう
preferred format指定しない方向でやってみようかな

頑張ってみたが、結局ダメだった......
最終手段として前に取り組んだプロジェクトでCameraIntrinsicsをいじったことがあるので
そのコードを参考にさせていただいた
本当は自力でやりたかったんだが......今度挑戦しよう

最終的に取得できたものは以下になった
これを決め打ちで使っていく

Immersal REST APIのreq/res用のinterfaceを定義したり、Hololens固有の値をセットしたりした
久しぶりにTypeScript触れてうれしい

TL;DR とりあえず必要なパラメータを指定してImmersal REST APIにPOSTを送って結果が返ってくる、ところまではできたみたい

以下のようにImmersal REST API用のreq/resのインターフェースを定義。

interface SDKMapId {
  id: number;
}

interface ImmersalLocalizeRequest {
  token: string,
  fx: number;
  fy: number;
  ox: number;
  oy: number;
  b64: string;
  mapIds: SDKMapId[];
}

interface ImmersalLocalizeResponse {
  success: boolean;
  map: number;
  px: number;
  py: number;
  pz: number;
  r00: number;
  r01: number;
  r02: number;
  r10: number;
  r11: number;
  r12: number;
  r20: number;
  r21: number;
  r22: number;
}

requestには使ったけど、responseはまだ使っていないので挙動はワカラン

カメラのIntrinsicsもこんな感じで定数を定義

export default class CameraIntrinsics {
  public static focalLength = {
    x: 1153.877,
    y: 1157.252,
  };

  public static principalOffset = {
    x: 737.4859,
    y: 401.8013,
  };
}

レスポンスを確認するためにログ用のテキストGUIを配置

こんな感じのコードを書いてボタン押下時に
カメラのキャプチャ画像をbase64エンコードしてRequestを作成し、POSTする
ところどころconsoleデバッグとかしててあまりきれいじゃないけど

    button.onPointerDownObservable.add(() => {
      videoCanvas.getContext("2d")!.drawImage(videoElement, 0, 0, 1270, 720);
      const imageURL = videoCanvas.toDataURL();

      button.imageUrl = imageURL;

      const req: ImmersalAPI.ImmersalLocalizeRequest = {
        token: <string>process.env.IMMERSAL_TOKEN,
        fx: CameraIntrinsics.focalLength.x,
        fy: CameraIntrinsics.focalLength.y,
        ox: CameraIntrinsics.principalOffset.x,
        oy: CameraIntrinsics.principalOffset.y,
        b64: imageURL.replace('data:image/png;base64,', ''),
        mapIds: [{ id: Number(<string>process.env.MAP_ID) }],
      };

      console.log(req);

      button.text += "!";

      fetch(ImmersalAPI.immersalLocalizeURL, {
        method: "POST",
        body: JSON.stringify(req),
      })
        .then((res) => res.json())
        .then((data) => {
          logTextBlock.text = JSON.stringify(data, null, "\t");
          console.log(data);
        });
    });

ちなみに、コード内でprocess.envが参照できているのは、
dotenvとvite.configであれこれしている結果である

.env.local
IMMERAL_TOKEN=hoge
MAP_ID=foo

という感じでenvファイルを作成して、vite.configで横流しにしている

vite.config.ts
import { defineConfig, loadEnv } from "vite";
import * as fs from "fs";
import dotenv from "dotenv";

export default ({ command, mode }) => {
  if (mode === "production") {
    // ...
  } else {
    console.log("for local...");

    const envConf = dotenv.config({
      path: "./.env.local",
    });

    const config = defineConfig({
      define: {
        "process.env": { ...envConf.parsed },
      },
      // ...
    });

    return config;
  }
};

viteのプロジェクトでbabylonjsからglbファイルを読み込もうとして苦戦している
viteではビルド時にインポートされるurlが書きかわるため、いろいろ留意する必要があり、
urlを直書きできない

今のところ

import url from '/models/room.glb?url`;
import rawData from 'models/room.glb?raw';

みたいな書き方によってurlだったりrawデータを読み込むことができるが、なんか失敗する

rawデータの場合はbase64エンコードされている必要があるのかな

結局glbを読み込むことはできないままだった
babylonjs sandboxを使って.babylonファイルにしたものを読み込むことにしたら
できた

まぁ最終的にはbabylonファイルを読み込もうと思っていたので、結果オーライ賭しよう

babylonファイルの読み込みにはSceneLoader.AppendAsyncメソッドを使ったのだが、
SceneLoader.ImportMeshAsyncを使ってしまうと、メッシュ情報だけが読み込まれ、
もともとbabylonファイルで定義していたtransofmrや親子関係が崩れてしまい、
ちょっと変なので注意

部屋のメッシュモデルをシーンに配置できたので、これがHoloLensで見れれば
あとは部屋のメッシュを動かせばよいことになる

LocalizationのRequestを送り、Responseが返ってきた時点で得られている主要なデータは以下のとおりである

  • ワールド座標系のカメラのPosition(Req)
  • ワールド座標系のカメラのRotation(Req)
  • マップ座標系のカメラのPosition(Res)
  • マップ座標系のカメラのRotation(Res)

最終的にこれらを用いてワールド座標系におけるマップのPositionとRotationを求める必要がある

ちなみにこれらのデータはリクエスト送信時のものであるため、レスポンス受信時のものと混同したら余裕で破綻する

まずワールド原点に単位回転がかかったマップがあるとする、というか今のところそうなっている
それに対し、マップ座標系におけるカメラの回転の逆回転を作用させ、マップ座標系に対するカメラの位置を引く。
こうすることによってカメラ座標系におけるマップの姿勢データが得られる。
それにたいして、ワールド座標系のカメラの回転を、カメラ座標系でマップに作用させ、
ワールド座標系における位置を加算する。
これによってワールド座標系におけるマップの位置が計算できる。

カメラの姿勢自体は取れている気がするが、
ワールド座標におけるマップ座標が計算できないでいる、

その後の進捗を簡単に報告
難しそうな課題だったので、一回寝かせて再度チャレンジしたところ、

モデルのWorldMatrixをいじって姿勢を調整する方法に切り替えた
具体的にはmesh.getWorldMatrixで取れた行列に、変換行列をかけてやるというものである
これによって、なぜかわからないけど、カメラ座標系におけるマップの姿勢を計算できた
ただワールド座標系への変換で詰まっている

一応動画撮ってみた
カメラ座標系でのマップの相対姿勢ということは、Immersalから帰ってきたデータの逆行列による写像を計算できたことになる、と思う

これをカメラの位置をピボットにして、リクエスト送信時のカメラの回転と位置にずらすことで位置合わせ終了だと思うんだけど
問題なのが、WebXRカメラのrotationが直接取得できないということ(なんで......)
positionは取れる、マジでこれがわからない

ちなWebXR Cameraについてのドキュメント

https://doc.babylonjs.com/divingDeeper/webXR/webXRCamera

apiドキュメント

https://doc.babylonjs.com/typedoc/classes/babylon.webxrcamera

いまは仕方なく別のオブジェクトにそれっぽい回転を計算してやって間接的にQuaternion を取得している
汚い実装だ......

    testCube.onBeforeRenderObservable.add(() => {
      const direction = xr.baseExperience.camera
        .getForwardRay()
        .direction.clone();
      testCube.rotation.x = -direction.y;
      testCube.rotation.y = direction.x;

      logTextBlock.text = `${testCube.rotation}`;
    });

問題なのが、WebXRカメラのrotationが直接取得できないということ(なんで......)
positionは取れる、マジでこれがわからない

この問題が解決しそう。
Babylon.jsにはrotationの他にrotationQuaternionというプロパティがTransformNodeにあって、
カメラには後者が設定されているっポイ
そしてrotationQuaternionが設定されているときはrotationの値を設定しても無視される、みたいな記事を読んだ気がする

なので、WebXR CameraのRotationを知りたいときは

const rotation:Vector3 = xr.baseExperience.camera.rotationQuaternion.toEulaerAngles();

がよさそう。
あと、なんかたまにrotationQuaternionから行列を生成して作用させるとせん断されたみたいになることがあるので、toEulerAngles()を噛ませてやると良いかなと思う。誤差は出るけど

カメラ座標系→ワールド座標系への変換では
カメラ座標系でのマップの姿勢をカメラのワールド位置/回転をワールドスペース適用することによって解決するため、カメラのワールド姿勢が必要だった

そのためカメラのrotationが欲しかったのだが、今考えればactiveCamera.getWorldMatrix()でいいじゃん!となってそうしている

イメージはこんな感じ……?

M_{Localized} = M_{Camera}M_{Immersal}^{-1}M_{Original}

ただなぜか一筋縄ではいかず、結果的に座標変換は以下のようになった

worldMatrix.multiplyToRef(
  m
    .transpose()
    .invert()
    .multiply(transXY)
    // カメラ基準の微調整(せこい)
    .multiply(
      Matrix.Translation(
        0,0,-0.5
      )
    )
    .multiply(
      Matrix.RotationYawPitchRoll(
        Math.PI/30, Math.PI/21, 0
      )
    )
    // カメラ座標系→ワールド座標系への変換
    .multiply(cameraTransformMatrix)
    ,
  worldMatrix
);

マジックナンバーの嵐、見るだけで頭痛がしそう

特に微調整の部分はとても経験的で不安定なので本当はやりたくなかった......
ただ、HoloLensのRGBカメラとみているディスプレイの位置が違うので、そのせいかな?と思って入れている

ただこのパラメータである程度精度が出ている位置合わせが複数回成功している
自分の環境でのみの話なので何とも言えないが

最終的にできたのがこちら
とりあえず動いてよかったなぁ

このスクラップは4ヶ月前にクローズされました
ログインするとコメントできます