利用 ImageData 实现图片翻转和视频镜像

最近在搞视频直播,其中有一个功能是视频镜像,那么有两种实现方式。
一种是老师端点击镜像,通过css把自己的video标签镜像,然后发送信令给学生端,学生端收到消息之后也通过css去实现镜像;
第二种就是老师端对视频流做一个镜像处理,学生端无需操作,很显然第二种方法比较好;

怎么实现对视频流的镜像呢,首先要知道的就是视频是由一帧帧的图片组合而成,我们要对视频流镜像,首先要对图片进行镜像。

对图片镜像的话,就要对ImageData做处理
首先了解一下什么是ImageData:

ImageData 接口描述 canvas 元素的一个隐含像素数据的区域。使用 ImageData() 构造函数创建或者使用和 canvas 在一起的 CanvasRenderingContext2D 对象的创建方法: createImageData() 和 getImageData()。也可以使用 putImageData() 设置 canvas 的一部分。

我们先以旋转图片为例子做个说明

基本原理1——像素矩阵变换

ImageData对象中存储着canvas对象真实的像素数据,它包含以下几个只读属性:

  • width 图片宽度,单位是像素
  • height 图片高度,单位是像素
  • data Uint8ClampedArray类型的一维数组,包含着RGBA格式的整型数据,范围在0至255之间(包括255)。
    data属性返回一个 Uint8ClampedArray,它可以被使用作为查看初始像素数据。每个像素用4个1bytes值(按照红,绿,蓝和透明值的顺序; 这就是"RGBA"格式) 来代表。每个颜色值部份用0至255来代表。每个部份被分配到一个在数组内连续的索引,左上角像素的红色部份在数组的索引0位置。像素从左到右被处理,然后往下,遍历整个数组。

具体请看像素操作

我们通过ImageData可以对图片的每个像素点做操作。比如我们现在要将图片向右旋转90度

一个 4 × 3 像素的原始图片,可以看作如下形式的像素矩阵 A:

图片向右旋转 90°,实质就是设法将 A 变为 A’ ——

这可以通过原矩阵一次 转置、与多次初等 列 变换(逆序排列各列)得到:

同理,图片向左旋转 90°,实际上就是得到矩阵 A’' :

这可以通过原矩阵一次 转置、与多次初等 行 变换(逆序排列各行)得到——

基本原理2——像素数组与矩阵的对应关系

由于 ImageData.data 对应一个数组,对于 4 × 3 的图片而言,ImageData.data 就是一个具有 48 个元素的数组 D,不妨每个元素的值就是其下标值,则:

D=[0,1,2,3,4,5,6,7...44,45,46,47]

其中:
元组 (0, 1, 2, 3) 表示第 1(= 0 / 4 + 1) 个像素的颜色为 rgba(0, 1, 2, 3/255);
元组 (4, 5, 6, 7) 表示第 2(= 4 / 4 + 1) 个像素的颜色为 rgba(4, 5, 6, 7/255);
元组 (8, 9, 10, 11) 表示第 3(= 8 / 4 + 1) 个像素的颜色为 rgba(8, 9, 10, 11/255);

元组 (i, i+1, i+2, i+3) 表示第 (i / 4 + 1) 个像素的颜色为 rgba(i, i+1, i+2, (i+3)/255);

元组 (44, 45, 46, 47) 表示第 12(= 44 / 4 + 1) 个像素的颜色为 rgba(44, 45, 46, 47/255)。

可见从 0 开始遍历 D 数组,每次递增 4 个单位,即可依次得到各个像素的红色值 R,再依次加1、加2、加3,即得到对应的绿色值 G、蓝色值 B、等效 α 通道值 A。

反之,如果知道图片的像素尺寸为 4 × 3,则可以通过下图找到数组 D 的各个元素:

可见各像素点是按照 从左至右、从上至下 的顺序排列的。设图片总宽度像素为 W,总高度像素为 H,任一像素点 P 的坐标为 (x, y),P 的红色值在数组 D 的下标为 R(x, y),则:

R(x,y)=(x+W*y)×4

验证:(x 与 y 均从 0 开始计数)

R(2, 1) = (2 + 1 × 4) × 4 = 24
R(1, 2) = (1 + 2 × 4) × 4 = 36
R(3, 1) = (3 + 1 × 4) × 4 = 28

