main
田子悦 2 weeks ago
parent ccedbc10df
commit 41c896ed7d

@ -0,0 +1,2 @@
#Thu Jun 05 00:28:15 CST 2025
java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home

Binary file not shown.

@ -51,6 +51,10 @@ dependencies {
implementation 'org.tensorflow:tensorflow-lite-gpu:2.5.0'
implementation 'org.tensorflow:tensorflow-lite-support:0.3.0'
// Glide
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
// Room
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"

@ -3,6 +3,7 @@
package="org.tensorflow.lite.examples.poseestimation">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
@ -39,6 +40,18 @@
android:exported="false" />
<activity android:name=".ExerciseDetailActivity" />
<activity android:name=".EditProfileActivity"/>
<activity android:name=".VideoAnalysisActivity"/>
<activity android:name=".VideoAnalysisResultActivity"/>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

@ -18,6 +18,7 @@ class AgeSelectionActivity : AppCompatActivity() {
private lateinit var nextButton: MaterialButton
private lateinit var backButton: ImageButton
private var selectedGender: String? = null
private var username: String? = null
private var currentAge = 25
private val minAge = 12
@ -31,6 +32,7 @@ class AgeSelectionActivity : AppCompatActivity() {
setContentView(R.layout.activity_age_selection)
selectedGender = intent.getStringExtra("selected_gender")
username = intent.getStringExtra("username")
selectedAgeText = findViewById(R.id.selectedAgeText)
age1Above = findViewById(R.id.age1Above)
@ -82,6 +84,7 @@ class AgeSelectionActivity : AppCompatActivity() {
val intent = Intent(this, WeightSelectionActivity::class.java)
intent.putExtra("selected_gender", selectedGender)
intent.putExtra("selected_age", currentAge)
intent.putExtra("username", username)
startActivity(intent)
finish()
}

@ -1,17 +1,77 @@
package org.tensorflow.lite.examples.poseestimation
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import org.tensorflow.lite.examples.poseestimation.R
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tensorflow.lite.examples.poseestimation.data.AppDatabase
class DataFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: VideoAnalysisAdapter
private var currentUsername: String? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
android.util.Log.d("DataFragment", "onCreateView called")
return inflater.inflate(R.layout.fragment_data, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
android.util.Log.d("DataFragment", "onViewCreated called")
// 获取当前登录用户名
val prefs = requireContext().getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
currentUsername = prefs.getString("username", null)?.trim()
android.util.Log.d("DataFragment", "Current Username: $currentUsername")
if (currentUsername == null) {
Toast.makeText(requireContext(), "未登录,无法查看数据", Toast.LENGTH_SHORT).show()
android.util.Log.w("DataFragment", "Current username is null, cannot load data.")
return
}
recyclerView = view.findViewById(R.id.recycler_view_video_analysis_results)
recyclerView.layoutManager = LinearLayoutManager(context)
loadVideoAnalysisResults()
}
private fun loadVideoAnalysisResults() {
android.util.Log.d("DataFragment", "loadVideoAnalysisResults called for username: $currentUsername")
currentUsername?.let { username ->
lifecycleScope.launch {
val db = AppDatabase.getDatabase(requireContext())
val results = withContext(Dispatchers.IO) {
db.videoAnalysisResultDao().getVideoAnalysisResultsByUsername(username).firstOrNull()
}
withContext(Dispatchers.Main) {
if (results != null && results.isNotEmpty()) {
android.util.Log.d("DataFragment", "Loaded ${results.size} video analysis results.")
adapter = VideoAnalysisAdapter(results)
recyclerView.adapter = adapter
} else {
// 没有数据,可以显示一个提示
Toast.makeText(requireContext(), "暂无训练记录", Toast.LENGTH_SHORT).show()
android.util.Log.d("DataFragment", "No video analysis results found for username: $username")
}
}
}
}
}
}

@ -3,6 +3,7 @@ package org.tensorflow.lite.examples.poseestimation
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
@ -11,12 +12,15 @@ import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tensorflow.lite.examples.poseestimation.data.AppDatabase
import org.tensorflow.lite.examples.poseestimation.data.UserProfile
class EditProfileActivity : AppCompatActivity() {
@ -27,8 +31,24 @@ class EditProfileActivity : AppCompatActivity() {
private lateinit var btnSave: com.google.android.material.button.MaterialButton
private lateinit var btnBack: ImageView
// 新增的个人信息输入框
private lateinit var editGender: EditText
private lateinit var editAge: EditText
private lateinit var editWeight: EditText
private lateinit var editHeight: EditText
private var currentUsername: String? = null
private var selectedImageUri: Uri? = null
private var selectedImageUri: Uri? = null // 存储复制到内部存储后的URI
// 用于请求READ_EXTERNAL_STORAGE权限
private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
isGranted: Boolean ->
if (isGranted) {
openGallery()
} else {
Toast.makeText(this, "需要读取存储权限才能选择头像", Toast.LENGTH_SHORT).show()
}
}
// 用于启动相册选择图片并获取结果
private val selectPhotoLauncher =
@ -38,9 +58,15 @@ class EditProfileActivity : AppCompatActivity() {
imageUri?.let {
// 在ImageView中显示选中的图片
imageAvatar.setImageURI(it)
selectedImageUri = it
// TODO: 在这里添加保存头像图片的逻辑例如保存URI或将图片复制到内部存储
// TODO: 需要修改User或UserProfile表结构来存储头像路径
// 将图片复制到内部存储
lifecycleScope.launch(Dispatchers.IO) {
val internalUri = copyImageToInternalStorage(it)
withContext(Dispatchers.Main) {
selectedImageUri = internalUri
android.util.Log.d("EditProfileActivity", "Image copied to internal storage: $internalUri")
}
}
}
}
}
@ -68,12 +94,18 @@ class EditProfileActivity : AppCompatActivity() {
btnSave = findViewById(R.id.btn_save)
btnBack = findViewById(R.id.btn_back)
// 绑定新增的个人信息输入框
editGender = findViewById(R.id.edit_gender)
editAge = findViewById(R.id.edit_age)
editWeight = findViewById(R.id.edit_weight)
editHeight = findViewById(R.id.edit_height)
// 加载并显示当前用户信息
loadUserProfile()
// 设置选择头像按钮的点击事件
btnSelectPhoto.setOnClickListener {
openGallery()
checkPermissionAndOpenGallery()
}
// 设置保存按钮的点击事件
@ -94,16 +126,37 @@ class EditProfileActivity : AppCompatActivity() {
val db = AppDatabase.getDatabase(this@EditProfileActivity)
// 使用getUserByUsername查询User对象其中包含signature字段
val user = db.userDao().getUserByUsername(username).first()
// 查询UserProfile数据
val userProfile = db.userProfileDao().getUserProfileByUsername(username).firstOrNull()
withContext(Dispatchers.Main) {
if (user != null) {
editUsername.setText(user.username)
editSignature.setText(user.signature)
// TODO: 添加加载用户头像的逻辑(如果已保存)
// TODO: 需要从数据库或SharedPreferences获取头像路径并加载到imageAvatar
// 加载用户头像的逻辑
userProfile?.avatarUri?.let { uriString ->
try {
val imageUri = Uri.parse(uriString)
imageAvatar.setImageURI(imageUri)
selectedImageUri = imageUri // 确保 selectedImageUri 也更新以防再次保存
} catch (e: Exception) {
android.util.Log.e("EditProfileActivity", "Error parsing avatar URI: $uriString", e)
}
}
} else {
// 用户不存在,这通常不应该发生如果已经登录
Toast.makeText(this@EditProfileActivity, "加载用户信息失败", Toast.LENGTH_SHORT).show()
}
// 显示UserProfile数据
if (userProfile != null) {
editGender.setText(userProfile.gender)
// 对于 Int 类型,需要转换为 String
if (userProfile.age != 0) editAge.setText(userProfile.age.toString())
if (userProfile.weight != 0) editWeight.setText(userProfile.weight.toString())
if (userProfile.height != 0) editHeight.setText(userProfile.height.toString())
}
}
}
}
@ -115,25 +168,83 @@ class EditProfileActivity : AppCompatActivity() {
selectPhotoLauncher.launch(galleryIntent)
}
// 检查权限并打开相册
private fun checkPermissionAndOpenGallery() {
when {
ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED -> {
// 权限已授予
openGallery()
}
shouldShowRequestPermissionRationale(android.Manifest.permission.READ_EXTERNAL_STORAGE) -> {
// 向用户解释为什么需要这个权限,然后再次请求
Toast.makeText(this, "需要读取存储权限来选择您的头像", Toast.LENGTH_LONG).show()
requestPermissionLauncher.launch(android.Manifest.permission.READ_EXTERNAL_STORAGE)
}
else -> {
// 请求权限
requestPermissionLauncher.launch(android.Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
// 将图片复制到应用内部存储并返回新URI
private fun copyImageToInternalStorage(uri: Uri): Uri? {
return try {
val inputStream = contentResolver.openInputStream(uri)
val outputFileName = "avatar_${System.currentTimeMillis()}.jpg"
val outputFile = java.io.File(filesDir, outputFileName)
val outputStream = outputFile.outputStream()
inputStream?.copyTo(outputStream)
inputStream?.close()
outputStream.close()
android.util.Log.d("EditProfileActivity", "Image copied to: ${outputFile.absolutePath}")
Uri.fromFile(outputFile)
} catch (e: Exception) {
android.util.Log.e("EditProfileActivity", "Error copying image to internal storage: ${e.message}", e)
null
}
}
// 保存修改后的个人资料
private fun saveProfile() {
currentUsername?.let { username ->
val newSignature = editSignature.text.toString().trim()
// 用户名暂时不可编辑,所以只更新个性签名
// 获取个人信息输入框的值
val newGender = editGender.text.toString().trim()
val newAge = editAge.text.toString().trim().toIntOrNull() ?: 0
val newWeight = editWeight.text.toString().trim().toIntOrNull() ?: 0
val newHeight = editHeight.text.toString().trim().toIntOrNull() ?: 0
// 添加日志输出,确认获取到的数据和当前用户名
android.util.Log.d("EditProfileActivity", "Current Username: $username")
android.util.Log.d("EditProfileActivity", "New Signature: $newSignature")
android.util.Log.d("EditProfileActivity", "New Gender: $newGender, Age: $newAge, Weight: $newWeight, Height: $newHeight")
android.util.Log.d("EditProfileActivity", "Selected Image URI before save: $selectedImageUri")
lifecycleScope.launch(Dispatchers.IO) {
val db = AppDatabase.getDatabase(this@EditProfileActivity)
// 调用UserDao中的updateSignature方法更新个性签名
db.userDao().updateSignature(username, newSignature)
// 保存头像URI到数据库
db.userDao().updateAvatarUri(username, selectedImageUri?.toString())
// 更新UserProfile表中的数据包括头像URI
val updatedProfile = UserProfile(username, newGender, newAge, newWeight, newHeight, selectedImageUri?.toString())
android.util.Log.d("EditProfileActivity", "Attempting to update UserProfile: $updatedProfile")
val rowsUpdated = db.userProfileDao().updateUserProfile(updatedProfile)
android.util.Log.d("EditProfileActivity", "UserProfile rows updated: $rowsUpdated for username: $username") // 记录更新的行数
withContext(Dispatchers.Main) {
if (rowsUpdated > 0) {
Toast.makeText(this@EditProfileActivity, "资料保存成功", Toast.LENGTH_SHORT).show()
// 设置Result为RESULT_OK通知SettingFragment数据已更新 (可选取决于SettingFragment是否需要知道保存成功)
setResult(Activity.RESULT_OK)
finish() // 保存成功后结束当前Activity返回Setting页面
finish()
} else {
Toast.makeText(this@EditProfileActivity, "资料保存失败:未找到对应用户档案", Toast.LENGTH_LONG).show()
}
}
}
}

@ -61,7 +61,7 @@ class ExerciseDetailActivity : AppCompatActivity() {
// 设置开始训练按钮点击事件,跳转到姿态识别页面
startTrainingButton.setOnClickListener {
val intent = Intent(this, MainActivity::class.java)
val intent = Intent(this, VideoAnalysisActivity::class.java)
// 传递当前动作名称给 MainActivity
intent.putExtra("current_exercise", exerciseNameFromIntent)
startActivity(intent)

@ -14,11 +14,14 @@ class GenderSelectionActivity : AppCompatActivity() {
private lateinit var femaleText: TextView
private lateinit var nextButton: MaterialButton
private var selectedGender: String? = null
private var username: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_gender_selection)
username = intent.getStringExtra("username")
maleButton = findViewById(R.id.maleButton)
femaleButton = findViewById(R.id.femaleButton)
maleText = findViewById(R.id.maleText)
@ -38,9 +41,9 @@ class GenderSelectionActivity : AppCompatActivity() {
}
nextButton.setOnClickListener {
// 跳转到年龄选择页面,并传递性别信息
val intent = Intent(this, AgeSelectionActivity::class.java)
intent.putExtra("selected_gender", selectedGender)
intent.putExtra("username", username)
startActivity(intent)
}
}

@ -43,7 +43,9 @@ class HeightSelectionActivity : AppCompatActivity() {
selectedGender = intent.getStringExtra("selected_gender")
selectedAge = intent.getIntExtra("selected_age", 0)
selectedWeight = intent.getIntExtra("selected_weight", 0)
username = intent.getStringExtra("username")
username = intent.getStringExtra("username")?.trim()
android.util.Log.d("HeightSelectionActivity", "Received username: $username")
selectedHeightText = findViewById(R.id.selectedHeightText)
heightUnit = findViewById(R.id.heightUnit)
@ -98,11 +100,22 @@ class HeightSelectionActivity : AppCompatActivity() {
val weight = selectedWeight
val height = currentHeight
val user = username ?: ""
android.util.Log.d("HeightSelectionActivity", "Attempting to save profile for username: $user, gender: $gender, age: $age, weight: $weight, height: $height")
lifecycleScope.launch(Dispatchers.IO) {
try {
val profile = UserProfile(user, gender, age, weight, height)
android.util.Log.d("HeightSelectionActivity", "Saving UserProfile: $profile")
val rowsUpdated = db.userProfileDao().updateUserProfile(profile)
android.util.Log.d("HeightSelectionActivity", "UserProfile rows updated: $rowsUpdated for username: $user")
if (rowsUpdated == 0) {
db.userProfileDao().insertUserProfile(profile)
} catch (_: Exception) {}
android.util.Log.d("HeightSelectionActivity", "UserProfile inserted after update failed for username: $user")
} else {
android.util.Log.d("HeightSelectionActivity", "UserProfile updated successfully for username: $user")
}
} catch (e: Exception) {
android.util.Log.e("HeightSelectionActivity", "Error saving user profile for username: $user: ${e.message}", e)
}
}
val intent = Intent(this, LoginActivity::class.java)
intent.putExtra("selected_gender", selectedGender)

@ -4,17 +4,24 @@ import android.os.Bundle
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.appcompat.widget.Toolbar
import android.widget.TextView
import org.tensorflow.lite.examples.poseestimation.R
class MainTabActivity : AppCompatActivity() {
private lateinit var navHome: ImageView
private lateinit var navData: ImageView
private lateinit var navSetting: ImageView
private lateinit var toolbar: Toolbar
private lateinit var toolbarTitle: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main_tab)
toolbar = findViewById(R.id.toolbar)
toolbarTitle = findViewById(R.id.toolbar_title)
navHome = findViewById(R.id.nav_home)
navData = findViewById(R.id.nav_data)
navSetting = findViewById(R.id.nav_setting)
@ -27,27 +34,28 @@ class MainTabActivity : AppCompatActivity() {
// 5. updateNavIcons 方法根据当前选中的导航项0、1、2更新三个导航按钮的图标资源
// 默认显示HomeFragment
switchFragment(HomeFragment())
switchFragment(HomeFragment(), "形动力")
updateNavIcons(0)
navHome.setOnClickListener {
switchFragment(HomeFragment())
switchFragment(HomeFragment(), "形动力")
updateNavIcons(0)
}
navData.setOnClickListener {
switchFragment(DataFragment())
switchFragment(DataFragment(), "形动力")
updateNavIcons(1)
}
navSetting.setOnClickListener {
switchFragment(SettingFragment())
switchFragment(SettingFragment(), "形动力")
updateNavIcons(2)
}
}
private fun switchFragment(fragment: Fragment) {
private fun switchFragment(fragment: Fragment, title: String) {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit()
toolbarTitle.text = title
}
private fun updateNavIcons(selected: Int) {

@ -15,11 +15,14 @@ class Onboarding3Fragment : Fragment() {
): View? {
val view = inflater.inflate(R.layout.activity_onboarding3, container, false)
val username = arguments?.getString("username") // 获取用户名
// 找到Start now按钮并设置点击事件
val startButton = view.findViewById<FrameLayout>(R.id.small_butto_container)
startButton.setOnClickListener {
// 跳转到性别选择页面
val intent = Intent(requireActivity(), GenderSelectionActivity::class.java)
intent.putExtra("username", username) // 传递用户名
startActivity(intent)
requireActivity().finish() // 结束当前的OnboardingActivity
}

@ -9,7 +9,9 @@ class OnboardingActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_onboarding)
val username = intent.getStringExtra("username")
val viewPager = findViewById<ViewPager2>(R.id.viewPager)
viewPager.adapter = OnboardingAdapter(this)
viewPager.adapter = OnboardingAdapter(this, username)
}
}

@ -1,18 +1,23 @@
package org.tensorflow.lite.examples.poseestimation
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class OnboardingAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
class OnboardingAdapter(activity: FragmentActivity, private val username: String?) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment {
return when (position) {
val fragment = when (position) {
0 -> Onboarding1Fragment()
1 -> Onboarding2Fragment()
2 -> Onboarding3Fragment()
else -> Onboarding1Fragment()
}
fragment.arguments = Bundle().apply {
putString("username", username)
}
return fragment
}
}

@ -24,6 +24,7 @@ import org.tensorflow.lite.examples.poseestimation.data.UserProfile
class SettingFragment : Fragment() {
private var isPersonalInfoExpanded = false
private var isMyDataExpanded = false
private lateinit var tvUsername: TextView
private lateinit var tvSignature: TextView
@ -36,6 +37,11 @@ class SettingFragment : Fragment() {
private lateinit var tvHeight: TextView
private lateinit var btnAccountInfo: TextView
private lateinit var imageAvatar: ImageView
private lateinit var tvTotalTrainings: TextView
private lateinit var tvTotalCalories: TextView
private lateinit var tvTotalTime: TextView
private lateinit var btnMyData: TextView
private lateinit var layoutMyData: View
private var currentUsername: String? = null
@ -48,6 +54,8 @@ class SettingFragment : Fragment() {
if (isPersonalInfoExpanded) {
loadPersonalInfo()
}
// 重新加载我的数据
loadMyData()
}
}
@ -86,9 +94,16 @@ class SettingFragment : Fragment() {
tvHeight = view.findViewById(R.id.tv_height)
btnAccountInfo = view.findViewById(R.id.btn_account_info)
imageAvatar = view.findViewById(R.id.image_avatar)
tvTotalTrainings = view.findViewById(R.id.tv_total_trainings)
tvTotalCalories = view.findViewById(R.id.tv_total_calories)
tvTotalTime = view.findViewById(R.id.tv_total_time)
btnMyData = view.findViewById(R.id.btn_my_data)
layoutMyData = view.findViewById(R.id.layout_my_data)
// 页面创建时加载并显示用户数据
loadUserData()
// 页面创建时加载并显示我的数据
layoutMyData.visibility = if (isMyDataExpanded) View.VISIBLE else View.GONE
// 账户信息点击事件使用Launcher启动EditProfileActivity
btnAccountInfo.setOnClickListener {
@ -106,6 +121,15 @@ class SettingFragment : Fragment() {
}
}
// 我的数据展开/收起
btnMyData.setOnClickListener {
isMyDataExpanded = !isMyDataExpanded
layoutMyData.visibility = if (isMyDataExpanded) View.VISIBLE else View.GONE
if (isMyDataExpanded) {
loadMyData()
}
}
// 注销
btnLogout.setOnClickListener {
prefs.edit().remove("username").apply()
@ -123,22 +147,30 @@ class SettingFragment : Fragment() {
val user = withContext(Dispatchers.IO) {
db.userDao().getUserByUsername(username).first()
}
// 获取UserProfile数据
val userProfile = withContext(Dispatchers.IO) {
db.userProfileDao().getUserProfileByUsername(username).firstOrNull()
}
// 在主线程更新UI
withContext(Dispatchers.Main) {
tvUsername.text = user?.username ?: "-"
tvSignature.text = user?.signature ?: "这个人很懒,什么都没写"
// 加载并显示用户头像
user?.avatarUri?.let { uriString ->
userProfile?.avatarUri?.let { uriString ->
android.util.Log.d("SettingFragment", "Attempting to load avatar from URI: $uriString")
try {
val imageUri = Uri.parse(uriString)
imageAvatar.setImageURI(imageUri)
android.util.Log.d("SettingFragment", "Avatar loaded successfully from URI: $imageUri")
} catch (e: Exception) {
// 处理URI无效或图片加载失败的情况可以显示默认头像或日志
e.printStackTrace()
android.util.Log.e("SettingFragment", "Error loading avatar from URI: $uriString", e)
imageAvatar.setImageResource(R.drawable.placeholder_image) // 显示默认头像
}
} ?: run { // 如果avatarUri为null也显示默认头像
android.util.Log.d("SettingFragment", "Avatar URI is null, displaying placeholder.")
imageAvatar.setImageResource(R.drawable.placeholder_image)
}
}
@ -162,13 +194,40 @@ class SettingFragment : Fragment() {
tvAge.text = "年龄:${profile.age}"
tvWeight.text = "体重:${profile.weight} kg"
tvHeight.text = "身高:${profile.height} cm"
// 添加日志输出
android.util.Log.d("SettingFragment", "UserProfile loaded: $profile")
} else {
tvGender.text = "性别:-"
tvAge.text = "年龄:-"
tvWeight.text = "体重:-"
tvHeight.text = "身高:-"
// 添加日志输出
android.util.Log.d("SettingFragment", "UserProfile not found for username: $username")
}
}
}
}
}
// 提取加载我的数据的逻辑
private fun loadMyData() {
currentUsername?.let { username ->
lifecycleScope.launch {
val db = AppDatabase.getDatabase(requireContext())
val videoAnalysisResults = withContext(Dispatchers.IO) {
db.videoAnalysisResultDao().getVideoAnalysisResultsByUsername(username).first()
}
withContext(Dispatchers.Main) {
val totalTrainings = videoAnalysisResults.size
// 简化的卡路里和时间估算 (假设每次训练消耗50kcal每次训练平均5分钟)
val totalCalories = totalTrainings * 50
val totalMinutes = totalTrainings * 5
tvTotalTrainings.text = "总训练次数:$totalTrainings"
tvTotalCalories.text = "总消耗卡路里:$totalCalories kcal"
tvTotalTime.text = "总训练时间:$totalMinutes 分钟"
}
}
}
}

@ -72,15 +72,18 @@ class SignupActivity : AppCompatActivity() {
// 创建新用户
val newUser = User(username, password)
db.userDao().insertUser(newUser)
android.util.Log.d("SignupActivity", "User inserted: $username")
// 同步插入user_profiles表默认值
val newProfile = UserProfile(username, "-", 0, 0, 0)
db.userProfileDao().insertUserProfile(newProfile)
val profileId = db.userProfileDao().insertUserProfile(newProfile)
android.util.Log.d("SignupActivity", "UserProfile inserted for username: $username with ID: $profileId")
runOnUiThread {
Toast.makeText(this@SignupActivity, "注册成功", Toast.LENGTH_SHORT).show()
// 注册成功后跳转到OnboardingActivity
val intent = Intent(this@SignupActivity, OnboardingActivity::class.java)
intent.putExtra("username", username) // 传递用户名
startActivity(intent)
finish() // 结束注册界面
}

@ -0,0 +1,286 @@
package org.tensorflow.lite.examples.poseestimation
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay
import org.tensorflow.lite.examples.poseestimation.data.AppDatabase
import org.tensorflow.lite.examples.poseestimation.data.VideoAnalysisResult
import org.tensorflow.lite.examples.poseestimation.ml.MoveNet
import org.tensorflow.lite.examples.poseestimation.ml.PoseDetector
import org.tensorflow.lite.examples.poseestimation.ml.ModelType
import org.tensorflow.lite.examples.poseestimation.data.Device
import org.tensorflow.lite.examples.poseestimation.data.Person
import org.tensorflow.lite.examples.poseestimation.data.KeyPoint
import org.tensorflow.lite.examples.poseestimation.evaluator.DeadliftEvaluator
import org.tensorflow.lite.examples.poseestimation.evaluator.SquatEvaluator
import org.tensorflow.lite.examples.poseestimation.evaluator.ExerciseEvaluator
import org.tensorflow.lite.examples.poseestimation.evaluator.PlankEvaluator
import org.tensorflow.lite.examples.poseestimation.evaluator.PullUpEvaluator
import org.tensorflow.lite.examples.poseestimation.evaluator.PushUpEvaluator
class VideoAnalysisActivity : AppCompatActivity() {
private lateinit var tvExerciseName: TextView
private lateinit var btnSelectVideo: Button
private lateinit var tvAnalysisStatus: TextView
private lateinit var progressBarAnalysis: ProgressBar
private var currentExerciseType: String? = null
private var currentUsername: String? = null
private lateinit var poseDetector: PoseDetector
// 用于存储复制到内部存储后的视频URI
private var internalVideoUri: Uri? = null
// Launcher for selecting video from local storage
private val selectVideoLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val videoUri: Uri? = result.data?.data
videoUri?.let {
// 将视频复制到内部存储
lifecycleScope.launch(Dispatchers.IO) {
val copiedUri = copyVideoToInternalStorage(it)
withContext(Dispatchers.Main) {
if (copiedUri != null) {
internalVideoUri = copiedUri
android.util.Log.d("VideoAnalysisActivity", "视频已复制到内部存储: $internalVideoUri")
tvAnalysisStatus.text = "已选择视频: ${getFileName(it)},开始分析..."
tvAnalysisStatus.visibility = View.VISIBLE
progressBarAnalysis.visibility = View.VISIBLE
progressBarAnalysis.progress = 0
startVideoAnalysis(internalVideoUri!!, currentExerciseType)
} else {
Toast.makeText(this@VideoAnalysisActivity, "视频复制失败,请重试", Toast.LENGTH_SHORT).show()
tvAnalysisStatus.text = "视频复制失败"
tvAnalysisStatus.visibility = View.VISIBLE
progressBarAnalysis.visibility = View.GONE
}
}
}
} ?: run {
Toast.makeText(this, "未选择视频", Toast.LENGTH_SHORT).show()
tvAnalysisStatus.text = "未选择视频"
tvAnalysisStatus.visibility = View.VISIBLE
progressBarAnalysis.visibility = View.GONE
}
} else {
Toast.makeText(this, "取消选择视频", Toast.LENGTH_SHORT).show()
tvAnalysisStatus.text = "取消选择视频"
tvAnalysisStatus.visibility = View.VISIBLE
progressBarAnalysis.visibility = View.GONE
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_video_analysis)
// 获取当前登录用户名 (假设用SharedPreferences存储)
val prefs = getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
currentUsername = prefs.getString("username", null)?.trim()
if (currentUsername == null) {
Toast.makeText(this, "未登录,请重新登录", Toast.LENGTH_SHORT).show()
startActivity(Intent(this, LoginActivity::class.java))
finish()
return
}
// Get exercise name from intent
currentExerciseType = intent.getStringExtra("current_exercise")
// Initialize views
tvExerciseName = findViewById(R.id.tv_exercise_name)
btnSelectVideo = findViewById(R.id.btn_select_video)
tvAnalysisStatus = findViewById(R.id.tv_analysis_status)
progressBarAnalysis = findViewById(R.id.progress_bar_analysis)
// Initialize PoseDetector (using MoveNet Lightning by default)
poseDetector = MoveNet.create(this, Device.CPU, ModelType.Lightning)
// Set exercise name
tvExerciseName.text = currentExerciseType ?: "未知动作"
// Set click listener for video selection button
btnSelectVideo.setOnClickListener {
openVideoPicker()
}
}
// Override onDestroy to close the poseDetector
override fun onDestroy() {
super.onDestroy()
poseDetector.close()
}
private fun openVideoPicker() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "video/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
selectVideoLauncher.launch(Intent.createChooser(intent, "选择视频"))
}
// Helper function to get file name from Uri
private fun getFileName(uri: Uri): String {
var result: String? = null
if (uri.scheme == "content") {
val cursor = contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
result = it.getString(nameIndex)
}
}
}
}
if (result == null) {
result = uri.path
val cut = result?.lastIndexOf('/')
if (cut != -1) {
result = result?.substring(cut!! + 1)
}
}
return result ?: "未知文件"
}
// 将视频复制到应用内部存储并返回新URI
private fun copyVideoToInternalStorage(uri: Uri): Uri? {
return try {
val inputStream = contentResolver.openInputStream(uri)
val outputFileName = "video_${System.currentTimeMillis()}.mp4"
val outputFile = java.io.File(filesDir, outputFileName)
val outputStream = outputFile.outputStream()
inputStream?.copyTo(outputStream)
inputStream?.close()
outputStream.close()
android.util.Log.d("VideoAnalysisActivity", "视频已复制到: ${outputFile.absolutePath}")
Uri.fromFile(outputFile)
} catch (e: Exception) {
android.util.Log.e("VideoAnalysisActivity", "复制视频到内部存储出错: ${e.message}", e)
null
}
}
// Placeholder for video analysis logic
private fun startVideoAnalysis(videoUri: Uri, exerciseType: String?) {
val db = AppDatabase.getDatabase(applicationContext)
val videoAnalysisResultDao = db.videoAnalysisResultDao()
lifecycleScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
tvAnalysisStatus.text = "正在初始化姿态识别模型..."
btnSelectVideo.isEnabled = false // Disable button during analysis
}
val retriever = MediaMetadataRetriever()
retriever.setDataSource(applicationContext, videoUri)
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0
// Process frames at a reasonable interval, e.g., 10 frames per second for analysis
val frameRate = 10 // frames per second
val frameIntervalUs = 1_000_000L / frameRate // interval in microseconds
var currentFrameTimeUs = 0L
// Initialize evaluator based on exercise type
val evaluator: ExerciseEvaluator? = when (exerciseType) {
"硬拉" -> DeadliftEvaluator()
"深蹲" -> SquatEvaluator()
"平板支撑" -> PlankEvaluator()
"引体向上" -> PullUpEvaluator()
"俯卧撑" -> PushUpEvaluator()
// TODO: Add evaluators for other exercises
else -> null
}
while (currentFrameTimeUs <= durationMs * 1000) { // durationMs is in milliseconds, getFrameAtTime expects microseconds
val bitmap = retriever.getFrameAtTime(currentFrameTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
bitmap?.let { frameBitmap ->
val persons = poseDetector.estimatePoses(frameBitmap)
val person = persons.firstOrNull()
person?.let { p ->
if (p.score > 0.3) { // Only consider poses with reasonable confidence
// Pass keypoints to the evaluator
evaluator?.evaluateFrame(p.keyPoints)
}
}
frameBitmap.recycle() // Recycle bitmap to free memory
}
val progress = ((currentFrameTimeUs.toFloat() / (durationMs * 1000)) * 100).toInt()
withContext(Dispatchers.Main) {
progressBarAnalysis.progress = progress
tvAnalysisStatus.text = "正在分析视频帧: ${currentFrameTimeUs / 1000}ms / ${durationMs}ms"
}
currentFrameTimeUs += frameIntervalUs
}
retriever.release()
withContext(Dispatchers.Main) {
tvAnalysisStatus.text = "姿态识别完成,正在评估..."
}
// --- Pose Evaluation and Scoring ---
val finalScore: Float
val evaluation: String
if (evaluator != null) {
finalScore = evaluator.getFinalScore()
evaluation = evaluator.getFinalEvaluation(finalScore)
} else {
finalScore = 0f
evaluation = "暂不支持该动作的详细评估。"
}
// Store result in database
val result = VideoAnalysisResult(
username = currentUsername ?: "未知用户",
exerciseType = exerciseType ?: "未知运动",
videoUri = videoUri.toString(), // 使用已经复制到内部存储的URI
score = finalScore,
evaluation = evaluation,
timestamp = System.currentTimeMillis()
)
videoAnalysisResultDao.insertVideoAnalysisResult(result)
withContext(Dispatchers.Main) {
tvAnalysisStatus.text = "分析完成!正在显示结果..."
progressBarAnalysis.visibility = View.GONE
btnSelectVideo.isEnabled = true // Re-enable button
val intent = Intent(this@VideoAnalysisActivity, VideoAnalysisResultActivity::class.java).apply {
putExtra("exercise_name", currentExerciseType)
putExtra("score", finalScore)
putExtra("evaluation", evaluation)
}
startActivity(intent)
finish() // Finish this activity so user can't go back to it
}
}
}
}

@ -0,0 +1,71 @@
package org.tensorflow.lite.examples.poseestimation
import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import androidx.core.content.FileProvider
import org.tensorflow.lite.examples.poseestimation.data.VideoAnalysisResult
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class VideoAnalysisAdapter(private val results: List<VideoAnalysisResult>) : RecyclerView.Adapter<VideoAnalysisAdapter.VideoAnalysisViewHolder>() {
class VideoAnalysisViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvExerciseType: TextView = itemView.findViewById(R.id.tv_exercise_type)
val tvScore: TextView = itemView.findViewById(R.id.tv_score)
val tvEvaluation: TextView = itemView.findViewById(R.id.tv_evaluation)
val videoThumbnail: ImageView = itemView.findViewById(R.id.video_thumbnail)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoAnalysisViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_video_analysis_card, parent, false)
return VideoAnalysisViewHolder(view)
}
override fun onBindViewHolder(holder: VideoAnalysisViewHolder, position: Int) {
val result = results[position]
holder.tvExerciseType.text = "运动类型:${result.exerciseType}"
holder.tvScore.text = "${result.score.toInt()}"
holder.tvEvaluation.text = "评价:${result.evaluation}"
Glide.with(holder.itemView.context)
.load(Uri.parse(result.videoUri))
.placeholder(R.drawable.placeholder_image)
.error(R.drawable.placeholder_image)
.into(holder.videoThumbnail)
android.util.Log.d("VideoAnalysisAdapter", "Loading video thumbnail for URI: ${result.videoUri}")
holder.videoThumbnail.setOnClickListener {
try {
val videoFile = File(Uri.parse(result.videoUri).path)
val context = holder.itemView.context
val contentUri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
videoFile
)
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(contentUri, "video/*")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
} catch (e: Exception) {
android.util.Log.e("VideoAnalysisAdapter", "无法播放视频: ${result.videoUri}, 错误: ${e.message}", e)
Toast.makeText(holder.itemView.context, "无法播放视频,请检查文件或权限", Toast.LENGTH_SHORT).show()
}
}
}
override fun getItemCount(): Int = results.size
}

@ -0,0 +1,30 @@
package org.tensorflow.lite.examples.poseestimation
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
class VideoAnalysisResultActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_video_analysis_result)
val tvExerciseName = findViewById<TextView>(R.id.tv_result_exercise_name)
val tvScore = findViewById<TextView>(R.id.tv_result_score)
val tvEvaluation = findViewById<TextView>(R.id.tv_result_evaluation)
// 从 Intent 中获取数据
val exerciseName = intent.getStringExtra("exercise_name")
val score = intent.getFloatExtra("score", 0f)
val evaluation = intent.getStringExtra("evaluation")
// 设置数据显示
tvExerciseName.text = exerciseName ?: "未知动作"
tvScore.text = String.format("%.1f", score) // 格式化得分,保留一位小数
tvEvaluation.text = evaluation ?: "暂无评价"
// 设置标题 (可选)
supportActionBar?.title = "分析结果"
}
}

