我用 10000 张图片合成我们美好的瞬间

202次阅读  |  发布于3年以前

1前言

昨天是情人节,大家想必都非常愉快的度过了节日~我也是

好了,废话不多说,今天给大家带来是一个比较有意思的项目,通过切割目标图片,获得10000个方块,用我们所选择到的图片,对应的填充方块实现一个千图成像的效果.你可以用它来拼任何你想拼的有意义的大图.(比如我,就想用它把我和对象恋爱到结婚拍的所有照片用来做一个超级超级超级超级大的婚纱照,在老家鄱阳湖的草地上铺着,用无人机高空俯瞰,啧,挺有意思~在这里先埋个点,希望几年后能够实现)

首先,这篇文章是基于我的上一篇fabric入门篇所出的一篇实用案例,也是我自己用来练手总结所用,在此分享给大家,一起成长!

2进入正题

首先我们初始一个 800*800 的画布

(界面的样式,在这里我就不过多表述了,我们主要讲逻辑功能的实现)

//初始化画布
initCanvas() {
    this.canvas = new fabric.Canvas("canvas", {
        selectable: false,
        selection: false,
        hoverCursor: "pointer",
    });
    this.ctx = canvas.getContext("2d");
    this.addCanvasEvent();//给画布添加事件
},

根据自己电脑的配置来自定义画布的大小, 目前还没找到直接在 web 端做类似千图成像的,在 web 端实现这个功能确实是很消耗性能的,因为需要处理的数据量好大,计算量也大 需要注意的是: 800*800 的画布有 640000 个像素,通过ctx.getImageData获取到的每个像素是 4 个值,就是 2560000 个值,我们后面需要处理这 2560000 个值,所以这里我就不做大了

用 fabric 绘制目标图片

需要注意的是,我们通过本地图片绘制到画布,需要将拿到的 file 文件通过window.URL.createObjectURL(file)将文件转为 blob 类型的 url

像你喜欢用 elementUI 的 upload 组件,你就这么写

//目标图片选择回调
slectFile(file, fileList) {
    let tempUrl = window.URL.createObjectURL(file.raw);
    this.drawImage(tempUrl);
},

这里我不喜欢它的组件,因为后面选择资源图片的时候,选择数千张图片会有文件列表,我又不想隐藏它(主要还是想分享一下自定义的文件选择) 所以我是这么写的

export function inputFile() {
    return new Promise(function (resolve, reject) {
        if (document.getElementById("myInput")) {
            let inputFile = document.getElementById("myInput");
            inputFile.onchange = (e) => {
                let urlArr = [];
                for (let i = 0; i < e.target.files.length; i++) {
                    urlArr.push(URL.createObjectURL(e.target.files[i]));
                }
                resolve(urlArr);
            };
            inputFile.click();
        } else {
            let inputFile = document.createElement("input");
            inputFile.setAttribute("id", "myInput");
            inputFile.setAttribute("type", "file");
            inputFile.setAttribute("accept", "image/*");
            inputFile.setAttribute("name", "file");
            inputFile.setAttribute("multiple", "multiple");
            inputFile.setAttribute("style", "display: none");
            inputFile.onchange = (e) => {
                // console.log(e.target.files[0]);
                // console.log(e.target.files);
                // let tempUrl = URL.createObjectURL(e.target.files[0]);
                // console.log(tempUrl);
                let urlArr = [];
                for (let i = 0; i < e.target.files.length; i++) {
                    urlArr.push(URL.createObjectURL(e.target.files[i]));
                }
                resolve(urlArr);
            };
            document.body.appendChild(inputFile);
            inputFile.click();
        }
    });
}

通过以上方法拿到文件后,我在里面已经将图片文件转为了 blob 的 URL 供我们使用 (需要注意的是文件的选择是异步的,所以这里需要用 promise 来写)

