電子透かしの取り出し成功率を約38%改善したアルゴリズムを公開しました。そして、このアルゴリズムをコアとしたSaaS「zerome.me」を開発しています。
もともと参考にしたアルゴリズムは、埋め込んだ情報のうち1bitでも誤りがあると取り出しに失敗する、非常に繊細なものでした。
これを解決するために、埋め込み情報をGolay符号化を用いて冗長化し、さらに画面全体にシャッフルして配置する実装をフルスクラッチしました。
これにより、JPEG圧縮などによる情報の欠損に強く、安定して取り出せるようになりました。
[改善前] vs [Golay符号化]
横軸が電子透かしの強さです。特に弱いパラメータでの取り出し成功率(縦軸)が改善していることが分かります。
この記事では、以下の3つのテーマについて書きます。
- アルゴリズムの改善: なぜ成功率が向上したのか、その核心を解説します。
- OSSとしての公開: なぜ競争優位の源泉となりうるコア技術を公開したのか、その思想的背景を説明します。
- SaaSアーキテクチャ: どのようにして個人開発でサービスを構築・運用しているのか、その技術スタックを紹介します。
リンクは最後にまとめて掲載します。
1. なぜ取り出し成功率が38%も向上したのか?
冒頭に述べたように、改善の鍵は「エラーを許容する設計」にあります。前提となる電子透かしのアルゴリズムを簡単に説明して、改善前後を比較していきます。
電子透かしのアルゴリズム概要
ここでの電子透かしは、画像に対してなんらかの情報を直接埋め込み、また取り出し可能とする技術です。処理後の画像には若干のノイズが入るのみで「インビジブルウォーターマーク」「ブラインドウォーターマーク」などとも呼ばれています。
この電子透かしのアルゴリズムといっても、実に様々な手法がありますが、今回は解説のためもととも参考にしたOSS(以下、Python版。改善したものをGo版と呼びます。)のアルゴリズムを扱います。
埋め込み処理
- 画像を8x8ピクセル(Block)に分割する
- 各Blockに1bit情報を加える
- Blockを組合わせて再構成する
加えるbit情報は、たとえばテキストをバイナリで表現したものや、画像を2値変換したものです。
各Blockにバイナリを対応させ加えていくイメージです。
bits := convBitsFromText("watermark-text")
blocks := getBlocksFromImage(image)
for i := 0; i < blocks.length; i++ {
blocks[i] = embed(blocks[i], bit[i])
}
block数がbit数を上回る場合、繰り返し同じbitが埋め込まれていきます。たとえばバイナリが100bit、200blockあった場合、バイナリは2回埋め込むことができます。
バイナリを複数回埋め込めれば、その取り出し時の成功率は上がります。
取り出し処理
- 画像を8x8ピクセル(Block)に分割する
- 各blockのbitを決定する
決定したbitを連結して元のテキストや2値画像に再構成していきます。
var bits []bool
blocks := getBlocksFromImage(image)
for i := 0; blocks.length; i++ {
bits[i] = extractBit(blocks[i])
}
txt := convTextFromBits(bits)
// watermark-text
Python版の改善点
埋め込みと取り出しのバイナリの再現性は、取り出し時に各blockから正しいbitを決定できるかが重要になってきます。取り出し時に1bitでも間違えると、再構成されるバイナリは異なるものになってしまうからです。
たとえば、user-123をバイナリ化して埋め込んだとして、1bitの誤りを許容した場合、user-124やuser-122も同じデータといえる状態になってしまいます。
この1bitの誤りをどのように解釈するのか用途によって様々だと思いますが、埋め込み情報の再現の点でいえば、少しもったいないと考えることもできます。
実際に誤った処理を観察すると、埋め込み時のバイナリを再現できなかったのは、ほとんどが1~2bitの誤りが発生していたからで、残りの大部分の取り出しには成功していました。
// バイナリは、99.8%一致するが、再現としては失敗している。
[FAIL] Size=640x360 BlockShape=8x8 D1D2=25x14 EmbedCount=5.42 TotalBlocks=3600 - Accuracy=99.8%
なぜ再現成功率をあげることができたのか?
このようにPython版では、埋め込んだ情報を正確に再現する点において、1,2bitの誤りが全体の再現に大きな影響を与えていました。これを逆に考えれば、数bitの誤りが訂正可能であれば、再現率は大きく改善できるだろうと考えられます。
そして、実際にGolay符号化を用いて、バイナリが誤って取り出されることを前提とした設計に見直しました。
Golay符号化は、12bitのデータを23bitに冗長化することで、最大3bitの誤りを訂正可能にするものです。これは全体として、13%程度(3/23bit)誤りが訂正可能になる計算です。
なぜ、このような誤り訂正能力で38%も性能が改善できたのかは、以下2点によって説明できます。
- もともとのアルゴリズムでも数bitの誤りで再現失敗となるケースが多かったこと
- もともとのアルゴリズムでも一定の取り出し能力を持っていた
- バイナリを複数回埋め込めないような小さな画像に対して、誤り訂正が有効に機能した
- もともとのアルゴリズムでは、複数回バイナリを埋め込んだ場合より精度が上がる工夫がすでにあったが、少ない場合に効果を発揮しづらく再現失敗が多かった
このため、Python版が優れていて設計思想をちょっと変えた程度というのは、身も蓋もありませんが、全くその通りかなとは思います。
バイナリのシャッフル
ところで、Golay符号化のみでなく、誤りが集中しないようにバイナリシャッフルする工程がとても重要になります。
Golay符号化の13%程度の誤り訂正能力というのは理論値で、極端に言えば、特定の23bitに誤りが集中していた場合、ほかの部分で誤りが一切なかったとしても、デコードされるバイナリは誤ったものになります。
これは実際の画像でも起こりがちで、誤りが集中する部分は、決まって画像の高周波領域にあるからです。
失敗したblockを青に塗った画像。さらにGolay符号化に失敗したエリアをピンクに塗りました。
画像の変化が大きい高周波領域は、JPEG圧縮によって情報が変更されやすく、結果取り出せるbitも誤ったものになりがちです。
この花部分を見れば一目瞭然ですが、花の部分に誤りが集中しています。たとえば左上blockから順番にbitを埋め込んでいけば、あるGolayブロックに誤りが集中しそうです。そして実際にこの画像の取り出しの再現には失敗しています。
バイナリをシャッフルしないと連続した高周波領域の誤りを訂正できなくなる可能性が高いために、バイナリをシャッフルして画面全体に誤りを分散させることが重要というわけです。
なぜGolay符号化なのか?改善余地
以上が、再現成功率の向上させるために取り組んだことになります。
なぜそもそも誤り訂正をしようと考えたのかについては、埋め込み情報にQRコード画像(の2値変換バイナリ)を利用することによって、取り出し時の再現成功率を上げるテクニックがPython版のREADMEにあったからです。ここから着想して、もっと純粋に誤り訂正をしたら精度が上がるだろうと仮説したわけです。ちなみに、QRコードのリード・ソロモン符号化も試してみましたが、バイトレベルのアルゴリズムであるために期待するような訂正はできませんでした。
Golay符号化を選んだのは、実装が簡単そうであったからです。一定成功率が上がったために検証は終了しています。そのため、ほかの符号化アルゴリズムを用いることでより成功率をあげられるかもしれません。
その他の改善案として、そもそもの埋め込み時に、バイナリを低周波領域に埋め込むなども考えられます。(いまは高周波領域に埋め込んでいて、低周波領域への埋め込みは画像の劣化とトレードオフの関係です)
余力があれば実施してみても良いかもしれません。
最後に蛇足ですが、Go版での改善を簡単に列挙します。
Go版ではGolay符号化をオプショナルに選択することができるようになっているほか、データ構造を工夫したりや並列処理を加えています。
- ローカルへのIOをなくして画像の取り扱いをユーザに委ねた
- PureなGoで実装して複雑な依存関係をなくした
- バイナリ変換から0値埋めを行いバイト長x8にバイナリ長を固定した
- 1次元スライスを用いて参照/書き換えを行いallocateを減らした
- 画像処理前にスライスを並べ替えることでスライスのコピーをなくした
- できるだけ正確な値がでるように8bitから16bitに量子化された色データを変換計算に使った
- 一部並列させた
- 数学計算のテスト項目をOpenCVを呼び出して作成したテストケースを用いた
- ベンチマークや試験データも再現できるように公開した
- 可読性の観点から一部のパラメータの意味を変更した
参考: 後述のSaaSにおいて、どのパラメータが良いのかを決定するに当たり、どの程度画像が劣化するのかという指標も見ています。
1に近ければもとの画像と近く変化量が小さいことを表します。
パラメータは、4x4,6x6, 8x8 などは、画像を分割するサイズを表すブロックサイズです。ブロックサイズが小さいほど多くのバイナリを埋め込むことが出来ます。
これまで書いてきたようなアルゴリズムの改善過程と実装をGitHub上で公開しています。Goのライブラリとしても利用可能ですので、利用してみてください。
2. なぜSaaSのコア技術であるアルゴリズムをOSSとして公開しているのか?
この電子透かしのアルゴリズムをコアにした、 zerome.me (よみ: ゼロミー・ドット・ミー)というサービスを開発しています。これは画像作品に自身のIDとタイムスタンプを電子透かしとして埋め込み、万が一証明が必要になったときの説明材料とすることが趣旨です。イラストレータやフォトグラファーが、クリエイターとしてのブランドや世界観を守ることを目的としています。
ところで、多くの類似C向けサービスまたは電子透かしを扱うB向けのサービスでは、アルゴリズムは非公開です。これは競争優位性を確保する上で重要だと思われます。
一方で、zerome.me では、既述のようにアルゴリズムを公開しています。
その主な理由は、非公開にするほどの技術競争力はなく、むしろ公開したほうがサービスの思想と一致するからです。
技術競争に挑むか
わたしのような一般的なプログラマが電子透かしアルゴリズムを改善にコミットしても、技術競争では勝てないと思います。
この電子透かしアルゴリズムについて、個人的な経歴を並べても、すこし他の人よりも詳しい程度です。このGo版でのスクラッチ実装より以前の話で言えば、個人的にGoでスクラッチしたことが過去にあったこと、またその知見を実戦投入する機会を得られた経験がある、くらいです。
経験に価値があっても、主な競合するサービスを運営する大手の資金力を背景とした技術力に太刀打ちできるとは考えられません。
また、このアルゴリズムの特徴は、JPEG圧縮に一定強いですが、回転やトリミング、スクーンショットには現状対応していません。あるいは全く別のアプローチとして機械学習を使うなど、少し探せばそもそもこの用途のアルゴリズムとして優れていそうなものも多くあります。
こうした現状を踏まえると、技術を隠して非公開にするモチベーションは限りなく低いものになります。言い換えれば、これまで説明してきた程度の改善であれば、普通に他の人もやれるだろうと考えているのです。
このOSSがコミュニティによって改善されていくことは、最も理想的な姿ではあります。
個人としての信頼性
また、競合するサービスを提供する企業には信頼があります。この信頼性を個人として確保するにはどのようにすればよいか?と考えた答えが、徹底的な「技術の透明化」です。
電子透かしのアルゴリズムはもちろん、透かしのバイナリ作成方法、利用する署名アルゴリズムも公開しています。どのようにして透かしを埋め込み、またどのように透かしの信頼性を担保しているのかを技術的にオープンにすることで、ユーザからの信頼を得ることを目指しています。
また、なにが出来て、なにが出来ないのかをユーザに積極的に開示するようにしました。この記事もその一環ですが、サービス上では「トリミングや回転や変更には対応していないこと」など、技術的な限界を隠さずに説明しています。
オープンで開かれた空間
先程、サービスの思想と一致すると述べました。
このサービスの思想とは、「だれでも開かれた空間で自身のオリジナルを説明できること」です。
逆に言えば、自分の作品である証が、特定の法人や特定サービスを介しなければ説明できない、という状況は避けたいと考えています。
とても極端な例ですが、もしその法人やサービスが終了したら、だれもその透かしの意義を説明することができません。
その点、サービスに依存しないOSSの場合、特定のプラットフォームへの依存を避けることができ、より長く効果のある透かしを提供できると考えました。
これも極端ですが、またこのOSSを使えばクリエイター自身が検証ツールをセルフホストすることも可能です。より自由を求めるようなクリエイターにとっては、より良い選択肢となると思います。
補足: サービスとしての透明性
以上が、なぜコア技術を公開するのか?に対する回答でした。
やや蛇足ですが、むしろアルゴリズムを公開したからこそ、実現できるサービスモデルがありました。
それは、「証明ツール」という機能をログインなしで利用可能にしていることです。
このログインなし、つまりブラウザさえあればだれでも画像の所有者を確認できるというのは、クリエイターの世界観やブランドを説明するのに、特定背景をもったユーザに限定したくなかったためです。これもサービスの思想によるものです。
この仕組みを維持するために、運営としても機能を維持するコストを下げることが大事だと考えています。たとえば、予想しない量の処理が発生した場合に、金銭的な理由で機能を制限する可能性があり、それはサービスの考え方からは外れてしまいます。
これを回避するため、画像処理をWASM化してブラウザで処理しています。これの意味することは、アルゴリズムを公開しているからこそ、ブラウザで処理しても何の問題もないということです。逆に言えば、アルゴリズムが非開示だとサーバで処理せざるを得ず、結果的に機能を維持のためにコストがかさんでしまいます。
見えづらい部分ですが、アルゴリズムを公開することでユーザに価値提供できる選択肢が広がり、やはりアルゴリズムを公開して良かったと現状言えると思います。
サービスが主に提供する2機能。
- 作成ツール:画像への埋め込み処理
- 証明ツール:画像からの取り出し処理(とアカウント照会)
1の作成ツールは、SNSなどに上げる前に使う日常利用を想定。
2の証明ツールは、万が一説明を迫られたのときや、第三者が画像を通してクリエイターにアクセスしたいなどの場面を想定。
2の維持コストが高く付く場合、運営として収支のバランスに苦慮しそうですが、ブラウザ上で処理をしているため気にならないという構造になっている。またそれはWASM化してアルゴリズムを公開しても構わない状況が可能にしています。
3. SaaSのアーキテクチャ
ここからは、どのようなサービス構成なのか、思いつく限り列挙していきます。
フロントエンド
フロントエンドはAstro+ReactでSPAにしています。レンダリングに状態やロジックが絡む場合は、Reactで書いて、ほかはAstroです。
Astroは、直感的にSSGとCSRを使い分けられるところで決めました(正直どれでもできると思います)。ページ数が少ない、機能が少ない、SSRを前提としない、ツールページも含めて基本SSGでSEOしたい、ということも選定理由の背景にありました。とくにツールページをSSGしてクライアントサイドも動かすときに、まさにAstroのアイランドの考え方がハマっていたのかなと思います。Astroは原則事前にビルドして、ツールの機能部分は、<script>タグに、あるいは <ReactComponent client:load/> のように宣言的に記述しています。学習コスト低くて助かりました。
Reactについては、UIコンポーネント資産を活かせること、条件付きのレンダリングがやりやすい(Astroは向いていない)ために使っています。
状態管理は、nanostore を使いました。Astro+Reactを利用し、ページやコンポーネントを横断して状態を管理する場合に、多く使われているのではないかと思っています。こちらは初めて触りましたが、ドキュメントがわかりやすく、APIもシンプルだったので、やりたいことは達成できました。
バックエンド
バックエンドはGoで書いています。電子透かし自体がGoであったのもあり、とくに選定基準などはないですが、選んだとしてもNodeかGoの2択であったと思います。
バックエンドではテストをごっつり書いています。インフラレベルではDynamoDBのDockerイメージを使って実機ライクに、Cognitoもテスト用のユーザプールを作成してテストしています。各ユニットテストやインテグレーションテストを行いました。ほとんどデプロイして書き直すようなことにはならずに、実装できましたし機能追加やリファクタリングも安心してできたかなと思います。
ロギングは、slogパッケージを使ってJSON形式で出力するようにしています。
デバックしやすいように、ユーザIDやLambdaのID、API GatewayのID、CloudFrontのIDをリクエスト毎にトレースできるようにしています。これは運用中も助かるかなと思う一方、開発中もかなり助かりました。
データ
このサービスの永続化データは、Cognitoのカスタム属性とDynamoDBで賄っています。ユーザのログイン情報やユーザ名などのいわゆるプロフィールに該当するようなデータは、すべてCognitoにあります。既述のようにテスト用にCognitoのユーザプールを作成しています。X認証、課金などの情報もCognitoであり、エミュレートツールよりも実機で確認したほうがより堅牢かなと思っています。
その他のデータ、DynamoDBを使っています。ちょっとした状態管理に使っています。データモデル自体は3,4個なのでマッピングツールなどは未使用です。実装ベースでデータの構成とパースを分かるように管理してます。
インフラ構成
AWSです。認証が不要のAPIはCloudFrontからLambdaURLにルーティングされます。この後段のLambdaは原則GETのみでポリシーレベルでデータアクセスを制限しています。毎回Lambdaが呼ばれるのもどうかと思うので、CDN側で一定のキャッシュしています。ここは運用中にコストとのバランスで変更するかもと思っています。
認証が必要なAPIについては、API Gatewayで、Authorizerで認証をCognitoにつけています。マネージドなサービスで、関数内で認証済みかどうかを判定する手間がなかったです。
その他はAstroのS3で受けます。
CloudFront
|
ㇳ--Lambda Function URL(Public API)
|
ㇳ--API Gateway +Authorizer (Private API)
|
ㇳ--S3 (Astro App)
他にLambdaは、画像処理用、ユーザ作成や課金のイベントを受けるLambdaがあります。
インフラはTerraformで管理してます。
CI/CD
基本、GitHub Actionsを利用して、mainにpushで本番環境にデプロイしてます。
その他、PR時にインフラリソースの差分をコメントしたりしてます。
蛇足ですが、Lambdaの実装を変更したときに、一回一回terraform applyを手元で行うのも大変なので、シェルスクリプトを用意してAWS CLI経由で検証環境にデプロイも多用してました。
これは案外助かりました。たとえばLambda Function URL をGoサーバにプロキシするときに多用しました。
バックエンドについては、テストを多く書いていたので、CI環境でもテストを回すようにしてました。
CognitoについてはCI環境からもテストできるようにロールを付与して実行してました。またCongitoのユーザプールは、Test環境とはまた別にCI環境用というものを作ってました。
画像処理
サービスには、作成ツールでの埋め込み処理と証明ツールでの取り出し処理が必要になっています。
前者の埋め込み処理についてはログインユーザが利用するもので、同時に複数の画像を処理することがありえます。一方で取り出し処理についてはログイン不要で、基本一枚だけ処理する用途が想定されます。
作成ツール
作成ツールでの埋め込み処理はサーバで行います。ログインユーザが利用する機能です。
内部で埋め込み->Jpeg化->取り出し確認していて、ユーザには取り出せることを確認できたもののみ提供する仕様としています。
サーバ側では同期処理になっていて、リクエスト内で処理を終了させています。レイテンシやスケーリング状況見て、非同期にするかもです。
ブラウザからみると処理手順はしたのようになっています。
- POST: PresingedURL取得
- PUT: 画像アップロード
- POST: 埋め込み処理リクエスト、DownloadURL取得
- GET: 画像ダウンロード
できるだけ、速く処理できたと思われることが重要であったので、実装の工夫してます。
- PresignedURLは非同期で事前に取得する
- ログイン直後にポーリングしてURLを事前に取るようになっています
- 実質1リクエスト減るのでやらないよりマシです
証明ツール
証明ツールはログイン不要で動作するように設計されています。無料機能のため、処理あたりのコストは限りなく安くするようにしています。必要なコストはマークの正当性を確認するための公開鍵をサーバから提供する部分のみです。
- 画像処理: 画像からユーザIDとタイムスタンプ、署名を取得
- 公開鍵取得: ユーザIDとタイムスタンプから公開鍵をサーバにリクエスト
- 検証: ペイロードと署名を検証
WASM化できたことで、運用コストが限りなく安くできたと思います。
マークの構造や作成するアルゴリズムはフルオープンなのでブラウザに露出しても問題なく、署名アルゴリズムによってユーザの利益が守られるイメージです。
4. リンク/データ集
最後に、関連するリンクまとめます。
お読みいただきありがとうございました。
ライブラリ
Python版
Confirm UIにも組み込まれている著名なOSSです。
Go版
スクラッチしたものです。
関連パッケージ
Golay符号化パッケージです。いい感じにパッケージ利用できるコードが見当たらなかったのでスクラッチしました。
https://pkg.go.dev/github.com/yyyoichi/golay
バイナリ操作用のパッケージも書いてます。[]uintからn番目を取得する用途です。
https://pkg.go.dev/github.com/yyyoichi/bitstream-go
周辺パッケージを書くのも楽しく、寄り道してしまった感はあります。
試行錯誤データ
試行錯誤の過程、途中経過などのスクリプトです。
状況をまとめるテキストはやや質の悪いものかもしれません。とにかく先に進みたい気持ちが先走って適当になってしまった感は否めません。
https://github.com/yyyoichi/watermark_zero/tree/main/exp
アルゴリズムの比較グラフ
これは100枚の画像を埋め込み回数が1回2回、..15回となるようにトリミングした計1500枚を、各パラメータ(横軸)ごとに、符号化なしとGolay(+Shuffle)で比較したものです。表題の38%というのは全体平均した(グラフで言うEC>=1)部分に注目して、だいたいどのパラメータでも38%改善した、といっています。あまり厳密な比較でないこと、どうかお許しください。
サービスリンク
zerome.me (ゼロミー・ドット・ミー)
https://zerome.me
周りにクリエイターが多いので、一緒になにか作った気分になりたいのが一番のモチベーションです。
こことは別に、画像の公開証明を前提としないクローズドなAPIも試作していたりします。
マークのアルゴリズム
19ByteのペイロードをEd25519で署名した計83Byteをマークとして利用します。
画像に埋め込まれるバイナリデータは、83ByteをGolay符号化で冗長化した1173bitです。
https://github.com/yyyoichi/watermark_zero/tree/main/wzeromark
開発関連記事
- 画像分割時のスライスのデータ構造の試行錯誤をまとめた記事です
- Cognitoをテスト環境用にリソースを作成するに至った経緯と大体の取り組み手順をまとめた記事です
- envファイルの管理の試行錯誤の過程で
SOPSを利用した顛末を書いてます
以上です!


