產生一把金鑰,蓋在我們的作品上,肉眼看不出任何差別。然後驗給自己看、試著用別人的金鑰偽造、把圖摧殘一輪看它什麼時候會死。 每一步的中間產物都畫出來,沒有黑箱。所有運算都在瀏覽器裡跑,圖片和金鑰不會離開這台電腦。
七步分兩種:三步會產出東西:金鑰、蓋好的作品、驗證結果;另外四步是實驗,不產出成品,但那才是我們把演算法拆開的地方。先講清楚哪一步是哪一種,才知道自己現在在幹嘛。
要做出一張有浮水印的作品:走步驟 1 和 3 就好。
步驟 1 產生一把金鑰(順便下載鑰圖,那是我們唯一的憑據,弄丟就證明不了)→
步驟 3 把作品拖進去、按「蓋上去」、下載蓋好的 PNG。成品到這裡就完成了,中間的步驟 2 不一定要做,它只是把過程畫出來而已。
要驗證一張圖是不是我們的:直接跳到步驟 7。
步驟 7 把那張圖和我們的鑰圖丟進去就好。不需要走前面任何一步,圖可以是別人傳來的,金鑰可以是半年前存的那張鑰圖。
其餘四步(2、4、5、6)不做也能用,但做了我們就知道它為什麼有效、什麼時候會失效。那才是這一頁存在的理由,不然這就只是一個我們不知道能不能信的按鈕。
所有運算都在瀏覽器裡跑,圖片和金鑰不會離開這台電腦。
這個實驗室是從LINE 對話製造機拆出來的。那個工具會在匯出的每張圖和每支影片上烙一個隱形標記, 這樣別人拿它做的假截圖去唬人時,任何人都能到驗證頁驗出「這是合成的」。 標記做完之後才發現:會用的人很多,看得懂它為什麼有效的人幾乎沒有。所以把演算法拆開、每一步都畫出來,就是這一頁。
兩邊用的是同一套場紋,但回答的是完全不同的問題。
| 一樣的地方 |
|---|
| 同一種圖樣:每 16 個像素一個節點的 +1/−1,中間平滑過渡,只在藍色通道加減 2/255 |
| 同一種驗法:把圖切成格子、算出每格的「偏藍程度」、跟圖樣逐格相乘再全部加起來 |
| 同樣把圖樣平鋪整張圖,再暴力搜尋位移,所以都抗裁切、抗重新壓縮 |
| 都是純瀏覽器、離線、開源,圖片不會上傳 |
| 不一樣的地方 | LINE 對話製造機 | 這一頁 |
|---|---|---|
| 回答的問題 | 這張圖是不是這個工具做的 | 這張圖有沒有蓋過我的金鑰 |
| 圖樣哪裡來 | 一個固定的公開函式,8×8 個節點 | 我們的金鑰算出來的,16×16 個節點 |
| 誰能驗 | 任何人:這正是目的 | 只有握有金鑰的人 |
| 怎麼判定 | 只看一個數字 z 「訊號有多強」 |
z 和峰值旁瓣比,兩個都要過 再加問「那個最高分有沒有明顯贏過其他位置」,步驟 4 會用數字算一遍 |
| 用途 | 防詐騙:昭告「這是合成的」 | 歸屬線索:說明「這張圖蓋過我的金鑰」 這不等於「這是我做的」:金鑰外流的話別人也蓋得出來,見誠實的界限 |
「為什麼那邊只看一個數字就夠,這邊卻要兩個?」:這是最值得問的一題,而且我原本的答案是錯的。
我本來以為:製造機只有一個公開圖樣,沒有「別人的金鑰」可以拿來碰撞,所以那個破口不存在。去量了才發現不是。
2026-08-14 實測(用製造機的偵測器驗 60 張圖一組,test/cross-lcm.mjs 可以自己重跑):
| 拿去驗的圖 | z 中位 | z 最高 | 判定(門檻 7) |
|---|---|---|---|
| 從沒經過任何工具的乾淨圖 | 0.9 | 1.5 | 0/60 誤判 |
| 製造機自己蓋的(對照組) | 32.3 | 32.6 | 60/60 正確檢出 |
| 這一頁用隨機金鑰蓋的 | 6.3–6.8 | 9.4–9.6 | 16~25 張被誤判成「製造機做的」 |
最後一列是區間,因為每跑一輪都用新的隨機金鑰,數字會浮動;跑過的幾輪落在 16/60 到 25/60 之間,也就是四分之一到三分之一。
圖樣完全不同、節點數也不同,為什麼會碰撞?因為兩張隨機的 ±1 圖樣,總會有一部分格子剛好同號。位移搜尋會試遍所有對齊方式,挑出最巧的那一種:巧到某個程度,加起來的分數就夠大了。 而這正是 z 這個數字分辨不出來的地方:蓋得越用力,連碰撞出來的最高分也跟著變大。
碰撞就是「兩個本來不相干的東西,算出來的結果卻剛好一樣」。這裡指的是不同的金鑰在某個對齊位置上剛好有一大半格子同號。
峰值旁瓣比擋得住它:同一批圖,製造機自己蓋的最低是 7.1,這一頁蓋的最高只有 4.4,中間隔得開。差別不在分數多高,在那個最高分有沒有明顯贏過其他位置。
所以正確的說法是:製造機還沒有人去跟它碰撞過,不能叫免疫。而這一頁現在正好會生產這種圖。它的標記本來就只是「提高濫用成本」而不是防偽,所以這個結果不影響它的用途。只是「沒看過誤判」和「不會誤判」是兩回事:我原本差一點就這樣寫下去了。
「為什麼那邊 8×8 個節點就夠,這邊要用 16×16?」
先把單位講清楚:一個節點涵蓋 16×16 個像素,所以 8×8 個節點是一塊 128×128 像素,16×16 個節點是一塊 256×256 像素。這一塊會不斷平鋪到整張圖上。
直覺會擔心:一塊變大,是不是就需要更大的殘存區域才驗得出來?量出來不是。(test/node-count.mjs,每組 40 張)
| 圖片尺寸 | 節點數 | 正向 z | 碰撞 z(中位/最高) | 裁到 75% |
|---|---|---|---|---|
| 640×480 | 8×8 | 32.3 | 12.4 / 17.1 | 22.5 |
| 16×16 | 32.3 | 8.1 / 9.9 | 22.6 | |
| 320×240 | 8×8 | 15.2 | 6.1 / 7.7 | 9.7 |
| 16×16 | 15.2 | 4.1 / 5.2 | 9.7 |
正向分數兩邊完全一樣,抗裁切也一樣,只有碰撞分數降下來了。
為什麼正向沒有變差?因為偵測時是環狀折疊的:圖上每一個 16 像素格子,都會被折進 N×N 格裡的某一格去累加。 所以「一塊 256×256」不等於「要有 256×256 才驗得出來」:決定強度的是整張圖總共有多少格子,跟一塊多大無關。
為什麼碰撞變難?因為別人亂猜的那把金鑰,要在256 格上都碰巧同號才碰撞得到;8×8 只要碰對 64 格。 格子越多,要碰巧的地方越多,運氣就得越好。
所以換成 16×16 是純賺的。抗碰撞明顯變好,而換之前擔心的兩件事:訊號會不會變弱、會不會更不耐裁切:量完發現都沒有發生。
對製造機來說 8×8 就夠了:它的圖樣本來就是公開的,防碰撞從來不在它的目標裡,任何人都能蓋才是它要的。
兩邊都在做的事情是一樣的,只是問題不同,所以在這頁玩懂的每一件事,回頭看那個工具的驗證頁就全部通了。
| 題目 | 需求 | 代表 |
|---|---|---|
| 洩漏溯源 | 圖裡要載得動 ID,才知道是誰外流的 | invisible-watermark(dwtDct)、RivaGAN |
| 歸屬線索 | 只要回答「這張圖有沒有蓋過我的金鑰」,一個位元就夠 | 本頁 |
因為只需要一個位元,我們可以把全部的工程餘裕拿去換抗破壞性:平鋪、暴力搜尋、只看整體相關性。溯源型要精準到每個位元的位置,所以對裁切、縮放特別脆弱。看完可能會發現該用別的工具,那也是這頁的目的。
金鑰是一段隨機字串,不是圖。它決定浮水印長什麼樣子,而且只有握有它的人能蓋、能驗。
這一步一定要做完的事:按「產生一把新金鑰」,然後把鑰圖下載存好。那張鑰圖是我們唯一的憑據:我們沒有伺服器、沒有備份、沒有「忘記密碼」。
| 圖上的位置 | 存了什麼 | 能不能還原回金鑰 |
|---|---|---|
| 中間那行大字 | 金鑰的明文 | 能。用眼睛照著抄就行 |
| 左下角的 QR | 同一串字 | 能。手機掃一下 |
| PNG 的中繼資料 看不見,藏在檔案裡 | 同一串字 | 能。把原檔拖回步驟 7 會自動讀出來 |
| 整張圖的底紋,和中間那塊色板 | 這把金鑰算出來的指紋 | 還原不出那串字,但已經足夠拿去蓋章了 |
前三份是故意重複的:字可能被裁掉、QR 可能拍糊、中繼資料一上傳到社群平台就會被洗掉。任何一份活著,這把金鑰就救得回來。
最後一列最容易被忽略。蓋章和驗證需要的是那串字算出來的 256 個 ±1,字本身只是拿來算它的。底紋就是它放大之後的樣子。
實測(2026-08-14):把明文和 QR 全部遮掉、只看底紋,還原出 82% 的節點,拿去驗一張用真金鑰蓋的圖:
| 用什麼去驗 | z | 峰值旁瓣比 | 結果 |
|---|---|---|---|
| 真金鑰 | 32.6 | 11.6 | 檢出 |
| 只從底紋偷出來的指紋 | 23.9 | 10.2 | 照樣檢出 |
| 隨機金鑰(對照) | 6.6 | 3.6 | 未檢出 |
所以把鑰圖打碼、只露出底紋,一樣等於把金鑰交出去。這張圖沒有「露一半」這種事。
被別人看到會怎樣?他能在任何一張圖上蓋出跟我們一模一樣的浮水印,然後宣稱那是他做的。 而驗證會回答「檢出」,因為驗證只能回答「這張圖蓋過這把金鑰」,它回答不了「是誰蓋的」。這就是為什麼金鑰外流等於整套機制失效。
弄丟會怎樣?就證明不了了。沒有伺服器、沒有備份、沒有「忘記密碼」:這也是為什麼同一把金鑰要在圖裡存三份。
警告是烙在像素裡的,不是寫在這個頁面上。說明沒人會看,但圖會跟著圖跑:哪天不小心把它貼出去,我們自己和看到的人都會先讀到那句話。
logo 完全不是必要的,加不加都不影響任何事。它純粹是標籤,讓我們在一資料夾長得一樣的鑰圖裡分得出哪張是哪張。 它對演算法零貢獻:至於「為什麼不能乾脆拿 logo 當金鑰」,附錄有可以動手跑的對照。
把金鑰餵給一個亂數產生器,得到 16×16 個 +1 或 −1。這就是我們的指紋。
這裡的亂數產生器是確定性的:看起來很亂,但同樣的金鑰永遠得到同樣的結果。不然今天蓋的圖,明天就驗不出來了。 代價是換一個字元就完全不同,所以金鑰要一個字不差。
第五次做壞的地方,是使用者問出來的。問題是:「金鑰只有 20 個字元,指紋有 16×16 格,會不會很容易重複?」
光看大小不會:20 個字元的 Crockford base32 是 100 bit(2¹⁰⁰ 種),指紋是 256 格 ±1(2²⁵⁶ 種),指紋空間大得多。 但中間那個亂數產生器把它掐掉了。舊寫法是先算出一個 32 bit 的雜湊,再從它連續吐四個數字去啟動亂數產生器: 看起來有 128 bit,其實全部由那 32 bit 決定。所以指紋最多只有 2³² 種。
實測 30 萬把不同的金鑰,撞出 9~12 組完全一樣的指紋(2³² 的理論期望值是 10.5 組)。
修法是改成跑四次獨立的雜湊、每次都吃過完整的金鑰;同樣 30 萬把,碰撞 0 組。
新產生的金鑰開頭是 IWL2-。舊的 IWL1- 走原本的路徑,以前蓋過的圖照樣驗得出來:
修一個安全問題卻讓別人的舊作品驗不出來,那是另一種做壞。出處:test/keyspace.mjs。
為什麼是隨機的 ±1,而不是我們自己的 logo?這頁的第一版真的是讓人上傳 logo 當金鑰,後來量出兩個補不掉的問題:
隨機 ±1 沒有這兩個問題:相鄰兩格互不相干,而且每一格都用滿振幅。這也是 1997 年那篇奠基論文用偽隨機圖樣的原因。
不過第二點沒有我原本寫的那麼嚴重。後來在同一張圖上實測,把 logo 的指紋「拉開」(見附錄)之後表現追得上來,所以真正否決 logo 的是第一點。附錄可以自己拖一張 logo 進去跑。
這一步做完,有浮水印的作品就完成了。把作品拖進來、按「蓋上去」、按「下載蓋好的 PNG」:那張下載下來的圖就是我們要拿去用的成品。 後面的步驟 4、5、6 都是拿這張圖做實驗,不影響成品。
拖曳滑桿:左邊原圖、右邊蓋好的。重點就是我們應該看不出差別。
只動藍色通道,每個像素最多加減 2。螢幕上每個像素的紅綠藍各是 0 到 255 的整數,所以「加減 2」是全部範圍的 0.8%。 而且人眼分辨藍色細節的能力最差:同樣的改動量藏在藍色裡最不容易被看到。
接近全白、接近全黑的地方只加減 1。那些地方最平、最容易露餡。
取整數的時候要「抖」一下。算出來的值有小數,例如 1.4 和 1.6,直接四捨五入會變成 1 和 2: 於是一整片平坦的底色上,會出現一圈一圈像等高線的痕跡(進位的地方連成一條線)。 做法是給每個像素一個固定的小偏移量,4×4 一組循環,讓進位的位置散開成細碎的點,人眼看到的是均勻的一片,不是線。 這叫有序抖動(ordered dithering),是報紙用網點印照片、用了一百年的老技術。這是我們做壞的第三版才修好的。
驗證會給我們兩個數字。這一整段用國中數學就能算完,先把它算一遍,等一下看到那兩個數字才有意義。
把圖切成 16×16 像素的小格子,每一格算出一個數字,代表這一格「偏藍多少」。算法是:
偏藍程度 = 藍 - (紅 + 綠) ÷ 2
為什麼要減紅綠?因為一張圖有亮有暗,亮的地方紅綠藍三個值都大。減掉紅綠的平均,等於把「這裡整體有多亮」扣掉,只留下「這裡比周圍偏藍多少」:那才是我們偷偷加上去的東西。
問題來了:藍天本來就偏藍,這樣算出來的數字裡,絕大部分是畫面自己的顏色,不是我們的浮水印。怎麼分開?靠變化的快慢:
所以把每一格減掉上下左右四個鄰居的平均。慢慢變化的畫面內容被扣得差不多;一格一格跳的圖樣不但留下來,還變得更強,因為鄰居常常是反號的,減掉一個負數等於加上一個正數。
這一步的專業名稱叫高通濾波(high-pass filter),意思就是「只放行變化快的部分」。名字聽起來很硬,做的事就是上面那個減法。
假設整張圖只有四格(真的圖有好幾千格,道理一樣)。扣掉畫面之後量到的數字,跟我們的圖樣擺在一起:
| 圖上量到的 | +3 | −2 | +4 | −1 | |
|---|---|---|---|---|---|
| 我們的圖樣 | +1 | −1 | +1 | −1 | |
| 逐格相乘 | 3 | 2 | 4 | 1 | 加起來 = 10 |
全部變成正的。因為圖樣說「這格該是正的」,圖上量到的就真的是正的;圖樣說負的,量到的也是負的:負負得正。
換一把不對的金鑰,圖樣就變成別的樣子:
| 圖上量到的 | +3 | −2 | +4 | −1 | |
|---|---|---|---|---|---|
| 別人的圖樣 | +1 | +1 | −1 | −1 | |
| 逐格相乘 | 3 | −2 | −4 | 1 | 加起來 = −2 |
有正有負,互相抵消。對得上就一路累積,對不上就抵消掉:這就是整套演算法的全部核心。格子越多,抵消得越乾淨。
這裡刻意不用相減去比對。相減要成立,得先假設圖上只有我們的圖樣、沒有別的東西;真實的圖有內容也有壓縮雜訊,相減永遠不會是零。乘法決定「這一格算不算數」,加法讓雜訊自己抵消。
光看 10 沒有意義,要有比較的基準。做法是:把圖樣滑到每一個可能的位置各算一次(16×16 個節點,共 256 種對齊方式)。這 256 個分數裡,絕大多數都是沒對齊的,它們就是「隨便猜會得到多少」的樣本。
假設這些分數大多落在 −3 到 +3 之間,那「一般會晃多大」差不多是 1.5。這個「一般會晃多大」,數學上叫標準差。
z = 我們這次的分數 ÷ 一般會晃多大 = 10 ÷ 1.5 ≒ 6.7
z 的全名是 z 分數(z-score),中文叫標準分數,意思就是「比一般的晃動大幾倍」。門檻設 ,是在說:要比一般的晃動大 6 倍以上,我才相信這不是碰巧。
z 有一個它自己分辨不出來的狀況:蓋得越用力,連碰撞出來的最高分也會跟著變大。別人拿一把完全不對的金鑰,在 256 個位置裡總會挑到最巧的那一個,剛好有一大半格子同號:分數就上去了。
所以要再問一句:那個最高分,有沒有明顯贏過其他 255 個位置?
峰值旁瓣比 = (最高分 - 其他位置的平均) ÷ 其他位置晃動的幅度
全名是峰值旁瓣比(peak-to-sidelobe ratio)。「峰值」是最高的那一根,「旁瓣」是旁邊那些沒對齊的。這是雷達判斷「螢幕上那個亮點到底是飛機還是雜訊」用的老方法,不是這頁發明的。
兩個數字問的事情不一樣:z 問「這個分數大不大」,峰值旁瓣比問「它有沒有明顯贏過別人」。蓋得用力會讓所有分數一起變大,但不會讓「贏多少」變大,所以第二個數字騙不了。
下面驗完會用兩張熱圖把這件事畫出來:那是這一節最重要的一張圖。
驗證做的事就是這個。指紋每滑一格算一次分數,灰色帶子是「沒對上」的分數落在哪個範圍(旁瓣)。 我們的金鑰會有一根衝出帶外,別人的金鑰整排都埋在帶子裡。
兩種「誤判」的破口不一樣,而且剛好相反:這就是為什麼上面那兩個數字缺一不可:
只看其中一個,都會被另一種騙過去。兩個都要求,就都擋掉了。這是實測逼出來的:一開始只用 z、門檻照舊版設 7,第一次跑負控制就抓到誤判。 上面那張跟 LINE 對話製造機的對照表就是這件事的實例:那邊只看 z,結果被這頁蓋出來的圖碰撞成功了三分之一。
這頁的第一版沒有金鑰字串,是上傳自己的 logo,把它壓成 16×16 的指紋。聽起來很美:我們的浮水印就是我們的標誌。當時列了兩個放棄的理由,後來重量一遍,發現其中一個是我講錯的。兩個都放在這裡。
理由一,定義上的,無解。logo 是我們到處在用的東西:網站上、名片上、每張作品的角落。公開的東西不能當金鑰。任何人下載我們的 logo,就能在別人的作品上蓋出我們的浮水印,宣稱那是我們做的。這一條改演算法救不回來。
理由二,我原本以為也是致命的,量完發現不是。偵測端第一件事就是高通(減掉四個鄰居的平均),因為畫面內容是低頻的、指紋是格子間交替的高頻。而 logo 也是低頻的:大色塊、平滑漸層,相鄰的格子長得差不多,高通一過能量掉一大半。但這件事有解:白化(把指紋每格振幅重新正規化到偵測端看得見的頻段)。下面這張表就是白化前後的差距。
指紋強度是每格振幅的均方根。白化前那張看得出成塊、相鄰格子同號:那就是低頻,會被高通殺掉的部分。白化把它拉開,金鑰字串那張則是天生格子間隨機跳。
| 指紋來源 | 強度 | 原圖 z | 原圖 PSR | 裁 75% z | 裁 75% PSR |
|---|
所以第二個理由被推翻了,logo 當金鑰在效能上其實堪用。它還是被放棄,因為第一個理由本身就足夠:一個公開的東西不管訊號多強,都不是金鑰。
把這段留在頁面上,是因為「為什麼不行」比「怎麼做」難學,而且連「為什麼不行」都會講錯。這個 repo 一共做壞四次:8×8 太好偽造、直接四捨五入在平坦底色留下等高線、還原用「蓋相反的場」只回得去 64.7%、以及這一次把可解的問題當成致命的。每一次都是量出來才知道的,包括這一次的更正。
有金鑰的話,可以把場減回去。這是理解整件事最快的一條路:浮水印就是一個加上去的訊號,不是什麼玄學。
用的是跟步驟 3 同一種「差異放大 20 倍」的看法。蓋好的那張看得到指紋鋪滿;還原後那張應該什麼都沒有。
為什麼一個「移除浮水印」的功能會放在這裡?因為它證明了兩件我們必須誠實說的事:這個標記是可逆的加法訊號;而且沒有金鑰就精確減不掉:別人只能用模糊、旋轉那種粗暴手段,那本來就擋不住。
這頁從頭到尾的立場是:開源、可拔除、防的是順手盜用而不是防偽。與其假裝拔不掉,不如把怎麼拔、以及拔了會怎樣,一次講完。
用的就是我們上面剛蓋好的那張圖,不是另外準備的範例。把它摧殘一輪再各驗一次。這張表同時證明「它真的活得下來」和「它有明確的極限」,比任何文案都可信。
表裡有兩把梯子,對照著看。純裁切 90→75→60→50→30,裁完再縮一半 90→75→60→50。 純裁切的斷點在 75% 與 60% 之間;裁完再縮一半的斷點在 60% 與 50% 之間: 也就是說多做一道縮小,反而更耐用。縮小再放大等於過了一次低通濾波,把畫面的高頻雜訊壓掉、留下平滑的場紋。 「有效格數」那一欄是還原之後的格數,縮放那幾列會補回來,裁切那幾列補不回來。
「預期」是 2026-08-13 用 test/survive-matrix.mjs 在四種代表性圖片上量出來的(平滑插畫、實拍照片、高細節紋理、大面積淺色),三張全活才寫「應該活」。
那四張都是 900×620。裁切那幾列只對這個尺寸成立:同一種內容放大到 3600×2480,裁到剩 10% 還活著,見誠實的界限。不是保證:存活很吃圖片本身。跟預期不符的那幾行會標成黃底,那才是新資訊。
這一項會跑一段時間:電腦上大約 20 秒,手機上大約一到兩分鐘(21 種摧殘方式,每一種都要重新搜尋位移和尺度)。跑的時候旁邊會顯示進度,可以先往下看。
| 摧殘後的樣子 同一個框,按真實比例畫 | 摧殘方式 | 有效格數 還原後 | z | PSR | 結果 | 預期 |
|---|
這是實際要驗證時唯一會用到的一步,前面那些都不必走。情境是這樣:作品流出去了,我們拿回一張圖、配上當初存的鑰圖,問它「這是不是我的」。 兩邊都不需要是這個分頁產生的,圖可以是別人傳來的,金鑰可以是半年前下載的那張鑰圖。
還原只在「原尺寸、沒被轉檔」的圖上有效。被縮過、裁過、存成 JPEG 的圖,格線已經對不回去,減下去只會多加一層雜訊。要拿回乾淨的原圖,用當初自己下載的那張 PNG。
「未檢出」不等於「不是我們的」。可能是被裁得太兇、被旋轉過、或那張圖本身雜訊太高蓋不上去。這個工具只能指出一個方向,不能當證據。
反過來「檢出」也只證明「這張圖蓋過這把金鑰」,不證明是誰蓋的:金鑰外流的話,別人也能蓋。
開源,所以拔得掉。防的是順手盜用,不是防偽。原始碼就在下面的連結裡。
金鑰外流就全毀。拿到我們金鑰的人可以在任何圖上蓋出我們的浮水印,宣稱那是我們做的。這是這套機制唯一的信任根。
金鑰弄丟就證明不了。我們沒有伺服器、沒有備份、沒有「忘記密碼」。所以鑰圖裡存了三份。
拿到一張你蓋過的圖,就能把場紋估出來:不需要金鑰,也不需要很多張。 這一條原本寫的是「蒐集夠多張取平均才可能反推」,2026-08-14 量完發現講輕了:一張就夠。 因為場紋是平鋪的,攻擊者知道演算法(開源),就能做跟偵測器一樣的事:高通、把每個格子按 16 為週期折疊累加、取正負號。 畫面內容被高通殺掉又被折疊平均掉,場紋每一塊都同號,累加起來就浮出來了。
| 他估出來之後能做什麼 | 實測 |
|---|---|
| 估中多少 | 約 70% 的節點(瞎猜是 50%) |
| 拿去驗另一張同金鑰的圖 | z 30–58、峰值旁瓣比 約 9,檢出:等於他手上有一把能用的金鑰 |
| 拿去把浮水印減掉 | z 從 44 掉到 25,原主人仍然驗得出來:抹不乾淨 |
這是平鋪式浮水印的結構性弱點,不是這份實作的疏忽:文獻上早就記載自同步(平鋪)浮水印容易被估計攻擊,
而平鋪正是它能抗裁切的原因。兩者是同一件事的兩面。出處與可重跑的腳本:test/estimate-attack.mjs。
「一件作品一把金鑰」擋不住這一條(單張就夠),它只擋得住跨作品的關聯。
高細節、高雜訊的圖蓋不上去。2026-08-13 量到的:一張密集紋理的測試圖連原圖都驗不出來(z=6.1)。±2 的訊號從一開始就蓋不過那張圖本身的雜訊底,跟有沒有被摧殘無關。平滑的插畫、實拍照片、大面積淺色的商品圖都沒問題。
裁切比想像中脆弱。同一批測試裡,裁掉一半只有大面積淺色那張活得下來,平滑漸層與實拍照片都死了。留得住的關鍵是剩餘格數加上雜訊底夠低。
旋轉會殺死它。我們沒有搜尋角度,這是明確的已知限制。
「能裁掉多少」沒辦法用一個百分比講。下面存活測試那張表寫的斷點(留下 75% 到 60% 之間)是用 900×620 量出來的: 那是那張圖的斷點,不是這套演算法的。同一種內容放大之後,能裁掉的比例差非常多:
| 同一種內容(平滑漸層) | 總格數 | 裁到剩多少還驗得出來 | 剩下的格子 |
|---|---|---|---|
| 3600×2480 | 34875 | 剩 10% 還活著 | 330 |
| 1800×1240 | 8624 | 剩 22% | 408 |
| 900×620 | 2128 | 剩 75% | 1218 |
| 600×414 | 925 | 幾乎裁不得 | 925 |
但它也不是單純的「剩下幾格」:斷點的格數從 176 格到 8624 格都有。真正決定的是 剩下的格子數 × 每一格的訊噪比:裁切減少前者,圖片本身的細節決定後者。 大圖佔兩個便宜:格子多,而且同樣的內容放大之後,每一格裡面更平滑。
高細節那張是極端例子,也剛好解釋了上面那條「蓋不上去」:900×620 的高細節圖連原圖都驗不出來(z=4.7),
但同一份內容放大到 3600×2480 就驗得出來,還能裁到只剩一半。所以這種圖不是不能用,要夠大才行。
出處:test/crop-vs-size.mjs。
縮放幾乎不影響(縮到 25% 照樣驗得出來),而且反直覺的是,裁完再縮一半反而比只裁還耐用: 裁到 60% 單獨會死,裁到 60% 再縮一半卻活。因為縮小再放大等於過了一次低通濾波:場紋是 16 像素節點的平滑訊號, 重新取樣殺不掉;畫面內容的高頻雜訊卻被平均掉了,每一格的訊噪比反而變好。跟上面那條是同一個道理的兩種走法。
未檢出不等於沒被盜。只代表這張圖不是、或已經無法辨識為用這把金鑰蓋的。
這裡沒有任何一塊是新的。把出處列出來,是為了讓你可以直接去讀原文:這頁只是把這些東西接起來、做成看得見、可以動手跑的樣子。
| 這頁用的 | 出自哪裡 |
|---|---|
| 把振幅很低的偽隨機圖樣鋪滿整張圖,偵測時用相關性比對 | Cox、Kilian、Leighton、Shamoon(1997),Secure Spread Spectrum Watermarking for Multimedia,
IEEE Transactions on Image Processing 6(12), 1673–1687。 差別:他們埋在 DCT 係數,而且論文裡抗裁切、縮放、旋轉那部分需要拿原圖來對位;這頁埋在空間域的藍色通道,驗證時不需要原圖。 |
| 只改藍色通道,振幅跟著亮度調整 | Kutter、Jordan、Bossen(1997),Digital signature of color images using amplitude modulation,SPIE 3022。 這是跟這頁最接近的前人工作:一樣是空間域、一樣只動藍色通道、一樣讓振幅跟著亮度走、一樣用祕密金鑰決定圖樣。 |
| 「逐格相乘再全部加起來」的偵測方式 | 訊號偵測理論裡的匹配濾波(matched filter),教科書內容 |
| 用峰值旁瓣比當第二道判準 | 雷達與相關濾波器的標準做法。我們用的公式(峰值 − 旁瓣平均)÷ 旁瓣標準差, 跟 MOSSE 追蹤器(Bolme 等人,2010)判斷「追丟了沒」用的是同一條 |
| 平鋪 + 暴力搜尋位移來對齊 | 對抗裁切的標準做法。代價也是文獻上早就知道的:平鋪的週期結構會被自相關看出來,所以可以被估計攻擊,就是誠實的界限裡量到的那一條 |
| 取整數之前先抖動 | 有序抖動(ordered dithering),印刷業用網點印照片用了幾十年 |
演算法上沒有。如果問題是「有沒有人這樣做過」:有,而且是 1997 年。上面前兩篇加起來,幾乎就是這頁在做的事。 說這是獨創、或說「沒有人這樣做」,都不成立。
自己的部分是工程取捨和量測:±2 的振幅、16 像素一個節點、近白近黑減半、抖動、雙門檻判定、 把位移搜尋折疊成環狀互相關(數值一位不差、快 40 倍),以及那些做壞的版本被淘汰的過程。 這頁上每一個數字都附了可以自己重跑的腳本,沒有一個是從論文抄來的,包括那些讓這套做法看起來很弱的(裁切比想像中脆弱、單張圖就能估出場紋、舊版的指紋空間只有 2³²)。
| 專案 | 做法 | 跟這裡的差別 |
|---|---|---|
| invisible-watermark | Python,dwtDct/dwtDctSvd/RivaGAN | 載得動 ID,可溯源;抗裁切與縮放較弱 |
| hiddenmark | 瀏覽器端,傅立葉頻域環狀調變 | 方向最接近,頻域天生較抗縮放旋轉,實作複雜 |
| watermark-js-plus | 瀏覽器端,可見+盲浮水印 | 功能最全,目標是保護網頁與文件 |