//绘制目标图片
drawImage(url) {
    fabric.Image.fromURL(url, (img) => {
        //设置缩放比例,长图的缩放比为this.canvas.width / img.width,宽图的缩放比为this.canvas.height / img.height
        let scale =
            img.height > img.width
                ? this.canvas.width / img.width
                : this.canvas.height / img.height;
        img.set({
            left: this.canvas.height / 2, //距离左边的距离
            originX: "center", //图片在原点的对齐方式
            top: 0,
            scaleX: scale, //横向缩放
            scaleY: scale, //纵向缩放
            selectable: false, //可交互
        });
        //图片添加到画布的回调函数
        img.on("added", (e) => {
            //这里有个问题,added后获取的是之前的画布像素数据,其他手动触发的事件,不会有这种问题
            //故用一个异步解决
            setTimeout(() => {
                this.getCanvasData();
            }, 500);
        });
        this.canvas.add(img); //将图片添加到画布
        this.drawLine(); //绘制网格线条
    });
},

绘制完图片后顺便在画布上绘制 100*100 的栅格

//栅格线
drawLine() {
    const blockPixel = 8;
    for (let i = 0; i <= this.canvas.width / blockPixel; i++) {
        this.canvas.add(
            new fabric.Line([i * blockPixel, 0, i * blockPixel, this.canvas.height], {
                left: i * blockPixel,
                stroke: "gray",
                selectable: false, //是否可被选中
            })
        );
        this.canvas.add(
            new fabric.Line([0, i * blockPixel, this.canvas.height, i * blockPixel], {
                top: i * blockPixel,
                stroke: "gray",
                selectable: false, //是否可被选中
            })
        );
    }
},

绘制完毕后可以看到图片加网格线的效果,还是挺好看的~

将图片颜色分块保存在数组中

一开始这么写把浏览器跑崩了


我哭 ,这么写循环嵌套太多(而且基数是 800*800*4==2560000-->得好好写,要不然对不起 pixelList 被我疯狂操作了 2560000 次)得优化一下写法,既然浏览器炸了,笨方法行不通,那只能换了~

首先说明,这里我们每个小块的长宽给的是 8 个像素 (越小后面合成图片的精度越精细,越大越模糊)

//获取画布像素数据
getCanvasData() {
    for (let Y = 0; Y < this.canvas.height / 8; Y++) {
        for (let X = 0; X < this.canvas.width / 8; X++) {
            //每8*8像素的一块区域一组
            let tempColorData = this.ctx.getImageData(X * 8, Y * 8, 8, 8).data;
            //将获取到数据每4个一组,每组都是一个像素
            this.blockList[Y * 100 + X] = { position: [X, Y], color: [] };
            for (let i = 0; i < tempColorData.length; i += 4) {
                this.blockList[Y * 100 + X].color.push([
                    tempColorData[i],
                    tempColorData[i + 1],
                    tempColorData[i + 2],
                    tempColorData[i + 3],
                ]);
            }
        }
    }
    console.log(mostBlockColor(this.blockList));
    this.mostBlockColor(this.blockList);//获取每个小块的主色调
    this.loading = false;
},

换了一种写法后,这里我们将每个 8*8 的像素块划为一组,得到 10000 个元素,每个元素里都有 4 个值,分别代表着 RGBA 的值,后面我们会用对应的 10000 张图片填充对应的像素块

拿到画布上的所有像素值后,我们需要求出每个小方块的主色调 后面我们需要通过这些小方块的主色调通过求它与资源图片的色差,来决定该方块具体是填充哪一张图片 到这里很兴奋,感觉是快完成了一半了,其实不然,后面更抓头皮

 //获取每个格子的主色调
