新種デジタルウォーターマークを開発した話

# 新種デジタルウォーターマークを開発した話

この記事は デジタルキューブグループ アドベントカレンダー2024 の12月16日分の記事です。

こんにちは、ヘプタゴンでクラウドエンジニアをしている砂江と言います。
今回は勉強がてら作成した新しいプログラムを披露します。

この記事および作ったプログラムは以下の知識が前提です。実装は Python で行っています。

- 二次元フーリエ変換→[二次元離散フーリエ変換](https://qiita.com/tatsunidas/items/bd5d53b7c73a9fd99f86)
- 短時間フーリエ変換 (Short-Time Fourier Transform、STFT)→[音声認識【４．短時間フーリエ変換】](https://qiita.com/nanoneko/items/be788279035b6af67366)
- AM (振幅変調) → [振幅変調 - Wikipedia](https://ja.wikipedia.org/wiki/%E6%8C%AF%E5%B9%85%E5%A4%89%E8%AA%BF)
- FM (周波数変調) → [周波数変調 - Wikipedia](https://ja.wikipedia.org/wiki/%E5%91%A8%E6%B3%A2%E6%95%B0%E5%A4%89%E8%AA%BF)

### 背景
2024年10月16日に発表された、X (旧Twitter) の新しい利用規約が、ユーザーの同意にかかわらず全てのコンテンツが生成AIの訓練に利用される内容だったため、X上のイラスト界隈で大騒ぎになりました。

これをきっかけに、作品にウォーターマーク (デジタルコンテンツへ埋め込む所有者を示すロゴやテキスト) を利用する方が大幅に増えている印象です。AI による利用はさておき、ウォーターマークは人による無断転載には有効なものの、保護目的とはいえ、作品を制作者自らの手によって傷付けるのは大変残念なことだと考えています。

なぜなら、ウォーターマークのほとんどは何かしらの意匠を持つだけでなく、勝手に消されないよう作品の目立つ部分に組み込まれるからです。作品の見た目を損ねず保護できる方法として真っ先に浮かんだのはデジタルウォーターマークです。機械によってのみ検出されるデジタルウォーターマークは、作品の見た目損なわなず、保護を両立できる選択肢と考えてます。

しかし、デジタルウォーターマークの実装で主流となっているDCT方式やWavlet方式はピクセル単位で情報を埋め込む仕様のため、インターネットサービスによく用いられる非可逆圧縮 (視認できない情報を削除してファイルサイズを減らす圧縮方法) に弱い課題があります。

そこで、自身の勉強をかねて、新種デジタルウォーターマークを開発することを決意しました。今回作った新しい方法の名前を、ここでは「AM watermark」とします。



### 発想

ウォーターマークといえば制作者名や「SAMPLE」「copyright」といった作品と異なる絵柄を組み込むのが連想されます。その実現には作品本体とウォーターマークとなる画像の二つが必要な印象ですが、同じサイズの画像を二つ重ねて、一見すると一つの画像にしか見えないが、必要となる時だけ分離可能な仕組みはないのでしょうか。

それを実現するヒントはラジオにありました。

ラジオは同じ地域で多くの放送施設からの電波が飛んでいる中、適当な周波数だけを拾い、復元する仕組みです。つまり、同じ空間でも周波数ごとにコンテンツを分けることが可能なのです。

周波数は電波だけでなく、画像にも同じ仕組みを適用できるため、二次元フーリエ変換を活用すれば、一枚の中にある複数の画像を分離することができると考えます。
 実現方法

ラジオの周波数方式にはAM（振幅変調）とFM（周波数変調）の二種類ありますが、AMの方が仕組みがシンプルなので、先にAMでの実装を試みました。
　
やり方は、まずウォーターマークの画像を二次元フーリエ変換します。人の目が認識できる範囲は低周波成分に限られるので、ウォーターマークを二次元フーリエ変換して、低周波成分を高周波の位置に移動して、逆フーリエ変換で画像に戻せば完成です。

こうするとウォーターマーク画像単体では模様がぼやけて見えるが、不透明度を調整した後、作品画像と重ねればウォーターマークをほぼ見えなくすることができます。

```python: DFT.py
# Import libraries
from matplotlib.image import imread
import matplotlib.pyplot as plt
import numpy as np
import click

#@click.command()
#@click.argument('fig')
#@click.option('--inverse', default=False)
#@click.option('--show', default=True)
def fft2d(fig, inverse=False, show=True):


    if type(fig) == str:
        fig = imread(fig)
        print(fig.shape)
        fig = np.mean(fig, -1)
    else:
        
    #    print(type(fig))
        pass
    
    if show:
    #    plt.imshow(fig)
    #    plt.show()


    if inverse==False:

        fft2 = np.fft.fft2(fig)
        if show:
            print(np.abs(fft2))
            plt.imshow(np.log(np.abs(fft2)))
            plt.colorbar()
            plt.show()
            
        return fft
    else:
        ifft2 = np.fft.ifft2(fig)
        if show:
            plt.imshow(ifft2.real)
            plt.show()
        return ifft2
    
if __name__ == '__main__':
    fft2d()
```

```python:overfreq.py
from DFT import fft2d
from matplotlib.image import imread
import matplotlib.pyplot as plt
import numpy as np
import click
import cv2

@click.command()
@click.argument('img')

def overfreq(img):
    cv2.imread(img, cv2.IMREAD_GRAYSCALE)
    print(type(img))
    fft2 = fft2d(img)
    print(np.abs(fft2))
    h,w = fft2.shape
    print(f'h:{h}, w:{w}')
    fft_plus = np.pad(fft2, ((h//2,h//2), (w//2,w//2)), mode='constant', constant_values=0)
    fig_plus = fft2d(fft_plus, inverse=True)
    print(fig_plus.real)
    plt.imshow(fig_plus.real)
    plt.show()
    cv2.imwrite('overfreq.jpg', fig_plus.real)

if __name__ == '__main__':
    overfreq()
```

従来の方法ではウォーターマークを組み込む度にプログラムを通す必要があるため、プログラミング経験の少ない方に対して敷居が高いと思われますが、この方法ではウォーターマークを発行する時だけプログラムを動かせばよく、生成したウォーターマークはその後何度でも繰り返し利用できるので、相対的に敷居が低くなると考えています。
　
画像に埋め込んだウォーターマークの検出には短時間フーリエ変換（STFT）を利用します。AMに決まった復調方法がありますが、STFTの方も比較的簡単な手順で復調できます。ただし、二次元画像に対するSTFTはライブラリ等で存在しなかったため、新しく作りました。

```python:STFT.py
from matplotlib.image import imread
import matplotlib.pyplot as plt
import numpy as np
import click
from tqdm import tqdm
from DFT import fft2d
window = [20, 20]
overlay = [1, 1]
band = [4, 4]
@click.command()
@click.argument('image')
@click.option('--show',default=True)
def stft(image, show=True):
    
    # Read data & check to display
    fig = imread(image)
    fig = np.mean(fig, -1)
    if show:
        #fft2d(fig, show=True)
        fft = np.fft.fft(fig, axis=0)
        fft = np.sum(np.abs(fft), axis=1)
        x = np.arange(0, len(fft), 1)
        plt.plot(x, np.log(fft))
        plt.show()
    print(f'Window: {window}')
    fig_h, fig_w = fig.shape
    print(f'image size:{fig.shape}')
    hout=[]
    for h in tqdm(range(0, fig_h-window[0]-1, overlay[0])):
        
        wout=[]
        for w in range(0, fig_w-window[1]-1, overlay[1]):
            
            win=fig[h:h+window[0],w:w+window[1]]
            #print(w,h)
            #print(win)
            f=fft2d(win,show=False)
            #f=np.abs(f)
            point=f[band[0]][band[1]]
            wout.append(point)
            #print(wout)
        hout.append(wout)
    #print(hout)
    plt.imshow(np.abs(hout))
    plt.show()

    return hout

if __name__ == '__main__':
    stft()
    
```

### 実験とその結果


ここからは作成したプログラムを実際に使用してみます。

この記事では画像一枚を`t＝1、sample rate＝縦長` としてフーリエ変換に適用させます。テスト用のウオーターマークは`900px*1200px`（Xのデフォルトサイズ）です。この場合は縦方向に1200個のサンプルが並んでいるとされ、理論上検出できる（埋め込める）周波数範囲は`1~600Hz`です。Xで使用する場合は圧縮される可能性があるため、今回は300Hzの帯域でエンコードすることにしました。

今回のウォーターマークにはポピュラーな文言を使用していますが、私自身は特にAIによる学習に反対する立場ではありません。

![wartermark.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935486/a6140337-731c-bb0a-8501-194d3eb46184.jpeg)


図1、ウォーターマーク（加工前）














![overfreq.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935486/dbd81180-fa80-4dfd-996c-f082fc2022a8.jpeg)







図2、ウォーターマーク（加工後）

加工後の画像 (図2) は肉眼でウォーターマークを目視できますが、重ねればさほど目立ちません。

ウォーターマークの作成はは、厳格な手順を踏むと面倒なので、ウォーターマークの画像を直接フーリエ変換してからゼロパディングして、さらに逆変換して画像に戻す流れとしました。

そうすると、ウォーターマークの縦横ピクセル長が2倍、面積的に4倍になりますが、最後は手作業でウォーターマークを作品のサイズに合わせて重ねるので影響がないものとします。このとき、300Hzの帯域から多少ずれたりすることはご了承ください。

加工したウォーターマークを重ねる前後の画像を図3と図4に示します。今回重ねるために使用した画像は、最近自作したものです。


![AnalogRecursion_draw_large.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935486/9af5c0ec-6a57-097f-5f9b-2d9625000cd0.jpeg)



図3、ウォーターマークなし作品

![WMAnalogRecursion_draw_large.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935486/c1eb5f99-fc5b-c6f9-5a16-00da00d31d7d.jpeg)



図4、ウォーターマーク付き作品（不透明度20%、加算モード）

次に、埋め込んだ画像のデコードにあたり、念の為フーリエ変換して帯域がずれていないかを確認します。その結果が図5になるのですが、`450~500Hz`にピークが現れているのが確認できます。

画像は基本的に低周波数帯に特徴が現れるので、ウォーターマークが450Hzあたりにある振幅が高い箇所 (ピーク) です。なお、窓関数使わないことにも影響される模様ですが、厳密な手順で行っていないため無視することにします。
　
次にSTFTに`20*20`サイズのフィルターを設定してそこから500Hzのデータだけ抽出し、順番で新しい画像を作ります。周波数が微妙にズレる場合だとSTFT細かく分析できない場合もあるので、その時フィルターを大きくして分解能を上げなければなりません。




![ARFFT.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935486/75162975-371e-5701-4987-4d805f06e568.png)






図5、周波数振幅



![after_stft.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935486/f93a052c-a0ad-aefb-a876-54d0e64ca0ff.png)







図6、500Hz帯域を抽出した画像

こうして生成した周波数を帯域に合わせた画像には、埋め込んだウォーターマークが薄く映ります (図6)。ただ、作品自体にも高周波成分が含まれるため、ウォーターマーク以外の情報を全く除去するのは難しいです。例えば、線などは普通に高周波から低周波まで全ての成分が検出されます。

　
### おわりに

フーリエ変換を用いた周波数操作によって、作品の景観を損ねないデジタルウォーターマークを作りましたが、ウォーターマーク検出時に作品の情報が完全に分離できない場合がありました。

これを克服するためには、今回行わなかったFM方式の加工が有効と考えられるため、機会があれば別の記事で発表しようと思います。周波数分解によるデジタルウォーターマークは画像のピクセルに依存せず、圧縮に強いというメリットがあると思われますが、これらのアイデアは開発中でまだ実験できていないので、機会がある時テストしてみようと考えています。