1. 程式人生 > >給圖片加水印--手把手教新碼農如何把技術變成產品

給圖片加水印--手把手教新碼農如何把技術變成產品

前言

加水印是為圖片宣告版權出處的一種常用方法。
平常都是寫技術文章,文章的重點在技術本身,照片往往不需要加水印,或者需要加也不多,祭出神器PhotoShop很快就能完成。
前一段趁著夏天還不很熱的時候出去遊蕩,回來應約寫了遊記,其實是給別人當做攻略來用。
遊記可就不同了,照片成為了主體,並且量很大。隨便一個景區的流程,十幾副照片總是免不了的。這個時候,還用PhotoShop來加水印,當然不是不行,但那顯然非我等“攻城獅”所願為的。
於是我們為圖片加水印的“產品”,就此立項啦。

某個技術的出現可能是因為積累,可能因為意外,可能因為愛好。但產品,總是因為一個“需求”而開始。

水印檔案

為圖片加水印,首先你得先有一個水印。當然隨隨便便在圖片上加一行字也是水印,但如果想拿得出手,有位美工幫你操刀再好不過。要說現在的程式設計師,每天團隊一起工作,誰還沒幾位要好的美工朋友。

什麼?你沒有?那你可要注意了。現在不管是做研發,還是做產品,一個人打天下的時代已經過了。

在團隊中,技術固然重要,溝通能力則更為重要。如果不能在每個崗位都有自己的鐵桿兄弟,忙碌一輩子,你也只能是個小碼農。

在這方面,可別迷信職位所帶來的“權利”,“權利”和“關係”所能起的作用,那可是天壤之別。

我手頭就有一個現成的水印,用了得十多年了。雖然看起來在設計上已經跟不上時代,但這種純個性化的東西,你架不住喜歡。

使用者的需求才是第一位的,作為程式設計師,你可以說使用者是外行,啥也不懂。但使用者要的才算數,你說的,不算數。
當然如果你的溝通能力超群,把使用者給勸服了,那當我沒說。

用作水印的圖片,首先要有“鏤空”的特質。比如你看題頭圖的右下角,水印只有主體的部分出現在圖片上。其餘的部分,仍然是照片本身。看上去水印圖片,就是鏤空的樣子。

其實很多標準的圖片格式本身就支援鏤空,比如GIF圖片,比如PNG圖片。在Web網頁的設計中,鏤空圖片本來就有很大的使用量。
但是在我們這個顯然並不大的專案中,採用這些圖形格式作為水印圖片的標準並不划算,一方面使用者製作水印圖片往往需要額外的操作增加工作量。另一方面在自動新增水印的程式中解析這些圖片中的鏤空結構也需要額外的工作量。

除非“標準化”本身也是使用者的需求之一,否則雖然標準化有很多好處,但快速完成專案才是第一追求的目標。

製作一個水印檔案最容易的方法是在PhotoShop中,把主體內容獨立一層,隨後把背景部分全部塗黑。這個黑一定要是真正的黑,也即RGB三個值全部為0。實際上任何不會引起衝突的顏色都是可以的,比如我們常見到特技拍攝中用到的藍箱、綠箱。但使用全黑的背景處理起來還是最容易的。


在程式中操作圖片,最強大的當然是opencv庫。給工程師用,拿Python寫個指令碼就夠了。如果是給普通使用者,可以編譯為可執行檔案的c/c++肯定是更優選。

版本1

接著不管是你本身就是影象處理的高手,原來就熟悉這方面的工作。還是在網際網路上搜索別人的經驗,學習別人的程式。總之,很快你就拿出了一個版本,為圖片新增水印。

#include <stdio.h>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>

using namespace std;
using namespace cv;

const char *picfile="IMG_20190521_125150.jpg";
const char *logofile="logo.png";
const char *outputfile="IMG_20190521_125150-logoed.jpg";
const int mx=10,my=10;

int main(int argc, char **argv){
    Mat image = imread(picfile);
    Mat logo = imread(logofile);
    Mat mask=imread(logofile,0);
    Mat imageROI;

    imageROI = image(Rect(mx,my,logo.cols,logo.rows));
    logo.copyTo(imageROI,mask);
    imshow("result",image);
    waitKey();
}

問題並不複雜,開啟圖片和作為水印的logo,然後再讀取圖片中作為鏤空的背景部分。接著把logo鏤空部分去除,然後複製到目標圖片上就完成了工作,主要的工作程式碼只有7行。
主要函式使用copyTo,點選連結是opencv官方的說明文件。
opencv的編譯,需要在命令列給出標頭檔案和連結庫的額外引數,建議寫一個指令碼來編譯,這裡也貼出來(本例中使用當前的opencv4):

