0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Plotly+Android】 Mediapipe Pose のワールドランドマークを3D散布図にプロットしてみた

0
Last updated at Posted at 2026-02-23

はじめに

ケアプラン作成は、いまだにエクセル使用している施設ケアマネジャーです。
Python で matplotlib を使って3D散布図プロットしていました。
Android で同じようなことができないものかとチャッピーやジェミニに聞いみたら、Plotly.jsをWebViewで動かすのか、自作するかとのこと。今回は、Plotly.js を使いました。

動作イメージ

ぱくたそ様から以下の画像をお借りしました。

実装のポイント

ワールドランドマーク

読み込んだ画像からワールドランドマークを取得します。

MainActivity.kt
    val result = withContext(Dispatchers.Default) {
        val mpImage = BitmapImageBuilder(mutableBitmap).build()
        poseLandmarker.detect(mpImage)
    }

Plotly.js の準備

インストール方法
以下のリンクからダウンロード(現在:plotly.js-gl3d-dist-min@3.4.0)します。
Plotly-gl3d.min.js

ダウンロードできたら、プロジェクトの assets フォルダに配置します。

KotlinからJavaScriptへのデータ転送
取得したランドマーク(x, y, z)とラベルをJSON形式の文字列に変換し、evaluateJavascript でWebView側に渡します。

MainActivity.kt
    private fun sendPoseDataToPlotly(result: PoseLandmarkerResult) {
        // 最初の1人分のランドマークを取得
        val landmarks = result.worldLandmarks().firstOrNull() ?: return
        
        val xJson = JSONArray(landmarks.map {it.x()}).toString()
        val yJson = JSONArray(landmarks.map {it.y()}).toString()
        val zJson = JSONArray(landmarks.map {it.z()}).toString()
        val labelsJson = JSONArray(poseNames.mapIndexed { i, name -> "$i: $name" }).toString()

        // JSの関数を実行
        webView.evaluateJavascript("draw3DGraph('$xJson', '$yJson', '$zJson', '$labelsJson')", null)
    }

3Dプロット WebView側での描画(plotly_chart.html)
見やすくするために Plotly のカメラの位置を調整し元画像と向きを一致させています。

plotly_chart.html
    function draw3DChart(xRaw, yRaw, zRaw, labelsRaw) {
        try {
  
            const x = JSON.parse(xRaw);
            const y = JSON.parse(yRaw);
            const z = JSON.parse(zRaw);
            const labels = JSON.parse(labelsRaw);

            ...

            // カメラ(視点)の位置を設定、グラフをどの角度から眺めるかを制御
            // 元画像と向きを合わせるための調整
            const commonLayout = {
                scene: {
                    camera: {
                      up: { x: 0, y: -1, z: 0 }, // 重力方向(Y軸が上なら {0,1,0})、符号も反転
                      eye: { x: 0.0, y: 0.0, z: -2.0 }, // 視点(カメラ位置)、どこから見るか
                      center: { x: 0, y: 0, z: 0 } // 注視点(通常は 0,0,0)、どこを見るか
                    }
                }
            };
            
            // 各トレース(点、線、補助線など)をプロット
            Plotly.newPlot('chart', [tracePoints, traceLines, cubeTrace, axisLines], commonLayout, { displayModeBar: false });
        } catch (err) {}
    }

layout.scene.camera

scatter3d

まとめ

AndroidのWebViewでPlotly.jsを動かすして3Dプロットが可能になりました。

座標の調整: そのままプロットすると向きがバラバラになるため、左右反転やカメラ角度の調整をしました。

視認性の向上: 中心点(腰の中点)からの補助線や、立方体ゲージを追加することで、空間内での姿勢が把握しやすくなりました。

参考

Mediapipe Pose Landmarker:Androidガイド

Plotly 公式:
Plotly JavaScript Library

3D Camera Controls

技術解説:
Plotly について(奥村晴彦先生のサイト)

※JavaScriptでのPlotlyの基本的な使い方が非常に丁寧に解説されており、大変参考にさせていただきました。

コードなど

環境
Windos11HOME
Android Studio Ladybug | 2024.2.1 Patch 2
Sony SO-41B
Amazon Fire HD8

モデル
app直下に assets フォルダを作ります。下記リンクより、Pose Landmarker(Lite)を、ダウンロードして assets に配置します。

build.gradle.kts(.app)

build.gradle.kts(.app) に、追加

build.gradle.kts
dependencies {
....
    implementation ("com.google.mediapipe:tasks-vision:latest.release")
...
}

plotly_chart.html

plotly-gl3d.min.js を assets に配置するのを忘れずに!!

plotly_chart.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>plotly_graph</title>
    <script src="./plotly-gl3d.min.js"></script>
    <style>
        body { margin: 0; background-color: white; overflow: hidden; }
        #chart { width: 100vw; height: 100vh; }
    </style>
