我用 10000 張圖片合成我們美好的瞬間

月亮照回湖心 野鶴奔向閑雲

前言

昨天是情人節,大家相比都非常愉快的度過了節日~我也是😚

好了,廢話不多說,今天給大家帶來是一個非常有意思的項目,通過切割目標圖片,獲得10000個方塊,用我們所選擇到的圖片,對應的填充方塊實現一個千圖成像的效果.你可以用它來拼任何你想拼的有意義的大圖.(比如我,就想用它把我和對象戀愛到結婚拍的所有照片用來做一個超級超級超級超級大的婚紗照,在老家鄱陽湖的草地上鋪著,用無人機高空俯瞰,嘖,挺有意思~在這裡先埋個點,希望幾年後能夠實現😊)

首先,這篇文章是基於我的上一篇fabric入門篇所出的一篇實用案例,也是我自己用來練手總結所用,在此分享給大家,一起成長!

進入正題

首先我們初始一個 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 所用的就是這個希臘字母。


其他顏色的相關介紹可以看
這裡://zhuanlan.zhihu.com/p/24281841
這裡//baike.baidu.com/item/顏色空間/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://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);
},

這個情人節過的,屬實是有點充實,現在是早上六點半~我又肝了一波,睡覺睡覺,保命要緊,白天還要出去玩 😅😅

最後

升華一下:

浪漫的七夕,連空氣中都飄蕩著一股愛情的味道。對對有情人歡喜相邀,黃昏後,柳梢頭,竊竊私語,良辰美景,月圓花好!祝福天下有情人,幸福快樂!

這個項目我放在我的github上( //github.com/wangrongding),喜歡的小夥伴,記得要點個贊哦~

很高興可以和大家一起變強! 可以關注我的公眾號,前埔寨。我組建了一個前端技術交流群,如果你想與志同道合的小夥伴一起交流學習,也可以加我個人微信(ChicSparrow),我拉你入群一起加油吧! 我是榮頂,和我一起在鍵帽與字元上橫跳,於程式碼和程式中穿梭。🦄