mostBlockColor(blockList) {
    for (let i = 0; i < blockList.length; i++) {
        let colorList = [];
        let rgbaStr = "";
        for (let k = 0; k < blockList[k].color.length; k++) {
            rgbaStr = blockList[i].color[k];
            if (rgbaStr in colorList) {
                ++colorList[rgbaStr];
            } else {
                colorList[rgbaStr] = 1;
            }
        }
        let arr = [];
        for (let prop in colorList) {
            arr.push({
                // 如果只获取rgb,则为`rgb(${prop})`
                color: prop.split(","),
                // color: `rgba(${prop})`,
                count: colorList[prop],
            });
        }
        // 数组排序
        arr.sort((a, b) => {
            return b.count - a.count;
        });
        arr[0].position = blockList[i].position;
        this.blockMainColors.push(arr[0]);
    }
    console.log(this.blockMainColors);
},

脑瓜子不好使,草稿纸都用上了

获取每张资源图的主色调

export function getMostColor(imgUrl) {
    return new Promise((resolve, reject) => {
        try {
            const canvas = document.createElement("canvas");
            //设置canvas的宽高都为20,越小越快,但是越小越不精确
            canvas.width = 20;
            canvas.height = 20;
            const img = new Image(); // 创建img元素
            img.src = imgUrl; // 设置图片源地址
            img.onload = () => {
                const ctx = canvas.getContext("2d");
                const scaleH = canvas.height / img.height;
                img.height = canvas.height;
                img.width = img.width * scaleH;
                ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
                console.log(img.width, img.height);
                // 获取像素数据
                let pixelData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
                let colorList = [];
                let color = [];
                let colorKey = "";
                let colorArr = [];
                // 分组循环
                for (let i = 0; i < pixelData.length; i += 4) {
                    color[0] = pixelData[i];
                    color[1] = pixelData[i + 1];
                    color[2] = pixelData[i + 2];
                    color[3] = pixelData[i + 3];
                    colorKey = color.join(",");
                    if (colorKey in colorList) {
                        ++colorList[colorKey];
                    } else {
                        colorList[colorKey] = 1;
                    }
                }
                for (let prop in colorList) {
                    colorArr.push({
                        color: prop.split(","),
                        count: colorList[prop],
                    });
                }
                // 对所有颜色数组排序,取第一个为主色调
                colorArr.sort((a, b) => {
                    return b.count - a.count;
                });
                colorArr[0].url = imgUrl;
                console.log(
                    `%c rgba(${colorArr[0].color.join(",")})`,
                    `background: rgba(${colorArr[0].color.join(",")})`
                );
                resolve(colorArr[0]);
            };
        } catch (e) {
            reject(e);
        }
    });
}

我们随机选择一些文件后,将他们的主色调打印出来看看效果

颜色空间

要求颜色的色差,我们首先需要一起来了解一下颜色的定义,颜色有很多种表示方式,它们的标准都不相同,有 CMYK,RGB,HSB,LAB 等等... 这里我们是 RGBA 的,它就是 RGB 的颜色模型附加了额外的 Alpha 信息

RGBA 是代表 Red(红色)Green(绿色)Blue(蓝色)和 Alpha 的色彩空间。虽然它有的时候被描述为一个颜色空间,但是它其实仅仅是 RGB 模型的附加了额外的信息。采用的颜色是 RGB,可以属于任何一种 RGB 颜色空间,但是 Catmull 和 Smith 在 1971 至 1972 年间提出了这个不可或缺的 alpha 数值,使得 alpha 渲染和 alpha 合成变得可能。提出者以 alpha 来命名是源于经典的线性插值方程 αA + (1-α)B 所用的就是这个希腊字母。

其他颜色的相关介绍可以看 这里:https://zhuanlan.zhihu.com/p/24281841 或这里https://baike.baidu.com/item/%E9%A2%9C%E8%89%B2%E7%A9%BA%E9%97%B4/10834848?fr=aladdin

求颜色差异的方法

由于颜色在空间中的分布如上面的介绍所示,这里我们采用中学学过的欧氏距离法,来求两个颜色的绝对距离,通过它们的远近就知道两个颜色的相似程度的大小

首先我们了解一下欧氏距离的基本概念