</head>
<body>
    <div id="chart"></div>
    <script>
        // X, Y, Z 軸の設定
        const axisTemplate = {
            range: [-1.1, 1.1],
            title: { font: { size: 14 }},
            showgrid: true,
            gridcolor: '#ddd',
            griddash: 'dot',
            tickmode: 'array',
            tickfont: { size: 14, color: 'black', family: 'sans-serif' }, //'monospace' },
            showline: false,
            zeroline: true,
            showticklabels: true,
            showspikes: false
        };

        // 立方体ゲージの補助線
        const cubeTrace = {
            type: 'scatter3d',
            mode: 'lines',
            x: [-1, 1, 1, -1, -1, null, -1, 1, 1, -1, -1, null, -1, -1, null, 1, 1, null, 1, 1, null, -1, -1],
            y: [-1, -1, 1, 1, -1, null, -1, -1, 1, 1, -1, null, -1, -1, null, -1, -1, null, 1, 1, null, 1, 1],
            z: [-1, -1, -1, -1, -1, null, 1, 1, 1, 1, 1, null, -1, 1, null, -1, 1, null, -1, 1, null, -1, 1],
            line: { color: '#666', width: 3 },
            hoverinfo: 'none',
            showlegend: false
        };

        // 赤い中心軸の設定
        const axisLines = {
            type: 'scatter3d',
            mode: 'lines',
            x: [-1, 1, null, 0, 0, null, 0, 0],
            y: [0, 0, null, -1, 1, null, 0, 0],
            z: [0, 0, null, 0, 0, null, -1, 1],
            line: { color: 'red', width: 2 },
            hoverinfo: 'none',
            showlegend: false
        };

        const commonLayout = {
            hoverinfo: 'none',
            hovermode: 'closest',
            dragmode: 'orbit',
            margin: { l: 0, r: 0, b: 0, t: 0 },
            showlegend: false,
            // カメラ(視点)の位置を設定、グラフをどの角度から眺めるかを制御
            scene: {
                camera: {
                    up: { x: 0, y: -1, z: 0 }, // 上方向(Y軸が上なら {0,1,0})、どっちが上か
                    eye: { x: 0.0, y: 0.0, z: -2.0 }, // 視点(カメラ位置)、どこから見るか
                    center: { x: 0, y: 0, z: 0 } // 注視点(通常は 0,0,0)、どこを見るか
                }
                ,
                xaxis:  axisTemplate,
                yaxis:  axisTemplate,
                zaxis:  axisTemplate,
                aspectmode: 'manual',
                aspectratio: { x: 1, y: 1, z: 1 }
            }
        };

        // 初期表示
        window.onload = function() {
            // cubeTrace (立方体ゲージ)と座標軸を表示
            Plotly.newPlot('chart', [cubeTrace, axisLines], commonLayout, { displayModeBar: false });
        };

        // カメラ位置をリセット
        function resetCamera() {
                const update = {
                    'scene.camera': {
                        up: { x: 0.0, y: -1.0, z: 0.0 },
                        eye: { x: 0.0, y: 0.0, z: -2.0 },
                        center: { x: 0.0, y: 0.0, z: 0.0 }
                    }
                };

                Plotly.relayout('chart', update);
        }

        function draw3DChart(xRaw, yRaw, zRaw, labelsRaw) {
            try {
                resetCamera()

                //JSON形式の文字列をデコード
                const x = JSON.parse(xRaw);
                const y = JSON.parse(yRaw);
                const z = JSON.parse(zRaw);
                const labels = JSON.parse(labelsRaw);

                // 関節を結ぶペアを作る
                const connections = [[11,12],[11,13],[13,15],[12,14],[14,16],[11,23],[12,24],[23,24],[23,25],[25,27],[24,26],[26,28]];
                let lineX = [], lineY = [], lineZ = [];
                connections.forEach(pair => {
                    lineX.push(x[pair[0]], x[pair[1]], null);
                    lineY.push(y[pair[0]], y[pair[1]], null);
                    lineZ.push(z[pair[0]], z[pair[1]], null);
                });

                // ランドマークをプロットする
                const tracePoints = {
                    x: x, y: y, z: z,
                    text: labels,
                    mode: 'markers',
                    type: 'scatter3d',
                    marker: { size: 5, color: 'blue' },
                    hoverinfo: 'text+x+y+z',

                    // ホバーのフォント設定
                    hoverlabel: {
                        bgcolor: '#FFF',
                        bordercolor: 'blue',
                        font: {
                            family: 'Arial, sans-serif',
                            size: 20,
                            color: 'black'
                        }
                    }
                };

                // ランドマークを線で結ぶ
                const traceLines = {
                    x: lineX, y: lineY, z: lineZ,
                    mode: 'lines',
                    type: 'scatter3d',
                    line: { width: 5, color: 'green' },
                    hoverinfo: 'none',
                    showlegend: false
                };

                // 更新時はすべて再描画
                Plotly.newPlot('chart', [tracePoints, traceLines, cubeTrace, axisLines], commonLayout, { displayModeBar: false });
            } catch (err) {}
        }
    </script>
</body>
</html>

