Android实时获取摄像头画面传输至PC端
阅读原文时间:2023年07月08日阅读:21

前言

最近在做一个PC端小应用,需要获取摄像头画面,但是电脑摄像头像素太低,而且位置调整不方便,又不想为此单独买个摄像头。于是想起了之前淘汰掉的手机,成像质量还是杠杠的,能不能把手机摄像头连接到电脑上使用呢?经过搜索,在网上找到了几款这类应用,但是都是闭源的。我一向偏好使用开源软件,但是找了挺久也没有找到一个比较合适的。想着算了,自己开发一个吧,反正这么个简单的需求,应该大概也许不难吧(

思路

通过Android的Camera API是可以拿到摄像头每一帧的原始图像数据的,一般都是YUV格式的数据,一帧2400x1080的图片大小为2400x1080x3/2字节,约等于3.7M。25fps的话,带宽要达到741mbps,太费带宽了,所以只能压缩一下再传输了。最简单的方法,把每一帧压缩成jpeg再传输,就是效率有点低,而更好的方法是压缩成视频流后再传输,PC端接收到视频流后再实时解压缩还原回图片。

实现

思路有了,那就开搞吧。

新建一个Android项目,然后在AndroidManifest.xml中声明摄像头和网络权限:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />

界面上搞一个SurfaceView用于预览

<SurfaceView
            android:id="@+id/surfaceview"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent" />

进入主Activity时,打开摄像头:

private void openCamera(int cameraId) {
    class CameraHandlerThread extends HandlerThread {

        private Handler mHandler;

        public CameraHandlerThread(String name) {
            super(name);
            start();
            mHandler = new Handler(getLooper());
        }

        synchronized void notifyCameraOpened() {
            notify();
        }

        void openCamera() {
            mHandler.post(() -> {
                camera = Camera.open(cameraId);
                notifyCameraOpened();
            });
            try {
                wait();
            } catch (InterruptedException e) {
                Log.w(TAG, "wait was interrupted");
            }
        }
    }
    if (camera == null) {
        CameraHandlerThread mThread = new CameraHandlerThread("camera thread");
        synchronized (mThread) {
            mThread.openCamera();
        }
    }
}

然后绑定预览surface并调用摄像头预览接口开始获取摄像头数据:

camera.setPreviewDisplay(surfaceHolder);
buffer.data = new byte[bufferSize];
camera.setPreviewCallbackWithBuffer(this);
camera.addCallbackBuffer(buffer.data);
camera.startPreview();

每一帧图像的数据准备好后,会通过onPreviewFrame回调把YUV数据传送过来,处理完后,一定要再调一次addCallbackBuffer以获取下一帧的数据。

@Override
public void onPreviewFrame(byte[] data, Camera c) {
    // data就是原始YUV数据
    // 这里处理YUV数据
    camera.addCallbackBuffer(buffer.data);
}

直接用ServerSocket就行了,反正也不需要考虑高并发场景。

try (ServerSocket srvSocket = new ServerSocket(6666)) {
    this.socketServer = srvSocket;
    for (; ; ) {
        Socket socket = srvSocket.accept();
        this.outputStream = new DataOutputStream(socket.getOutputStream());
        // 初始化视频编码器
    }
} catch (IOException ex) {
    Log.e(TAG, ex.getMessage(), ex);
}

Android上可以使用系统自带的MediaCodec实现视频编解码,但是这里我并不打算使用它,而是使用灵活度更高的ffmpeg(谁知道后面有没有一些奇奇怪怪的需求)。 网上已经有大神封装好适用于Android的ffmpeg了,直接在Gradle上引用javacv库就行。

configurations {
    javacpp
}

task javacppExtract(type: Copy) {
    dependsOn configurations.javacpp

    from { configurations.javacpp.collect { zipTree(it) } }
    include "lib/**"
    into "$buildDir/javacpp/"
    android.sourceSets.main.jniLibs.srcDirs += ["$buildDir/javacpp/lib/"]

    tasks.getByName('preBuild').dependsOn javacppExtract
}
dependencies {
    implementation group: 'org.bytedeco', name: 'javacv', version: '1.5.9'
    javacpp group: 'org.bytedeco', name: 'openblas-platform', version: '0.3.23-1.5.9'
    javacpp group: 'org.bytedeco', name: 'opencv-platform', version: '4.7.0-1.5.9'
    javacpp group: 'org.bytedeco', name: 'ffmpeg-platform', version: '6.0-1.5.9'
}

javacv库自带了一个FFmpegFrameRecorder类可以实现视频录制功能,但是灵活度太低,还是直接调原生ffmpeg接口吧。

初始化H264编码器:

public void init(int width, int height, int[] preferredPixFmt) throws IOException {
    int bitRate = width * height * 3 / 2 * 16;
    int frameRate = 25;
    encoder = avcodec_find_encoder(AV_CODEC_ID_H264);
    codecCtx = initCodecCtx(width, height, fmt, bitRate, frameRate);
    tempFrame = av_frame_alloc();
    scaledFrame = av_frame_alloc();
    tempFrame.pts(-1);
    packet = av_packet_alloc();
}

private AVCodecContext initCodecCtx(int width, int height,int pixFmt, int bitRate, int frameRate) {
    AVCodecContext codec_ctx = avcodec_alloc_context3(encoder);
    codec_ctx.codec_id(AV_CODEC_ID_H264);
    codec_ctx.pix_fmt(pixFmt);
    codec_ctx.width(width);
    codec_ctx.height(height);
    codec_ctx.bit_rate(bitRate);
    codec_ctx.rc_buffer_size(bitRate);
    codec_ctx.framerate().num(frameRate);
    codec_ctx.framerate().den(1);
    codec_ctx.gop_size(frameRate);//每秒1个关键帧
    codec_ctx.time_base().num(1);
    codec_ctx.time_base().den(frameRate);
    codec_ctx.has_b_frames(0);
    codec_ctx.global_quality(1);
    codec_ctx.max_b_frames(0);
    av_opt_set(codec_ctx.priv_data(), "tune", "zerolatency", 0);
    av_opt_set(codec_ctx.priv_data(), "preset", "ultrafast", 0);
    int ret = avcodec_open2(codec_ctx, encoder, (AVDictionary) null);
    return ret == 0 ? codec_ctx : null;
}

把摄像头数据送进来编码,由于摄像头获取到的数据格式和视频编码需要的数据格式往往不一样,所以,编码前需要调用sws_scale对图像数据进行格式转换。

public int recordFrame(Frame frame) {
    byte[] data = frame.data;    // 对应onPreviewFrame回调里的data
    int pf = frame.pixelFormat;
    if (tempFrameDataLen < data.length) {
        if (tempFrameData != null) {
            tempFrameData.releaseReference();
        }
        tempFrameData = new BytePointer(data.length);
        tempFrameDataLen = data.length;
    }
    tempFrameData.put(data);
    int width = frame.width;
    int height = frame.height;
    av_image_fill_arrays(tempFrame.data(), tempFrame.linesize(), tempFrameData, pf, width, height, frame.align);
    tempFrame.format(pf);
    tempFrame.width(width);
    tempFrame.height(height);
    tempFrame.pts(tempFrame.pts() + 1);
    return recordFrame(tempFrame);
}

public int recordFrame(AVFrame frame) {
    int res = 0;
    int srcFmt = frame.format();
    int dstFmt = codecCtx.pix_fmt();
    int width = frame.width();
    int height = frame.height();
    if (srcFmt != dstFmt) {
        // 图像数据格式转换
        convertCtx = sws_getCachedContext(
                convertCtx,
                width, height, srcFmt,
                width, height, dstFmt,
                SWS_BILINEAR, null, null, (DoublePointer) null
        );
        int requiredDataLen = width * height * 3 / 2;
        if (scaledFrameDataLen < requiredDataLen) {
            if (scaledFrameData != null) {
                scaledFrameData.releaseReference();
            }
            scaledFrameData = new BytePointer(requiredDataLen);
            scaledFrameDataLen = requiredDataLen;
        }
        av_image_fill_arrays(scaledFrame.data(), scaledFrame.linesize(), scaledFrameData, dstFmt, width, height, 1);
        scaledFrame.format(dstFmt);
        scaledFrame.width(width);
        scaledFrame.height(height);
        scaledFrame.pts(frame.pts());
        res = sws_scale(convertCtx, frame.data(), frame.linesize(), 0, height, scaledFrame.data(), scaledFrame.linesize());
        if (res == 0) {
            throw new RuntimeException("scale frame failed");
        }
        frame = scaledFrame;
    }
    res = avcodec_send_frame(codecCtx, frame);
    scaledFrame.pts(scaledFrame.pts() + 1);
    if (res != 0 && res != AVERROR_EAGAIN()) {
        throw new RuntimeException("Failed to encode frame:" + res);
    }
    res = avcodec_receive_packet(codecCtx, packet);
    if (res != 0 && res != AVERROR_EAGAIN()) {
        return res;
    }
    return res;
}

编码完一帧图像后,需要检查是否有AVPacket生成,如果有,把它回写给请求端即可。

AVPacket pkg = encoder.getPacket();
if (outBuffer == null || outBuffer.length < pkg.size()) {
    outBuffer = new byte[pkg.size()];
}
BytePointer pkgData = pkg.data();
if (pkgData == null) {
    return;
}
pkgData.get(outBuffer, 0, pkg.size());
os.write(outBuffer, 0, pkg.size());

重点流程的代码都写好了,把它们连接起来就可以收工了。

收尾

请求端还没写好,先在电脑端使用ffplay测试一下。

ffplay tcp://手机IP:6666

嗯,一切正常!就是延时有点大,主要是ffplay不知道视频流的格式,所以缓冲了很多帧的数据来侦测视频格式,造成了较大的延时。后面有时间,再写篇使用ffmpeg api实时解码H264的文章(

完整项目代码:https://github.com/kasonyang/net-camera