画像をクライアント側で縮小してプレビューした後にアップロードする

2015-08-10

iPhoneでひしゃげてしまうバグに対応した、JavaScriptで画像を縮小処理する方法を紹介します。 JPEGの回転情報から正しい向きで表示したり、ジャギーを減らすためにエルミートフィルターを使ったスムージングも行います。

画像を投稿できるサイトなどでは、写真を"魅せる"ためのサイトでない限りディスプレイ幅以上の解像度のデータは必要なく、アップロードされた画像ファイルをサーバ側で縮小処理したデータのみを保存していることも多いのではないでしょうか?

iPhoneのOSがバージョン6になってから、ようやくHTMLの<input type="file">がサポートされ、スマホ界隈でも画像投稿が熱くなってる感じがしますが、スマホのカメラはメガピクセルが当たり前なので、そのままだとファイルサイズが1メガ以上になってしまい、通信帯域を無駄に使ってしまいます。

HTML5で実用化されたFileReaderを使用すると、<input type="file">で読み取ったローカルの画像ファイルを<canvas>に縮小表示することが簡単になりましたので、アップロードファイルサイズが小さくなり、サーバ上の縮小処理も必要なくなったのでいいコトだらけです。

しかし、FileReaderで読み取ったデータをそのままcanvasに貼り付けると、iPhoneでひしゃげてしまう問題があったり、JPEGの画像の向きがおかしくなったりすることがあります。
画像の向きはExif情報に格納された画像の回転情報を適切に適用すれば問題ありませんが、Exifを読み取るためには大きなサイズのライブラリに頼ったりする必要がありました。

今回、VanillaなJavaScriptで他のライブラリを一切使わないでクライアント側で画像を縮小するサンプルを紹介します。

以前に紹介していたときは、画像の縮小アルゴリズムを考慮していなかったので、ジャギーが目立つ感じでしたが、今回はエルミートフィルターを使って、綺麗に縮小できるようにしました。

上の2つの画像はどちらもiPhone5で撮った8メガピクセルの写真を縮小したもので、左(スマホなどの狭いディスプレーでは上)が以前使用していた標準の縮小アルゴリズム、右(スマホなどの狭いディスプレーでは下)が今回適用したエルミートフィルター適用の画像です。
エルミートフィルターの方がジャギーが目立たないのがお分かりになるでしょうか?

ジャギーが目立たないだけでなく、画像の解像感が減った影響で2割ほどファイルサイズが小さくなっていますので、通信帯域の削減にも貢献しています。

デモ


以下のコードは、そのままHTMLのファイルとして保存すれば自分でも動作を確認することができます(アップロードはローカルにおいたままではセキュリティの警告が出て、実行できませんが)。