activity_main.xml

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/guideline"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.66" />

    <WebView
        android:id="@+id/webView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/guideline"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" >
    </WebView>

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="12dp"
        android:layout_marginEnd="8dp"
        app:layout_constraintHorizontal_weight="1"
        app:layout_constraintBottom_toTopOf="@+id/button"
        app:layout_constraintEnd_toStartOf="@+id/textView"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/webView"
        tools:srcCompat="@tools:sample/avatars" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="12dp"
        android:layout_marginEnd="16dp"
        android:text="TextView"
        android:textSize="12sp"
        android:scrollbars="vertical"
        app:layout_constraintHorizontal_weight="2"
        app:layout_constraintBottom_toBottomOf="@+id/imageView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/imageView"
        app:layout_constraintTop_toTopOf="@+id/imageView" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginBottom="16dp"
        android:text="button"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.kt

MainActivity.kt
package yourpackageName

import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.webkit.WebView
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.google.mediapipe.framework.image.BitmapImageBuilder
import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarker
import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarkerResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.util.Locale

class MainActivity : AppCompatActivity() {

    private lateinit var webView: WebView
    private lateinit var poseLandmarker: PoseLandmarker

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

        webView = findViewById(R.id.webView)
        webView.settings.apply {
            javaScriptEnabled = true
            allowFileAccess = false 
            allowContentAccess = false
        }

        webView.loadUrl("file:///android_asset/plotly_chart.html")

        setupPoseLandmarker()

        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            pickMedia.launch(
                PickVisualMediaRequest(
                    ActivityResultContracts.PickVisualMedia.ImageOnly
                )
            )
        }

        val textView = findViewById<TextView>(R.id.textView)
        textView.movementMethod = ScrollingMovementMethod()
    }

    private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
        if (uri != null) {
            processSelectedImage(uri)
        } else {
            findViewById<TextView>(R.id.textView).text = "画像が選択されませんでした"
        }
    }

    private fun processSelectedImage(uri: Uri) {
        lifecycleScope.launch {
            try {
                val mutableBitmap = withContext(Dispatchers.IO) {
                    val source = ImageDecoder.createSource(contentResolver, uri)
                    val bitmap = ImageDecoder.decodeBitmap(source)
                    bitmap.copy(Bitmap.Config.ARGB_8888, true)
                }

                findViewById<ImageView>(R.id.imageView).setImageBitmap(mutableBitmap)

                val result = withContext(Dispatchers.Default) {
                    val mpImage = BitmapImageBuilder(mutableBitmap).build()
                    poseLandmarker.detect(mpImage)
                }

                if (result.worldLandmarks().isNotEmpty()) {
                    //
                    sendPoseDataToPlotly(result)
                    //ワールドランドマークをテキストビューに表示する
                    worldLandMarkToTextView(result)

                } else {
                    findViewById<TextView>(R.id.textView).text = "人物が検出されませんでした。"
                }

            } catch (e: Exception) {
                e.printStackTrace()
                findViewById<TextView>(R.id.textView).text = "エラーが発生しました。"
            }
        }
    }

    private val poseNames = listOf(
        "鼻", "左目(内側)", "左目", "左目(外側)", "右目(内側)", "右目", "右目(外側)", "左耳", "右耳", "口角(左)",
        "口角(右)", "左肩", "右肩", "左肘", "右肘", "左手首", "右手首", "左小指", "右小指", "左人差指",
        "右人差指", "左親指", "右親指", "左腰", "右腰", "左膝", "右膝", "左足首", "右足首", "左かかと",
        "右かかと", "左足先", "右足先"
    )

    private fun worldLandMarkToTextView(result: PoseLandmarkerResult) {
        val worldLandmarks = result.worldLandmarks().firstOrNull() ?: return
        val builder = StringBuilder("--- World Landmarks (meters) ---\n")

        worldLandmarks.forEachIndexed {index, landmark ->
            val name = poseNames.getOrNull(index) ?: "UNKNOWN"
            builder.append(
                String.format(
                    Locale.US,
                    "ID %2d [%-8s]: x=%.2f, y=%.2f, z=%.2f\n",
                    index, name, landmark.x(), landmark.y(), landmark.z()
                )
            )
        }
        findViewById<TextView>(R.id.textView).text = builder.toString()
    }

    private fun sendPoseDataToPlotly(result: PoseLandmarkerResult) {
        // 最初の1人分のランドマークを取得
        val landmarks = result.worldLandmarks().firstOrNull() ?: return
        val xJson = JSONArray(landmarks.map {it.x()}).toString()
        val yJson = JSONArray(landmarks.map {it.y()}).toString()
        val zJson = JSONArray(landmarks.map {it.z()}).toString()
        val labelsJson = JSONArray(poseNames.mapIndexed { i, name -> "$i: $name" }).toString()
        webView.evaluateJavascript("draw3DChart('$xJson', '$yJson', '$zJson', '$labelsJson')", null)
    }

    private fun setupPoseLandmarker() {
        val baseOptionBuilder = BaseOptions.builder()
        baseOptionBuilder.setModelAssetPath("pose_landmarker_lite.task")
        val baseOptions = baseOptionBuilder.build()
        val options = PoseLandmarker.PoseLandmarkerOptions.builder()
            .setBaseOptions(baseOptions)
            .setRunningMode(RunningMode.IMAGE)
            .build()

        poseLandmarker = PoseLandmarker.createFromOptions(this, options)
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?