#!/bin/bash

g++ -std=c++11 -o $1 $1.cpp `pkg-config --cflags --libs opencv4` 

使用指令碼來編譯和執行使用如下命令(假設原始碼名稱為wmv1.cpp):

$ ./mkcv4.sh wmv1
$ ./wmv1

在一張樣本的圖片上執行這個程式,得到的結果效果如下:

看起來,完美的解決了使用者的需求,完活收工......

等等,這是我們“虛擬”的一個專案,寫文章嘛,沒點藉口怎麼向下寫。不過如果這是一個真實的專案,這就到了見客戶的時候。相信我,如果客戶見了這個程式,肯定會提出一堆的意見回來。比如:

  • 這是水印嗎?水印應當是半透明的,這隻能叫不乾膠。
  • 為什麼只能處理什麼亂七八糟的IMG_20190521_125150.jpg檔案,我要把每個檔案都改成這個名字才能處理嗎?
  • 為什麼水印看上去這麼大,跟畫面一點也不協調
  • 水印為什麼只能放在左上角,我想放在右下角可不可以?
  • ......

從客戶那邊回來,甭管是產品經理還是銷售經理,我估計已經被使用者教訓的懷疑人生了。所以這個時候他們的脾氣不會太好,然後跟程式設計師溝通起來,耐心肯定也就不夠。於是程式設計師,就處在了崩潰的邊緣。使用者有多少條意見,程式設計師就有多少條抓狂的理由。

  • 使用者是掏錢的,既然想從使用者那裡掙錢,使用者說什麼你都得學會聽著。
  • 使用者其實根本不知道自己想要什麼,喬布斯都這麼說。但使用者天生會挑毛病。
  • 記著前面說的,一個人打不了天下,因為有很多人挑毛病,你的產品才能適應更多人。

版本2

不管有多麼不高興,生活總要繼續,工作也得推動下去。
其實使用者挑毛病永遠不是最可怕的,可怕的是使用者不挑毛病,並且還不買單。
所以既然使用者有反饋,我們逐條解決就好了。
首先看“水印效果”的問題,opencv中有專門的函式addWeighted處理兩幅圖片之間的重疊互動問題。用起來更簡單,連蒙版mask部分都不需要了:

    const float _alpha=0.5;

    Mat image = imread(picfile);
    Mat logo = imread(logofile);
    Mat imageROI;

    imageROI = image(Rect(mx,my,logo.cols,logo.rows));
    addWeighted(imageROI, 1.0, logo, _alpha, 0, imageROI);
    imwrite(outputfile,image);

水印尺寸偏大的問題,水印檔案本身肯定是固定的。但在大的圖片中,水印肯定顯得小,小的圖片中,水印就會顯得大。因此需要水印圖片的尺寸是可以變化的,是一個合理的需求。
opencv中調整圖片的尺寸很容易,我們可以要求使用者輸入一個水印logo尺寸的寬度,隨後保持logo的比例,計算出來logo的新高度。然後調整logo的尺寸就可以了。

    int neww,newh;
    neww = (int)_logowidth;
    newh = (int)(logo.rows * ((float)neww / logo.cols));
    Size dsize=Size(neww,newh);
    resize(logo,logo,dsize);

檔名、logo位置問題,都可以由程式執行時,使用者輸入的引數來確定,這個再簡單不過。
很快,第二版新鮮出爐:

#include <stdio.h>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>

using namespace std;
using namespace cv;

#define PATH_MAX 1024

const float _alpha=0.5;

char _picfile[PATH_MAX];
char _outputfile[PATH_MAX];
char _logofile[PATH_MAX];
int _logowidth;
int _mx,_my;

int main(int argc, char **argv){
    if (argc != 7) {
        printf("Wrong parament!\n");
        return 1;
    }

    strcpy(_picfile,argv[1]);
    strcpy(_outputfile,argv[2]);
    strcpy(_logofile,argv[3]);
    _logowidth=atol(argv[4]);
    _mx=atol(argv[5]);
    _my=atol(argv[6]);


    Mat image = imread(_picfile);
    Mat logo = imread(_logofile);
    Mat imageROI;

    int neww,newh;
    neww = (int)_logowidth;
    newh = (int)(logo.rows * ((float)neww / logo.cols));
    Size dsize=Size(neww,newh);
    resize(logo,logo,dsize);

    imageROI = image(Rect(_mx,_my,logo.cols,logo.rows));
    addWeighted(imageROI, 1.0, logo, _alpha, 0, imageROI);
    imwrite(_outputfile,image);
}

