diff --git a/android/.gradle/8.5/checksums/checksums.lock b/android/.gradle/8.5/checksums/checksums.lock index 4ef611b..ae37106 100644 Binary files a/android/.gradle/8.5/checksums/checksums.lock and b/android/.gradle/8.5/checksums/checksums.lock differ diff --git a/android/.gradle/8.5/checksums/md5-checksums.bin b/android/.gradle/8.5/checksums/md5-checksums.bin index 6054570..36f1fc6 100644 Binary files a/android/.gradle/8.5/checksums/md5-checksums.bin and b/android/.gradle/8.5/checksums/md5-checksums.bin differ diff --git a/android/.gradle/8.5/checksums/sha1-checksums.bin b/android/.gradle/8.5/checksums/sha1-checksums.bin index 6bb3849..c968a6d 100644 Binary files a/android/.gradle/8.5/checksums/sha1-checksums.bin and b/android/.gradle/8.5/checksums/sha1-checksums.bin differ diff --git a/android/.gradle/8.5/executionHistory/executionHistory.bin b/android/.gradle/8.5/executionHistory/executionHistory.bin index 79d1c30..ec82e1f 100644 Binary files a/android/.gradle/8.5/executionHistory/executionHistory.bin and b/android/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/android/.gradle/8.5/executionHistory/executionHistory.lock b/android/.gradle/8.5/executionHistory/executionHistory.lock index 21a4f51..1f3aa40 100644 Binary files a/android/.gradle/8.5/executionHistory/executionHistory.lock and b/android/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/android/.gradle/8.5/fileHashes/fileHashes.bin b/android/.gradle/8.5/fileHashes/fileHashes.bin index ffa5ed6..bfa6bb8 100644 Binary files a/android/.gradle/8.5/fileHashes/fileHashes.bin and b/android/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/android/.gradle/8.5/fileHashes/fileHashes.lock b/android/.gradle/8.5/fileHashes/fileHashes.lock index 45d5cde..ccb5e0e 100644 Binary files a/android/.gradle/8.5/fileHashes/fileHashes.lock and b/android/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/android/.gradle/8.5/fileHashes/resourceHashesCache.bin b/android/.gradle/8.5/fileHashes/resourceHashesCache.bin index e8869ed..e2f2aab 100644 Binary files a/android/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/android/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock index d350e3d..d603f86 100644 Binary files a/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/android/.gradle/buildOutputCleanup/outputFiles.bin b/android/.gradle/buildOutputCleanup/outputFiles.bin index 1e5dc95..d05cd6f 100644 Binary files a/android/.gradle/buildOutputCleanup/outputFiles.bin and b/android/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/android/.gradle/config.properties b/android/.gradle/config.properties new file mode 100644 index 0000000..428649b --- /dev/null +++ b/android/.gradle/config.properties @@ -0,0 +1,2 @@ +#Thu Jun 05 00:28:15 CST 2025 +java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home diff --git a/android/.gradle/file-system.probe b/android/.gradle/file-system.probe index 91c6f0e..74e41fc 100644 Binary files a/android/.gradle/file-system.probe and b/android/.gradle/file-system.probe differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 0595b7f..556abdd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4727907..d3f87af 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ package="org.tensorflow.lite.examples.poseestimation"> + @@ -39,6 +40,18 @@ android:exported="false" /> + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/AgeSelectionActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/AgeSelectionActivity.kt index 6dda173..b3ccc61 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/AgeSelectionActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/AgeSelectionActivity.kt @@ -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() } diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/DataFragment.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/DataFragment.kt index 250c609..70e7ff1 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/DataFragment.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/DataFragment.kt @@ -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") + } + } + } + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/EditProfileActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/EditProfileActivity.kt index f36257a..77a87b7 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/EditProfileActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/EditProfileActivity.kt @@ -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) { - Toast.makeText(this@EditProfileActivity, "资料保存成功", Toast.LENGTH_SHORT).show() - // 设置Result为RESULT_OK,通知SettingFragment数据已更新 (可选,取决于SettingFragment是否需要知道保存成功) - setResult(Activity.RESULT_OK) - finish() // 保存成功后结束当前Activity,返回Setting页面 + if (rowsUpdated > 0) { + Toast.makeText(this@EditProfileActivity, "资料保存成功", Toast.LENGTH_SHORT).show() + setResult(Activity.RESULT_OK) + finish() + } else { + Toast.makeText(this@EditProfileActivity, "资料保存失败:未找到对应用户档案", Toast.LENGTH_LONG).show() + } } } } diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/ExerciseDetailActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/ExerciseDetailActivity.kt index d4e540d..262e5d0 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/ExerciseDetailActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/ExerciseDetailActivity.kt @@ -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) diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/GenderSelectionActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/GenderSelectionActivity.kt index 202b14e..fdbaf6c 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/GenderSelectionActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/GenderSelectionActivity.kt @@ -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) } } diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/HeightSelectionActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/HeightSelectionActivity.kt index ac9121e..cad8040 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/HeightSelectionActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/HeightSelectionActivity.kt @@ -43,8 +43,10 @@ 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) height1Above = findViewById(R.id.height1Above) @@ -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) - db.userProfileDao().insertUserProfile(profile) - } catch (_: Exception) {} + 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) + 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) diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/MainTabActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/MainTabActivity.kt index adc9a94..0b93025 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/MainTabActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/MainTabActivity.kt @@ -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) { diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/Onboarding3Fragment.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/Onboarding3Fragment.kt index 3ad9896..df66273 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/Onboarding3Fragment.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/Onboarding3Fragment.kt @@ -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(R.id.small_butto_container) startButton.setOnClickListener { // 跳转到性别选择页面 val intent = Intent(requireActivity(), GenderSelectionActivity::class.java) + intent.putExtra("username", username) // 传递用户名 startActivity(intent) requireActivity().finish() // 结束当前的OnboardingActivity } diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingActivity.kt index aa1a487..74993ff 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingActivity.kt @@ -9,7 +9,9 @@ class OnboardingActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_onboarding) + val username = intent.getStringExtra("username") + val viewPager = findViewById(R.id.viewPager) - viewPager.adapter = OnboardingAdapter(this) + viewPager.adapter = OnboardingAdapter(this, username) } } \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingAdapter.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingAdapter.kt index 7c91147..cffa2a9 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingAdapter.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/OnboardingAdapter.kt @@ -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 } } \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SettingFragment.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SettingFragment.kt index c1170d2..60f0258 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SettingFragment.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SettingFragment.kt @@ -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,14 +194,41 @@ 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 分钟" + } + } + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SignupActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SignupActivity.kt index d6c9ce7..cffa659 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SignupActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/SignupActivity.kt @@ -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() // 结束注册界面 } diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisActivity.kt new file mode 100644 index 0000000..5244ec3 --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisActivity.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisAdapter.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisAdapter.kt new file mode 100644 index 0000000..bb8c5d6 --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisAdapter.kt @@ -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) : RecyclerView.Adapter() { + + 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 +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisResultActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisResultActivity.kt new file mode 100644 index 0000000..5295a9a --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/VideoAnalysisResultActivity.kt @@ -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(R.id.tv_result_exercise_name) + val tvScore = findViewById(R.id.tv_result_score) + val tvEvaluation = findViewById(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 = "分析结果" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/WeightSelectionActivity.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/WeightSelectionActivity.kt index 1384a9b..d363e93 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/WeightSelectionActivity.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/WeightSelectionActivity.kt @@ -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() } diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/AppDatabase.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/AppDatabase.kt index c12e6a5..4ddc78d 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/AppDatabase.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/AppDatabase.kt @@ -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 diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfile.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfile.kt index 07be1ce..e76b8df 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfile.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfile.kt @@ -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 ) \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfileDao.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfileDao.kt index 70456fb..cb440b5 100644 --- a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfileDao.kt +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/UserProfileDao.kt @@ -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 diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/VideoAnalysisResult.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/VideoAnalysisResult.kt new file mode 100644 index 0000000..60decaa --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/VideoAnalysisResult.kt @@ -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 +) \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/VideoAnalysisResultDao.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/VideoAnalysisResultDao.kt new file mode 100644 index 0000000..be6778b --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/data/VideoAnalysisResultDao.kt @@ -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> + + // 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> +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/DeadliftEvaluator.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/DeadliftEvaluator.kt new file mode 100644 index 0000000..9fa843f --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/DeadliftEvaluator.kt @@ -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 = 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) { + 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() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/ExerciseEvaluator.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/ExerciseEvaluator.kt new file mode 100644 index 0000000..ca3443d --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/ExerciseEvaluator.kt @@ -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) + fun getFinalScore(): Float + fun getFinalEvaluation(finalScore: Float): String +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PlankEvaluator.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PlankEvaluator.kt new file mode 100644 index 0000000..60ed828 --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PlankEvaluator.kt @@ -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 = 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) { + 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() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PullUpEvaluator.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PullUpEvaluator.kt new file mode 100644 index 0000000..db4289f --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PullUpEvaluator.kt @@ -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 = 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) { + 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() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PushUpEvaluator.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PushUpEvaluator.kt new file mode 100644 index 0000000..c30510d --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/PushUpEvaluator.kt @@ -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 = 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) { + 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() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/SquatEvaluator.kt b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/SquatEvaluator.kt new file mode 100644 index 0000000..5137b0f --- /dev/null +++ b/android/app/src/main/java/org/tensorflow/lite/examples/poseestimation/evaluator/SquatEvaluator.kt @@ -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 = 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) { + 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() + } +} \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_edit_profile.xml b/android/app/src/main/res/layout/activity_edit_profile.xml index 6710178..9a78fe3 100644 --- a/android/app/src/main/res/layout/activity_edit_profile.xml +++ b/android/app/src/main/res/layout/activity_edit_profile.xml @@ -119,6 +119,113 @@ android:layout_marginTop="8dp" android:background="#333" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main_tab.xml b/android/app/src/main/res/layout/activity_main_tab.xml index da339d4..f13cff6 100644 --- a/android/app/src/main/res/layout/activity_main_tab.xml +++ b/android/app/src/main/res/layout/activity_main_tab.xml @@ -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"> + + + + + + + + + + + android:layout_marginBottom="56dp" + app:layout_behavior="@string/appbar_scrolling_view_behavior"/> + + + + +