はじめに
ケアプラン作成は、いまだにエクセル使用している施設ケアマネジャーです。
今回は画像から立位・座位・臥位を分類してみました。
今回の記事の背景
センサーマットの効果
私が勤務する介護施設では、センサーマットを導入したことで、職員が気づかないうちに利用者が転倒してしまうような事故が減少しました。アラームが鳴るとすぐに訪室できるため、のどが渇いた、トイレに行きたいといった利用者の要望にも迅速に応えることができるようになりました。
職員負担の増加と倫理的配慮のジレンマ
ただし、ほとんどの方が認知症であるため意思が明確でない場合や、「このまま座っていたい」「立っていたい」「歩きたい」など継続的な介護を望むケースがあり、その場での付き添いによる見守りが必要となります。見守りは大切な介護のひとつですが、職員が長時間その場に拘束されることになり、多忙な現場にとっては大きな負担になります。また、新たなナースコールにも対応しなければならず、できる限り付き添い時間を減らしたいという現場の切実な声もあります。
しかし、忙しさから利用者に一方的に「ベッドに戻ってください」と指示してしまうことは、介護の原則である「自己決定の原則(本人の意思の尊重)」に反します。利用者の行動や気持ちの背景に目を向け、「落ち着かない」「誰かと話したい」といった理由をくみ取りながら、その方に合った対応を考えることが大切です。たとえば、「お茶の時間にしませんか」「体操をしましょうか」といった提案を通して、本人の希望に寄り添う姿勢が求められます。
利用者の視点 | 職員の視点 |
---|---|
「このまま座っていたい」「立っていたい」「歩きたい」などの意思 | 安全確保と業務効率の両立が難しい |
私の意思を尊重してもらいたい | 長時間の付き添いは負担になる |
私の意思の背景を理解してもらいたい、共感的対応をしてもらいたい | 限られた人手と時間での対応 |
技術による補完の可能性(MediaPipeなどの姿勢推定技術を活用して、姿勢変化を機械的に検出することで、見守りを代替できる可能性がある)
そこで必要となるのが、利用者の現在の姿勢やその変化を検知する技術です。見守り時に職員が介助の必要性を判断するポイントは、「立ち上がった」「立ち上がろうとした」「歩き出そうとした」といった姿勢の変化・姿勢の変化の兆候です。このような動きの兆候を機器で把握できれば、職員は常時そばで見守る必要はなくなり、他の業務にも対応しやすくなります。転倒事故のリスクをゼロにすることはできませんが、センサーマットと姿勢検知機器を組み合わせることで、転倒リスクを軽減しつつ、必要なタイミングで適切な介助を提供できる環境が整います。このようなことが実現できれば、利用者の尊厳を守りながらも、介護の質と業務効率の両立を図ることができるようになると考えています。
見守りとは:見守りも大切な介護サービスのひとつです。介護が必要な人のそばにいて、安全を確保し、必要な時に適切な介助ができるように、その人の状態や行動を観察することです。
「認知症および軽度認知障害(MCI)の高齢者数と有病率の将来推計」
介護の原則等の解釈の根拠として(介護保険法)
介護の原則等の解釈の根拠として
介護保険制度の理念は、「尊厳の保持」「自立支援」「利用者本位」です。
介護保険法 第一条
(目的)
第一条 この法律は、加齢に伴って生ずる心身の変化に起因する疾病等により要介護状態となり、入浴、排せつ、食事等の介護、機能訓練並びに看護及び療養上の管理その他の医療を要する者等について、これらの者が尊厳を保持し、その有する能力に応じ自立した日常生活を営むことができるよう、必要な保健医療サービス及び福祉サービスに係る給付を行うため、国民の共同連帯の理念に基づき介護保険制度を設け、その行う保険給付等に関して必要な事項を定め、もって国民の保健医療の向上及び福祉の増進を図ることを目的とする。
(介護保険)
第二条 介護保険は、被保険者の要介護状態又は要支援状態(以下「要介護状態等」という。)に関し、必要な保険給付を行うものとする。
2 前項の保険給付は、要介護状態等の軽減又は悪化の防止に資するよう行われるとともに、医療との連携に十分配慮して行われなければならない。
3 第一項の保険給付は、被保険者の心身の状況、その置かれている環境等に応じて、被保険者の選択に基づき、適切な保健医療サービス及び福祉サービスが、多様な事業者又は施設から、総合的かつ効率的に提供されるよう配慮して行われなければならない。
4 第一項の保険給付の内容及び水準は、被保険者が要介護状態となった場合においても、可能な限り、その居宅において、その有する能力に応じ自立した日常生活を営むことができるように配慮されなければならない。
介護施設の職員の動きをイメージする
「介護現場における『並列処理』という視点」
静的な並列処理
介護施設のフロアでは、複数の介護職員がそれぞれの利用者に対して同時に支援を行っています。これは、介護職員をコンピュータのコアに例えてみると、複数のコアが並列処理を行う状態に似ています。ある職員が食事介助を行いながら、他のある職員はトイレ誘導を担当し、さらに別の職員はナースコールに対応しています。これらの業務はお互いに干渉することなく、物理的に同時進行しています。利用者一人ひとりに適切な支援をリアルタイムに提供しています。
動的な並列処理
個々の職員の動きをみると介護現場の並列処理はもっと動的です。A職員がB利用者の食事介助中にナースコールに対応すると、B利用者の介助は一時中断します。このとき、手が空いたC職員がB利用者の食事介助を引き継ぎます。A職員がナースコールの対応を終えて戻ってくると、C職員と申し送りをし、再びB利用者の介助に戻ります。これにより、B利用者の食事介助はわずかな時間中断するだけで、円滑に継続されます。この一連の流れは、個々の職員が担当するタスクを柔軟に入れ替え、現場全体の状況に応じてリアルタイムに役割を再調整していることを示しています。
現場全体の有機的な機能
このように、介護現場の並列処理は、単に複数のタスクを同時にこなすだけでなく、タスクの優先順位付けや動的なリソース(職員)の再配置を伴っています。これは、ただの「業務分担」ではなく現場全体が一つの生きたシステムとして機能しているということです。そしてその根幹にあるのが職員の協調性・観察力・判断力です。こうした環境は、ITシステムでいえば「分散処理」「リアルタイム性」「フォールトトレランス」にも似た特徴を持っていて、高度な知的処理がヒューマンベースで自然に行われていると見ることもできます。
手順
MediaPipe Pose を使い、画像からランドマークを取得します。
取得したランドマークから体位分類のもとになるキーポイントを求めます。キーポイントは、肩、股関節(ヒップ)、膝、足首の左右の中点です。各キーポイントの位置や角度を求め、各特徴量の関係から体位を分類します。
No. | キーポイント | ランドマーク(左) | ランドマーク(左) |
---|---|---|---|
1 | 左右肩の中点 | 11 - left shoulder | 12 - right shoulder |
2 | 左右股関節の中点 | 23 - left hip | 24 - right hip |
3 | 左右膝の中点 | 25 - left knee | 26 - right knee |
4 | 左右足首の中点 | 27 - left ankle | 28 - right ankle |
出展
https://ai.google.dev/static/mediapipe/images/solutions/pose_landmarks_index.png?hl=ja をもとに改編
姿勢分類例
臥位(寝ている) | 座位(座っている) | 立位(立っている) |
---|---|---|
![]() Photo by Rayner Simpson on Unsplash |
![]() Photo by Giovanna Gomes on Unsplash |
![]() Photo by Laura Chouette on Unsplash |
結果
基本的な三つの体位、立位、座位、臥位の3つの体位の分類ができるが課題がある。
課題(わかったこと)
入力画像には要件が必要である。寝ているのに立っていると判定されたり、座っているのに立っていると判定されることがある。
姿勢分類
以下のルールで姿勢を分類しています。
val posture = when {
torsoAngle > 60 && hipAngle > 100 -> "姿勢推定結果:臥位(寝ている)"
kneeAngle < 160 && hipHeightRatio < 0.55 -> "姿勢推定結果:座位(座っている)"
kneeAngle > 160 && torsoAngle < 20 && hipHeightRatio > 0.5 -> "姿勢推定結果:立位(立っている)"
else -> "姿勢推定結果:不明"
}
変数名 | 意味 | 判定 |
---|---|---|
torsoAngle | 垂直方向と胴体の角度傾き | 立っているか横になっているか |
hipAngle | 股関節の角度 | 胴体と足の角度(股関節の角度)座位の深さ |
kneeAngle | 膝関節の角度 | 膝の角度、曲がり具合を見る、小さいと座っている可能性がある |
hipHeightRatio | 垂直方向の肩の高さと股関節の高さの比 | この値が小さいと足が曲がっている |
姿勢 | torsoAngle | hipAngle | kneeAngle | hipHeightRatio | 特徴 |
---|---|---|---|---|---|
立位 | < 20° | > 160° | ≈180° | > 0.5 | 全体が伸びている |
座位 | 10°〜45° | 60°〜120° | 90°〜120° | < 0.5 | 膝が曲がり、胴体がやや傾く |
臥位 | > 60° | > 160° | ≈180° | ≈1.0 | 肩と腰が水平、胴体と垂直方向が90° |
hipHeightRatio について(詳しく)
座っていても体幹を立てていると torsoAngle は小さく立っている状態と似ています。このとき、hipHeightRatio を使えば「立っている(体が縦に伸びている)」か「座っている(体が折れ曲がっている)」かを、高さの比率で区別できる。
状態 | hipHeightRatio |
---|---|
立っている | 肩と股関節の高さ差が大きい(0.5より大きくなる) |
座っている | 股関節の高さが低い(0.5より小さくなる) |
上記のコードではいろいろな実画像で調整して0.55にしています。
キーポイント計算
肩、股関節(ヒップ)、膝、足首の中点を求める
private fun midPoint(a: NormalizedLandmark, b: NormalizedLandmark): PointF {
return PointF((a.x() + b.x()) / 2f, (a.y() + b.y()) / 2f)
}
val leftShoulder = landmarks[11]
val rightShoulder = landmarks[12]
val leftHip = landmarks[23]
val rightHip = landmarks[24]
val leftKnee = landmarks[25]
val rightKnee = landmarks[26]
val leftAnkle = landmarks[27]
val rightAnkle = landmarks[28]
val shoulderMid = midPoint(leftShoulder, rightShoulder)//左右肩の中点
val hipMid = midPoint(leftHip, rightHip)//左右股関節の中点
val kneeMid = midPoint(leftKnee, rightKnee)//左右膝の中点
val ankleMid = midPoint(leftAnkle, rightAnkle)//左右足首の中点
角度を求める
//2点間のベクトルを計算
private fun vectorBetween(from: PointF, to: PointF): PointF {
return PointF(to.x - from.x, to.y - from.y)
}
//2つのベクトル間の角度を求める
private fun angleBetween(v1: PointF, v2: PointF): Float {
val dot = v1.x * v2.x + v1.y * v2.y
val norm1 = sqrt(v1.x * v1.x + v1.y * v1.y)
val norm2 = sqrt(v2.x * v2.x + v2.y * v2.y)
val cosAngle = dot / (norm1 * norm2)
return Math.toDegrees(acos(cosAngle.coerceIn((-1.0).toFloat(), 1.0F)).toDouble()).toFloat()
}
//少数第2位で四捨五入
private fun roundingOff(number: Float): String{
return (Math.round(number * 100) / 100.0).toString()
}
//胴体ベクトルを求める
val torsoVec = vectorBetween(hipMid, shoulderMid)
//垂直方向のベクトル
val verticalVec = PointF(0f, -1f)
//胴体角度を求める
val torsoAngle = angleBetween(torsoVec, verticalVec)
//大腿(ふともも)ベクトル
val thighVec = vectorBetween(hipMid, kneeMid)
//下腿(すね)ベクトル
val shinVec = vectorBetween(ankleMid, kneeMid)
//膝の角度を求める kneeAngle
val kneeAngle = angleBetween(thighVec, shinVec)
//股関節の高さ比(立ち/座りの判定に使用)
//hipHeightRatio は 股関節から足首までの距離を 肩から足首までの距離で割ったものです。
//股関節から足首の距離
val hipToAnkleDist = abs(hipMid.y - ankleMid.y)
//肩から足首の距離
val shoulderToAnkleDist = abs(shoulderMid.y - ankleMid.y)
val hipHeightRatio = hipToAnkleDist / shoulderToAnkleDist
//股関節の角度
val hipAngle = angleBetween(torsoVec, thighVec)
環境
Windos11HOME
Android Studio Ladybug | 2024.2.1 Patch 2
Sony SO-41B
Amazon Fire HD8
コード等
手順
New Project で Empty Views Activity を選択します。適当なプロジェクト名をつけて Finish ボタンを押下します。
ファイル構成

