Imported from amoeher/MizuFace (
AGENTS.md). Install upstream withnpx skills add amoeher/MizuFace. Copyright stays with the author.
AGENTS.md
Project Snapshot
- Android app (
:app) for real-time face tracking and VTuber streaming over UDP (com.amoherom.mizuface). - Primary runtime path is
MainActivity-> Navigation graph ->VtuberPCFragment(app/src/main/res/navigation/nav_graph.xml). - Core stack: CameraX + MediaPipe Face Landmarker + manual JSON/UDP transport.
Architecture That Matters
app/src/main/java/com/amoherom/mizuface/fragment/VtuberPCFragment.ktis the orchestration hub (camera setup, inference callbacks, UI updates, network send, prefs).app/src/main/java/com/amoherom/mizuface/FaceLandmarkerHelper.ktwraps MediaPipe setup/inference and ownsRunningModeconstraints and delegate selection.app/src/main/java/com/amoherom/mizuface/UDPListner.ktlistens for tracker discovery broadcasts ("iOSTrackingDataRequest") on port21412.app/src/main/java/com/amoherom/mizuface/CameraFov.ktconverts camera metadata into FOV math used for face position estimation; also exposeswidthAtDistance(distanceCm, fov)andheightAtDistance(distanceCm, fov)utilities consumed inonResults().MainViewModelstores landmarker thresholds/delegate across fragment recreation; no repository/data layer exists.app/src/main/java/com/amoherom/mizuface/Blendshape.kt— enum whose members exactly mirror the keys inBlenshapeMapper.blendshapeBundle; not used in the live-stream path but defines the canonical shape vocabulary.app/src/main/java/com/amoherom/mizuface/BlendshapeRow.kt— data class(blendshapeName, blendshapeProgress: ProgressBar, blendshapeWeight: EditText, blendshapeValue: Float, settings: LinearLayout, @Volatile cachedMultiplier: Float)linking inflated UI rows to per-frame inference values.cachedMultipliercaches the parsed weight float to avoid per-frametoFloatOrNull()on the EditText string;settingsis the collapsible row panel.app/src/main/java/com/amoherom/mizuface/fragment/FaceBlendshapesResultAdapter.kt— RecyclerView adapter powering the blendshape debug preview panel; holds a 52-slotcategorieslist, sorted by descending score per frame.app/src/main/java/com/amoherom/mizuface/fragment/SelectableText.kt— customAppCompatTextViewsubclass; overridessetSelected()to switch text color (color_text_active/color_text_dark) and typeface (BOLD / NORMAL). Used as theBlendshapeSelectorandTrackingSelectortab labels infragment_vtuber_pc.xml.app/src/main/java/com/amoherom/mizuface/OverlayView.ktis a stub (class OverlayViewwith no body); not wired into any layout or use-case.
End-to-End Data Flow
- Camera frames:
ImageAnalysis(OUTPUT_IMAGE_FORMAT_RGBA_8888) ->detectFace()->FaceLandmarkerHelper.detectLiveStream(). - Inference callback:
VtuberPCFragment.onResults()computes rotation/position/eye vectors from landmarks + iris points. - Blendshape names come from
BlenshapeMapper.blendshapeBundle; weights are multiplied by per-shaperow.cachedMultipliervalues (not re-parsed from EditText each frame) before sending. - Payload format:
buildTrackerFaceJson()emits compact JSON with keysTimestamp,Hotkey(fixed-1),FaceFound(fixedtrue),Rotation,Position,EyeLeft,EyeRight,BlendShapes. It also accepts avnYanPosparameter (always passed asTriple(0,0,0)currently — placeholder for future VNyan position support). Do not removeHotkey/FaceFound; VSeeFace/VNyan expect them. buildJson()is a thin wrapper that passes through tobuildTrackerFaceJson(); edit the latter for payload changes.- Transport:
DatagramSocketsends toPC_IP:PC_PORT(default port string initialized as50509). - Default per-shape weight pref value is
"1"(thegetPref()fallback), meaning a 1× passthrough when no user edit has been saved.
Project-Specific Conventions
- Preference storage is activity-scoped via
activity?.getPreferences(Context.MODE_PRIVATE)inVtuberPCFragment; keys likepc_ip,pc_port,cam_cover_id, and each blendshape name. - UI blendshape rows are created dynamically by inflating
app/src/main/res/layout/blendshape_row.xmlintoblendshapeList. Each row's weight is edited inline viablendshapeWeightEditText (usingsetOnEditorActionListenerfor IME_ACTION_DONE andsetOnFocusChangeListenerfor focus-lost); this saves to prefs and callsInitiatePCConnection(). Do not switch blendshape weights to an AlertDialog flow. - IP/Port editing uses the AlertDialog + temporary EditText pattern: show dialog pre-filled with
"$PC_IP:$PC_PORT", validate, callsavePref(), then callInitiatePCConnection(). This pattern is only used for IP/Port, not for blendshape weights. - Clicking a blendshape row toggles the visibility of its
settingsLinearLayout (containsblendshapeMin,blendshapeMax, andblendshapeWeightEditTexts).blendshapeMinandblendshapeMaxare present inblendshape_row.xmlbut are not yet read or applied inVtuberPCFragment. - Navigation fallback pattern: camera permission checks redirect to
PermissionsFragmentboth ononViewCreatedandonResume. - Naming is inconsistent in legacy code (
BlenshapeMapper,UDPListner,InitiatePCConnection); preserve existing names unless refactoring broadly. - IP:Port is entered as a single colon-separated string (
"192.168.1.x:50509"), validated by regex^(\d{1,3}(\.\d{1,3}){3})(\s*:\s*\d{1,5})?$before splitting. pcLinkStateImageView cycles through four states:search(discovery/scanning),connecting(socket being opened),link_connected(socket open and connected),unlink(socket closed or error).- Camera cover pref
cam_cover_idstores"1"→ animatedidlerecdrawable,"2"→ solidcover_soliddrawable. - Tab navigation in
blend_shape_preview:BlendshapeSelectorandTrackingSelectorareSelectableTextviews;ShowBlendshapes()/ShowTrackingSettings()set their.isSelectedstates.hideControlsButton(bottomButton) toggles visibility of thecameraPreviewCardView andblendShapePreviewFrameLayout entirely; it is hidden while the keyboard is open. - Keyboard/IME insets are handled via
ViewCompat.setOnApplyWindowInsetsListeneronbinding.root; when the keyboard opens, bottom padding adjusts and the scroll view auto-scrolls to the focused EditText.
Build/Test/Debug Workflows
- Windows wrapper is present:
gradlew.bat; standard Android/Gradle project layout. - Verified in this environment: Gradle cannot run until Java is configured (
JAVA_HOME/javamissing). - After Java setup, start with:
./gradlew.bat :app:assembleDebug./gradlew.bat :app:testDebugUnitTest./gradlew.bat :app:connectedDebugAndroidTest(device/emulator required)
- App version:
versionCode = 2/versionName = "2.0",minSdk = 29,targetSdk = 35,compileSdk = 36. - Both
compose = trueandviewBinding = trueare declared inbuildFeatures; the entire UI is XML + ViewBinding — no Compose UI is actually rendered. Do not add Compose UI without aligning the architecture.
Integration Notes
- Model asset path is fixed to
face_landmarker.task(loaded byFaceLandmarkerHelperfrom app assets). - Camera lens switching toggles front/back and recomputes FOV before rebinding use cases.
- Discovery and streaming are UDP-based; keep protocol compatibility with VSeeFace/VNyan JSON expectations when editing payload keys or units.
- Manifest currently requests
CAMERAandACCESS_WIFI_STATE; permission UI only requests camera at runtime. - Specific MediaPipe landmark indices used in
onResults(): nose tip = 1; left eye outer/inner/top/bottom = 33/133/159/145; right eye = 263/362/386/374; left iris = 468; right iris = 473. Face has 478 landmarks total when iris tracking is enabled. - Hard-coded motion weights (all in
VtuberPCFragment):EYE_WEIGHT = 80,HEAD_YAW_WEIGHT = 90/π ≈ 28.6,HEAD_PITCH_WEIGHT = 1000/π ≈ 318.3,HEAD_ROLL_WEIGHT = 100/π ≈ 31.8,CAMERA_FOV_CM = 20f,ipdCm = 6.3f. These are candidates for future global-settings exposure (see roadmap). FaceLandmarkerHelper.LandmarkerListenerhasonEmpty()with a default no-op; called when a live-stream frame yields zero face landmarks.FaceLandmarkerHelperalso containsdetectImage()/detectVideoFile()forIMAGE/VIDEOmodes, but neither is invoked in the current app flow.UDPListnerextracts the streaming port from the JSONports[0]field of the broadcast payload (not from the discovery socket's source port); only packets containing"iOSTrackingDataRequest"are acted on.BlenshapeMapper.getBlendshapeIndex(name: String): Intreturns the index of a shape by name inblendshapeBundle, or-1if not found; use this instead ofindexOfFirstcall-sites when looking up positions by name.