我們再次編譯、執行來試一試:

$ ./mkcv4.sh wmv2
$ ./wmv2 IMG_20190521_125150.jpg IMG_20190521_125150-logoed.jpg logo.png 150 100 100

得到的圖片如下:

看起來順眼多了,剛才的問題,也都得到了解決。

我們就不再“裝作”有使用者的樣子,相信剛才描述的使用者反饋,大多人都有過這種經歷,誰也不開心別人在自己的心血上指手畫腳。但在真實的工作中,往往如此。
這只是一個虛擬的專案,使用者也只是我們自己。所以還是讓我們自己來繼續為專案挑毛病,期望能進一步完善。

  • 找到問題最好的辦法就是大量使用,大範圍使用。
  • 要珍視給你反饋意見的人,不管是測試還是產品經理,他們是在幫你完善產品。

第二版的程式的確有了進步,但問題依然很多。

  • 引數太多,用起來很繁瑣並且不友好,引數多了、少了、錯了都會導致程式錯誤。
  • 第一版“不乾膠”模式新增水印的方式,實際還是有意義的,值得保留。
  • 雖然水印新增位置可以隨意了,但並不好用,我們並不希望水印出現在主題的位置。
  • 水印的尺寸雖然可以指定,但用起來並不方便,當目標圖片尺寸不確定的時候,給定水印的尺寸實際上不現實。

版本3

同樣是挑毛病,由自己主動挑出來,是不是比別人挑出來在心理上更舒服?
同理,由自己的團隊挑出來,當然也比讓使用者挑出來,更容易讓所有人滿意。
而且,如果把為圖片加水印這一個動作算作“核心技術”的話,這一次挑出的所有毛病,基本都不是技術問題。而都是“好用”問題,或者叫“使用者體驗”問題。

在正常的工作中,最多不超過10%算的上技術問題,絕大多數開發工作,都是為了把技術,開發成可被使用者接受的產品。而這些工作中,仍然有絕大多數不過是把引數換個順序,按鈕換個顏色之類的內容。

對於上面找出來的問題,c/c++中本來就有比較好的解決方案。就是使用getopt_long/switch配合的引數處理系統。在處理過程中,為沒有給出的引數,給出合理的預設值。
命令列程式,一般的竅門都是儘量支援更多的引數,讓動手能力強的使用者可以更精細的定製。同時為引數儘可能的提供預設值,讓極少必要的引數,程式就能正常執行。
隨後在這樣的命令列程式的支援下,既可以在伺服器端定製網頁把程式包裝成網路雲服務。也能夠寫圖形介面的外殼,給使用者單機使用。
在這個思想的指導下,我們梳理一下可能定製的引數:

  • 輸入的圖片檔名,程式將為這個圖片新增水印,這個引數必不可少。
  • 輸出的圖片檔名,新增水印之後的圖片,儲存到這個檔案。這個引數可以省略,省略的話,程式應當自動在輸入檔名的基礎上重新命名一個檔名輸出。此外還有一個潛在需求,輸出檔名如果等同於輸入檔名的話,相當於新增水印後替換原始檔案。這要求程式讀取完輸入檔案後,馬上關閉檔案,否則寫出到原檔案會失敗。
  • 水印Logo檔名。如果省略,應當使用當前目錄中的一個預設Logo檔案。
  • 水印圖片縮放尺寸。創意一下,如果這個引數小於1,則代表水印圖片縮放到目標圖片的比例,比如0.3個目標圖片寬度。如果這個引數大於1,則代表水印圖片縮放到實際給定的尺寸。潛在需求,在這個應用中,使用者天生只對圖片寬度敏感,所以這個引數實際代表Logo寬度,Logo的高度應當等比縮放。
  • 水印的位置。剛才一個版本有了高度的自由,實際上並不好用。我們只要指定水印在目標圖片的四角之一就夠了。這也能避免使用者無法知道目標圖片中,水印圖片座標的問題。
  • 水印方式,預設使用水印圖片和目標圖片混合的方式,也可以指定水印圖片覆蓋目標圖片的方式。

梳理完修改需求,再次印證了上面的話,這些修改內容,跟核心的技術完全沒有關係。現在你知道“碼農”這個詞所為何來了吧?

#include <stdio.h>
#include <stdlib.h>
#include <getopt.h>
#include <string.h>

#include <opencv2/highgui/highgui.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>

using namespace std;
using namespace cv;

#define PATH_MAX 1024
#define LOGOPIC "./logo.png"