クライアント側のコード

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>画像をアップロードする前にプレビュー、縮小する</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
</head>
<body>
    <h1>Image縮小サンプル</h1>
    <p>
        <input type="file" id="photo" accept="image/*">
        <br><input type="button" id="submit" value="アップロード" disabled="disabled">
    </p>
    <script>
    (function(){
        'use strict';

        // Google App Engineのように動的にアップロードURLが変わる場合じゃなければ
        // uploadurlにはStaticな値を設定してOK
        var uploadurl;

        // 縮小する画像のサイズ
        var maxWidth = 300;
        var maxHeight = 300;

        // Google App EngineではBlobのアップロードURLが動的に変わるので、
        // それを事前に取得してuploadurlに値設定
        var xhr = new XMLHttpRequest();
        xhr.open('GET', '/upload');
        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xhr.send();
        xhr.onload = function(){
            if(xhr.status >= 200 && xhr.status < 300){
                uploadurl = xhr.responseText;
            }
        }

        document.getElementById('photo').addEventListener('change', resize);
        document.getElementById('submit').addEventListener('click', upload);

        function upload(e){
            e.preventDefault();
            var dataurl = document.getElementById('preview').src;
            var filename = document.getElementById('preview').getAttribute('alt');
            var blob = dataURLtoBlob(dataurl);
            var fd = new FormData();
            fd.append('photo', blob, filename);
            // ajax post
            var xhr = new XMLHttpRequest();

            xhr.open('POST', uploadurl);
            xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
            xhr.send(fd);
            xhr.onload = function(){
                alert(xhr.responseText);
            }
        }

        function resize(e){
            e.target.style.display = 'none';
            var file = e.target.files[0];

            if (!file.type.match(/^image\/(png|jpeg|gif)$/)) return;
            var img = new Image();
            var reader = new FileReader();

            reader.onload = function(e) {
                var data = e.target.result;

                img.onload = function() {

                    var iw = img.naturalWidth, ih = img.naturalHeight;
                    var width = iw, height = ih;

                    var orientation;

                    // JPEGの場合には、EXIFからOrientation(回転)情報を取得
                    if (data.split(',')[0].match('jpeg')) {
                        orientation = getOrientation(data);
                    }
                    // JPEG以外や、JPEGでもEXIFが無い場合などには、標準の値に設定
                    orientation = orientation || 1;

                    // 90度回転など、縦横が入れ替わる場合には事前に最大幅、高さを入れ替えておく
                    if (orientation > 4) {
                        var tmpMaxWidth = maxWidth;
                        maxWidth = maxHeight;
                        maxHeight = tmpMaxWidth;
                    }

                    if(width > maxWidth || height > maxHeight) {
                        var ratio = width/maxWidth;
                        if(ratio <= height/maxHeight) {
                            ratio = height/maxHeight;
                        }
                        width = Math.floor(img.width/ratio);
                        height = Math.floor(img.height/ratio);
                    }


                    var canvas = document.createElement('canvas');
                    var ctx = canvas.getContext('2d');
                    ctx.save();

                    // EXIFのOrientation情報からCanvasを回転させておく
                    transformCoordinate(canvas, width, height, orientation);

                    // iPhoneのサブサンプリング問題の回避
                    // see http://d.hatena.ne.jp/shinichitomita/20120927/1348726674
                    var subsampled = detectSubsampling(img);
                    if (subsampled) {
                        iw /= 2;
                        ih /= 2;
                    }
                    var d = 1024; // size of tiling canvas
                    var tmpCanvas = document.createElement('canvas');
                    tmpCanvas.width = tmpCanvas.height = d;
                    var tmpCtx = tmpCanvas.getContext('2d');
                    var vertSquashRatio = detectVerticalSquash(img, iw, ih);
                    var dw = Math.ceil(d * width / iw);
                    var dh = Math.ceil(d * height / ih / vertSquashRatio);
                    var sy = 0;
                    var dy = 0;
                    while (sy < ih) {
                        var sx = 0;
                        var dx = 0;
                        while (sx < iw) {
                            tmpCtx.clearRect(0, 0, d, d);
                            tmpCtx.drawImage(img, -sx, -sy);
                            // 何度もImageDataオブジェクトとCanvasの変換を行ってるけど、Orientation関連で仕方ない。本当はputImageDataであれば良いけどOrientation効かない
                            var imageData = tmpCtx.getImageData(0, 0, d, d);
                            var resampled = resample_hermite(imageData, d, d, dw, dh);
                            ctx.drawImage(resampled, 0, 0, dw, dh, dx, dy, dw, dh);
                            sx += d;
                            dx += dw;
                        }
                        sy += d;
                        dy += dh;
                    }
                    ctx.restore();
                    tmpCanvas = tmpCtx = null;

                    var displaySrc = ctx.canvas.toDataURL('image/jpeg', .9);
                    var displayImg = document.createElement('img');
                    displayImg.id = 'preview';
                    displayImg.setAttribute('src', displaySrc);
                    displayImg.setAttribute('alt', file.name);
                    displayImg.setAttribute('style','max-width:90%;max-height:90%');
                    document.body.appendChild(displayImg);

                    document.getElementById('submit').removeAttribute('disabled');

                }
                img.src = data;
            }
            reader.readAsDataURL(file);
        }

        // hermite filterかけてジャギーを削除する
        function resample_hermite(img, W, H, W2, H2){
            var canvas = document.createElement('canvas');
            canvas.width = W2;
            canvas.height = H2;
            var ctx = canvas.getContext('2d');
            var img2 = ctx.createImageData(W2, H2);
            var data = img.data;
            var data2 = img2.data;
            var ratio_w = W / W2;
            var ratio_h = H / H2;
            var ratio_w_half = Math.ceil(ratio_w/2);
            var ratio_h_half = Math.ceil(ratio_h/2);
            for(var j = 0; j < H2; j++){
                for(var i = 0; i < W2; i++){
                    var x2 = (i + j*W2) * 4;
                    var weight = 0;
                    var weights = 0;
                    var gx_r = 0, gx_g = 0,  gx_b = 0, gx_a = 0;
                    var center_y = (j + 0.5) * ratio_h;
                    for(var yy = Math.floor(j * ratio_h); yy < (j + 1) * ratio_h; yy++){
                        var dy = Math.abs(center_y - (yy + 0.5)) / ratio_h_half;
                        var center_x = (i + 0.5) * ratio_w;
                        var w0 = dy*dy;
                        for(var xx = Math.floor(i * ratio_w); xx < (i + 1) * ratio_w; xx++){
                            var dx = Math.abs(center_x - (xx + 0.5)) / ratio_w_half;
                            var w = Math.sqrt(w0 + dx*dx);
                            if(w >= -1 && w <= 1){
                                weight = 2 * w*w*w - 3*w*w + 1;
                                if(weight > 0){
                                    dx = 4*(xx + yy*W);
                                    gx_r += weight * data[dx];
                                    gx_g += weight * data[dx + 1];
                                    gx_b += weight * data[dx + 2];
                                    gx_a += weight * data[dx + 3];
                                    weights += weight;
                                }
                            }
                        }
                    }
                    data2[x2]         = gx_r / weights;
                    data2[x2 + 1] = gx_g / weights;
                    data2[x2 + 2] = gx_b / weights;
                    data2[x2 + 3] = gx_a / weights;
                }
            }
            ctx.putImageData(img2, 0, 0);
            return canvas;
        };

        // JPEGのEXIFからOrientationのみを取得する
        function getOrientation(imgDataURL){
            var byteString = atob(imgDataURL.split(',')[1]);
            var orientaion = byteStringToOrientation(byteString);
            return orientaion;

            function byteStringToOrientation(img){
                var head = 0;
                var orientation;
                while (1){
                    if (img.charCodeAt(head) == 255 & img.charCodeAt(head + 1) == 218) {break;}
                    if (img.charCodeAt(head) == 255 & img.charCodeAt(head + 1) == 216) {
                        head += 2;
                    }
                    else {
                        var length = img.charCodeAt(head + 2) * 256 + img.charCodeAt(head + 3);
                        var endPoint = head + length + 2;
                        if (img.charCodeAt(head) == 255 & img.charCodeAt(head + 1) == 225) {
                            var segment = img.slice(head, endPoint);
                            var bigEndian = segment.charCodeAt(10) == 77;
                            if (bigEndian) {
                                var count = segment.charCodeAt(18) * 256 + segment.charCodeAt(19);
                            } else {
                                var count = segment.charCodeAt(18) + segment.charCodeAt(19) * 256;
                            }
                            for (var i=0;i<count;i++){
                                var field = segment.slice(20 + 12 * i, 32 + 12 * i);
                                if ((bigEndian && field.charCodeAt(1) == 18) || (!bigEndian && field.charCodeAt(0) == 18)) {
                                    orientation = bigEndian ? field.charCodeAt(9) : field.charCodeAt(8);
                                }
                            }
                            break;
                        }
                        head = endPoint;
                    }
                    if (head > img.length){break;}
                }
                return orientation;
            }
        }

        // iPhoneのサブサンプリングを検出
        function detectSubsampling(img) {
            var iw = img.naturalWidth, ih = img.naturalHeight;
            if (iw * ih > 1024 * 1024) {
                var canvas = document.createElement('canvas');
                canvas.width = canvas.height = 1;
                var ctx = canvas.getContext('2d');
                ctx.drawImage(img, -iw + 1, 0);
                return ctx.getImageData(0, 0, 1, 1).data[3] === 0;
            } else {
                return false;
            }
        }

        // iPhoneの縦画像でひしゃげて表示される問題の回避
        function detectVerticalSquash(img, iw, ih) {
            var canvas = document.createElement('canvas');
            canvas.width = 1;
            canvas.height = ih;
            var ctx = canvas.getContext('2d');
            ctx.drawImage(img, 0, 0);
            var data = ctx.getImageData(0, 0, 1, ih).data;
            var sy = 0;
            var ey = ih;
            var py = ih;
            while (py > sy) {
                var alpha = data[(py - 1) * 4 + 3];
                if (alpha === 0) {
                    ey = py;
                } else {
                    sy = py;
                }
                py = (ey + sy) >> 1;
            }
            var ratio = (py / ih);
            return (ratio===0)?1:ratio;
        }

        function transformCoordinate(canvas, width, height, orientation) {
            if (orientation > 4) {
                canvas.width = height;
                canvas.height = width;
            } else {
                canvas.width = width;
                canvas.height = height;
            }
            var ctx = canvas.getContext('2d');
            switch (orientation) {
                case 2:
                    // horizontal flip
                    ctx.translate(width, 0);
                    ctx.scale(-1, 1);
                    break;
                case 3:
                    // 180 rotate left
                    ctx.translate(width, height);
                    ctx.rotate(Math.PI);
                    break;
                case 4:
                    // vertical flip
                    ctx.translate(0, height);
                    ctx.scale(1, -1);
                    break;
                case 5:
                    // vertical flip + 90 rotate right
                    ctx.rotate(0.5 * Math.PI);
                    ctx.scale(1, -1);
                    break;
                case 6:
                    // 90 rotate right
                    ctx.rotate(0.5 * Math.PI);
                    ctx.translate(0, -height);
                    break;
                case 7:
                    // horizontal flip + 90 rotate right
                    ctx.rotate(0.5 * Math.PI);
                    ctx.translate(width, -height);
                    ctx.scale(-1, 1);
                    break;
                case 8:
                    // 90 rotate left
                    ctx.rotate(-0.5 * Math.PI);
                    ctx.translate(-width, 0);
                    break;
                default:
                    break;
            }
        }

        function dataURLtoBlob(dataurl) {
            var bin = atob(dataurl.split("base64,")[1]);
            var len = bin.length;
            var barr = new Uint8Array(len);
            for (var i = 0; i < len; i++) {
                barr[i] = bin.charCodeAt(i);
            }
            return new Blob([barr], {
                type: 'image/jpeg',
            });
        };

    })();
    </script>
</body>
</html>

サーバ側は最小限のコードのみ掲載します。

サーバ側のコード(Google App Engine Golang)


package app

import (
    "appengine"
    "appengine/blobstore"
    "appengine/image"
    "fmt"
    "net/http"
)

func init() {
    http.HandleFunc("/upload", uploadHandler)
    http.HandleFunc("/", defaultHandler)
}

func defaultHandler(w http.ResponseWriter, r *http.Request) {
    http.Redirect(w, r, "/", http.StatusSeeOther)
}

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    switch r.Method {
    case "GET":
        url, _ := blobstore.UploadURL(c, r.URL.Path, nil)
        fmt.Fprint(w, url.String())
    case "POST":
        blobs, params, _ := blobstore.ParseUpload(r)
        file := blobs["photo"]
        url, _ := image.ServingURL(c, file[0].BlobKey, nil)
        c.Debugf("PARAMS: %+v", params)
        fmt.Fprint(w, url)
    }
}

ぜひ使ってみてもらえたら嬉しいです。質問などがあれば、右下にある(はずの)Commentボタンを押してください。

JavaScriptGoogle App EngineGolang