拿到了 R(x, y),不难求出该像素的纵向中心对称像素 Rh(x, y)、横向中心对称像素 Rw(x, y)、以及主对角线对称像素 Rd(x, y):

Rh(x,y)=[x+W*(H−1−y)]*4 // 初等行变换
Rw(x,y)=[(W−1−x)+W*y]*4 // 初等列变换
Rd(x,y)=(y+H*x)*4
具体实现

基本思路:

  • 通过 canvas 获取目标图片的 ImageData 对象;
  • 转置原图片数组,得到数组 AT;
  • 对 AT 执行一组初等行变换,使各行逆序排列,得到左旋 90° 效果;
  • 对 AT 执行一组初等列变换,使各列逆序排列,得到右旋 90° 效果;
  • 将新的像素数组写回图片源标签。

HTML:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Rotate by ImageData</title>
    <style>
        .image{ margin-top: 5px; }
    </style>
</head>
<body>
    <div class="btns">
        <input type="button" value="左转 90°" id="turnLeft" />
        <input type="button" value="右转 90°" id="turnRight" />
    </div>
    <div class="image">
        <img id="fruit" src="fruit.jpg" class="image" alt="fruit" title="fruit" />
    </div>
    <script src="imageRotate.js"></script>
</body>

</html>

imageRotate.js:

document.querySelector('#turnLeft' ).addEventListener('click', e => rotateImage('l'))
document.querySelector('#turnRight').addEventListener('click', e => rotateImage('r'))

function rotateImage(direction = 'l') {
    // 1. Prepare ImageData
    let img = document.querySelector('#fruit')
    const { width: W, height: H } = img
    let cvs = document.createElement('canvas')
    cvs.width = W
    cvs.height = H
    let ctx = cvs.getContext('2d')
    ctx.drawImage(img, 0, 0)
    let imgDt0 = ctx.getImageData(0, 0, W, H)
    let imgDt1 = new ImageData(H, W)
    let imgDt2 = new ImageData(H, W)
    let dt0 = imgDt0.data
    let dt1 = imgDt1.data
    let dt2 = imgDt2.data

    // 2. Transpose
    let r = r1 = 0  // index of red pixel in old and new ImageData, respectively
    for (let y = 0, lenH = H; y < lenH; y++) {
        for (let x = 0, lenW = W; x < lenW; x++) {
            r  = (x + lenW * y) * 4
            r1 = (y + lenH * x) * 4
            dt1[r1 + 0] = dt0[r + 0]
            dt1[r1 + 1] = dt0[r + 1]
            dt1[r1 + 2] = dt0[r + 2]
            dt1[r1 + 3] = dt0[r + 3]
        }
    }
    
    // 3. Reverse width / height
    for (let y = 0, lenH = W; y < lenH; y++) {
        for (let x = 0, lenW = H; x < lenW; x++) {
            r  = (x + lenW * y) * 4
            r1 = direction === 'l'
                ? (x + lenW * (lenH - 1 - y)) * 4
                : ((lenW - 1 - x) + lenW * y) * 4
            dt2[r1 + 0] = dt1[r + 0]
            dt2[r1 + 1] = dt1[r + 1]
            dt2[r1 + 2] = dt1[r + 2]
            dt2[r1 + 3] = dt1[r + 3]
        }
    }
    
    // 4. Redraw image
    cvs.width = H
    cvs.height = W
    ctx.clearRect(0, 0, W, H)
    ctx.putImageData(imgDt2, 0, 0, 0, 0, H, W)
    img.src = cvs.toDataURL('image/jpeg', 1)
}

运行结果:
原始图片

左转90度

右转90度

好了,现在实现了图片旋转,那么我们接着套用公式实现图片镜像和视频镜像

html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>js通过浏览器调用摄像头</title>
    <style>
    #video {
        /* transform: rotate3d(1, 1, 1, 45deg); */
    }
    </style>
</head>
<body>
    <div class="banner">
    <video id="video" width="500px" height="500px" autoplay="autoplay" />
    </div>
    <canvas width="500" height="500" id="canvas"></canvas>
    <video
    id="video2"
    width="500px"
    height="500px"
    autoplay="autoplay"
    controls="controls"
    />
