feedbackui
can show in recording
This commit is contained in:
@@ -61,6 +61,18 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
// doc. Open only while a recording is in progress.
|
||||
private lateinit var debugSessionLogger: DebugSessionLogger
|
||||
|
||||
// class for FeedbackUI
|
||||
private lateinit var feedbackUI: FeedbackUI
|
||||
private val stepLabels = listOf(
|
||||
R.string.starting_position,
|
||||
R.string.first_step,
|
||||
R.string.second_step,
|
||||
R.string.third_step,
|
||||
R.string.fourth_step,
|
||||
R.string.end_position
|
||||
)
|
||||
private var currentStepIndex = 0
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ ->
|
||||
val granted = CameraPermissions.allGranted(this)
|
||||
@@ -87,7 +99,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
cameraXController = CameraXController(applicationContext, cameraExecutor)
|
||||
debugSessionLogger = DebugSessionLogger(applicationContext)
|
||||
feedbackUI = FeedbackUI(this, binding.root)
|
||||
|
||||
binding.poseOverlay.attachFeedback(feedbackUI)
|
||||
binding.btnGrantPermissions.setOnClickListener {
|
||||
permissionLauncher.launch(CameraPermissions.REQUIRED)
|
||||
}
|
||||
@@ -95,6 +109,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
binding.switchPose.setOnCheckedChangeListener { _, isChecked -> viewModel.onPoseToggled(isChecked) }
|
||||
binding.btnSwitchCamera.setOnClickListener { onSwitchCameraClicked() }
|
||||
binding.btnBack.setOnClickListener { finish() }
|
||||
binding.btnShowStep.setOnClickListener { onStepIncrease() }
|
||||
|
||||
observeViewModel()
|
||||
|
||||
@@ -114,7 +129,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
lifecycleOwner = this,
|
||||
previewView = binding.cameraPreview,
|
||||
callback = this,
|
||||
lensFacing = lensFacing
|
||||
lensFacing = lensFacing,
|
||||
feedbackUi = feedbackUI
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,12 +232,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
// Pose mode can only be changed between recordings, not
|
||||
// mid-flight -- see setPoseDetectionEnabled()'s doc comment.
|
||||
binding.switchPose.isEnabled = true
|
||||
// Feedback UI - buttons only shown when recording
|
||||
binding.btnShowStep.isEnabled = false
|
||||
}
|
||||
is CameraViewModel.RecordingState.Starting -> {
|
||||
// Can't stop a recording that hasn't started yet, and pose
|
||||
// mode for it is already locked in.
|
||||
binding.btnRecord.isEnabled = false
|
||||
binding.switchPose.isEnabled = false
|
||||
binding.btnShowStep.isEnabled = true
|
||||
binding.btnShowStep.setText(R.string.starting_position)
|
||||
}
|
||||
is CameraViewModel.RecordingState.Recording -> {
|
||||
binding.btnRecord.isEnabled = true
|
||||
@@ -231,6 +251,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
val minutes = state.elapsedSeconds / 60
|
||||
val seconds = state.elapsedSeconds % 60
|
||||
binding.textTimer.text = String.format(Locale.US, "%02d:%02d", minutes, seconds)
|
||||
binding.btnShowStep.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,4 +368,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
cameraExecutor.shutdown()
|
||||
debugSessionLogger.stop()
|
||||
}
|
||||
|
||||
fun onStepIncrease() {
|
||||
currentStepIndex = (currentStepIndex + 1) % stepLabels.size
|
||||
binding.btnShowStep.setText(stepLabels[currentStepIndex])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ class CameraXController(
|
||||
}
|
||||
}
|
||||
|
||||
private var feedbackUI: FeedbackUI? = null
|
||||
|
||||
/** @brief Whether a video recording is currently in progress. */
|
||||
val isRecording: Boolean
|
||||
get() = activeRecording != null
|
||||
@@ -140,10 +142,12 @@ class CameraXController(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
previewView: PreviewView,
|
||||
callback: Callback,
|
||||
lensFacing: Int = CameraSelector.LENS_FACING_BACK
|
||||
lensFacing: Int = CameraSelector.LENS_FACING_BACK,
|
||||
feedbackUi: FeedbackUI
|
||||
) {
|
||||
this.callback = callback
|
||||
this.currentLensFacing = lensFacing
|
||||
this.feedbackUI = feedbackUi
|
||||
|
||||
val providerFuture = ProcessCameraProvider.getInstance(appContext)
|
||||
providerFuture.addListener({
|
||||
@@ -267,6 +271,8 @@ class CameraXController(
|
||||
mirror = frame.isMirroring
|
||||
)
|
||||
PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint)
|
||||
feedbackUI?.drawCircles(canvas, poseFrame.landmarks, transform, true)
|
||||
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step", true)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import android.text.Layout
|
||||
import android.text.TextPaint
|
||||
import android.text.StaticLayout
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.content.Context
|
||||
import android.graphics.Paint
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.example.jnicpp.R
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.graphics.Matrix
|
||||
import kotlin.math.min
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
|
||||
|
||||
class FeedbackUI(private val context: Context, private val rootView: View) {
|
||||
private var landmarks: Map<Int, SmoothedLandmark>? = null
|
||||
private var CIRCLE_RADIUS = 128f
|
||||
private val uiTextSize = 32f
|
||||
private val uiStrokeWidth = 12f
|
||||
private val bannerPaddingY = 24
|
||||
private val bannerPaddingX = 24
|
||||
private val camScale = 0.5f
|
||||
|
||||
private var liveUiWidth: Int = 0
|
||||
private var liveUiHeight: Int = 0
|
||||
|
||||
private val glowPaint = Paint().apply {
|
||||
color = Color.RED
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
strokeWidth = uiStrokeWidth // thicker stroke so glow is visible
|
||||
maskFilter = BlurMaskFilter(25f, BlurMaskFilter.Blur.OUTER)
|
||||
}
|
||||
|
||||
private val circlePaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
strokeWidth = uiStrokeWidth
|
||||
}
|
||||
|
||||
private val textPaint = TextPaint().apply {
|
||||
color = Color.WHITE
|
||||
textSize = uiTextSize
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val bgPaint = Paint().apply {
|
||||
color = Color.argb(64, 255, 255, 255)
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
init {
|
||||
rootView.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
setLiveUiSize(rootView)
|
||||
}
|
||||
}
|
||||
|
||||
fun showBanner(canvas: Canvas, message: String, forRecord: Boolean = false) {
|
||||
val scale = if (forRecord) computeCamScale(canvas) else 1f
|
||||
val paddingX = bannerPaddingX * scale
|
||||
val paddingY = bannerPaddingY * scale
|
||||
val maxWidth = (canvas.width * 0.8f).toInt()
|
||||
|
||||
// Build StaticLayout for multi-line text
|
||||
textPaint.textSize = uiTextSize * scale
|
||||
val staticLayout = StaticLayout.Builder
|
||||
.obtain(message, 0, message.length, textPaint, maxWidth)
|
||||
.setAlignment(Layout.Alignment.ALIGN_CENTER) // center text horizontally
|
||||
.setLineSpacing(0f, 1f)
|
||||
.setIncludePad(false)
|
||||
.build()
|
||||
|
||||
// Use StaticLayout dimensions
|
||||
val textWidth = staticLayout.width.toFloat()
|
||||
val textHeight = staticLayout.height.toFloat()
|
||||
|
||||
val bannerWidth = textWidth + paddingX * 2
|
||||
val bannerHeight = textHeight + paddingY * 2
|
||||
|
||||
// Center horizontally
|
||||
val left = (canvas.width - bannerWidth) / 2f
|
||||
val top = 172f * scale
|
||||
val right = left + bannerWidth
|
||||
val bottom = top + bannerHeight
|
||||
|
||||
// Draw background
|
||||
val rect = RectF(left, top, right, bottom)
|
||||
|
||||
// Scale corner radius too
|
||||
val cornerRadius = 24f * scale
|
||||
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
|
||||
|
||||
// Draw text layout inside background
|
||||
canvas.save()
|
||||
canvas.translate(left + paddingX, top + paddingY)
|
||||
staticLayout.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// --- Shape overlay (circle) ---
|
||||
fun drawCircles(canvas: Canvas, landmarks: Map<Int, SmoothedLandmark>, transform: Matrix, forRecord: Boolean = false) {
|
||||
for (landmark in landmarks.values) {
|
||||
val point = floatArrayOf(landmark.x, landmark.y)
|
||||
transform.mapPoints(point)
|
||||
drawCircle(canvas, point[0], point[1], 0f, forRecord)
|
||||
}
|
||||
}
|
||||
|
||||
fun drawCircle(canvas: Canvas, x: Float, y: Float, radius: Float = 0f, forRecord: Boolean = false) {
|
||||
val scale = if (forRecord) camScale else 1f
|
||||
|
||||
var rad = (if (radius == 0f) CIRCLE_RADIUS else radius) * scale
|
||||
circlePaint.strokeWidth = uiStrokeWidth * scale
|
||||
glowPaint.strokeWidth = uiStrokeWidth * scale
|
||||
canvas.drawCircle(x, y, rad, circlePaint)
|
||||
canvas.drawCircle(x, y, rad, glowPaint)
|
||||
}
|
||||
|
||||
// Store live UI size once
|
||||
fun setLiveUiSize(rootView: View) {
|
||||
liveUiWidth = rootView.width
|
||||
liveUiHeight = rootView.height
|
||||
}
|
||||
|
||||
// Compute scale when drawing to recording canvas
|
||||
private fun computeCamScale(recordCanvas: Canvas): Float {
|
||||
if (liveUiWidth == 0 || liveUiHeight == 0) return 1f
|
||||
val scaleX = recordCanvas.width.toFloat() / liveUiWidth.toFloat()
|
||||
val scaleY = recordCanvas.height.toFloat() / liveUiHeight.toFloat()
|
||||
return min(scaleX, scaleY)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.jnicpp.R
|
||||
import com.google.mlkit.vision.pose.PoseLandmark // for testing
|
||||
|
||||
/**
|
||||
* @brief Draws the 33 ML Kit pose landmarks and connecting skeleton lines
|
||||
@@ -63,6 +64,8 @@ class PoseOverlayView @JvmOverloads constructor(
|
||||
|
||||
private var transform = Matrix()
|
||||
|
||||
private var feedbackUI: FeedbackUI? = null
|
||||
|
||||
/**
|
||||
* @brief Updates the view with the latest analyzer result and triggers a redraw.
|
||||
* @param frame The latest analyzer result to draw, or null to clear the overlay.
|
||||
@@ -123,5 +126,25 @@ class PoseOverlayView @JvmOverloads constructor(
|
||||
val currentLandmarks = landmarks ?: return
|
||||
PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint)
|
||||
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) }
|
||||
|
||||
// currentLandmarks to change to landmarks that require highlighting
|
||||
// test code to contain only left wrist in currentLandmarks to not clutter the screen
|
||||
val leftWrist = currentLandmarks[PoseLandmark.LEFT_WRIST]
|
||||
|
||||
// Build a single‑item map if it exists
|
||||
val singleLandmark = if (leftWrist != null) {
|
||||
mapOf(PoseLandmark.LEFT_WRIST to leftWrist)
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
// end test code
|
||||
feedbackUI?.drawCircles(canvas, singleLandmark, transform)
|
||||
|
||||
// Trigger feedback advice for this step
|
||||
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step efsdfdg d dg df gdgdfg df ")
|
||||
}
|
||||
|
||||
fun attachFeedback(feedbackUI: FeedbackUI) {
|
||||
this.feedbackUI = feedbackUI
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,17 @@
|
||||
app:layout_constraintBottom_toTopOf="@id/btn_record"
|
||||
app:layout_constraintEnd_toEndOf="@id/btn_record" />
|
||||
|
||||
<!-- to only show when recording-->
|
||||
<Button
|
||||
android:id="@+id/btn_show_step"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/starting_position"
|
||||
android:layout_marginBottom="32dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<!-- Shown instead of the camera UI when CAMERA/RECORD_AUDIO aren't granted -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_permission_rationale"
|
||||
|
||||
@@ -85,6 +85,17 @@
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- to only show when recording-->
|
||||
<Button
|
||||
android:id="@+id/btn_show_step"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/starting_position"
|
||||
android:layout_marginBottom="128dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<!-- Live pose overlay + baked-in-recording toggle. Only togglable while
|
||||
not recording (see BowlingCameraActivity#renderRecordingState) since
|
||||
the recording pipeline picks its pose mode once at start. -->
|
||||
|
||||
@@ -18,4 +18,10 @@
|
||||
<string name="error_camera_unavailable">Camera unavailable: %1$s</string>
|
||||
<string name="error_recording_failed">Recording failed: %1$s</string>
|
||||
<string name="error_pose_detector">Pose detector error: %1$s</string>
|
||||
<string name="starting_position">Starting Position</string>
|
||||
<string name="first_step">1st Step</string>
|
||||
<string name="second_step">2nd Step</string>
|
||||
<string name="third_step">3rd Step</string>
|
||||
<string name="fourth_step">4th Step</string>
|
||||
<string name="end_position">Ending Position</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user