Android 實踐:基于雙人實時視頻的互動小游戲——拿頭玩

在 RTC 2020 編程挑戰賽春季賽中。我們還有一個獲獎團隊,思路新穎,開發了一款基于雙人視頻聊天場景的Android小游戲——“拿頭玩”。在視頻聊天過程中即可開啟游戲。通過人臉識別算法識別轉頭方向,實現以“接鍋”和“甩鍋”為主題的玩法。目前實現了Android版本。

我們請“拿頭玩”團隊分享他們的開發思路與應用的功能實現:

項目介紹

《拿頭玩》是一款基于雙人視頻聊天場景的小游戲,在視頻聊天過程中即可開啟游戲。通過人臉識別算法識別轉頭方向,實現以“接鍋”和“甩鍋”為主題的玩法。目前實現了Android版本。


image

項目初心

頸椎問題是困擾所有辦公族的難題,大多數人工作中很難有機會能起身動一動,回到家里也會因為疲倦而放棄做一些頸椎康復的運動。所以我們想設計一款游戲,讓大家在休息的時候可以通過游戲的形式活動頸椎,舒緩疼痛。我們選擇了職場中的“甩鍋”和“接鍋”的場景,來作為游戲中的元素,希望能增加玩家的代入感。此外,我們還添加了截圖分享模塊,方便游戲進行傳播。

主要功能

經過了5天的設計和開發,我們最終完成了《拿頭玩》這個作品,下面來分享一下它的主要功能和其中的代碼細節。

  • 視頻聊天模塊的搭建

    視頻聊天模塊主要是使用聲網的音視頻sdk,它可以快速的開發出一個基本的視頻對話模塊,核心代碼如下:
//onCreate
val rtcEngine = RtcEngine.create(this, AppConfig.appKey,
            object : IRtcEngineEventHandler() {
                override fun onFirstRemoteVideoDecoded(uid: Int,width: Int,height: Int,elapsed: Int) {
                    setupRemoteVideo(uid)
                }
            }
//setup
private fun setupRemoteVideo(uid: Int) {
    val remoteView = RtcEngine.CreateRendererView(baseContext)
    remoteView.setZOrderMediaOverlay(true)
    container.addView(remoteView)
    rtcEngine.setupRemoteVideo(VideoCanvas(remoteView, VideoCanvas.RENDER_MODE_HIDDEN, uid))
}
  • 視頻幀數據的獲取和處理

    為了進行下一步的人臉識別,我們需要獲取到視頻幀數據,對幀數據進行預處理。在閱讀聲網提供的文檔和demo后,我們搭建了一個簡單的apm-plugin插件,通過這個插件,就可以得到視頻聊天過程中的裸數據了。
    首先我們創建apm-plugin-packet-processing.cpp文件,然后通過CMakeLists.txt配置編譯參數:

cmake_minimum_required(VERSION 3.4.1)

add_library(
        apm-plugin-packet-processing
        SHARED
        apm-plugin-packet-processing.cpp)

include_directories(../cpp/include) //這里需要導入sdk中的.h文件
...
target_link_libraries(
        apm-plugin-packet-processing
        ${log-lib})

然后我們定義兩個jni方法來注冊和反注冊裸數據的回調:

JNIEXPORT void JNICALL Java_com_zero_game_utils_frame_VideoFrameHandler_doRegisterProcessing
        (JNIEnv *env, jobject obj) {
    if (!rtcEngine) {
        return;
    } else {
        agora::util::AutoPtr<agora::media::IMediaEngine> mediaEngine;
        mediaEngine.queryInterface(rtcEngine, agora::AGORA_IID_MEDIA_ENGINE);
        s_packetObserver = *new AgoraVideoFrameObserver(jvm, env, env->NewGlobalRef(obj));
        mediaEngine->registerVideoFrameObserver(&s_packetObserver);
    }
}

JNIEXPORT void JNICALL Java_com_zero_game_utils_frame_VideoFrameHandler_doUnregisterProcessing
        (JNIEnv *env, jobject obj) {
    if (!rtcEngine) {
        return;
    } else {
        agora::util::AutoPtr<agora::media::IMediaEngine> mediaEngine;
        mediaEngine.queryInterface(rtcEngine, agora::AGORA_IID_MEDIA_ENGINE);
        s_packetObserver.release();
        mediaEngine->registerVideoFrameObserver(nullptr);
    }
}

agora::media::IVideoFrameObserver這個接口就是聲網sdk提供的視頻幀回調,只要實現它即可:

class AgoraVideoFrameObserver : public agora::media::IVideoFrameObserver {
public:
    AgoraVideoFrameObserver() {
    }
    
    AgoraVideoFrameObserver(JavaVM *vm, JNIEnv *env, jobject jobj) {
       //...
    }
    
    // 獲取本地攝像頭采集到的視頻幀
    virtual bool onCaptureVideoFrame(VideoFrame &videoFrame) override {
        //processVideoFrame(videoFrame)
        return true;
    }
    // 獲取遠端用戶發送的視頻幀
    virtual bool onRenderVideoFrame(unsigned int uid, VideoFrame &videoFrame) override {
        return true;
    }
    // 獲取本地視頻編碼前的視頻幀
    virtual bool onPreEncodeVideoFrame(VideoFrame &videoFrame) override {
        return true;
    }
    void release() {
        //...
    }
};

由于Android平臺中攝像頭返回的裸數據是YUV420編碼,所以我們還要轉換為提供給人臉識別模塊的rgba格式才行,最后通過jni方法將數據傳遞到java層,進行后續的處理:

int width = videoFrame.width;
int height = videoFrame.height;
int index = 0;
char *rgba = new char[width * height * 4];
unsigned char *ybase = static_cast<unsigned char *>(videoFrame.yBuffer);
unsigned char *ubase = static_cast<unsigned char *>(videoFrame.uBuffer);;
unsigned char *vbase = static_cast<unsigned char *>(videoFrame.vBuffer);;
for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
    //YYYYYYYYUUVV
        u_char Y = ybase[x + y * width];
        u_char U = ubase[y / 2 * width / 2 + (x / 2)];
        u_char V = vbase[y / 2 * width / 2 + (x / 2)];
        int r = static_cast<int>(Y + 1.402 * (V - 128));
        if (r > 255) { r = 255; } if (r < 0) { r = 0; }
        int g = static_cast<int>(Y - 0.34413 * (U - 128) - 0.71414 * (V - 128));
        if (g > 255) { g = 255;} if (g < 0) { g = 0; }
        int b = static_cast<int>(Y + 1.772 * (U - 128));
        if (b > 255) { b = 255; } if (b < 0) { b = 0; }
        rgba[index++] = static_cast<char>(r); //R
        rgba[index++] = static_cast<char>(g); //G
        rgba[index++] = static_cast<char>(b); //B
        rgba[index++] = static_cast<char>(255);
    }
}