@ -20,6 +20,7 @@ class WeightSelectionActivity : AppCompatActivity() {
private var selectedGender: String? = null
private var selectedAge: Int = 0
private var username: String? = null
private var currentWeight = 54
private var lastY: Float = 0f
@ -34,6 +35,7 @@ class WeightSelectionActivity : AppCompatActivity() {
// 获取从上一个页面传递的数据
selectedGender = intent.getStringExtra("selected_gender")
selectedAge = intent.getIntExtra("selected_age", 0)
username = intent.getStringExtra("username")
// 初始化视图
selectedWeightText = findViewById(R.id.selectedWeightText)
@ -87,6 +89,7 @@ class WeightSelectionActivity : AppCompatActivity() {
intent.putExtra("selected_gender", selectedGender)
intent.putExtra("selected_age", selectedAge)
intent.putExtra("selected_weight", currentWeight)
intent.putExtra("username", username)
startActivity(intent)
finish()
}
@ -94,6 +97,7 @@ class WeightSelectionActivity : AppCompatActivity() {
backButton.setOnClickListener {
val intent = Intent(this, AgeSelectionActivity::class.java)
intent.putExtra("selected_gender", selectedGender)
intent.putExtra("username", username)
startActivity(intent)
finish()
}

@ -4,16 +4,33 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [User::class, UserProfile::class], version = 4, exportSchema = false)
@Database(entities = [User::class, UserProfile::class, VideoAnalysisResult::class], version = 6, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun userProfileDao(): UserProfileDao
abstract fun videoAnalysisResultDao(): VideoAnalysisResultDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `video_analysis_results` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `exerciseType` TEXT NOT NULL, `videoUri` TEXT NOT NULL, `score` REAL NOT NULL, `evaluation` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)"
)
}
}
private val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE user_profiles ADD COLUMN avatarUri TEXT")
}
}
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
@ -21,7 +38,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"app_database"
)
.fallbackToDestructiveMigration()
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.build()
INSTANCE = instance
instance