char _logoFilename[PATH_MAX];
char _srcFilename[PATH_MAX];
char _dstFilename[PATH_MAX];
const float _margin=0.01;
const float _alpha=0.5;
float _scale=0.3;
int _position=0;
int _copy=0;

struct option longopts[] = {
    { "input",        required_argument, NULL, 'i'},
    { "out",        required_argument, NULL, 'o'},
    { "scale",      required_argument, NULL, 's'},
    { "position",   required_argument, NULL, 'p'},
    { "logo",   required_argument, NULL, 'l'},
    { "copy",   no_argument, NULL, 'c'},
    { 0, 0, 0, 0},
};

void usage(){
    printf("Options:\n");
    printf("\t -i,--input\tPicture file to add water mark.\n");
    printf("\t -o,--out\tOutput picture name, add postfix '_logoed' on src filename if omit.\n");
    printf("\t -l,--logo\tlogo picture name, set to ./logo.png if omited.\n");
    printf("\t -s,--scale\tZooming logo picture to a new size, if this value below 1, \n");
    printf("\t\t\tmeans width of logo set to width of src picture * scale value,\n");
    printf("\t\t\totherwise, means width of logo scale to this pixel.\n");
    printf("\t -p,--position\tLogo position on src picture. can be 0/1/2/3, four corner.\n");    
    printf("\t -c,--copy\tCopy is keep logo's color, or shadow as default.\n");    
}

void dumpDefault(){
    printf("input:%s\n",_srcFilename);
    printf("out:%s\n",_dstFilename);
    printf("logo:%s\n",_logoFilename);
    printf("scale:%f\n",_scale);
    printf("postion:%d\n",_position);
    printf("copy:%d\n",_copy);
}

void addPostfix(char *srcfile,char *dstfile){
    const char *postfix="_logoed";
    char fname[PATH_MAX];
    strcpy(fname,srcfile);
    // char extName[PATH_MAX];
    char *p=strrchr(fname,'.');
    if (p == NULL) {
        strcpy(dstfile,fname);
        strcat(dstfile,postfix);
        return;
    }
    *p = '\0';
    strcpy(dstfile,fname);
    strcat(dstfile,postfix);
    strcat(dstfile,".");
    strcat(dstfile,p+1);
    return;
}

int getOptions(int argc,char **argv){
    int optIndex = 0;
    int c;

    strcpy(_logoFilename,LOGOPIC);
    strcpy(_srcFilename,"");
    strcpy(_dstFilename,"");

    while(1){
        c = getopt_long(argc, argv, "i:o:s:p:l:c", longopts, &optIndex);
        if(c == -1) {
            break;
        }
        switch(c) {
            case 'i':
                strncpy(_srcFilename,optarg,PATH_MAX);
                break;
            case 'o':
                strncpy(_dstFilename,optarg,PATH_MAX);
                break;
            case 'l':
                strncpy(_logoFilename,optarg,PATH_MAX);
                break;
            case 's':
                _scale = atof(optarg);
                break;
            case 'p':
                _position = atol(optarg);
                if ((_position>3) || (_position<0))
                    _position=0;
                break;
            case 'c':
                _copy = 1; //meas true
                break;
            default:
                usage();
        }
    }
    if (strlen(_srcFilename) == 0) {
        usage();
        exit(1);
    };
    if (strlen(_dstFilename) == 0) {
        addPostfix(_srcFilename,_dstFilename);
    };
    return 0;
}

/*
    position = 0, logo on right,bottom
    position = 1, logo on left,bottom
    position = 2, logo on left,top
    position = 3, logo on right,top
*/
void getPosition(int position,Mat image,Mat logo,int *X,int *Y){
    // x/y _margin using image.cols,not rows

    switch(position){
        case 0:
            *X=(image.cols-logo.cols) - (image.cols * _margin);
            *Y=(image.rows-logo.rows) - (image.cols * _margin);
            break;
        case 1:
            *X=image.cols * _margin;
            *Y=(image.rows-logo.rows) - image.cols * _margin;
            break;
        case 2:
            *X=image.cols * _margin;
            *Y=image.cols * _margin;
            break;
        case 3:
            *X=(image.cols-logo.cols) - (image.cols * _margin);
            *Y=image.cols * _margin;
            break;
        default:
            *X=(image.cols-logo.cols) - (image.cols * _margin);
            *Y=(image.rows-logo.rows) - (image.cols * _margin);
            break;
    };
    return;
}