jbyte buf[width * height * 4];
int i = 0;
for (i = 0; i < width * height * 4; i++) {
    buf[i] = rgba[i];
}

jbyteArray jarrRV = env->NewByteArray(width * height * 4);
env->SetByteArrayRegion(jarrRV, 0, width * height * 4, buf);
env->CallVoidMethod(jobj, jSendMethodId, jarrRV, width, height, videoFrame.rotation);
env->DeleteLocalRef(jarrRV);
  • 人臉識別和方向檢測

    人臉識別主要使用的是MLKit,通過Firebase即可簡單配置使用,在上一個環節中,我們把源數據通過jni傳到了java層,現在我們需要將它轉化成bitmap對象然后傳給MLKit中提供的VisionFaceDetector。
val bitmap = Bitmap.createBitmap(color,width,height,Bitmap.Config.ARGB_8888)
//裸數據還需要進行旋轉和水平翻轉
val matrix = Matrix()
matrix.postRotate(rotation.toFloat())
matrix.postScale(-1.0f, 1.0f)
val rotationBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true)
val image = FirebaseVisionImage.fromBitmap(rotationBitmap)
val detect = FirebaseVision.getInstance().getVisionFaceDetector(highAccuracyOpts)
detect.detectInImage(image)
    .addOnSuccessListener {
        val leftEye = face.getLandmark(FirebaseVisionFaceLandmark.LEFT_EYE)
        val rightEye = face.getLandmark(FirebaseVisionFaceLandmark.RIGHT_EYE)
        val nose = face.getLandmark(FirebaseVisionFaceLandmark.NOSE_BASE)
        //獲取到左眼、右眼和鼻子的位置
        val leftEyeNose = euclidean(leftEye,nose)//計算鼻子到左眼的距離
        val rightEyeNode = euclidean(rightEye,nose)//計算鼻子到右眼的距離
        val ratio = min(leftEyeNose,rightEyeNose) / max(leftEyeNose,rightEyeNose)
        if (ratio > 0.7 && ratio < 1) {
            //左右眼離鼻子的比例在0.7-1.0之間我們認為沒有轉頭
            FaceState.FRONT
        } else {
            if (rightHalfFace > leftHalfFace) {
                //右邊眼睛到鼻子距離大于左邊的,我們認為轉向了左邊
                FaceState.LEFT
            } else {
                //反之右邊
                FaceState.RIGHT
            }
        }
    }

實現了轉頭識別后,配合上UI和動畫,我們就可以使游戲中的人偶跟隨我們的轉頭方向運動了。

  • 游戲流程控制

    由于游戲是在兩端同時進行的,所以我們需要進行端對端的數據傳遞,我們采用的是聲網提供的消息傳輸方案。通過實時傳遞游戲過程中的指令,對雙方游戲畫面進行控制,傳遞的指令包括:游戲開始,游戲結束,向左轉頭,向右轉頭,沒有轉頭以及實時分數等。
//發送方
streamId = rtcEngine.createDataStream(true, true)
rtcEngine.sendStreamMessage(streamId, "left".toByteArray())

//接收方 object : IRtcEngineEventHandler
override fun onStreamMessage(uid: Int, s: Int, data: ByteArray?) {
    data?.let {
        val string = String(it)
        when (string) {
            "left" -> {
                //處理游戲
            }
            "right"->{
                //處理游戲
            }
            .....
        }
}

尾聲:未來展望

《拿頭玩》這個項目是一個起點,基于它的框架,其實可以快速地添加到各種app中,形成一個額外的小游戲模塊。將“接鍋”“甩鍋”的替換成“接優惠券”、“采集素材”等不同元素,可以擴展它的使用場景。通過提供更多有趣的包裝,可以有效實現社交裂變引流。

開源鏈接

開源地址 : https://github.com/AgoraIO-Community/RTC-Hackathon/tree/master/SDKChallengeProject/Zero_PlayHead

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374