@ -9,5 +9,6 @@ data class UserProfile(
val gender: String,
val age: Int,
val weight: Int,
val height: Int
val height: Int,
val avatarUri: String? = null // 新增用户头像URI
)

@ -3,12 +3,16 @@ package org.tensorflow.lite.examples.poseestimation.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface UserProfileDao {
@Insert
fun insertUserProfile(profile: UserProfile)
fun insertUserProfile(profile: UserProfile): Long
@Update
fun updateUserProfile(profile: UserProfile): Int
@Query("SELECT * FROM user_profiles WHERE username = :username")
fun getUserProfileByUsername(username: String): Flow<UserProfile?>

@ -0,0 +1,15 @@
package org.tensorflow.lite.examples.poseestimation.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "video_analysis_results")
data class VideoAnalysisResult(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val username: String,
val exerciseType: String,
val videoUri: String,
val score: Float, // Simplified score for now
val evaluation: String,
val timestamp: Long // Timestamp for when the analysis was performed
)

@ -0,0 +1,19 @@
package org.tensorflow.lite.examples.poseestimation.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface VideoAnalysisResultDao {
@Insert
fun insertVideoAnalysisResult(result: VideoAnalysisResult): Long
@Query("SELECT * FROM video_analysis_results WHERE username = :username ORDER BY timestamp DESC")
fun getVideoAnalysisResultsByUsername(username: String): Flow<List<VideoAnalysisResult>>
// Optional: Get results for a specific exercise type
@Query("SELECT * FROM video_analysis_results WHERE username = :username AND exerciseType = :exerciseType ORDER BY timestamp DESC")
fun getVideoAnalysisResultsByUsernameAndExerciseType(username: String, exerciseType: String): Flow<List<VideoAnalysisResult>>
}

