
前言
加水印是為圖片聲明版權出處的一種常用方法。
平常都是寫技術文章,文章的重點在技術本身,照片往往不需要加水印,或者需要加也不多,祭出神器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
至此,才可以真的完活,收工!