</body>
<!-- <script src="./test.js"></script> -->
<script>
    function getMedia() {
    let video = document.getElementById("video");
    let constraints = {
        video: { width: 500, height: 500 },
        audio: false,
    };
    navigator.getUserMedia =
        navigator.getUserMedia ||
        navigator.webkitGetUserMedia ||
        navigator.mozGetUserMedia ||
        navigator.msGetUserMedia;
    let promise = navigator.mediaDevices.getUserMedia(
        constraints,
        function (stream) {
        video.src = stream;
        video.play();
        },
        function (error) {
        console.log(error);
        }
    );
    promise.then(function (MediaStream) {
        video.srcObject = MediaStream;
        video.play();
    });
    }

    getMedia();
    window.onload = () => {
    const data = [];
    var recorder;
    // 获取视频流之后,用canvas对视频进行反转,然后使用captureStream获取视频流,此时可以通过声网自定义流获取视频轨道getVideoTracks。代码中为了测试canvas转视频可用,调用了MediaRecorder录制
    function imageDataHRevert(sourceData, newData) {
        for (var i = 0, h = sourceData.height; i < h; i++) {
        for (j = 0, w = sourceData.width; j < w; j++) {
            newData.data[i * w * 4 + j * 4 + 0] =
            // (y*W+x)*4
            sourceData.data[i * w * 4 + (w - j) * 4  -4+ 0];
            // (y*W+(W-x-1))*4
            newData.data[i * w * 4 + j * 4 + 1] =
            sourceData.data[i * w * 4 + (w - j) * 4 -4+ 1];

            newData.data[i * w * 4 + j * 4 + 2] =
            sourceData.data[i * w * 4 + (w - j) * 4 -4+ 2];

            newData.data[i * w * 4 + j * 4 + 3] =
            sourceData.data[i * w * 4 + (w - j) * 4-4 + 3];
        }
        }
        return newData;
    }
    function imageDataVRevert(sourceData, newData) {
        for (var i = 0, h = sourceData.height; i < h; i++) {
        for (var j = 0, w = sourceData.width; j < w; j++) {
            newData.data[i * w * 4 + j * 4 + 0] =
            sourceData.data[(h - i) * w * 4 + j * 4 + 0];

            newData.data[i * w * 4 + j * 4 + 1] =
            sourceData.data[(h - i) * w * 4 + j * 4 + 1];

            newData.data[i * w * 4 + j * 4 + 2] =
            sourceData.data[(h - i) * w * 4 + j * 4 + 2];

            newData.data[i * w * 4 + j * 4 + 3] =
            sourceData.data[(h - i) * w * 4 + j * 4 + 3];
        }
        }

        return newData;
    }

    function render() {
        var canvas = document.getElementById("canvas");
        var video = document.getElementById("video");
        var ctx1 = canvas.getContext("2d");
        ctx1.drawImage(video, 0, 0, 500, 500);
        var imgData = ctx1.getImageData(0, 0, 500, 500);
        var newImgData = ctx1.getImageData(0, 0, 500, 500);
        const HRevet = imageDataHRevert(newImgData, imgData);
        console.error(HRevet, "HRevet");
        // 亮一点
        for (var i = 0; i < HRevet.data.length; i += 4) {
        HRevet.data[i] += 5;
        HRevet.data[i + 1] += 5;
        HRevet.data[i + 2] += 5;
        }
        ctx1.putImageData(HRevet, 0, 0);
        requestAnimationFrame(render);
    }
    requestAnimationFrame(render);
    const stream = canvas.captureStream(30);
    // todo 自定义视频 canvas.captureStream(30).getVideoTracks()[0]
    console.error(stream, "stream");
    // 自己测试录制视频的,可以不用看
    recorder = new MediaRecorder(stream, { mimeType: "video/webm" });
    recorder.ondataavailable = function (event) {
        if (event.data && event.data.size) {
        data.push(event.data);
        }
    };
    recorder.onstop = () => {
        const url = URL.createObjectURL(new Blob(data, { type: "video/webm" }));
        document.getElementById("video2").src = url;
    };
    recorder.start();
    setTimeout(() => {
        recorder.stop();
    }, 6000);
    };
</script>
</html>

结果:

阅读全文 »