@ -0,0 +1,239 @@
package org.tensorflow.lite.examples.poseestimation.evaluator
import org.tensorflow.lite.examples.poseestimation.data.KeyPoint
import org.tensorflow.lite.examples.poseestimation.data.BodyPart
import kotlin.math.abs
import kotlin.math.roundToInt
// 定义硬拉动作的阶段
enum class DeadliftState {
START, // 起始姿态
DESCENT, // 下降过程
BOTTOM, // 底部姿态
ASCENT, // 上升过程
LOCKOUT // 锁定(完成)姿态
}
data class EvaluationMessage(
val message: String,
val isError: Boolean = false
)
class DeadliftEvaluator : ExerciseEvaluator {
private var currentState: DeadliftState = DeadliftState.START
private var totalScore: Float = 0f
private var frameCount: Int = 0
private var evaluationMessages: MutableList<EvaluationMessage> = mutableListOf()
private var repCount: Int = 0
private var isRepCounting: Boolean = false
// 硬拉关键角度阈值 (示例值,需要根据实际模型和标准进行调整)
private val HIP_ANGLE_START_MAX = 170f // 站立时髋关节角度
private val KNEE_ANGLE_START_MAX = 170f // 站立时膝关节角度
private val TORSO_ANGLE_START_MAX = 10f // 站立时躯干与垂直方向最大夹角
private val HIP_ANGLE_BOTTOM_MIN = 70f // 硬拉底部髋关节最小角度
private val KNEE_ANGLE_BOTTOM_MIN = 90f // 硬拉底部膝关节最小角度
private val TORSO_ANGLE_BOTTOM_MAX = 45f // 硬拉底部躯干与垂直方向最大夹角
private val BACK_STRAIGHT_THRESHOLD = 160f // 躯干挺直角度阈值 (肩-髋-膝角度)
// Helper function to calculate angle between three keypoints (A-B-C)
// B is the vertex of the angle
private fun calculateAngle(A: KeyPoint, B: KeyPoint, C: KeyPoint): Float {
// Use the abttPoints function from KeyPoint.kt
return B.abttPoints(A, B, C)
}
override fun evaluateFrame(keyPoints: List<KeyPoint>) {
if (keyPoints.isEmpty()) {
evaluationMessages.add(EvaluationMessage("未检测到关键点,无法评估。", true))
return
}
frameCount++
// 获取硬拉所需关键点
val leftShoulder = keyPoints.find { it.bodyPart == BodyPart.LEFT_SHOULDER }
val rightShoulder = keyPoints.find { it.bodyPart == BodyPart.RIGHT_SHOULDER }
val leftHip = keyPoints.find { it.bodyPart == BodyPart.LEFT_HIP }
val rightHip = keyPoints.find { it.bodyPart == BodyPart.RIGHT_HIP }
val leftKnee = keyPoints.find { it.bodyPart == BodyPart.LEFT_KNEE }
val rightKnee = keyPoints.find { it.bodyPart == BodyPart.RIGHT_KNEE }
val leftAnkle = keyPoints.find { it.bodyPart == BodyPart.LEFT_ANKLE }
val rightAnkle = keyPoints.find { it.bodyPart == BodyPart.RIGHT_ANKLE }
// 确保所有关键点都存在,如果缺失,则跳过此帧或给出警告
if (leftShoulder == null || rightShoulder == null || leftHip == null || rightHip == null ||
leftKnee == null || rightKnee == null || leftAnkle == null || rightAnkle == null) {
evaluationMessages.add(EvaluationMessage("关键点缺失,评估可能不准确。", true))
return
}
// 计算左右平均关键点,提高稳定性
val midShoulder = KeyPoint(
BodyPart.NOSE, // 使用 NOSE 作为占位符,因为没有一个"中肩"的 BodyPart
android.graphics.PointF(
(leftShoulder.coordinate.x + rightShoulder.coordinate.x) / 2,
(leftShoulder.coordinate.y + rightShoulder.coordinate.y) / 2
),
(leftShoulder.score + rightShoulder.score) / 2
)
val midHip = KeyPoint(
BodyPart.NOSE, // 同样,使用 NOSE 作为占位符
android.graphics.PointF(
(leftHip.coordinate.x + rightHip.coordinate.x) / 2,
(leftHip.coordinate.y + rightHip.coordinate.y) / 2
),
(leftHip.score + rightHip.score) / 2
)
val midKnee = KeyPoint(
BodyPart.NOSE, // 同样,使用 NOSE 作为占位符
android.graphics.PointF(
(leftKnee.coordinate.x + rightKnee.coordinate.x) / 2,
(leftKnee.coordinate.y + rightKnee.coordinate.y) / 2
),
(leftKnee.score + rightKnee.score) / 2
)
val midAnkle = KeyPoint(
BodyPart.NOSE, // 同样,使用 NOSE 作为占位符
android.graphics.PointF(
(leftAnkle.coordinate.x + rightAnkle.coordinate.x) / 2,
(leftAnkle.coordinate.y + rightAnkle.coordinate.y) / 2
),
(leftAnkle.score + rightAnkle.score) / 2
)
// 计算核心角度
val hipAngle = calculateAngle(midShoulder, midHip, midKnee) // 肩-髋-膝
val kneeAngle = calculateAngle(midHip, midKnee, midAnkle) // 髋-膝-踝
// 躯干角度 (这里简化为肩与髋的y坐标差与x坐标差的反正切并转换为相对于垂直线的角度)
// 更准确的躯干角度需要更复杂的向量计算,例如通过肩-髋连线与竖直方向的夹角
val torsoAngle = abs(midShoulder.abtPoints(midHip).second - 90f) // 假设 midShoulder.abtPoints(midHip).second 是与Y轴的夹角减去90得到与X轴的夹角再取绝对值但这里我们想要与垂直线的夹角所以需要调整
// 简化的躯干角度直接使用肩和髋的y坐标差与x坐标差的反正切
val dxTorso = midHip.coordinate.x - midShoulder.coordinate.x
val dyTorso = midHip.coordinate.y - midShoulder.coordinate.y
val torsoAngleVertical = Math.toDegrees(kotlin.math.atan2(dxTorso.toDouble(), dyTorso.toDouble())).toFloat() // 与y轴正方向的夹角
// 如果要与垂直方向的夹角,通常是 90 - atan2(dy, dx)
// 或者直接根据象限判断
val realTorsoAngle = abs(torsoAngleVertical) // 简化处理,取绝对值
var frameScore = 0f
var frameEvaluation = ""
when (currentState) {
DeadliftState.START -> {
// 评估起始姿态
if (hipAngle > HIP_ANGLE_START_MAX && kneeAngle > KNEE_ANGLE_START_MAX && realTorsoAngle < TORSO_ANGLE_START_MAX) {
frameEvaluation = "很棒的起始姿态!准备好向下,让身体进入训练模式。"
isRepCounting = false // 重置,等待下一次下降
currentState = DeadliftState.DESCENT
frameScore = 300f
} else {
frameEvaluation = "小提示:起始姿态可以再调整一下。试着站得更直,核心收得更紧,为硬拉做好准备!"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
}
DeadliftState.DESCENT -> {
// 评估下降过程
if (hipAngle < HIP_ANGLE_START_MAX && kneeAngle < KNEE_ANGLE_START_MAX) { // 髋和膝盖开始弯曲
if (realTorsoAngle < BACK_STRAIGHT_THRESHOLD) { // 检查背部是否挺直
frameEvaluation = "下降得很稳!记得保持背部挺直,感受髋部的发力。"
currentState = DeadliftState.BOTTOM
frameScore = 300f
} else {
frameEvaluation = "下降时背部有点弓起哦!试着挺胸收腹,保持脊柱中立,会更安全有效。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
} else {
frameEvaluation = "继续向下,保持动作流畅,感受肌肉的拉伸。"
}
}
DeadliftState.BOTTOM -> {
// 评估底部姿态
if (hipAngle >= HIP_ANGLE_BOTTOM_MIN && hipAngle <= HIP_ANGLE_START_MAX &&
kneeAngle >= KNEE_ANGLE_BOTTOM_MIN && kneeAngle <= KNEE_ANGLE_START_MAX &&
realTorsoAngle <= TORSO_ANGLE_BOTTOM_MAX) {
frameEvaluation = "底部姿态非常稳定!现在准备向上发力,让身体爆发起来!"
currentState = DeadliftState.ASCENT
frameScore = 450f
} else {
frameEvaluation = "底部姿态可以再优化一下。尝试让髋部再低一些,膝盖不要超过脚尖太多,同时保持背部挺直。你离完美不远了!"
if (hipAngle < HIP_ANGLE_BOTTOM_MIN) evaluationMessages.add(EvaluationMessage("髋部下降不足。", true))
if (kneeAngle < KNEE_ANGLE_BOTTOM_MIN) evaluationMessages.add(EvaluationMessage("膝盖弯曲不足。", true))
if (realTorsoAngle > TORSO_ANGLE_BOTTOM_MAX) evaluationMessages.add(EvaluationMessage("注意!你的背部有些弓起或者过度前倾了。记得整个过程都要保持背部挺直,这是硬拉的关键!", true))
}
}
DeadliftState.ASCENT -> {
// 评估上升过程
if (hipAngle > HIP_ANGLE_BOTTOM_MIN && kneeAngle > KNEE_ANGLE_BOTTOM_MIN) { // 髋和膝盖开始伸展
if (realTorsoAngle < BACK_STRAIGHT_THRESHOLD) { // 检查背部是否挺直
frameEvaluation = "向上发力非常有力!继续保持背部挺直,让髋部和膝盖同步伸展,爆发力十足!"
currentState = DeadliftState.LOCKOUT
frameScore = 300f
} else {
frameEvaluation = "上升时背部有些弓起了。集中注意力,保持背部挺直,用臀部发力带动身体向上。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
} else {
frameEvaluation = "加油,继续向上,把身体完全推回起始位置!"
}
}
DeadliftState.LOCKOUT -> {
// 评估锁定姿态
if (hipAngle > HIP_ANGLE_START_MAX - 5 && kneeAngle > KNEE_ANGLE_START_MAX - 5 && realTorsoAngle < TORSO_ANGLE_START_MAX + 5) {
frameEvaluation = "完美完成一次!锁定姿态非常棒,全身收紧,力量感十足!"
if (!isRepCounting) {
repCount++
isRepCounting = true
evaluationMessages.add(EvaluationMessage("恭喜你,又完成了一次硬拉!目前累计完成了 $repCount 次,继续保持!"))
}
currentState = DeadliftState.START // 完成一次后回到起始状态,准备下一次
frameScore = 150f
} else {
frameEvaluation = "快完成了!在顶部时,记得让髋部和膝盖完全伸展,轻轻收紧臀部,肩膀向后收拢,充分锁定。"
if (hipAngle < HIP_ANGLE_START_MAX - 5 || kneeAngle < KNEE_ANGLE_START_MAX - 5) evaluationMessages.add(EvaluationMessage("顶部锁定还不够充分哦,髋部和膝盖可以再伸展一点。", true))
if (realTorsoAngle > TORSO_ANGLE_START_MAX + 5) evaluationMessages.add(EvaluationMessage("注意了!在锁定阶段,身体可能有点过度后仰或者背部没有完全挺直。保持核心收紧,稳稳地完成动作。", true))
}
}
}
totalScore += frameScore // 累加每帧分数
evaluationMessages.add(EvaluationMessage(frameEvaluation)) // 添加每帧的评估
}
override fun getFinalScore(): Float {
val rawScore = if (frameCount > 0) totalScore / frameCount else 0f
val roundedScore = (rawScore / 10.0f).roundToInt() * 10.0f
return roundedScore.coerceIn(0f, 100f)
}
override fun getFinalEvaluation(finalScore: Float): String {
val uniqueMessages = evaluationMessages.map { it.message }.distinct().joinToString("\n")
val errors = evaluationMessages.filter { it.isError }.map { it.message }.distinct()
val overallEvaluationBuilder = StringBuilder()
if (errors.isEmpty()) {
when (finalScore.toInt()) {
in 90..100 -> overallEvaluationBuilder.append("太棒了!你的硬拉动作几乎完美无瑕,姿态标准,力量十足!继续保持!")
in 70..89 -> overallEvaluationBuilder.append("非常不错的硬拉!动作基本流畅,姿态也比较到位,再稍加注意细节就能更完美!")
in 50..69 -> overallEvaluationBuilder.append("硬拉动作有进步空间哦!虽然有些地方做得不错,但还需要多练习,让姿态更稳定、发力更集中。")
in 30..49 -> overallEvaluationBuilder.append("本次硬拉需要更多练习。动作中存在一些明显的姿态问题,这会影响训练效果和安全性。")
else -> overallEvaluationBuilder.append("硬拉动作仍需大量改进。请务必仔细对照标准,从基础开始练习,避免受伤。")
}
} else {
overallEvaluationBuilder.append("本次硬拉分析完成!发现了一些可以改进的地方:\n")
overallEvaluationBuilder.append(errors.joinToString("\n"))
}
overallEvaluationBuilder.append("\n\n以下是本次训练的详细分析过程,希望能帮助你更好地理解和改进:\n")
overallEvaluationBuilder.append(uniqueMessages)
return overallEvaluationBuilder.toString()
}
}

@ -0,0 +1,9 @@
package org.tensorflow.lite.examples.poseestimation.evaluator
import org.tensorflow.lite.examples.poseestimation.data.KeyPoint
interface ExerciseEvaluator {
fun evaluateFrame(keyPoints: List<KeyPoint>)
fun getFinalScore(): Float
fun getFinalEvaluation(finalScore: Float): String
}

@ -0,0 +1,198 @@
package org.tensorflow.lite.examples.poseestimation.evaluator
import org.tensorflow.lite.examples.poseestimation.data.KeyPoint
import org.tensorflow.lite.examples.poseestimation.data.BodyPart
import kotlin.math.abs
import kotlin.math.roundToInt
// 定义平板支撑动作的阶段
enum class PlankState {
HOLDING, // 保持姿态
ERROR // 错误姿态
}
class PlankEvaluator : ExerciseEvaluator {
private var currentState: PlankState = PlankState.HOLDING
private var totalScore: Float = 0f
private var frameCount: Int = 0
private var evaluationMessages: MutableList<EvaluationMessage> = mutableListOf()
private var timeInCorrectPoseMs: Long = 0L // 记录正确姿态的持续时间
private var lastFrameTimestamp: Long = 0L
// 平板支撑关键角度阈值 (示例值,需要根据实际模型和标准进行调整)
private val HIP_SHOULDER_ANKLE_MIN = 160f // 髋-肩-踝角度,用于判断身体是否呈直线
private val HIP_SHOULDER_ANKLE_MAX = 185f // 允许略微的弧度
private val HIP_SAG_THRESHOLD_PERCENT = 0.05f // 臀部下沉阈值(相对于肩-踝距离的百分比)
private val HIP_PIKE_THRESHOLD_PERCENT = 0.05f // 臀部过高阈值
// Helper function to calculate angle between three keypoints (A-B-C)
// B is the vertex of the angle
private fun calculateAngle(A: KeyPoint, B: KeyPoint, C: KeyPoint): Float {
return B.abttPoints(A, B, C)
}
override fun evaluateFrame(keyPoints: List<KeyPoint>) {
if (keyPoints.isEmpty()) {
evaluationMessages.add(EvaluationMessage("未检测到关键点,无法评估。", true))
return
}
frameCount++
val currentTimestamp = System.currentTimeMillis() // 假设每帧的时间间隔可以通过外部传入或自行估算
if (lastFrameTimestamp != 0L) {
val deltaTimeMs = currentTimestamp - lastFrameTimestamp
// 假设视频帧率稳定或者直接使用帧间隔例如100ms一帧
// 这里简化处理,直接使用一个固定的帧分数,而不是基于时间
}
lastFrameTimestamp = currentTimestamp
// 获取平板支撑所需关键点
val leftShoulder = keyPoints.find { it.bodyPart == BodyPart.LEFT_SHOULDER }
val rightShoulder = keyPoints.find { it.bodyPart == BodyPart.RIGHT_SHOULDER }
val leftHip = keyPoints.find { it.bodyPart == BodyPart.LEFT_HIP }
val rightHip = keyPoints.find { it.bodyPart == BodyPart.RIGHT_HIP }
val leftAnkle = keyPoints.find { it.bodyPart == BodyPart.LEFT_ANKLE }
val rightAnkle = keyPoints.find { it.bodyPart == BodyPart.RIGHT_ANKLE }
val nose = keyPoints.find { it.bodyPart == BodyPart.NOSE }
// 确保所有关键点都存在
if (leftShoulder == null || rightShoulder == null || leftHip == null || rightHip == null ||
leftAnkle == null || rightAnkle == null || nose == null) {
evaluationMessages.add(EvaluationMessage("关键点缺失,评估可能不准确。", true))
return
}
// 计算左右平均关键点,提高稳定性
val midShoulder = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftShoulder.coordinate.x + rightShoulder.coordinate.x) / 2,
(leftShoulder.coordinate.y + rightShoulder.coordinate.y) / 2
),
(leftShoulder.score + rightShoulder.score) / 2
)
val midHip = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftHip.coordinate.x + rightHip.coordinate.x) / 2,
(leftHip.coordinate.y + rightHip.coordinate.y) / 2
),
(leftHip.score + rightHip.score) / 2
)
val midAnkle = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftAnkle.coordinate.x + rightAnkle.coordinate.x) / 2,
(leftAnkle.coordinate.y + rightAnkle.coordinate.y) / 2
),
(leftAnkle.score + rightAnkle.score) / 2
)
// 计算身体直线角度(肩-髋-踝)
val bodyLineAngle = calculateAngle(midShoulder, midHip, midAnkle)
// 计算头部姿态 (肩-鼻-肩 角度或直接看鼻子的Y坐标相对肩的Y坐标)
val headY = nose.coordinate.y
val shoulderY = midShoulder.coordinate.y
var headDrop = false
if (headY > shoulderY + 20) { // 假设头部Y坐标显著低于肩部Y坐标视为下垂
headDrop = true
}
var frameScore = 0f
var frameEvaluation = ""
var isCorrectPose = true
// 新增臀部位置检查
val ankleY = midAnkle.coordinate.y
val hipY = midHip.coordinate.y
// 计算肩部到踝部的垂直距离
val verticalDistanceShoulderAnkle = abs(shoulderY - ankleY)
val hipSagThreshold = verticalDistanceShoulderAnkle * HIP_SAG_THRESHOLD_PERCENT
val hipPikeThreshold = verticalDistanceShoulderAnkle * HIP_PIKE_THRESHOLD_PERCENT
// 假设理想的髋部Y坐标应该在肩部和踝部的Y坐标之间或者与其中一个大致平齐
// 更精确地我们假设身体呈直线时髋部Y坐标应该与肩部和踝部连线的Y坐标在同一直线上
// 我们可以计算肩部和踝部连线在髋部X坐标处的Y值
val expectedHipY = shoulderY + (ankleY - shoulderY) * ((midHip.coordinate.x - midShoulder.coordinate.x) / (midAnkle.coordinate.x - midShoulder.coordinate.x))
var hipSag = false
var hipPike = false
if (hipY > expectedHipY + hipSagThreshold) { // 臀部Y坐标低于预期表示下沉
hipSag = true
isCorrectPose = false
evaluationMessages.add(EvaluationMessage("臀部下沉了!收紧核心,将臀部向上抬起,保持身体的平直。", true))
} else if (hipY < expectedHipY - hipPikeThreshold) { // 臀部Y坐标高于预期表示过高
hipPike = true
isCorrectPose = false
evaluationMessages.add(EvaluationMessage("臀部抬得太高了!尝试放低臀部,让身体呈一条直线。", true))
}
// 评估身体是否呈直线 (现在只关注角度,垂直位置由上面新的逻辑处理)
if (bodyLineAngle !in HIP_SHOULDER_ANKLE_MIN..HIP_SHOULDER_ANKLE_MAX) {
isCorrectPose = false
evaluationMessages.add(EvaluationMessage("身体不够平直,请调整髋部位置。", true))
}
// 评估头部姿态
if (headDrop) {
isCorrectPose = false
evaluationMessages.add(EvaluationMessage("头部下垂,请保持颈部中立,眼睛看向地面。", true))
}
if (isCorrectPose) {
frameEvaluation = "姿态保持良好,身体呈一条直线。"
frameScore = 100f // 正确姿态每帧获得固定分数
timeInCorrectPoseMs += (currentTimestamp - lastFrameTimestamp) // 累加正确姿态时间
} else {
frameEvaluation = "请调整姿态,注意身体的直线。"
frameScore = 0f
currentState = PlankState.ERROR // 进入错误姿态状态
}
totalScore += frameScore
evaluationMessages.add(EvaluationMessage(frameEvaluation))
// 检查状态转换如果从错误姿态恢复到正确姿态则重新设置为HOLDING
if (currentState == PlankState.ERROR && isCorrectPose) {
currentState = PlankState.HOLDING
}
}
override fun getFinalScore(): Float {
// 平板支撑的最终分数更侧重于保持正确姿态的时长
// 这里简化为正确姿态帧数占总帧数的比例并四舍五入到10的倍数
val rawScore = if (frameCount > 0) (totalScore / frameCount) else 0f
val roundedScore = (rawScore / 10.0f).roundToInt() * 10.0f
return roundedScore.coerceIn(0f, 100f)
}
override fun getFinalEvaluation(finalScore: Float): String {
val uniqueMessages = evaluationMessages.map { it.message }.distinct().joinToString("\n")
val errors = evaluationMessages.filter { it.isError }.map { it.message }.distinct()
val overallEvaluationBuilder = StringBuilder()
if (errors.isEmpty()) {
when (finalScore.toInt()) {
in 90..100 -> overallEvaluationBuilder.append("太棒了!你的平板支撑姿态几乎完美无瑕,身体呈一条直线,核心非常稳定!")
in 70..89 -> overallEvaluationBuilder.append("非常不错的平板支撑!姿态基本保持良好,再稍加注意细节就能更完美!")
in 50..69 -> overallEvaluationBuilder.append("平板支撑姿态有进步空间哦!虽然有些地方做得不错,但还需要多练习,让身体更稳定、更平直。")
in 30..49 -> overallEvaluationBuilder.append("本次平板支撑需要更多练习。姿态中存在一些明显的姿态问题,这会影响训练效果。")
else -> overallEvaluationBuilder.append("平板支撑姿态仍需大量改进。请务必仔细对照标准,从基础开始练习,避免受伤。")
}
} else {
overallEvaluationBuilder.append("本次平板支撑分析完成!发现了一些可以改进的地方:\n")
overallEvaluationBuilder.append(errors.joinToString("\n"))
// 对于平板支撑,时间更重要,但目前没有直接累加,可以考虑后期优化
}
overallEvaluationBuilder.append("\n\n以下是本次训练的详细分析过程,希望能帮助你更好地理解和改进:\n")
overallEvaluationBuilder.append(uniqueMessages)
return overallEvaluationBuilder.toString()
}
}