モデル
app直下に assets フォルダを作ります。
下記リンクより、Pose Landmarker(Lite)を、ダウンロードして assets にセットします。
build.gradle.kts(.app)
build.gradle.kts(.app) に、追加
dependencies {
....
implementation ("com.google.mediapipe:tasks-vision:latest.release")
...
}
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">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_marginTop="5dp"
android:text="Button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="500dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/button"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:text="TextView"
android:textSize="18sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt
package yourpackageName
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.SystemClock
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var poseLandmarker: PoseLandmarker
private val imageProcessor = ImageProcessor()
private val poseClassifier = PoseClassifier()
private val poseDrawer = PoseDrawer()
//ファイル選択の開始
private val getContent =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
uri?.let {
processSelectedImage(it)
}
}
//画像のデコードからポーズ検出
private fun processSelectedImage(uri: Uri) {
val source = ImageDecoder.createSource(contentResolver, uri)
val targetWidth = 512
val targetHeight = 512
//読み込んだ画像を512x512にリサイズして、ポーズ推定
CoroutineScope(Dispatchers.IO).launch {
val decodedBitmap = decodeBitmap(source, targetWidth, targetHeight)
val finalBitmap = decodedBitmap.copy(Bitmap.Config.ARGB_8888, true)
val paddedBitmap = imageProcessor.resizeAndPadBitmap(finalBitmap, targetWidth, targetHeight)
// ポーズ推定とUI更新の処理
performPoseDetectionAndUpdateUI(paddedBitmap)
}
}
//ビットマップのデコードと、calculateInSampleSizeを使ったサンプリングサイズの設定
private fun decodeBitmap(source: ImageDecoder.Source, targetWidth: Int, targetHeight: Int): Bitmap {
return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
val sampleSize = imageProcessor.calculateInSampleSize(info.size.width, info.size.height, targetWidth, targetHeight)
decoder.setTargetSampleSize(sampleSize)
}
}
//ポーズ推定と結果表示
private suspend fun performPoseDetectionAndUpdateUI(bitmap: Bitmap) {
val mpImage = BitmapImageBuilder(bitmap).build()
val startTime = SystemClock.uptimeMillis()
//姿勢推定、ランドマーク
val result = poseLandmarker.detect(mpImage)
val inferenceTimeMs = SystemClock.uptimeMillis() - startTime
var pose = ""
if (result.landmarks().isNotEmpty()) {
//ランドマークを使って体位(臥位、座位、立位)推定
pose = poseClassifier.classifyPose(result.landmarks()[0])
}
withContext(Dispatchers.Main) {
updateUIWithDetectionResult(bitmap, result, pose, inferenceTimeMs)
}
}
//ポーズランドマークを使っての結果と推論時間
private fun updateUIWithDetectionResult(
bitmap: Bitmap, result: PoseLandmarkerResult, pose: String, inferenceTimeMs: Long) {
val textView = findViewById<TextView>(R.id.textView)
val imageView = findViewById<ImageView>(R.id.imageView)
if (result.landmarks().isNotEmpty()) {
textView.text = spannableString(pose)
textView.append("\n inferenceTime :" + String.format(Locale.JAPANESE, "%d ms", inferenceTimeMs))
poseDrawer.drawLine(bitmap, result.landmarks()[0])
imageView.setImageBitmap(bitmap)
} else {
textView.text = "人物が検出されませんでした。"
imageView.setImageBitmap(null)
}
}
//指定した文字に色を付ける
private fun spannableString(text: String): SpannableStringBuilder {
val spannable = SpannableStringBuilder(text)
val torso = "torsoAngle"
val hip = "hipAngle"
val knee = "kneeAngle"
val hipHeight = "hipHeightRatio"
// 色を付けたい部分とその色をマッピング
val colorMap = mapOf(
torso to Color.argb(255,34, 200, 34),//Color.GREEN,
hip to Color.BLUE,
knee to Color.MAGENTA,
hipHeight to Color.DKGRAY
)
for ((word, color) in colorMap) {
val start = text.indexOf(word)
if (start >= 0) {
val end = start + word.length
spannable.setSpan(ForegroundColorSpan(color), start, end, Spannable.SPAN_EXCLUSIVE_INCLUSIVE)
}
}
return spannable
}
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
}
//mediapipe pose を初期化
setupPoseLandmarker()
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
getContent.launch(arrayOf("image/*"))
}
}
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)
}
}
ImageProcessor.kt
package yourpackageName
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Rect
import android.graphics.RectF
class ImageProcessor {
//サンプリングサイズの設定
fun calculateInSampleSize(
imageWidth: Int, imageHeight: Int, reqHeight: Int, reqWidth: Int
): Int {
var inSampleSize = 1
if (imageHeight > reqHeight || imageWidth > reqWidth) {
val halfHeight: Int = imageHeight / 2
val halfWidth: Int = imageWidth / 2
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
fun resizeAndPadBitmap(
bitmap: Bitmap, targetWidth: Int, targetHeight: Int, backgroundColor: Int = Color.WHITE
): Bitmap {
val originalWidth = bitmap.width
val originalHeight = bitmap.height
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
val (scaledWidth, scaledHeight) = if (aspectRatio > 1) {
// 横長
val width = targetWidth
val height = (targetWidth / aspectRatio).toInt()
width to height
} else {
// 縦長または正方形
val height = targetHeight
val width = (targetHeight * aspectRatio).toInt()
width to height
}
// 新しい正方形ビットマップ(背景つき)
val outputBitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outputBitmap)
canvas.drawColor(backgroundColor)
// 中央に描画するためのオフセットを計算
val left = ((targetWidth - scaledWidth) / 2).toFloat()
val top = ((targetHeight - scaledHeight) / 2).toFloat()
// スケーリングしながら描画
val srcRect = Rect(0, 0, originalWidth, originalHeight)
val dstRect = RectF(left, top, left + scaledWidth, top + scaledHeight)
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
return outputBitmap
}
}
PoseClassifier.kt
package yourpackageName
import android.graphics.PointF
import com.google.mediapipe.tasks.components.containers.NormalizedLandmark
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.sqrt
class PoseClassifier {
//小数点第三位を四捨五入する(表示用)
fun roundingOff(number: Float): String {
return (Math.round(number * 100) / 100.0).toString()
}
//aとbの中点を求める
fun midPoint(a: NormalizedLandmark, b: NormalizedLandmark): PointF {
return PointF((a.x() + b.x()) / 2f, (a.y() + b.y()) / 2f)
}
//from(ベクトルの始点)からto(ベクトルの終点)へのベクトルを求める
fun vectorBetween(from: PointF, to: PointF): PointF {
return PointF(to.x - from.x, to.y - from.y)
}
//ベクトル間の角度を求める
fun angleBetween(v1: PointF, v2: PointF): Float {
val dot = v1.x * v2.x + v1.y * v2.y
val norm1 = sqrt(v1.x * v1.x + v1.y * v1.y)
val norm2 = sqrt(v2.x * v2.x + v2.y * v2.y)
val cosAngle = dot / (norm1 * norm2)
return Math.toDegrees(acos(cosAngle.coerceIn((-1.0).toFloat(), 1.0F)).toDouble()).toFloat()
}
//体位推定(臥位、座位、立位)
fun classifyPose(landmarks: MutableList<NormalizedLandmark>): String {
val leftShoulder = landmarks[11]
val rightShoulder = landmarks[12]
val leftHip = landmarks[23]
val rightHip = landmarks[24]
val leftKnee = landmarks[25]
val rightKnee = landmarks[26]
val leftAnkle = landmarks[27]
val rightAnkle = landmarks[28]
//ランドマークの中点を求める
val shoulderMid = midPoint(leftShoulder, rightShoulder)
val hipMid = midPoint(leftHip, rightHip)
val kneeMid = midPoint(leftKnee, rightKnee)
val ankleMid = midPoint(leftAnkle, rightAnkle)
//torsoAngle(胴体角度)を求める
//torsoVec(胴体のベクトル) verticalVec(垂直方向のベクトル)
val torsoVec = vectorBetween(hipMid, shoulderMid)
val verticalVec = PointF(0f, -1f)
val torsoAngle = angleBetween(torsoVec, verticalVec)
//kneeAngle(膝の角度を求める)
//thighVec(太ももベクトル) shinVec(すねベクトル)
val thighVec = vectorBetween(hipMid, kneeMid)
val shinVec = vectorBetween(ankleMid, kneeMid)
val kneeAngle = angleBetween(thighVec, shinVec)
//hipAngle(股関節角度)を求める
val hipAngle = angleBetween(torsoVec, thighVec)
//hipHeightRatio(股関節から足首までの距離を 肩から足首までの距離で割った値)を求める
//hipToAnkleDist(股関節から足首までの距離 = ほぼ股下長) shoulderToAnkleDist(肩から足首までの距離)
val hipToAnkleDist = abs(hipMid.y - ankleMid.y)
val shoulderToAnkleDist = abs(shoulderMid.y - ankleMid.y)
val hipHeightRatio = hipToAnkleDist / shoulderToAnkleDist
val messageString =
"torsoAngle = " + roundingOff(torsoAngle) + "\n" +
"hipAngle = " + roundingOff(hipAngle) + "\n" +
"kneeAngle = " + roundingOff(kneeAngle) + "\n" +
"hipHeightRatio = " + roundingOff(hipHeightRatio) + "\n"
val posture = when {
torsoAngle > 60 && hipAngle > 100 -> "姿勢推定結果:臥位(寝ている)"
kneeAngle < 160 && hipHeightRatio < 0.55 -> "姿勢推定結果:座位(座っている)"
kneeAngle > 160 && torsoAngle < 20 && hipHeightRatio > 0.5 -> "姿勢推定結果:立位(立っている)"
else -> "姿勢推定結果:不明"
}
val returnString = messageString + posture
return returnString
}
}
PoseDrawer.kt
package yourpackageName
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.RectF
import com.google.mediapipe.tasks.components.containers.NormalizedLandmark
import kotlin.math.atan2
class PoseDrawer {
//肩、股(腰)、膝、足首の中点を線で結び、それぞれのなす角度を円弧で表示する
fun drawLine(mutableBitmap: Bitmap, landmarks: MutableList<NormalizedLandmark>) {
//画面(canvas)上の座標に変換する
fun toPixelCoordinates(x: Float, y: Float, width: Int, height: Int): PointF {
return PointF(x * width, y * height)
}
fun createPaint(color: Int, strokeWidth: Float = 4f): Paint {
return Paint().apply {
this.color = color
this.strokeWidth = strokeWidth
style = Paint.Style.STROKE
isAntiAlias = true
}
}
//胴体、股関節、膝関節の角度を円弧で描画
fun drawAngleArc(
center: PointF,
from: PointF,
to: PointF,
radius: Float,
canvas: Canvas,
paint: Paint
) {
val rect = RectF(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
)
// from点とto点が、center点を基準としてX軸の正の方向から時計回りに何度離れているかを示す角度
val angle1 = Math.toDegrees(
atan2(
(from.y - center.y).toDouble(),
(from.x - center.x).toDouble()
)
)
val angle2 =
Math.toDegrees(atan2((to.y - center.y).toDouble(), (to.x - center.x).toDouble()))
// 角度を0〜359度の範囲に正規化
var startAngle = (angle1 + 360) % 360
val endAngle = (angle2 + 360) % 360
//endAngle と startAngle の差を計算し、+ 360) % 360 で結果が常に0から359度の範囲に収まるように正規化
//例えば startAngle が300度で endAngle が30度の場合、30 - 300 = -270 となるが、
// (-270 + 360) % 360 = 90 となり、正しく90度の掃引角度が得られる。
var sweep = (endAngle - startAngle + 360) % 360
//小さい方の角度を描画できるように調整(股関節や膝は180度以上になることは稀なので)
//180度を超える場合、反対回りにして小さい角度にする
if (sweep > 180) {
val temp = startAngle
startAngle = endAngle
sweep = (temp - endAngle + 360) % 360
}
// 描画(時計回り)
canvas.drawArc(rect, startAngle.toFloat(), sweep.toFloat(), false, paint)
}
//小数点第三位を四捨五入する(表示用)
fun roundingOff(number: Float): String {
return (Math.round(number * 100) / 100.0).toString()
}
val width = mutableBitmap.width
val height = mutableBitmap.height
val leftShoulder = landmarks[11]
val rightShoulder = landmarks[12]
val leftHip = landmarks[23]
val rightHip = landmarks[24]
val leftKnee = landmarks[25]
val rightKnee = landmarks[26]
val leftAnkle = landmarks[27]
val rightAnkle = landmarks[28]
//キーポイント(左右ランドマークの中点)の画面上のピクセル座標を求める
val shoulderMid = toPixelCoordinates(
(leftShoulder.x() + rightShoulder.x()) / 2,
(leftShoulder.y() + rightShoulder.y()) / 2,
width,
height
)
val hipMid = toPixelCoordinates(
(leftHip.x() + rightHip.x()) / 2,
(leftHip.y() + rightHip.y()) / 2,
width,
height
)
val kneeMid = toPixelCoordinates(
(leftKnee.x() + rightKnee.x()) / 2,
(leftKnee.y() + rightKnee.y()) / 2,
width,
height
)
val ankleMid = toPixelCoordinates(
(leftAnkle.x() + rightAnkle.x()) / 2,
(leftAnkle.y() + rightAnkle.y()) / 2,
width,
height
)
// 胴体の角度を計算するための仮想的な上方向の点(hipMidからY軸方向に100fだけ上に行った点)
val verticalPoint = PointF(hipMid.x, hipMid.y - 100f)
// 股関節の高さの比率を計算 (股関節-足首のY距離) / (肩-足首のY距離)
val shoulderMidToankleMidY = shoulderMid.y - ankleMid.y
val hipMidToankleMidY = hipMid.y - ankleMid.y
val hipHeightRatio = roundingOff(hipMidToankleMidY / shoulderMidToankleMidY)
val canvas = Canvas(mutableBitmap)
val paintLine = createPaint(Color.GREEN, 5f)
val paintTorso = createPaint(Color.RED, 5f)
val paintHip = createPaint(Color.BLUE, 5f)
val paintKnee = createPaint(Color.MAGENTA, 5f)
val paintHipHeightRatio = createPaint(Color.BLUE, 5f)
//肩ー股関節ー膝ー足首の中点を線分で結ぶ
canvas.drawLine(shoulderMid.x, shoulderMid.y, hipMid.x, hipMid.y, paintLine)// 肩-股関節
canvas.drawLine(
hipMid.x,
hipMid.y,
verticalPoint.x,
verticalPoint.y,
paintTorso
)// 股関節-仮想上点(胴体の上部)
canvas.drawLine(hipMid.x, hipMid.y, kneeMid.x, kneeMid.y, paintHip)// 股関節-膝
canvas.drawLine(kneeMid.x, kneeMid.y, ankleMid.x, ankleMid.y, paintKnee)// 膝-足首
canvas.drawLine(30f, shoulderMid.y, 30f, ankleMid.y, paintHipHeightRatio)// 基準となる縦線
canvas.drawLine(30f, shoulderMid.y, 40f, shoulderMid.y, paintHipHeightRatio)// 肩の高さのマーク
canvas.drawLine(30f, hipMid.y, 40f, hipMid.y, paintHipHeightRatio)// 股関節の高さのマーク
canvas.drawLine(30f, ankleMid.y, 40f, ankleMid.y, paintHipHeightRatio)// 足首の高さのマーク
paintHipHeightRatio.textSize = 20f
paintHipHeightRatio.strokeWidth = 1f
canvas.drawText(hipHeightRatio, 40f, hipMid.y, paintHipHeightRatio)
//各角度用の円弧を描画
drawAngleArc(
hipMid,
verticalPoint,
shoulderMid,
80f,
canvas,
paintTorso
) // torsoAngle(胴体の角度)
drawAngleArc(hipMid, shoulderMid, kneeMid, 50f, canvas, paintHip) // hipAngle(股関節の角度)
drawAngleArc(kneeMid, hipMid, ankleMid, 30f, canvas, paintKnee) // kneeAngle(膝関節の角度)
}
}
参考
ベクトルについて