はじめに
ケアプラン作成は、いまだにエクセル使用している施設ケアマネジャーです。
Python で matplotlib を使って3D散布図プロットしていました。
Android で同じようなことができないものかとチャッピーやジェミニに聞いみたら、Plotly.jsをWebViewで動かすのか、自作するかとのこと。今回は、Plotly.js を使いました。
動作イメージ
ぱくたそ様から以下の画像をお借りしました。
実装のポイント
ワールドランドマーク
読み込んだ画像からワールドランドマークを取得します。
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側に渡します。
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 のカメラの位置を調整し元画像と向きを一致させています。
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) に、追加
dependencies {
....
implementation ("com.google.mediapipe:tasks-vision:latest.release")
...
}
plotly_chart.html
plotly-gl3d.min.js を assets に配置するのを忘れずに!!
<!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
<?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
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)
}
}