• h264,265实时视频流解码及人脸追踪的实现


    原文链接为本人在51CTO上分享
    原文地址

    以下为本人实际工作中经验所得分享,

    日常项目中涉及到实时视频流播放,大都会选择flvJs,后者videoJs。而由于这两款无法满足实际需求并且无法解码h265视频

    流,所以在后端C++的配合下,一起写了一套自用的视频流播放器,视频解码使用的是libffmpeg,找不到资源的可以私信我,

    原理就是利用wasm编写c++代码使用ffmpeg进行视频解码。由于算法解码压力,解码动作在浏览器完成对电脑及带宽都有一定要求。

    视频播放我们采用了两种方式来实现,可以通过配置设置,一种是使用video播放mediaSource的流媒体,一种是使用webgl绘

    制画面。mediaSource支持播放流媒体片段,这样播放器无需等待所有视频资源全都下载完再播放,可以不断的向播放器喂视

    频流片段。

    ------------

    ### 定义websockt连接类,管理信令及数据交互,分发事件
    >首先连接websocket,监听websockt事件及跟C++定义好将要发送的信令。

    ```javascript
    export default class WSReader extends Event {

    constructor(url) {
    super('WSReader');
    this.TAG = '[WSReader]';
    this.ws = new WebSocket(url);
    this.ws.binaryType = 'arraybuffer';
    this.ws.onopen = this._onWebSocketOpen.bind(this);
    this.ws.onerror = this._onWebSocketError.bind(this);
    this.ws.onmessage = this._onWebSocketMessage.bind(this);
    this.ws.onclose = this._onWebSocketClose.bind(this);

    this.wsMethods = {
    open: 'open', // 请求mime
    play: 'play', // 请求推流
    pause: 'pause', // 停止推流
    close: 'close', // 关闭ws连接
    slow: 'slow', // 请求降低推流频率
    fast: 'fast', // 请求提高推流频率
    complete: 'complete', // 视频流已经全部发送完。
    };
    this.seq = 1;
    this.sendRate = 1; // 视频流传输频率,用来调整播放速度
    this.isFullPause = false;
    }

    ```

    >处理推送过来的数据,如果是二进制数据则为视频流,否则为信令通知

    ```javascript
    // 处理websocket message
    _onWebSocketMessage(ev) {
    let {data} = ev;
    if (data instanceof ArrayBuffer) {
    this.dispatch(VideoEvents.VIDEO_EVENT, data);
    } else {
    this.wsMessageHandle(data);
    }
    }
    ```

    >根据信令类型分发不同事件,其中说明一下slow跟fast是发送流的速率,来实现倍数播放,从8倍速到1/8倍速,mediachange,是切换mime类型,用来实现高清跟标清切换。

    ```javascript
    // 根据ws主体内容来分配事件
    wsMessageHandle(data) {
    let mes = JSON.parse(data);
    switch (mes.method) {
    case 'open':
    debug.log(`get mime ${mes.mime}`);
    this.openHandle(mes)
    break;
    case 'play':
    debug.log(`ws play signal`);
    this.playHandle(mes)
    break;
    case 'pause':
    debug.log(`ws pause signal`);
    this.pauseHandle(mes)
    break;
    case 'close':
    debug.log(`ws close signal`);
    this.closeHandle()
    break;
    case 'slow':
    debug.log(`ws slow signal`);
    this.speedHandle(mes)
    break;
    case 'fast':
    debug.log(`ws fast signal`);
    this.speedHandle(mes)
    break;
    case 'complete':
    debug.log(`ws complete signal`);
    this.completeHandle()
    break;
    case 'mediaChange':
    debug.log(`ws mediaChange signal`);
    this.mediaChangeHandle(mes)
    break;
    }
    }
    ```
    >接受到当前视频流的mime类型,可以从mime类型中判断当前视频流的编码格式,avc则是h264, hevc则是h265,由于我们项目中只会存在这两种,所以我只简单的做了个判断。从代码第四行可以看到这里有做renderType的判断,是webgl绘制还是使用mediaSource,本文只讲述一下webgl模式,因为要实现0延时的人脸追踪跟车像追踪就需要使用这种模式。

    ```javascript
    // 获取到MIME
    _onWsGetMime(data) {
    if (data.ret == 0) {
    this.videoInfo.mime = data.mime;
    if (this.options.renderType === 'webgl') {
    // 获取到mime,根据mime判断为h264还是h265,初始化decoder
    this.decoder.init(data.mime.indexOf('avc') !== -1 ? 0 : 1);
    }
    this.createBuffer();
    // 成功获取到mime, 接下来发送play指令。
    this.videoReader.play();
    } else {
    debug.log(this.TAG, `get mime type failed`);
    this.dispatch(HSPlayer.Events.SERVER_PLAY_ERR, {msg: 'get mime type failed'})
    }
    }
    ```
    ### 定义对外基础类,包含播放器初始化,基础属性配置(倍速列表,缓冲区大小,播放类型等),播放器状态监听及异常分发。处理视频流数据
    ```javascript
    export default class HSPlayer extends Event {

    // 向外开放的监听事件类型。
    static get Events() {
    return{
    // 请求播放视频失败
    SERVER_PLAY_ERR: 'SERVER_PLAY_ERR',
    // 请求暂停视频失败
    SERVER_PAUSE_ERR: 'SERVER_PAUSE_ERR',
    // 请求变速失败
    SERVER_SPEED_ERR: 'SERVER_SPEED_ERR',
    // 服务器的连接出现错误
    SERVER_NET_ERR: 'SERVER_NET_ERR',
    // 由于网络异常导致到连接中断
    ABNORMAL_DISCONNECT: 'ABNORMAL_DISCONNECT',
    // 浏览器不支持当前视频的格式
    CHROME_CODEC_UNSUPPORT: 'CHROME_CODEC_UNSUPPORT',
    // 视频有缺失或被污染
    VIDEO_STREAM_INCORRECT: 'VIDEO_STREAM_INCORRECT',
    // 实时视频流缓冲太长,需要seek到最新点
    VIDEO_LIVE_STREAM_TOO_LOOG: 'VIDEO_LIVE_STREAM_TOO_LOOG',
    // 通知前端播放成功
    VIDEO_PLAY_SUCESS: 'VIDEO_PLAY_SUCESS',
    };
    }
    static isSupported(mimeCode) {
    return (window.MediaSource && window.MediaSource.isTypeSupported(mimeCode));
    }
    constructor(options) {
    super('HSPlayer');
    this.TAG = '[HSPlayer]';
    let defaults = {
    node: '', // video 节点
    cacheBufferTime: 60, // 回放最大缓存时长 单位秒
    cacheBufferMinTime: 30, // 回放缓存小于cacheBufferMinTime时,重新获取流
    cleanOffset: 0.8, // 清除buf时剩余的时长,单位秒
    debug: false, // 是否打印出控制台信息
    delayPlay: 0, // 获取实时视频流可以设置延时播放,单位ms
    type: 'live', // live 直播, playback 回放
    wsUrl: null, // websocket 地址,目前项目信令跟视频流都用同一个地址
    flushTime: 3 * 1000, // 清空buffer的间隔,用于直播
    drawArInfo: false, // 是否需要画ar信息
    renderType: null, // 如果是 'webgl',则使用本地解码。
    };
    ```

    >处理推送过来的视频流数据(说明一下,如果不需要实现人脸追踪的情况下,一般是每次推送一个完整的媒体片段,否则需要每一帧一帧的推送),我们和C++约定好视频流里的头部信息,这里粗略看下就好,主要包含版本信息,头部长度等
    我们约定在28,29个字节存放当前帧里的ar数据的长度,如果为0则无数据,有则截取该长度的字节,解析ar数据。最后解析到的是一个ar对象的list,里面会描绘当前画面有多少个目标及分别的位置信息(当然如果能够解析出更多的信息用也可以同步推送过来,如人脸特征,是否有嫌疑信息,这对底层算法要求太高,实时解析压力大)。,

    ```javascript
    // _onWsVideoBuffer
    /*
    * int8_t version;
    int16_t headLen;
    int8_t frameNum;
    int8_t type;
    int8_t codec;

    int32_t beginTimeStampSec;
    int32_t beginTimeStampMs;

    int32_t EndTimeStampSec;
    int32_t EndTimeStampMs;
    * */
    _onWsVideoBuffer(originData) {
    // 判断是否有头信息
    let headMagic = new Uint8Array(originData.slice(0, 4));
    // 获取头部长度
    let hAr, hLen = 0;
    if (
    headMagic[0] == 117 &&
    headMagic[1] == 109 &&
    headMagic[2] == 120 &&
    headMagic[3] == 115
    ) {
    hAr = new Uint8Array(originData.slice(5, 8));
    hLen = (hAr[0] << 8) + hAr[1];
    if (!this.firstFrameTime && originData) {
    let hBuffer = new Uint8Array(originData.slice(0, hLen));
    // 前6个字节是version, headLen, type....等
    // 后8个字节是帧结束时间,暂时不用
    let sec = (hBuffer[10] << 24) +
    (hBuffer[11] << 16) +
    (hBuffer[12] << 8) +
    hBuffer[13];
    let ms = (hBuffer[14] << 24) +
    (hBuffer[15] << 16) +
    (hBuffer[16] << 8) +
    hBuffer[17];
    this.firstFrameTime = sec * 1000 + ms;
    }
    }
    let data = originData.slice(hLen);

    if (this.options.renderType === 'webgl') {
    // ar数据
    let arLenBuf = new Uint8Array(originData.slice(27, 29));
    let arLen = (arLenBuf[0] << 8) + arLenBuf[1];
    if (arLen) {
    let arTarget = new Uint8Array(originData.slice(29, arLen + 29));
    let arJson = '';
    arTarget.forEach(x => {
    arJson += String.fromCharCode(x);
    })
    let targetObj = JSON.parse(arJson);
    if ( targetObj.arInfo && targetObj.arInfo.objList ) {
    this.decoder.feed(data, targetObj.arInfo.objList);
    } else {
    this.decoder.feed(data, null);
    }
    } else {
    this.decoder.feed(data, null);
    }
    return false;
    }
    // 如果是回放则把没有播放的buffer放入pendingBufs。直播则直接遗弃
    if (this.options.type != 'live') {
    while (this.pendingBufs.length > 0 && this.bufferController) {
    let buf = this.pendingBufs.shift();
    this.bufferController.feed(buf);
    }
    if(this.bufferController) {
    this.bufferController.feed(data);
    } else {
    this.pendingBufs.push(data);
    }
    } else {
    if(this.bufferController) {
    this.bufferController.feed(data);
    }
    }
    }
    ```

    ### 定义h264,h265解码类,因为此webgl模式下是由前端负责解码工作并绘制,c++只负责推送裸流,如果使用mediaSource模式则是c++将视频解码之后再推送过来。所以需要使用Decoder类,主要负责视频流队列管理,ar队列管理,定时解码,发布解码之后的数据

    ```javascript
    export default class Decoder {
    constructor(node) {
    this.queue = []; // 队列
    this.arIndex = -1;
    this.arQueue = {}; // ar信息队列
    this.LOG_LEVEL_FFMPEG = 2;
    this.LOG_LEVEL_JS = 0;
    this.LOG_LEVEL_WASM = 1;
    this.node = node;
    // 视频画面画布
    canvas = document.createElement('canvas');
    canvas.width = node.clientWidth;
    canvas.setAttribute(
    'style',
    ` 100%;height: auto;position: absolute;left: 0;top:0;`
    );
    // ar信息画布
    arCanvas = document.createElement('canvas');
    arCanvas.width = node.clientWidth;
    arCanvas.setAttribute(
    'style',
    ` 100%;height: auto;position: absolute;left: 0;top:0;`
    );
    node.parentNode.appendChild(canvas);
    node.parentNode.appendChild(arCanvas);
    }

    feed(buffer, ar) {
    this.arIndex++;
    this.queue.push(buffer);
    if (ar) {
    this.arQueue[this.arIndex] = ar;
    }
    }
    ```
    >将媒体数据跟ar数据都存到响应的队列,等待解码绘制,方便解码完成之后找到对应帧的ar数据。

    >Wasm封装了ffmpeg插件,js引入之后向外抛出了一个大的Module对象,里面封装解码的相关方法,可以直接调用。Wasm内部是有C++代码构成的,目前本人也没有深入了解。这里我们不做深究,能用就行。到这一步的时候遇到一个问题,解码是异步的,解码之后怎么跟当前ar数据对应。

    ```javascript
    init(decoderType) {
    videoCallback = Module.addFunction((addr_y, addr_u, addr_v, stride_y, stride_u, stride_v, width, height, pts) => {
    // console.log("[%d]In video callback, size = %d * %d, pts = %d", ++videoSize, width, height, pts)
    let size = width * height + (width / 2) * (height / 2) + (width / 2) * (height / 2);
    let data = new Uint8Array(size);
    let pos = 0;
    for(let i=0; i< height; i++) {
    let src = addr_y + i * stride_y
    let tmp = HEAPU8.subarray(src, src + width)
    tmp = new Uint8Array(tmp)
    data.set(tmp, pos)
    pos += tmp.length
    }
    for(let i=0; i< height / 2; i++) {
    let src = addr_u + i * stride_u
    let tmp = HEAPU8.subarray(src, src + width / 2)
    tmp = new Uint8Array(tmp)
    data.set(tmp, pos)
    pos += tmp.length
    }
    for(let i=0; i< height / 2; i++) {
    let src = addr_v + i * stride_v
    let tmp = HEAPU8.subarray(src, src + width / 2)
    tmp = new Uint8Array(tmp)
    data.set(tmp, pos)
    pos += tmp.length
    }
    var obj = {
    data: data,
    width,
    height
    }
    this.displayVideoFrame(obj);
    this.displayVideoAr(pts, width, height);
    });
    var ret = Module._openDecoder(decoderType, videoCallback, this.LOG_LEVEL_WASM)
    if(ret == 0) {
    console.log("openDecoder success");
    } else {
    console.error("openDecoder failed with error", ret);
    return;
    }
    var pts = 0;

    // 定时解码
    setInterval(() => {
    const data = this.queue.shift();
    if (data) {
    const typedArray = new Uint8Array(data);
    const size = typedArray.length;

    var cacheBuffer = Module._malloc(size);
    Module.HEAPU8.set(typedArray, cacheBuffer);

    Module._decodeData(cacheBuffer, size, pts++)
    if (cacheBuffer != null) {
    Module._free(cacheBuffer);
    cacheBuffer = null;
    }
    // if(size < CHUNK_SIZE) {
    // console.log('Flush frame data')
    // Module._flushDecoder();
    // Module._closeDecoder();
    // }
    }
    }, 1)
    }
    ```

    >首先执行openDecoder打开解码器,该方法接受当前视频流类型h264还是h265,还有一个videoCallBack的回调函数,解码成功之后调用他,为了解决解码数据跟ar数据对应的问题,请当时C++同事查看了Wasm源码跟处理逻辑,发现pts字段本身代表时间刻度,但可以由外部传入自定义数据并回调的时候可以拿到该自定义数据(仅此字段可用)。所以我们在定时解码的时候自定义了个pts当做索引,每次解码则+1,刚好跟我们队列里的索引一一对应。接受到解码之后的数据则开始准备绘制,

    ```javascript
    displayVideoFrame(obj) {
    var data = new Uint8Array(obj.data);
    var width = obj.width;
    var height = obj.height;
    var yLength = width * height;
    var uvLength = (width / 2) * (height / 2);
    if(!glPlayer) {
    canvas.height = (canvas.width / width) * height;
    arCanvas.height = (canvas.width / width) * height;
    glPlayer = new WebGLPlayer(canvas, {
    preserveDrawingBuffer: false
    }, arCanvas);
    }
    glPlayer.renderFrame(data, width, height, yLength, uvLength);
    }
    displayVideoAr(pts, width, height) {
    if (!glPlayer) return;
    let target = this.arQueue[pts];
    if (target) {
    delete this.arQueue[pts];
    glPlayer.renderAR(target, width, height);
    }
    }
    ```
    >视频数据跟ar数据分别在两个canvas上绘制,canvas本身是透明的,两者叠加在一起就可以,这样也方便对ar数据进行事件处理,比如细节人像的点击事件获取更多数据,或者人脸分析等。

    ### 最后就是webgl渲染类,主要负责处理解码之后的Yuv数据跟ar数据进行绘制,当然另外再抽离了一个webgl用的texture类,这里就不列出来了。就是标准的webgl纹理处理

    ```javascript
    export default class WebGLPlayer {
    constructor(canvas, options, arCanvas) {
    this.canvas = canvas;
    this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
    this.ctx = arCanvas.getContext("2d")
    this.initGL(options);
    }
    initGL(options) {
    if (!this.gl) {
    console.log("[ER] WebGL not supported.");
    return;
    }

    var gl = this.gl;
    gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
    var program = gl.createProgram();
    var vertexShaderSource = [
    "attribute highp vec4 aVertexPosition;",
    "attribute vec2 aTextureCoord;",
    "varying highp vec2 vTextureCoord;",
    "void main(void) {",
    " gl_Position = aVertexPosition;",
    " vTextureCoord = aTextureCoord;",
    "}"
    ].join("\n");
    var vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vertexShaderSource);
    gl.compileShader(vertexShader);
    var fragmentShaderSource = [
    "precision highp float;",
    "varying lowp vec2 vTextureCoord;",
    "uniform sampler2D YTexture;",
    "uniform sampler2D UTexture;",
    "uniform sampler2D VTexture;",
    "const mat4 YUV2RGB = mat4",
    "(",
    " 1.1643828125, 0, 1.59602734375, -.87078515625,",
    " 1.1643828125, -.39176171875, -.81296875, .52959375,",
    " 1.1643828125, 2.017234375, 0, -1.081390625,",
    " 0, 0, 0, 1",
    ");",
    "void main(void) {",
    " gl_FragColor = vec4( texture2D(YTexture, vTextureCoord).x, texture2D(UTexture, vTextureCoord).x, texture2D(VTexture, vTextureCoord).x, 1) * YUV2RGB;",
    "}"
    ].join("\n");

    var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, fragmentShaderSource);
    gl.compileShader(fragmentShader);
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);
    gl.useProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.log("[ER] Shader link failed.");
    }
    var vertexPositionAttribute = gl.getAttribLocation(program, "aVertexPosition");
    gl.enableVertexAttribArray(vertexPositionAttribute);
    var textureCoordAttribute = gl.getAttribLocation(program, "aTextureCoord");
    gl.enableVertexAttribArray(textureCoordAttribute);

    var verticesBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0]), gl.STATIC_DRAW);
    gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);
    var texCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0]), gl.STATIC_DRAW);
    gl.vertexAttribPointer(textureCoordAttribute, 2, gl.FLOAT, false, 0, 0);

    gl.y = new Texture(gl);
    gl.u = new Texture(gl);
    gl.v = new Texture(gl);
    gl.y.bind(0, program, "YTexture");
    gl.u.bind(1, program, "UTexture");
    gl.v.bind(2, program, "VTexture");
    };
    ```
    >初始化webgl,有兴趣深入了解webgl的可以看下webgl编程指南这本书,大概意思就是绘制了YUV三种纹理,在顶点着色器里定义一个变量存储纹理坐标,在片段着色器里定义了3个纹理像素拾取器(sampler2D)因为视频解码之后返回过来的就是yuv的数据信息,对于前端人员可能对RGB比较数据,YUV是一种颜色编码方法,像小时候的黑白电视机就只有Y数据。

    ```javascript
    renderFrame(videoFrame, width, height, uOffset, vOffset) {
    if (!this.gl) {
    console.log("[ER] Render frame failed due to WebGL not supported.");
    return;
    }

    var gl = this.gl;
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
    gl.clearColor(0.0, 0.0, 0.0, 0.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    // 清空ar画布
    this.ctx.clearRect(0, 0, gl.canvas.width, gl.canvas.height);

    gl.y.fill(width, height, videoFrame.subarray(0, uOffset));
    gl.u.fill(width >> 1, height >> 1, videoFrame.subarray(uOffset, uOffset + vOffset));
    gl.v.fill(width >> 1, height >> 1, videoFrame.subarray(uOffset + vOffset, videoFrame.length));

    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    };
    renderAR(arr, width, height) {
    var gl = this.gl;
    arr.forEach( obj => {
    const x = (gl.canvas.width / width) * obj.objRect.left;
    const y = (gl.canvas.height / height) * obj.objRect.top;
    const w = (gl.canvas.width / width) * (obj.objRect.right - obj.objRect.left);
    const h = (gl.canvas.height / height) * (obj.objRect.bottom - obj.objRect.top);
    const c = this.ctx;
    ```
    >开始绘制,注意绘制ar数据的时候需要根据当前画布大小跟原视频大小对比,计算出正确的ar位置。每次绘制视频画面之前先把ar的内容清空。至此基本介绍就完成了。

    #### 注意细节

    1. 如果是回放则需要把没有播放完的片段保留在队列,直播则直接舍弃seek到最新的点

    2. mediaSource的sourceBuffer.mode记得设置为sequence,此配置意味着video将按照buffer队列依次播放,不会根据buffer的时间戳来播放。

  • 相关阅读:
    jQuery之防止冒泡事件
    jQuery复制节点
    jQuery查找节点
    jQuery表单选择器
    jQuery之事件触发trigger
    jQuery样式操作
    为FLASH正名!HTML5前景分析
    iframe 高度自动调节,最简单解决
    Iframe和母版页(.net)
    表单遮住弹出层解决方法(select遮住DIV)
  • 原文地址:https://www.cnblogs.com/hsdying/p/16333631.html
Copyright © 2020-2023  润新知