@ -0,0 +1,240 @@
package org.tensorflow.lite.examples.poseestimation.evaluator
import org.tensorflow.lite.examples.poseestimation.data.KeyPoint
import org.tensorflow.lite.examples.poseestimation.data.BodyPart
import kotlin.math.abs
import kotlin.math.roundToInt
// 定义引体向上动作的阶段
enum class PullUpState {
START, // 起始姿态(完全下放)
ASCENT, // 上拉过程
TOP, // 顶部姿态(下巴过杠)
DESCENT, // 下放过程
LOCKOUT // 恢复到起始姿态
}
class PullUpEvaluator : ExerciseEvaluator {
private var currentState: PullUpState = PullUpState.START
private var totalScore: Float = 0f
private var frameCount: Int = 0
private var evaluationMessages: MutableList<EvaluationMessage> = mutableListOf()
private var repCount: Int = 0
private var isRepCounting: Boolean = false
// 引体向上关键角度阈值 (示例值,需要根据实际模型和标准进行调整)
private val ELBOW_ANGLE_START_MIN = 170f // 起始姿态(手臂伸直,进一步放宽要求)
private val SHOULDER_ELBOW_WRIST_TOP_MAX = 80f // 顶部姿态(手肘弯曲程度,进一步放宽要求)
private val NOSE_BAR_HEIGHT_THRESHOLD = 0f // 鼻子与杠的相对高度(需要通过实际坐标判断)
private val BACK_STRAIGHT_THRESHOLD = 160f // 躯干挺直角度阈值 (肩-髋-膝角度,用于判断背部是否弓起)
// Helper function to calculate angle between three keypoints (A-B-C)
// B is the vertex of the angle
private fun calculateAngle(A: KeyPoint, B: KeyPoint, C: KeyPoint): Float {
return B.abttPoints(A, B, C)
}
override fun evaluateFrame(keyPoints: List<KeyPoint>) {
if (keyPoints.isEmpty()) {
evaluationMessages.add(EvaluationMessage("未检测到关键点,无法评估。", true))
return
}
frameCount++
// 获取引体向上所需关键点
val leftShoulder = keyPoints.find { it.bodyPart == BodyPart.LEFT_SHOULDER }
val rightShoulder = keyPoints.find { it.bodyPart == BodyPart.RIGHT_SHOULDER }
val leftElbow = keyPoints.find { it.bodyPart == BodyPart.LEFT_ELBOW }
val rightElbow = keyPoints.find { it.bodyPart == BodyPart.RIGHT_ELBOW }
val leftWrist = keyPoints.find { it.bodyPart == BodyPart.LEFT_WRIST }
val rightWrist = keyPoints.find { it.bodyPart == BodyPart.RIGHT_WRIST }
val nose = keyPoints.find { it.bodyPart == BodyPart.NOSE }
val leftHip = keyPoints.find { it.bodyPart == BodyPart.LEFT_HIP }
val rightHip = keyPoints.find { it.bodyPart == BodyPart.RIGHT_HIP }
val leftKnee = keyPoints.find { it.bodyPart == BodyPart.LEFT_KNEE }
val rightKnee = keyPoints.find { it.bodyPart == BodyPart.RIGHT_KNEE }
// 确保所有关键点都存在
if (leftShoulder == null || rightShoulder == null || leftElbow == null || rightElbow == null ||
leftWrist == null || rightWrist == null || nose == null || leftHip == null || rightHip == null ||
leftKnee == null || rightKnee == null) {
evaluationMessages.add(EvaluationMessage("关键点缺失,评估可能不准确。", true))
return
}
// 计算左右平均关键点,提高稳定性
val midShoulder = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftShoulder.coordinate.x + rightShoulder.coordinate.x) / 2,
(leftShoulder.coordinate.y + rightShoulder.coordinate.y) / 2
),
(leftShoulder.score + rightShoulder.score) / 2
)
val midElbow = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftElbow.coordinate.x + rightElbow.coordinate.x) / 2,
(leftElbow.coordinate.y + rightElbow.coordinate.y) / 2
),
(leftElbow.score + rightElbow.score) / 2
)
val midWrist = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftWrist.coordinate.x + rightWrist.coordinate.x) / 2,
(leftWrist.coordinate.y + rightWrist.coordinate.y) / 2
),
(leftWrist.score + rightWrist.score) / 2
)
val midHip = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftHip.coordinate.x + rightHip.coordinate.x) / 2,
(leftHip.coordinate.y + rightHip.coordinate.y) / 2
),
(leftHip.score + rightHip.score) / 2
)
val midKnee = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftKnee.coordinate.x + rightKnee.coordinate.x) / 2,
(leftKnee.coordinate.y + rightKnee.coordinate.y) / 2
),
(leftKnee.score + rightKnee.score) / 2
)
// 计算核心角度
val elbowAngle = calculateAngle(midShoulder, midElbow, midWrist) // 肩-肘-腕
val torsoAngle = calculateAngle(midShoulder, midHip, midKnee) // 肩-髋-膝,用于评估背部姿态
// 假设杠的位置,这里需要根据实际情况调整,或者通过用户输入来设定
// 这里暂时用一个简化的逻辑假设杠在肩部上方一定距离通过鼻子的Y坐标与肩的Y坐标判断
val isChinOverBar = nose.coordinate.y < midWrist.coordinate.y + 0 // 鼻子的Y坐标不低于手腕Y坐标大幅放宽
var frameScore = 0f
var frameEvaluation = ""
when (currentState) {
PullUpState.START -> {
// 评估起始姿态 (手臂完全伸直)
if (elbowAngle > ELBOW_ANGLE_START_MIN - 15) { // 进一步放宽手臂伸直要求
frameEvaluation = "起始姿态良好,手臂基本伸直,准备上拉。"
isRepCounting = false
currentState = PullUpState.ASCENT
frameScore = 250f // 适当提高分数
} else {
frameEvaluation = "请基本伸直手臂,回到起始位置。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
}
PullUpState.ASCENT -> {
// 评估上拉过程
if (elbowAngle < ELBOW_ANGLE_START_MIN - 10) { // 手肘开始弯曲,与起始角度保持衔接
if (torsoAngle > BACK_STRAIGHT_THRESHOLD) { // 检查背部是否挺直
if (isChinOverBar) { // 下巴过杠
frameEvaluation = "上拉有力,下巴已过杠,非常棒!"
currentState = PullUpState.TOP
frameScore = 450f // 适当提高分数
} else {
frameEvaluation = "继续上拉,努力让下巴过杠。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
} else {
frameEvaluation = "上拉时背部有点弓起。请保持背部挺直。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
} else {
frameEvaluation = "继续上拉,感受背部和手臂的发力。"
}
}
PullUpState.TOP -> {
// 评估顶部姿态 (下巴过杠,手肘弯曲)
if (isChinOverBar && elbowAngle < SHOULDER_ELBOW_WRIST_TOP_MAX + 30 && torsoAngle > BACK_STRAIGHT_THRESHOLD) { // 进一步放宽手肘弯曲和躯干角度
frameEvaluation = "完美!下巴过杠,顶部姿态保持得很好。"
currentState = PullUpState.DESCENT
frameScore = 700f // 适当提高分数
} else {
if (!isChinOverBar) evaluationMessages.add(EvaluationMessage("顶部锁定不充分,下巴未过杠。", true))
if (elbowAngle >= SHOULDER_ELBOW_WRIST_TOP_MAX + 30) evaluationMessages.add(EvaluationMessage("手肘弯曲不足,请再用力拉高。", true))
if (torsoAngle <= BACK_STRAIGHT_THRESHOLD) evaluationMessages.add(EvaluationMessage("顶部姿态背部弓起。", true))
frameEvaluation = "顶部姿态可以保持更稳定,确保下巴完全过杠。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
}
PullUpState.DESCENT -> {
// 评估下放过程
if (elbowAngle > SHOULDER_ELBOW_WRIST_TOP_MAX + 10) { // 手肘开始伸直,与顶部角度保持衔接
if (torsoAngle > BACK_STRAIGHT_THRESHOLD) { // 检查背部是否挺直
if (elbowAngle > ELBOW_ANGLE_START_MIN - 20) { // 进一步放宽回到起始位置的要求
frameEvaluation = "下放得很稳,即将完成一次引体向上。"
currentState = PullUpState.LOCKOUT
frameScore = 450f // 适当提高分数
} else {
frameEvaluation = "继续缓慢下放,控制好身体。"
}
} else {
frameEvaluation = "下放时背部有点弓起。请保持背部挺直。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
} else {
frameEvaluation = "继续下放,感受背部肌肉的拉伸。"
}
}
PullUpState.LOCKOUT -> {
// 评估完全下放(锁定)姿态
if (elbowAngle > ELBOW_ANGLE_START_MIN - 20 && torsoAngle > BACK_STRAIGHT_THRESHOLD) { // 进一步放宽手臂伸直要求
frameEvaluation = "完美完成一次引体向上!手臂基本伸直,为下一次做准备。"
if (!isRepCounting) {
repCount++
isRepCounting = true
evaluationMessages.add(EvaluationMessage("恭喜你,又完成了一次引体向上!目前累计完成了 $repCount 次,继续保持!"))
}
currentState = PullUpState.START // 完成一次后回到起始状态,准备下一次
frameScore = 250f // 适当提高分数
} else {
if (elbowAngle < ELBOW_ANGLE_START_MIN - 20) evaluationMessages.add(EvaluationMessage("手臂未完全伸直,请确保回到起始位置。", true))
if (torsoAngle <= BACK_STRAIGHT_THRESHOLD) evaluationMessages.add(EvaluationMessage("锁定姿态背部弓起。", true))
frameEvaluation = "请完成锁定:手臂基本伸直,身体保持平直。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
}
}
totalScore += frameScore
evaluationMessages.add(EvaluationMessage(frameEvaluation))
}
override fun getFinalScore(): Float {
val rawScore = if (frameCount > 0) totalScore / frameCount else 0f
val roundedScore = (rawScore / 10.0f).roundToInt() * 10.0f
return roundedScore.coerceIn(0f, 100f)
}
override fun getFinalEvaluation(finalScore: Float): String {
val uniqueMessages = evaluationMessages.map { it.message }.distinct().joinToString("\n")
val errors = evaluationMessages.filter { it.isError }.map { it.message }.distinct()
val overallEvaluationBuilder = StringBuilder()
if (errors.isEmpty()) {
when (finalScore.toInt()) {
in 90..100 -> overallEvaluationBuilder.append("太棒了!你的引体向上动作几乎完美无瑕,姿态标准,力量十足!继续保持!")
in 70..89 -> overallEvaluationBuilder.append("非常不错的引体向上!动作基本流畅,姿态也比较到位,再稍加注意细节就能更完美!")
in 50..69 -> overallEvaluationBuilder.append("引体向上动作有进步空间哦!虽然有些地方做得不错,但还需要多练习,让姿态更稳定、发力更集中。")
in 30..49 -> overallEvaluationBuilder.append("本次引体向上需要更多练习。动作中存在一些明显的姿态问题,这会影响训练效果和安全性。")
else -> overallEvaluationBuilder.append("引体向上动作仍需大量改进。请务必仔细对照标准,从基础开始练习,避免受伤。")
}
} else {
overallEvaluationBuilder.append("本次引体向上分析完成!发现了一些可以改进的地方:\n")
overallEvaluationBuilder.append(errors.joinToString("\n"))
overallEvaluationBuilder.append("\n总次数:$repCount")
}
overallEvaluationBuilder.append("\n\n以下是本次训练的详细分析过程,希望能帮助你更好地理解和改进:\n")
overallEvaluationBuilder.append(uniqueMessages)
return overallEvaluationBuilder.toString()
}
}

@ -0,0 +1,237 @@
package org.tensorflow.lite.examples.poseestimation.evaluator
import org.tensorflow.lite.examples.poseestimation.data.KeyPoint
import org.tensorflow.lite.examples.poseestimation.data.BodyPart
import kotlin.math.abs
import kotlin.math.roundToInt
// 定义俯卧撑动作的阶段
enum class PushUpState {
START, // 起始姿态(手臂伸直)
DESCENT, // 下降过程
BOTTOM, // 底部姿态(胸部接近地面)
ASCENT, // 上推过程
LOCKOUT // 恢复到起始姿态
}
class PushUpEvaluator : ExerciseEvaluator {
private var currentState: PushUpState = PushUpState.START
private var totalScore: Float = 0f
private var frameCount: Int = 0
private var evaluationMessages: MutableList<EvaluationMessage> = mutableListOf()
private var repCount: Int = 0
private var isRepCounting: Boolean = false
// 俯卧撑关键角度阈值 (示例值,需要根据实际模型和标准进行调整)
private val ELBOW_ANGLE_START_MIN = 170f // 起始姿态(手臂伸直,放宽要求)
private val ELBOW_ANGLE_BOTTOM_MAX = 100f // 底部姿态(手肘弯曲程度,放宽要求)
private val HIP_SHOULDER_ANKLE_MIN = 160f // 身体直线(髋-肩-踝)
private val HIP_SHOULDER_ANKLE_MAX = 185f // 允许略微的弧度
// Helper function to calculate angle between three keypoints (A-B-C)
// B is the vertex of the angle
private fun calculateAngle(A: KeyPoint, B: KeyPoint, C: KeyPoint): Float {
return B.abttPoints(A, B, C)
}
override fun evaluateFrame(keyPoints: List<KeyPoint>) {
if (keyPoints.isEmpty()) {
evaluationMessages.add(EvaluationMessage("未检测到关键点,无法评估。", true))
return
}
frameCount++
// 获取俯卧撑所需关键点
val leftShoulder = keyPoints.find { it.bodyPart == BodyPart.LEFT_SHOULDER }
val rightShoulder = keyPoints.find { it.bodyPart == BodyPart.RIGHT_SHOULDER }
val leftElbow = keyPoints.find { it.bodyPart == BodyPart.LEFT_ELBOW }
val rightElbow = keyPoints.find { it.bodyPart == BodyPart.RIGHT_ELBOW }
val leftWrist = keyPoints.find { it.bodyPart == BodyPart.LEFT_WRIST }
val rightWrist = keyPoints.find { it.bodyPart == BodyPart.RIGHT_WRIST }
val leftHip = keyPoints.find { it.bodyPart == BodyPart.LEFT_HIP }
val rightHip = keyPoints.find { it.bodyPart == BodyPart.RIGHT_HIP }
val leftAnkle = keyPoints.find { it.bodyPart == BodyPart.LEFT_ANKLE }
val rightAnkle = keyPoints.find { it.bodyPart == BodyPart.RIGHT_ANKLE }
// 确保所有关键点都存在
if (leftShoulder == null || rightShoulder == null || leftElbow == null || rightElbow == null ||
leftWrist == null || rightWrist == null || leftHip == null || rightHip == null ||
leftAnkle == null || rightAnkle == null) {
evaluationMessages.add(EvaluationMessage("关键点缺失,评估可能不准确。", true))
return
}
// 计算左右平均关键点,提高稳定性
val midShoulder = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftShoulder.coordinate.x + rightShoulder.coordinate.x) / 2,
(leftShoulder.coordinate.y + rightShoulder.coordinate.y) / 2
),
(leftShoulder.score + rightShoulder.score) / 2
)
val midElbow = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftElbow.coordinate.x + rightElbow.coordinate.x) / 2,
(leftElbow.coordinate.y + rightElbow.coordinate.y) / 2
),
(leftElbow.score + rightElbow.score) / 2
)
val midWrist = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftWrist.coordinate.x + rightWrist.coordinate.x) / 2,
(leftWrist.coordinate.y + rightWrist.coordinate.y) / 2
),
(leftWrist.score + rightWrist.score) / 2
)
val midHip = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftHip.coordinate.x + rightHip.coordinate.x) / 2,
(leftHip.coordinate.y + rightHip.coordinate.y) / 2
),
(leftHip.score + rightHip.score) / 2
)
val midAnkle = KeyPoint(
BodyPart.NOSE,
android.graphics.PointF(
(leftAnkle.coordinate.x + rightAnkle.coordinate.x) / 2,
(leftAnkle.coordinate.y + rightAnkle.coordinate.y) / 2
),
(leftAnkle.score + rightAnkle.score) / 2
)
// 计算核心角度
val elbowAngle = calculateAngle(midShoulder, midElbow, midWrist) // 肩-肘-腕
val bodyLineAngle = calculateAngle(midShoulder, midHip, midAnkle) // 肩-髋-踝
var frameScore = 0f
var frameEvaluation = ""
var isBodyStraight = true
// 检查身体是否呈直线
if (bodyLineAngle !in HIP_SHOULDER_ANKLE_MIN..HIP_SHOULDER_ANKLE_MAX) {
isBodyStraight = false
if (midHip.coordinate.y < midShoulder.coordinate.y) { // 臀部过高
evaluationMessages.add(EvaluationMessage("臀部抬得太高了,尝试放低臀部,保持身体呈一条直线。", true))
} else if (midHip.coordinate.y > midAnkle.coordinate.y) { // 臀部下沉
evaluationMessages.add(EvaluationMessage("臀部下沉了,收紧核心,将臀部向上抬起,保持身体的平直。", true))
} else {
evaluationMessages.add(EvaluationMessage("身体不够平直,请调整髋部位置。", true))
}
}
when (currentState) {
PushUpState.START -> {
// 评估起始姿态 (手臂伸直,身体呈直线)
if (elbowAngle > ELBOW_ANGLE_START_MIN - 10 && isBodyStraight) { // 稍微放宽手臂伸直要求
frameEvaluation = "起始姿态良好,手臂基本伸直,身体呈一条直线。"
isRepCounting = false
currentState = PushUpState.DESCENT
frameScore = 200f // 提高分数
} else {
if (elbowAngle < ELBOW_ANGLE_START_MIN - 10) evaluationMessages.add(EvaluationMessage("手臂未完全伸直,请确保回到起始位置。", true))
if (!isBodyStraight) evaluationMessages.add(EvaluationMessage("身体未保持一条直线,请调整核心,不要塌腰或撅臀。", true))
frameEvaluation = "请调整至正确起始姿态。"
}
}
PushUpState.DESCENT -> {
// 评估下降过程
if (elbowAngle < ELBOW_ANGLE_START_MIN - 15) { // 手肘开始弯曲
if (isBodyStraight) {
frameEvaluation = "下降得很稳,保持身体平直。"
currentState = PushUpState.BOTTOM
frameScore = 400f // 提高分数
} else {
evaluationMessages.add(EvaluationMessage("下降时身体不够平直,请调整核心,保持身体直线。", true))
frameEvaluation = "下降中,注意保持身体直线。"
}
} else {
frameEvaluation = "继续下降,感受胸部拉伸。"
}
}
PushUpState.BOTTOM -> {
// 评估底部姿态 (胸部接近地面,手肘弯曲到位,身体呈直线)
if (elbowAngle < ELBOW_ANGLE_BOTTOM_MAX + 10 && isBodyStraight) { // 放宽手肘弯曲
frameEvaluation = "底部姿态完美,胸部接近地面!"
currentState = PushUpState.ASCENT
frameScore = 600f // 提高分数
} else {
if (elbowAngle > ELBOW_ANGLE_BOTTOM_MAX + 10) evaluationMessages.add(EvaluationMessage("下降不够深,请尝试让胸部更接近地面。", true))
if (!isBodyStraight) evaluationMessages.add(EvaluationMessage("底部姿态身体不直,请调整。", true))
frameEvaluation = "底部姿态可改进。"
}
}
PushUpState.ASCENT -> {
// 评估上推过程
if (elbowAngle > ELBOW_ANGLE_BOTTOM_MAX + 15) { // 手肘开始伸直
if (isBodyStraight) {
frameEvaluation = "上推有力,身体保持平直。"
currentState = PushUpState.LOCKOUT
frameScore = 400f // 提高分数
} else {
evaluationMessages.add(EvaluationMessage("上推时身体不够平直,请调整核心,保持身体直线。", true))
frameEvaluation = "上推中,注意保持身体直线。"
}
} else {
frameEvaluation = "继续上推,感受胸部和手臂发力。"
}
}
PushUpState.LOCKOUT -> {
// 评估完全推起(锁定)姿态
if (elbowAngle > ELBOW_ANGLE_START_MIN - 15 && isBodyStraight) { // 稍微放宽手臂伸直要求
frameEvaluation = "完美完成一次俯卧撑!手臂基本伸直,身体呈一条直线。"
if (!isRepCounting) {
repCount++
isRepCounting = true
evaluationMessages.add(EvaluationMessage("恭喜你,又完成了一次俯卧撑!目前累计完成了 $repCount 次,继续保持!"))
}
currentState = PushUpState.START // 完成一次后回到起始状态,准备下一次
frameScore = 200f // 提高分数
} else {
if (elbowAngle < ELBOW_ANGLE_START_MIN - 15) evaluationMessages.add(EvaluationMessage("手臂未完全伸直,请确保回到起始位置。", true))
if (!isBodyStraight) evaluationMessages.add(EvaluationMessage("锁定姿态身体不直,请调整。", true))
frameEvaluation = "请完成锁定:手臂基本伸直,身体保持平直。"
}
}
}
totalScore += frameScore
evaluationMessages.add(EvaluationMessage(frameEvaluation))
}
override fun getFinalScore(): Float {
val rawScore = if (frameCount > 0) totalScore / frameCount else 0f
val roundedScore = (rawScore / 10.0f).roundToInt() * 10.0f
return roundedScore.coerceIn(0f, 100f)
}
override fun getFinalEvaluation(finalScore: Float): String {
val uniqueMessages = evaluationMessages.map { it.message }.distinct().joinToString("\n")
val errors = evaluationMessages.filter { it.isError }.map { it.message }.distinct()
val overallEvaluationBuilder = StringBuilder()
if (errors.isEmpty()) {
when (finalScore.toInt()) {
in 90..100 -> overallEvaluationBuilder.append("太棒了!你的俯卧撑动作几乎完美无瑕,姿态标准,力量十足!继续保持!")
in 70..89 -> overallEvaluationBuilder.append("非常不错的俯卧撑!动作基本流畅,姿态也比较到位,再稍加注意细节就能更完美!")
in 50..69 -> overallEvaluationBuilder.append("俯卧撑动作有进步空间哦!虽然有些地方做得不错,但还需要多练习,让姿态更稳定、发力更集中。")
in 30..49 -> overallEvaluationBuilder.append("本次俯卧撑需要更多练习。动作中存在一些明显的姿态问题,这会影响训练效果和安全性。")
else -> overallEvaluationBuilder.append("俯卧撑动作仍需大量改进。请务必仔细对照标准,从基础开始练习,避免受伤。")
}
} else {
overallEvaluationBuilder.append("本次俯卧撑分析完成!发现了一些可以改进的地方:\n")
overallEvaluationBuilder.append(errors.joinToString("\n"))
overallEvaluationBuilder.append("\n总次数:$repCount")
}
overallEvaluationBuilder.append("\n\n以下是本次训练的详细分析过程,希望能帮助你更好地理解和改进:\n")
overallEvaluationBuilder.append(uniqueMessages)
return overallEvaluationBuilder.toString()
}
}

