MENU

blog
スタッフブログ

dot
画像をバラバラにして元に戻す
技術

画像をバラバラにして元に戻す

こんにちは、クリエイティブSecの長谷川です。

以前、私が漫画をマンガなどをWEBで読めるようなリーダーの機能を
作ろうとしていた時、とあるマンガサイトでネットワークから
1枚の画像がブロックで分けられ、バラバラになっている画像が送られ
にもかかわらず、画面上ではきちんとした画像で読めるようになっている
サイトがあることに気づきました。

バラバラになっているって、言葉ではどういうことかイメージがつきづらいので
サンプルをお見せすると以下のような感じです。

なぜこんなことをしているのかというと、サイト上で画像を保存して
違法に配布されないためにやっているんですね。

画像の保存対策としては右クリック禁止などいろんな方法があるわけですが
画像に直接アクセスされても、これだと問題はないですね。

ということで、なんとなくこの仕組みが面白いなと思ったので自分なりに作ってみました。

まずは画像をバラバラにする

さて、バックエンドはLaravelを使用することにします。
必要なライブラリのインストールなどの説明は省きますが
こんな感じのソースコードでそれっぽいことができました。

    public function test()
    {
        // 画像のパスを指定してください
        $originalImagePath = app_path('../sample.jpg');
        $originalImage = imagecreatefromjpeg($originalImagePath);

        // 画像のサイズを変更する
        $width = imagesx($originalImage);
        $height = imagesy($originalImage);
        if ($height > 800) {
            $newHeight = 800;
            $newWidth = (int) ($width * ($newHeight / $height));
            $newImage = imagecreatetruecolor($newWidth, $newHeight);
            imageantialias($newImage, false);
            imagecopyresampled($newImage, $originalImage, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
            $originalImage = $newImage;
        }

        // 画像のサイズを取得する
        $width = imagesx($originalImage);
        $height = imagesy($originalImage);

        // ブロックの数を計算する関数を呼び出す
        $blockNum = $this->calcBlock($width, $height);

        // 画像をブロックに分割する
        $blockWidth = (int) (ceil($width / $blockNum));
        $blockHeight = (int) ($height / $blockNum);
        $blocks = [];
        $loop = 0;

        for ($y = 0; $y < $blockNum; $y++) {
            for ($x = 0; $x < $blockNum; $x++) {
                $loop++;
                $block = imagecreatetruecolor($blockWidth, $blockHeight);
                imageantialias($block, false);
                imagecopyresampled( $block, $originalImage, 0, 0, $x * $blockWidth, $y * $blockHeight, $blockWidth, $blockHeight, $blockWidth, $blockHeight);

                // キーを計算してブロックを保存する
                $keyIndex = $loop % strlen(self::KEY);
                $keyIndex = $keyIndex === 0 ? strlen(self::KEY) - 1 : $keyIndex - 1;
                $keyChar = substr(self::KEY, $keyIndex, 1);
                $key = md5($keyChar . $loop);
                $blocks[$key] = $block;
            }
        }

        // ブロックをキーソートする
        ksort($blocks);

        // 新しい画像を生成して保存する
        $newImage = imagecreatetruecolor($width, $height);
        imageantialias($newImage, false);
        $offsetX = 0;
        $offsetY = 0;

        foreach ($blocks as $block) {
            // 切り取る範囲が元の画像範囲を超える場合は調整する
            $blockWidth = imagesx($block);
            $blockHeight = imagesy($block);

            if ($offsetX + $blockWidth > $width) {
                $blockWidth = $width - $offsetX;
            }

            if ($offsetY + $blockHeight > $height) {
                $blockHeight = $height - $offsetY;
            }

            imagecopyresampled($newImage, $block, $offsetX, $offsetY, 0, 0, $blockWidth, $blockHeight, $blockWidth, $blockHeight);

            $offsetX += $blockWidth;

            if ($offsetX >= $width) {
                $offsetX = 0;
                $offsetY += $blockHeight;
            }
        }

        // 画像を表示
        header('Content-Type: image/jpeg');
        // imagejpeg($newImage, null, 100);
        imagepng($newImage, null, 9, PNG_FILTER_NONE);

        // 画像を破棄してリソースを解放
        imagedestroy($newImage);
        imagedestroy($originalImage);
    }


    private function calcBlock($width, $height)
    {
        for ($i = 10; $i > 0; $i--) {
            if ($width % $i === 0 && $height % $i === 0) {
                return $i;
            }
        }
    }

はい、一気にソースコードを貼ったので、よくわからないですね。
画像の読み込みとかリサイズ周りは説明を省略しますが、重要なのは以下のとおりです。

        // ブロックの数を計算する関数を呼び出す
        $blockNum = $this->calcBlock($width, $height);

...

    private function calcBlock($width, $height)
    {
        for ($i = 10; $i > 0; $i--) {
            if ($width % $i === 0 && $height % $i === 0) {
                return $i;
            }
        }
    }

今回は手っ取り早く実装したかったので、分割するブロックは5×5や3×3など
縦横同じ数で区切ったブロックで分割しようと思いました。

なので、calcBlock()関数では、2つの数の公約数を求めています。
あまり対象の数で分割されても、復元処理が重たくなるだろうなーと思ったので
今回は10以下の公約数を求めるようにしています。
ただ注意点として、画像サイズによっては公約数が1しかないときもありますので
そうなると分割も何もなく元の画像のままになってしまいます。
なので、本当はブロックの数の求め方を変えるか、サービスとして提供する際は
画像のサイズは固定にしてしまって、ブロック数も固定になるようにすればいいと思います。

そんなこんなでブロック数が決まったら、次は画像を切り出していきます。

        for ($y = 0; $y < $blockNum; $y++) {
            for ($x = 0; $x < $blockNum; $x++) {
                $loop++;
                $block = imagecreatetruecolor($blockWidth, $blockHeight);
                imageantialias($block, false);
                imagecopyresampled( $block, $originalImage, 0, 0, $x * $blockWidth, $y * $blockHeight, $blockWidth, $blockHeight, $blockWidth, $blockHeight);

                // キーを計算してブロックを保存する
                $keyIndex = $loop % strlen(self::KEY);
                $keyIndex = $keyIndex === 0 ? strlen(self::KEY) - 1 : $keyIndex - 1;
                $keyChar = substr(self::KEY, $keyIndex, 1);
                $key = md5($keyChar . $loop);
                $blocks[$key] = $block;
            }
        }

下記の画像のようにブロックを左上から右下に向かって分割して切り出していきます。

このとき、分割した画像を配列に格納するのですが、その際に配列のキーを
以下のように求めています。

キー=(ループ回数 + シークレットキーのX番目の文字)をmd5で暗号化したもの
X = ループ回数 % シークレットキーの文字数

たとえばシークレットキーが「secret」だとすると
キーは、1s、2e、3c、4r、5e、6t、7s、8e…となります。
これをmd5で暗号化すると、シークレットキーを変えれば
ループ回数にくっつく文字が変わるので、md5で暗号化したときに
異なる文字列になります。

さらに、そのあとksort($blocks)としているわけですが
これでブロックを格納した配列をキーで並び替えています。

その後は、並び替えられたキー順にブロックの画像を再配置していくだけですね。

てことでできたのが、先程の画像になります。

バラバラになった画像

あとはフロント側で復元するだけ

はい、フロント側のJSコードはこんな感じです。

    <script>
        // #imageを読み込み完了後、画像を10x10のブロックに分割し、ランダムに並び替えて表示する
        var image = document.getElementById('image');
        // var image2 = document.getElementById('image2');
        const key = 'ThisIsSecretKey';

        function getBlockNum($width, $height)
        {
            for (i = 10; i > 0; i--) {
                if ($width % i === 0 && $height % i === 0) {
                    return i;
                }
            }
        }

        image.onload = function () {
            const gridSize = getBlockNum(image.width, image.height);
            const width = parseInt(image.width / gridSize);
            const height = parseInt(image.height / gridSize);
            const keyArray = [];
            let loop = 0;

            for (i = 0; i < gridSize * gridSize; i++) {
                loop++;
                // keyよりloop番目の文字を取得する。
                // もし、loop番目がkeyの文字数を超えていたら、loop番目をself::keyの文字数で割った余りを使う。
                let keyIndex = loop % key.length;
                if (keyIndex === 0) {
                    keyIndex = key.length;
                }   
                const keyChar = key.charAt(keyIndex - 1);
                console.log(keyChar);
                // keyChar + loopの文字列結合したものをmd5変換する
                // console.log(keyChar + loop);
                const md5 = CryptoJS.MD5(keyChar + loop).toString();
                keyArray.push({
                    key: keyChar + loop,
                    md5: md5,
                    index: i + 1
                });
            }

            // md5の値をもとに、keyArrayを並び替える
            keyArray.sort(function (a, b) {
                if (a.md5 < b.md5) {
                    return -1;
                } else {
                    return 1;
                }
            });

            // imageと同じサイズのcanvasを作成する
            const canvas = document.createElement('canvas');
            canvas.width = image.width;
            canvas.height = image.height;
            const ctx = canvas.getContext('2d');
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.imageSmoothingEnabled = false;
            ctx.globalAlpha = 1.0;
            document.body.appendChild(canvas);

            // 画像を10x10のブロックに分割したうち、左からi番目、上からj番目のブロックを取得
            // そのブロックを、左からi番目、上からj番目の位置に描画する
            for (i = 1; i <= gridSize * gridSize; i++) {
                const keyIndex = i % key.length;
                const keyChar = key.charAt(keyIndex - 1);
                const index = keyArray.findIndex((item) => {
                    return item.index === i;
                }) + 1;

                let sx = index % gridSize;
                let sy = sx !== 0 ? Math.floor(index / gridSize) : Math.floor(index / gridSize) - 1;
                if (sx === 0) {
                    sx = gridSize;
                }
                sx--;

                let dx = i % gridSize;
                let dy = dx!== 0 ? Math.floor(i / gridSize) : Math.floor(i / gridSize) - 1;
                if (dx === 0) {
                    dx = gridSize;
                }
                dx--;

                ctx.drawImage(image, sx * width, sy * height, width, height, dx * width, dy * height, width, height);
            }

            // canvasをimageの隣に表示する
            
            console.log(ctx);

        };
    </script>

だいたいはバックエンドと同じことを逆順にやっている感じです。
imgタグの画像の読み込みが完了したあと、canvasに画像を描画しています。

反省点としては、シークレットキーをJSコード内に普通に記載しているので
この辺は少しでもわかりにくいようにするひつようがありますね。
あと、実際にサービスでやるなら、難読化などの対策も必要かなと思います。

JPEGだと変な線が入る

試していて気づいたのですが、JPEGだと画像フォーマットの問題なのか
画像を復元したときに、ブロック感にうっすらと線が入ってしまうことがありました。
なので、その場合はバックエンド側でJPEGではなくPNGフォーマットにて返す必要があります。

さいごに

たぶん実際はもっと賢い方法があるとは思うのですが
こんな方法もあるよ的に見ていただけたらと思います。

それでは、今回はこの辺で。

dot
dot
PAGETOP