void markIt(const char *srcpic, const char *logopic, const char *dstpic, int position=0){
    Mat image = imread(srcpic);
    Mat logo = imread(logopic);
    Mat imageROI;
    int markx,marky;

    Mat mask=imread(logopic,0);

    if (_scale < 1){
        float scale=(image.cols * _scale) / logo.cols;
        Size dsize=Size(logo.cols*scale,logo.rows*scale);
        resize(logo,logo,dsize);
        resize(mask,mask,dsize);
    } else if(_scale > 1) {
        int neww,newh;
        neww = (int)_scale;
        newh = (int)(logo.rows * ((float)neww / logo.cols));
        Size dsize=Size(neww,newh);
        resize(logo,logo,dsize);
        resize(mask,mask,dsize);
    };
logo.rows);

    getPosition(position,image,logo,&markx,&marky);
    imageROI = image(Rect(markx,marky,logo.cols,logo.rows));
    if (_copy){
        logo.copyTo(imageROI,mask);
    } else {
        addWeighted(imageROI, 1.0, logo, _alpha, 0, imageROI);
    }
    imwrite(dstpic,image);
}

int main(int argc, char **argv){
    getOptions(argc,argv);
    dumpDefault();

    markIt(_srcFilename,_logoFilename,_dstFilename,_position);
    return 0;
}

從完成的程式程式碼上看同樣也是如此,大量的程式碼都是用於處理引數和預設值邏輯,實際加水印的程式碼,幾乎沒有什麼變化。

技術人員不能只沉迷於技術,技術人員的升職加薪,往往得益於其它經驗的積累,比如行業經驗,比如溝通協調經驗。

假設我們當前目錄準備了一張圖片叫DSCF2183.jpg:

並且準備兩個logo水印檔案,一張logo.png是剛才的黑白圖片,另外一張logo1.png是紅字黑底的圖片:

我們把第三版的程式編譯一下,然後做幾個測試,

$ ./mkcv4.sh wmv3
$ ./wmv3 -i DSCF2183.jpg
input:DSCF2183.jpg
out:DSCF2183_logoed.jpg
logo:./logo.png
scale:0.300000
postion:0
copy:0
$

這是最簡的執行模式,只需要一個輸入檔案。水印檔案自動縮放到目標圖片寬度的30%,然後透明疊加在右下角:

簡單使用-c引數,可以用覆蓋的方式疊加水印:

$ ./wmv3 -i DSCF2183.jpg -c
input:DSCF2183.jpg
out:DSCF2183_logoed.jpg
logo:./logo.png
scale:0.300000
postion:0
copy:1


更換第二幅水印logo來試試:

$ ./wmv3 -i DSCF2183.jpg --logo logo1.png -o DSCF2183_red.jpg
input:DSCF2183.jpg
out:DSCF2183_red.jpg
logo:logo1.png
scale:0.300000
postion:0
copy:0
$ ./wmv3 -i DSCF2183.jpg --logo logo1.png -o DSCF2183_red_copy.jpg -c
input:DSCF2183.jpg
out:DSCF2183_red_copy.jpg
logo:logo1.png
scale:0.300000
postion:0
copy:1


補充

作為一個命令列程式,第三版已經基本可以滿足應用見使用者了。忘了提醒你注意附加在程式內部的程式使用文件,千萬注意保證文件的完善、準確。很多優秀的產品,使用者能不能用的好,往往是由文件的水平決定的。
回到最初的話題,如果是自己作為這個使用者,那還有一個小需求沒有被滿足。那就是,我的圖片量很大,並且分佈在多篇遊記的複雜目錄結構中。如何同時為多幅圖片新增水印?
這算的上非常個性化的需求,當然可以實現在程式中。但在沒有大量使用者支援的情況下,這種需求可能只是增加了程式的複雜度,但並沒有多少人用。
對於這種需求,完全可以使用外圍指令碼的形式來解決。使用bash寫這樣的指令碼,也不過幾行程式碼而已:

#!/bin/bash

files=$(find $1 -name "*jpg" -o -name "*png" -o -name "*jpeg")

for file in $files
do
    wmv3 -i $file -o $file
done

把指令碼設定為可執行,然後把指令碼和主程式都拷貝到系統的可執行資料夾:

$ chmod +x markall.sh
$ sudo cp markall.sh /usr/bin
$ sudo cp wmv3 /usr/bin

這次為再多的圖片加水印也不怕了,比如我們有一個測試資料夾,是這樣的結構:

只要如此執行就可以為資料夾下面,及其子資料夾中所有的jpg/jpeg/png檔案新增水印:

$ markall.sh test

至此,才可以真的完活,收工