欧几里得度量(euclidean metric)(也称欧氏距离)是一个通常采用的距离定义,指在 m 维空间中两个点之间的真实距离,或者向量的自然长度(即该 点到原点的距离)。在二维和三维空间中的欧氏距离就是两点之间的实际距离。

将公式转化为代码:

//计算颜色差异
colorDiff(color1, color2) {
    let distance = 0;//初始化距离
    for (let i = 0; i < color1.length; i++) {
        distance += (color1[i] - color2[i]) ** 2;//对两组颜色r,g,b[a]的差的平方求和
    }
    return Math.sqrt(distance);//开平方后得到两个颜色在色彩空间的绝对距离
},

计算颜色差异的方法有多种,可以看wikiwand:https://www.wikiwand.com/en/Color_difference#/sRGB 或者你也可以使用类似 ColorRNA.js 等颜色处理库进行对比,这里我们不做过多描述

计算差值后渲染图片

在这里我们需要将每个像素块的主色调与所有资源图片的主色调作比较,取差异最小的那张渲染到对应的方块上

//生成图片
generateImg() {
    this.loading = true;
    let diffColorList = [];
    //遍历所有方块
    for (let i = 0; i < this.blockMainColors.length; i++) {
        diffColorList[i] = { diffs: [] };
        //遍历所有图片
        for (let j = 0; j < this.imgList.length; j++) {
            diffColorList[i].diffs.push({
                url: this.imgList[j].url,
                diff: this.colorDiff(this.blockMainColors[i].color, this.imgList[j].color),
                color: this.imgList[j].color,
            });
        }
        //对比较过的图片进行排序,差异最小的放最前面
        diffColorList[i].diffs.sort((a, b) => {
            return a.diff - b.diff;
        });
        //取第0个图片信息
        diffColorList[i].url = diffColorList[i].diffs[0].url;
        diffColorList[i].position = this.blockMainColors[i].position;
        diffColorList[i].Acolor = this.blockMainColors[i].color;
        diffColorList[i].Bcolor = diffColorList[i].diffs[0].color;
    }
    this.loading = false;
    console.log(diffColorList);
    //便利每一个方块,对其渲染
    diffColorList.forEach((item) => {
        fabric.Image.fromURL(item.url, (img) => {
            let scale = img.height > img.width ? 8 / img.width : 8 / img.height;
            // img.scale(8 / img.height);
            img.set({
                left: item.position[0] * 8,
                top: item.position[1] * 8,
                originX: "center",
                scaleX: scale,
                scaleY: scale,
            });
            this.canvas.add(img);
        });
    });
},

好家伙!!! 这是什么玩意???这搞了一晚上,出个这?

我哭了,现在都五点多了,我还没睡呢~

不抛弃不放弃,坚持到底就是胜利

仔细分析了下每一个步骤,逐步查找问题所在 从最开始的目标图片像素数据开始看像素数据的正确性,但是没找到问题所在,数据都没啥问题,初步判断是计算像素块的主色调上出了问题,于是想到,会不会主色调并不是取一张图片或者一块像素块中出现最多次数的颜色为主色调,而是取它们的所有颜色的平均值作为主色调呢? 想到这里,我很兴奋! 差点吵醒已经熟睡的瓜娃子,我开始重新梳理

这里,我对每个 8*8 的小方块都改成了通过平均值求主色调

//获取每个格子的主色调
mostBlockColor(blockList) {
    for (let i = 0; i < blockList.length; i++) {
        let r = 0,
            g = 0,
            b = 0,
            a = 0;
        for (let j = 0; j < blockList[i].color[j].length; j++) {
            r += blockList[i].color[j][0];
            g += blockList[i].color[j][1];
            b += blockList[i].color[j][2];
            a += blockList[i].color[j][3];
        }
        // 求取平均值
        r /= blockList[i].color[0].length;
        g /= blockList[i].color[0].length;
        b /= blockList[i].color[0].length;
        a /= blockList[i].color[0].length;
        // 将最终的值取整
        r = Math.round(r);
        g = Math.round(g);
        b = Math.round(b);
        a = Math.round(a);
        this.blockMainColors.push({
            position: blockList[i].position,
            color: [r, g, b, a],
        });
    }
    console.log(this.blockMainColors);
}

