This time I built an offline hand gesture recogniser on a LubanCat 3, running Android 14. The camera sees a hand, draws the skeleton, and when you make an OK sign it automatically saves a screenshot with the skeleton drawn on it.
The model is Google's MediaPipe. It already finds 21 hand keypoints and classifies seven static gestures. What is left for you is the camera, the screen coordinates, and turning recognition results into actual actions.
Use the model as it comes
The Android side is written in Kotlin, the camera uses CameraX, and recognition uses MediaPipe Tasks. These are the dependencies.
def cameraxVersion = '1.4.2'
implementation "androidx.camera:camera-core:$cameraxVersion"
implementation "androidx.camera:camera-camera2:$cameraxVersion"
implementation "androidx.camera:camera-lifecycle:$cameraxVersion"
implementation 'com.google.mediapipe:tasks-vision:1.0.0'
Put the official model into app/src/main/assets, point the recogniser at that file when you create it, set the running mode to LIVE_STREAM and take results from the callback. The official Android example already wires it up completely; reading that is faster than guessing from an empty project.
The model ships inside the APK and inference runs on the board, so at runtime it only needs camera permission.

The base image comes from MediaPipe's official test image; this is the board-side output from this project. The red dots are joints, the blue lines mark the thumb, and the remaining connections are green.
Don't open two camera streams
The usual CameraX setup uses Preview to show the image and ImageAnalysis to feed frames to the model. On the firmware for my RK3576 board, opening both at once hangs.
In the end I kept a single ImageAnalysis and shared it between preview and recognition. After getting a 1920×1080 image, I handle the row stride, scale the longest side down to 640 pixels, then rotate and mirror it consistently before feeding it to the model.
The frame strategy is KEEP_ONLY_LATEST: if it cannot keep up, drop the old frames instead of queueing recognition. The current code also throttles submissions to at least 100 ms apart, so the submission rate tops out at roughly 10 frames per second; what you actually see still depends on the inference callback.
Always close ImageProxy when you are done, including on skipped frames and error paths. If the buffer is held, the camera may simply stop producing frames later.
Also, on the board, CAM0, Android's Camera ID 0 and /dev/video0 have to be checked separately. This project checks the physical m00_ module, the logical ID 0 and the current firmware's FRONT attribute together, and stops if the conditions are not met. That mapping only holds for the board and firmware I verified.
Why the skeleton drifts
You have to look at both coordinates and timing.
On coordinates: the model returns normalised keypoints, but the preview uses centerCrop. The image is scaled up first and then cropped to the display area. Multiplying keypoints by the screen size directly obviously will not line up.
To project keypoints onto the screen you have to use the same scale and crop offset as the preview. Below, x and y are the normalised coordinates from the model, and srcW, srcH are the width and height of the model's input image.
scale = max(viewW / srcW, viewH / srcH)
offsetX = (viewW - srcW * scale) / 2
offsetY = (viewH - srcH * scale) / 2
screenX = x * srcW * scale + offsetX
screenY = y * srcH * scale + offsetY
These divisions need floating point. Rotation and mirroring are also handled before inference so that the preview and the model do not each compute their own direction.
On timing: the hand has already moved away and the previous frame's result comes back. Overlaying an old skeleton on a new image is a bit like an out-of-body experience.
My fix is to take the matching input image out of the result callback and update the interface with this frame's keypoints at the same time. The image and the skeleton have to belong to the same frame. The cost is that the display waits for the recognition result, and preview smoothness suffers with it.
There is another condition that is easy to miss. A classification result of None only means no supported gesture was recognised. As long as the keypoints are there, keep drawing the skeleton; only clear it when no hand is detected, the result is stale, or the app goes to the background.
The OK screenshot needs burst protection
None of the official seven classes is OK, so I added a geometric check on top of the existing keypoints: the thumb tip and index tip close together, the index finger bent, and the other three fingers straight.
Distances are normalised by palm size and combined with joint angles. With a fixed pixel distance, extending your hand a little further makes the threshold wrong. This uses the 2D coordinates restored from the input dimensions, so it stays consistent with the skeleton on screen.
Only then comes the trigger control. Every frame of video can recognise an OK, so if you keep holding your hand up it keeps saving images, and the photo library cannot take that.
| Condition | Current rule |
|---|---|
| Confirm the gesture | At least 4 strong matches in the most recent 5 frames, with the current frame matching too, and the window lasting at least 300 ms |
| Lock after triggering | Hold the OK sign and only one photo is taken |
| Allow another photo | Leave OK for at least 600 ms, and at least 1.5 seconds between triggers |
Make the trigger condition strict and the hold condition loose, to reduce jitter around the boundary. If sampling breaks for more than 400 ms, the old observation window is cleared. These are the current settings; actual trigger timing still depends on the frame rate.
The screenshot composites the current image and the skeleton from the same frame, then saves it as a PNG through MediaStore. Only those two layers are drawn, so the status bar and dialogs do not get mixed in. Encoding and writing to disk go on a separate thread so they do not block camera recognition.
What is saved is a composite of the displayed image; the input was already scaled down, so it is not the sensor's original image.
This version still runs Delegate.CPU. The RK3576 has an NPU, but an app needs RKNN integration to use it. RKNN's official pipeline includes model conversion and runtime integration, and the pre- and post-processing have to be realigned too; changing one setting will not do it.