@ -0,0 +1,218 @@
package org.tensorflow.lite.examples.poseestimation.evaluator
import org.tensorflow.lite.examples.poseestimation.data.KeyPoint
import org.tensorflow.lite.examples.poseestimation.data.BodyPart
import kotlin.math.abs
import kotlin.math.roundToInt
// 定义深蹲动作的阶段
enum class SquatState {
START, // 起始姿态(站立)
DESCENT, // 下蹲过程
BOTTOM, // 底部姿态(深蹲最低点)
ASCENT, // 起身过程
LOCKOUT // 锁定(完成站立)姿态
}
class SquatEvaluator : ExerciseEvaluator {
private var currentState: SquatState = SquatState.START
private var totalScore: Float = 0f
private var frameCount: Int = 0
private var evaluationMessages: MutableList<EvaluationMessage> = mutableListOf()
private var repCount: Int = 0
private var isRepCounting: Boolean = false
// 深蹲关键角度阈值 (示例值,需要根据实际模型和标准进行调整)
private val HIP_ANGLE_START_MAX = 175f // 站立时髋关节角度上限
private val KNEE_ANGLE_START_MAX = 175f // 站立时膝关节角度上限
private val TORSO_ANGLE_START_MAX = 15f // 站立时躯干与垂直方向最大夹角(微前倾)
private val HIP_ANGLE_BOTTOM_MIN = 60f // 深蹲底部髋关节最小角度(深蹲深度)
private val KNEE_ANGLE_BOTTOM_MIN = 70f // 深蹲底部膝关节最小角度(深蹲深度)
private val TORSO_ANGLE_BOTTOM_MAX = 45f // 深蹲底部躯干与垂直方向最大夹角(通常会前倾)
private val BACK_STRAIGHT_THRESHOLD = 160f // 躯干挺直角度阈值 (肩-髋-膝角度,用于判断背部是否弓起)
// Helper function to calculate angle between three keypoints (A-B-C)
// B is the vertex of the angle
private fun calculateAngle(A: KeyPoint, B: KeyPoint, C: KeyPoint): Float {
return B.abttPoints(A, B, C)
}
override fun evaluateFrame(keyPoints: List<KeyPoint>) {
if (keyPoints.isEmpty()) {
evaluationMessages.add(EvaluationMessage("未检测到关键点,无法评估。", true))
return
}
frameCount++
// 获取深蹲所需关键点
val leftShoulder = keyPoints.find { it.bodyPart == BodyPart.LEFT_SHOULDER }
val rightShoulder = keyPoints.find { it.bodyPart == BodyPart.RIGHT_SHOULDER }
val leftHip = keyPoints.find { it.bodyPart == BodyPart.LEFT_HIP }
val rightHip = keyPoints.find { it.bodyPart == BodyPart.RIGHT_HIP }
val leftKnee = keyPoints.find { it.bodyPart == BodyPart.LEFT_KNEE }
val rightKnee = keyPoints.find { it.bodyPart == BodyPart.RIGHT_KNEE }
val leftAnkle = keyPoints.find { it.bodyPart == BodyPart.LEFT_ANKLE }
val rightAnkle = keyPoints.find { it.bodyPart == BodyPart.RIGHT_ANKLE }
// 确保所有关键点都存在,如果缺失,则跳过此帧或给出警告
if (leftShoulder == null || rightShoulder == null || leftHip == null || rightHip == null ||
leftKnee == null || rightKnee == null || leftAnkle == null || rightAnkle == null) {
evaluationMessages.add(EvaluationMessage("关键点缺失,评估可能不准确。", true))
return
}
// 计算左右平均关键点,提高稳定性
val midShoulder = KeyPoint(
BodyPart.NOSE, // 使用 NOSE 作为占位符
android.graphics.PointF(
(leftShoulder.coordinate.x + rightShoulder.coordinate.x) / 2,
(leftShoulder.coordinate.y + rightShoulder.coordinate.y) / 2
),
(leftShoulder.score + rightShoulder.score) / 2
)
val midHip = KeyPoint(
BodyPart.NOSE, // 同样,使用 NOSE 作为占位符
android.graphics.PointF(
(leftHip.coordinate.x + rightHip.coordinate.x) / 2,
(leftHip.coordinate.y + rightHip.coordinate.y) / 2
),
(leftHip.score + rightHip.score) / 2
)
val midKnee = KeyPoint(
BodyPart.NOSE, // 同样,使用 NOSE 作为占位符
android.graphics.PointF(
(leftKnee.coordinate.x + rightKnee.coordinate.x) / 2,
(leftKnee.coordinate.y + rightKnee.coordinate.y) / 2
),
(leftKnee.score + rightKnee.score) / 2
)
val midAnkle = KeyPoint(
BodyPart.NOSE, // 同样,使用 NOSE 作为占位符
android.graphics.PointF(
(leftAnkle.coordinate.x + rightAnkle.coordinate.x) / 2,
(leftAnkle.coordinate.y + rightAnkle.coordinate.y) / 2
),
(leftAnkle.score + rightAnkle.score) / 2
)
// 计算核心角度
val hipAngle = calculateAngle(midShoulder, midHip, midKnee) // 肩-髋-膝
val kneeAngle = calculateAngle(midHip, midKnee, midAnkle) // 髋-膝-踝
// 躯干角度通过肩和髋的y坐标差与x坐标差的反正切并转换为相对于垂直线的角度
val dxTorso = midHip.coordinate.x - midShoulder.coordinate.x
val dyTorso = midHip.coordinate.y - midShoulder.coordinate.y
val torsoAngleVertical = Math.toDegrees(kotlin.math.atan2(dxTorso.toDouble(), dyTorso.toDouble())).toFloat()
val realTorsoAngle = abs(torsoAngleVertical) // 简化处理,取绝对值
var frameScore = 0f
var frameEvaluation = ""
when (currentState) {
SquatState.START -> {
if (hipAngle > HIP_ANGLE_START_MAX - 5 && kneeAngle > KNEE_ANGLE_START_MAX - 5 && realTorsoAngle < TORSO_ANGLE_START_MAX + 5) {
frameEvaluation = "起始姿态良好,准备下蹲。"
isRepCounting = false
currentState = SquatState.DESCENT
frameScore = 300f
} else {
frameEvaluation = "请调整至正确起始姿态:站直,核心收紧。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
}
SquatState.DESCENT -> {
if (hipAngle < HIP_ANGLE_START_MAX && kneeAngle < KNEE_ANGLE_START_MAX) {
if (realTorsoAngle < TORSO_ANGLE_BOTTOM_MAX + 15) { // 下降过程中背部不应过度前倾
frameEvaluation = "下降得很稳,保持背部挺直。"
currentState = SquatState.BOTTOM
frameScore = 300f
} else {
frameEvaluation = "下蹲时背部有点弓起或过度前倾。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
} else {
frameEvaluation = "继续下蹲,感受臀腿发力。"
}
}
SquatState.BOTTOM -> {
if (hipAngle >= HIP_ANGLE_BOTTOM_MIN && kneeAngle >= KNEE_ANGLE_BOTTOM_MIN && realTorsoAngle <= TORSO_ANGLE_BOTTOM_MAX) {
frameEvaluation = "底部姿态非常棒,深蹲深度足够!"
currentState = SquatState.ASCENT
frameScore = 450f
} else {
frameEvaluation = "底部姿态可改进。"
if (hipAngle < HIP_ANGLE_BOTTOM_MIN) evaluationMessages.add(EvaluationMessage("深蹲深度不足,请尝试蹲得更深。", true))
if (kneeAngle < KNEE_ANGLE_BOTTOM_MIN) evaluationMessages.add(EvaluationMessage("膝盖弯曲不足。", true))
if (realTorsoAngle > TORSO_ANGLE_BOTTOM_MAX) evaluationMessages.add(EvaluationMessage("背部过度前倾或弓起。", true))
}
}
SquatState.ASCENT -> {
if (hipAngle > HIP_ANGLE_BOTTOM_MIN && kneeAngle > KNEE_ANGLE_BOTTOM_MIN) {
if (realTorsoAngle < TORSO_ANGLE_START_MAX + 15) { // 上升过程中背部不应过度前倾
frameEvaluation = "起身有力,保持背部挺直。"
currentState = SquatState.LOCKOUT
frameScore = 300f
} else {
frameEvaluation = "起身时背部有点弓起或过度前倾。"
evaluationMessages.add(EvaluationMessage(frameEvaluation, true))
}
} else {
frameEvaluation = "继续起身,将身体推回起始位置。"
}
}
SquatState.LOCKOUT -> {
if (hipAngle > HIP_ANGLE_START_MAX - 5 && kneeAngle > KNEE_ANGLE_START_MAX - 5 && realTorsoAngle < TORSO_ANGLE_START_MAX + 5) {
frameEvaluation = "完美完成一次深蹲!姿态非常棒!"
if (!isRepCounting) {
repCount++
isRepCounting = true
evaluationMessages.add(EvaluationMessage("恭喜你,又完成了一次深蹲!目前累计完成了 $repCount 次,继续保持!"))
}
currentState = SquatState.START
frameScore = 150f
} else {
frameEvaluation = "请完成锁定:髋部和膝盖完全伸展,全身收紧。"
if (hipAngle < HIP_ANGLE_START_MAX - 5 || kneeAngle < KNEE_ANGLE_START_MAX - 5) evaluationMessages.add(EvaluationMessage("顶部锁定还不够充分哦,髋部和膝盖可以再伸展一点。", true))
if (realTorsoAngle > TORSO_ANGLE_START_MAX + 5) evaluationMessages.add(EvaluationMessage("注意!在锁定阶段,身体可能有点过度后仰。保持核心收紧,稳稳地完成动作。", true))
}
}
}
totalScore += frameScore
evaluationMessages.add(EvaluationMessage(frameEvaluation))
}
override fun getFinalScore(): Float {
val rawScore = if (frameCount > 0) totalScore / frameCount else 0f
val roundedScore = (rawScore / 10.0f).roundToInt() * 10.0f
return roundedScore.coerceIn(0f, 100f)
}
override fun getFinalEvaluation(finalScore: Float): String {
val uniqueMessages = evaluationMessages.map { it.message }.distinct().joinToString("\n")
val errors = evaluationMessages.filter { it.isError }.map { it.message }.distinct()
val overallEvaluationBuilder = StringBuilder()
if (errors.isEmpty()) {
when (finalScore.toInt()) {
in 90..100 -> overallEvaluationBuilder.append("太棒了!你的深蹲动作几乎完美无瑕,姿态标准,力量十足!继续保持!")
in 70..89 -> overallEvaluationBuilder.append("非常不错的深蹲!动作基本流畅,姿态也比较到位,再稍加注意细节就能更完美!")
in 50..69 -> overallEvaluationBuilder.append("深蹲动作有进步空间哦!虽然有些地方做得不错,但还需要多练习,让姿态更稳定、发力更集中。")
in 30..49 -> overallEvaluationBuilder.append("本次深蹲需要更多练习。动作中存在一些明显的姿态问题,这会影响训练效果和安全性。")
else -> overallEvaluationBuilder.append("深蹲动作仍需大量改进。请务必仔细对照标准,从基础开始练习,避免受伤。")
}
} else {
overallEvaluationBuilder.append("本次深蹲分析完成!发现了一些可以改进的地方:\n")
overallEvaluationBuilder.append(errors.joinToString("\n"))
overallEvaluationBuilder.append("\n总次数:$repCount")
}
overallEvaluationBuilder.append("\n\n以下是本次训练的详细分析过程,希望能帮助你更好地理解和改进:\n")
overallEvaluationBuilder.append(uniqueMessages)
return overallEvaluationBuilder.toString()
}
}

@ -119,6 +119,113 @@
android:layout_marginTop="8dp"
android:background="#333" />
<!-- 性别输入框 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="性别"
android:textColor="#AAA"
android:textSize="14sp" />
<EditText
android:id="@+id/edit_gender"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@android:color/transparent"
android:textColor="#FFF"
android:textSize="18sp"
android:hint="请输入性别 (男/女)"
android:textColorHint="#555"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="#333" />
<!-- 年龄输入框 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="年龄"
android:textColor="#AAA"
android:textSize="14sp" />
<EditText
android:id="@+id/edit_age"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@android:color/transparent"
android:textColor="#FFF"
android:textSize="18sp"
android:hint="请输入年龄"
android:inputType="number"
android:textColorHint="#555"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="#333" />
<!-- 体重输入框 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="体重"
android:textColor="#AAA"
android:textSize="14sp" />
<EditText
android:id="@+id/edit_weight"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@android:color/transparent"
android:textColor="#FFF"
android:textSize="18sp"
android:hint="请输入体重 (kg)"
android:inputType="numberDecimal"
android:textColorHint="#555"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="#333" />
<!-- 身高输入框 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="身高"
android:textColor="#AAA"
android:textSize="14sp" />
<EditText
android:id="@+id/edit_height"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@android:color/transparent"
android:textColor="#FFF"
android:textSize="18sp"
android:hint="请输入身高 (cm)"
android:inputType="numberDecimal"
android:textColorHint="#555"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="#333" />
</LinearLayout>
<!-- 保存按钮 -->

@ -3,13 +3,40 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".MainTabActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="#222222"
app:titleTextColor="#FFFFFF">
<TextView
android:id="@+id/toolbar_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="形动力"
android:textColor="#FFBB86FC"
android:textSize="20sp"
android:textStyle="bold" />
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="56dp" />
android:layout_marginBottom="56dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
<LinearLayout
android:id="@+id/bottom_nav"

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1C1C1E"
tools:context=".VideoAnalysisActivity">
<TextView
android:id="@+id/tv_exercise_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="32dp"
android:text="动作名称"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold" />
<Button
android:id="@+id/btn_select_video"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="选择本地视频"
android:textColor="#FFFFFF"
android:backgroundTint="#A020F0"
android:textSize="18sp"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp" />
<TextView
android:id="@+id/tv_analysis_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/btn_select_video"
android:layout_centerHorizontal="true"
android:layout_marginTop="24dp"
android:textColor="#CCCCCC"
android:textSize="16sp"
android:text="请选择视频进行分析..."
android:visibility="gone"/>
<ProgressBar
android:id="@+id/progress_bar_analysis"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/tv_analysis_status"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginTop="16dp"
android:progressTint="#A020F0"
android:visibility="gone" />
</RelativeLayout>

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1C1C1E"
tools:context=".VideoAnalysisResultActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_result_exercise_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="动作名称"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"/>
<TextView
android:id="@+id/tv_result_score_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="最终得分: "
android:textColor="#CCCCCC"
android:textSize="18sp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="8dp"/>
<TextView
android:id="@+id/tv_result_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="N/A"
android:textColor="#A020F0"
android:textSize="36sp"
android:textStyle="bold"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="24dp"/>
<TextView
android:id="@+id/tv_result_evaluation_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="详细评价: "
android:textColor="#CCCCCC"
android:textSize="18sp"
android:layout_marginBottom="8dp"/>
<TextView
android:id="@+id/tv_result_evaluation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="分析中..."
android:textColor="#FFFFFF"
android:textSize="16sp"
android:lineSpacingExtra="4dp"/>
</LinearLayout>
</ScrollView>

@ -1,14 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<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:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1C1C1E">
android:background="#1C1C1E"
tools:context=".DataFragment">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="数据页面"
android:textColor="#FFF"
android:textSize="24sp"
android:layout_gravity="center"/>
</FrameLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view_video_analysis_results"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:clipToPadding="false"
android:background="#1C1C1E"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -82,6 +82,29 @@
</LinearLayout>
</LinearLayout>
<!-- 新增的 我的数据 栏 -->
<TextView
android:id="@+id/btn_my_data"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:text="我的数据"
android:textColor="#FFF"
android:textSize="20sp" />
<LinearLayout
android:id="@+id/layout_my_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:background="#222"
android:visibility="gone">
<TextView android:id="@+id/tv_total_trainings" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="总训练次数0" android:textColor="#FFF" android:textSize="18sp" />
<TextView android:id="@+id/tv_total_calories" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="总消耗卡路里0 kcal" android:textColor="#FFF" android:textSize="18sp" />
<TextView android:id="@+id/tv_total_time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="总训练时间0 分钟" android:textColor="#FFF" android:textSize="18sp" />
</LinearLayout>
<!-- 账户信息和设置按钮 -->
<TextView
android:id="@+id/btn_account_info"

@ -10,7 +10,8 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="vertical"
android:background="#333333">
<ImageView
android:id="@+id/exercise_image"
@ -23,7 +24,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:textColor="#000000"
android:textColor="#FFFFFF"
android:textSize="18sp" />
<TextView
@ -31,7 +32,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:textColor="#666666"
android:textColor="#AAAAAA"
android:textSize="14sp"
android:visibility="gone" />

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
app:cardBackgroundColor="#222222">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:padding="8dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/video_thumbnail"
android:layout_width="100dp"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_image"
android:background="#000000"
android:contentDescription="视频缩略图"
android:clickable="true"
android:focusable="true" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<TextView
android:id="@+id/tv_exercise_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="运动类型:硬拉"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_evaluation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:maxLines="3"
android:ellipsize="end"
android:text="评价:动作完成度很高,但下放深度不足。请注意控制节奏。"
android:textColor="#AAAAAA"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="85 分"
android:textColor="#FFBB86FC"
android:textSize="28sp"
android:textStyle="bold"
android:gravity="center_vertical"/>
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="my_videos" path="."/>
</paths>
Loading…
Cancel
Save