然后,对每张图片也改成了通过平均值求主色调

export function getAverageColor(imgUrl) {
    return new Promise((resolve, reject) => {
        try {
            const canvas = document.createElement("canvas");
            //设置canvas的宽高都为20,越小越快,但是越小越不精确
            canvas.width = 20;
            canvas.height = 20;
            const img = new Image(); // 创建img元素
            img.src = imgUrl; // 设置图片源地址
            img.onload = () => {
                console.log(img.width, img.height);
                let ctx = canvas.getContext("2d");
                const scaleH = canvas.height / img.height;
                img.height = canvas.height;
                img.width = img.width * scaleH;
                ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
                // 获取像素数据
                let data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
                let r = 0,
                    g = 0,
                    b = 0,
                    a = 0;
                // 取所有像素的平均值
                for (let row = 0; row < canvas.height; row++) {
                    for (let col = 0; col < canvas.width; col++) {
                        r += data[(canvas.width * row + col) * 4];
                        g += data[(canvas.width * row + col) * 4 + 1];
                        b += data[(canvas.width * row + col) * 4 + 2];
                        a += data[(canvas.width * row + col) * 4 + 3];
                    }
                }
                // 求取平均值
                r /= canvas.width * canvas.height;
                g /= canvas.width * canvas.height;
                b /= canvas.width * canvas.height;
                a /= canvas.width * canvas.height;

                // 将最终的值取整
                r = Math.round(r);
                g = Math.round(g);
                b = Math.round(b);
                a = Math.round(a);
                console.log(
                    `%c ${"rgba(" + r + "," + g + "," + b + "," + a + ")"}
                                                                        `,
                    `background: ${"rgba(" + r + "," + g + "," + b + "," + a + ")"};`
                );
                resolve({ color: [r, g, b, a], url: imgUrl });
            };
        } catch (e) {
            reject(e);
        }
    });
}

激动人心的时候到了!!!!!!!!!!!!!啊啊啊啊啊!!我很激动,胜利就在眼前,临门一 jor 了!

一顿操作,选择目标图片,选择资源图片,点击生成图片按钮后,我开始了等待胜利的召唤!

我去,更丑了,这咋回事

紧接着我直接热血了起来,遇到这种有挑战的事情我就很有劲头,我要搞不过它,那不符合我的气质, 于是我开始分析处理过的小块主色调,我发现它们好像都有规律

我想是什么影响到了呢,图片绘制上去不可能会一样的颜色啊,一样的颜色是什么呢???

wo kao~不会是我画的 100*100 的线条吧

于是我回到,drawLine函数,我把它给注释掉了~

nice!

每一个方块都可以交互的拉伸旋转,移动,到这里画布的基本功能就已经完结啦~撒花~

我们还可以把生成好的图片导出来,机器好的小伙伴们可以定义一个很大的画布,或者给图片做上编号,打印出来,是可以用来做巨大的合成图的 (比如我前面提到的婚纱照等等,还是很有意思的)

 //导出图片
exportCanvas() {
    const dataURL = this.canvas.toDataURL({
        width: this.canvas.width,
        height: this.canvas.height,
        left: 0,
        top: 0,
        format: "png",
    });
    const link = document.createElement("a");
    link.download = "canvas.png";
    link.href = dataURL;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
},

这个情人节过的,属实是有点充实,现在是早上六点半~我又肝了一波,睡觉睡觉,保命要紧,白天还要出去玩

3最后

升华一下:

浪漫的七夕,连空气中都飘荡着一股爱情的味道。对对有情人欢喜相邀,黄昏后,柳梢头,窃窃私语,良辰美景,月圆花好!祝福天下有情人,幸福快乐!

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8