🤨

BlurHash +α で実現する Progressive Image Loading 体験

2025/01/06に公開

どんな話?

BlurHash を Ruby on Rails に組み込んで Progressive Image Loading を実装するお話です。
BlurHash は Rails でも使用できるのですが、View で表示する機能は存在しなかったのでついでに自作してみました。

はじめに: 画像読み込みが与えるサイト体験について

画像の読み込み速度は、ページ全体の表示速度やユーザーの満足度に大きな影響を与えます。パフォーマンス評価指標である LCP 要素に関しては、約 70% が画像である [1] と言われており、画像の最適化はユーザ体験改善には必要不可欠な存在となっております。

また、モバイルサイトの読み込みに3秒以上かかると、訪問者の53%が離脱する調査結果 [2] やページの読み込み速度が1秒から5秒に増えると、モバイルサイトの訪問者の直帰率は90%増える調査結果 [3] も報告されています。

Core Web Vitals [4] や Lighthouse [5] でも FCP や LCP が評価指標として組み込まれており、画像読み込み速度の改善によるユーザ体験の向上はまだまだ求められていることがわかります。

Progressive Image Loading とは

Progressive Image Loading は、スムーズな画像表示を提供するための手法の一つです。最初に大きな画像を一度に読み込むのではなく、徐々に高解像度のデータを取得して表示品質を向上させることで、ページの読み込み体験手法のことを指します。

この手法の利点は以下のとおりです:

  • 読み込み待ちのストレスを軽減:読み込み中も画像の内容が雰囲気でわかるため、ストレス軽減につながる
  • 帯域幅の最適化:必要に応じて高解像度のデータを取得するため、無駄なデータ転送を避けられます

具体的な実現方法としては、以下の方法が挙げられます。

  1. 低解像度の画像を設置してぼかしをかけたうえで、プレースホルダー画像として表示する
  2. その後に高解像度の画像を読み込み表示する

この方法により、画像の読み込みストレスを減らしつつ、目的の画像を表示することが可能となります。

BlurHash とは

BlurHash [6] は、Progressive Image Loading を実現するための機能を提供するライブラリです。フードデリバリーサービスを提供している Wolt が開発したアルゴリズムライブラリとなっており、ぼかしを加えた画像データを、軽量な文字列で表現できる利点を持っています。

0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~

この文字列から Base83 文字列が生成される形となります。
生成された文字列を表示すると、以下のように ぼかしを加えた画像 を生み出すことが可能となります。

BlurHash によって画像変換されたイメージ図
https://blurha.sh/ より引用

様々な言語に対応しており、柔軟に組み込むことが可能です。

さまざまなプログラミング言語やフレームワークでのエンコーダおよびデコーダの実装リスト。C、Swift、Kotlin、TypeScript、Pythonなどの公式実装と、Pure Python、Go、PHP、Java、Clojure、Rust、Ruby、Crystal、Elm、Dart、.NET、Haskell、Scala、Elixir、ReScript、JavaScript、Xojo、React Native、Zig、Titanium SDK、BQN、Jetpack Compose、C++、Kotlin Multiplatform、OCamlなどのサードパーティ実装が含まれています。

Rails に組み込んでみる

さて、それでは Rails に組み込んでみます。コードは抜粋です。
シロクオンラインショップは Rails で動作するアプリケーションなので、Ruby 版のライブラリ を使用します。

1. Base83 にエンコードする

def encode83(width, height, pixels)
    Blurhash.encode(width, height, pixels, x_comp: 4, y_comp: 3)
end

これだけです。1行だけでハッシュ化できます。便利ですね。

2. Base83 からデコードする

React を使用して実装を行う場合であれば、React 用のライブラリ があるので、そちらを使えば良いです。

ただし、Ruby 版のライブラリには表示部分の処理が実装されていないので、自前で実装する必要があります。今回は TypeScript 版の実装 を参考に実装を行いました。(GPT でいい感じに大枠の変換を行いました)

長いのでアコーディオンにしています
def decode83(str)
    value = 0
    str.each_char do |c|
        digit = BASE83_CHARACTERS.index(c)
        if digit.nil?
            # 無効な文字が見つかった場合はnilを返す
            return nil
        end

        value = value * 83 + digit
    end
    value
end

def srgb_to_linear(value)
    v = value / 255.0
    if v <= 0.04045
        v / 12.92
    else
        ((v + 0.055) / 1.055) ** 2.4
    end
end

def linear_to_srgb_narray(values)
    a = 0.055
    threshold = 0.0031308

    # 範囲を0から1にクリップ
    values = values.clip(0, 1)

    # sRGB変換
    mask = values.gt(threshold)
    srgb = Numo::DFloat.zeros(*values.shape)
    srgb[mask] = (1 + a) * values[mask] ** (1 / 2.4) - a
    srgb[~mask] = values[~mask] * 12.92

    # 0から255の範囲に変換し、整数化
    ((srgb * 255).round).clip(0, 255).cast_to(Numo::UInt8)
end

def sign_pow(value, exp)
    value.abs ** exp * (value >= 0 ? 1 : -1)
end

def validate_blurhash(blurhash)
    if blurhash.nil? || blurhash.length < 6
        return false
    end
    
    size_flag = decode83(blurhash[0])
    return false if size_flag.nil?
    
    num_y = (size_flag / 9).floor + 1
    num_x = (size_flag % 9) + 1
    expected_length = 4 + 2 * num_x * num_y
    
    if blurhash.length != expected_length
        return false
    end
    
    true
end

def blurhash_valid?(blurhash)
    validate_blurhash(blurhash)
end

def decode_dc(value)
    r = value >> 16
    g = (value >> 8) & 255
    b = value & 255
    [srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)]
end

def decode_ac(value, maximum_value)
    quant_r = (value / (19 * 19)).floor
    quant_g = ((value / 19).floor) % 19
    quant_b = value % 19
    r = sign_pow((quant_r - 9) / 9.0, 2.0) * maximum_value
    g = sign_pow((quant_g - 9) / 9.0, 2.0) * maximum_value
    b = sign_pow((quant_b - 9) / 9.0, 2.0) * maximum_value
    [r, g, b]
end

def decode(blurhash, width, height, punch = 1)
    # バリデーションチェック
    unless validate_blurhash(blurhash)
        return nil # エラー発生時はnilを返す
    end
    
    size_flag = decode83(blurhash[0])
    return nil if size_flag.nil?
    
    num_y = (size_flag / 9).floor + 1
    num_x = (size_flag % 9) + 1
    quantised_maximum_value = decode83(blurhash[1])
    return nil if quantised_maximum_value.nil?
    
    maximum_value = (quantised_maximum_value + 1) / 166.0
    
    # カラー成分のデコード
    total_components = num_x * num_y
    colors = []
    
    # DC成分
    value = decode83(blurhash[2, 4])
    return nil if value.nil?
    
    colors << decode_dc(value)

    # AC成分
    (1...total_components).each do |i|
        start = 4 + i * 2
        value = decode83(blurhash[start, 2])
        return nil if value.nil?
        
        colors << decode_ac(value, maximum_value * punch)
    end
    
    # colorsをNumo::NArrayに変換
    colors = Numo::DFloat.asarray(colors) # shape: [total_components, 3]
    
    # ピクセル座標の生成
    x_coords = Numo::DFloat.new(width).seq / width
    y_coords = Numo::DFloat.new(height).seq / height
    
    # 基底関数の計算
    cos_x = Numo::DFloat.zeros(num_x, width)
    cos_y = Numo::DFloat.zeros(num_y, height)
    
    num_x.times do |i|
        cos_x[i, true] = Numo::NMath.cos(Math::PI * i * x_coords)
    end
    
    num_y.times do |j|
        cos_y[j, true] = Numo::NMath.cos(Math::PI * j * y_coords)
    end
    
    # ピクセルごとの色計算
    r = Numo::DFloat.zeros(height, width)
    g = Numo::DFloat.zeros(height, width)
    b = Numo::DFloat.zeros(height, width)

    num_y.times do |j|
        num_x.times do |i|
            # cos_y[j, :].reshape(height, 1) で形状を [height, 1] にする
            cos_y_j = cos_y[j, false].reshape(height, 1)
            # cos_x[i, :].reshape(1, width) で形状を [1, width] にする
            cos_x_i = cos_x[i, false].reshape(1, width)
            # 基底関数の外積を計算
            basis = cos_y_j * cos_x_i # shape: [height, width]
            color = colors[i + j * num_x, true] # shape: [3]
            r += color[0] * basis
            g += color[1] * basis
            b += color[2] * basis
        end
    end

    # 線形RGBからsRGBへの変換
    int_r = linear_to_srgb_narray(r)
    int_g = linear_to_srgb_narray(g)
    int_b = linear_to_srgb_narray(b)
    alpha = Numo::UInt8.zeros(height, width).fill(255)
    
    # チャンネルを結合してピクセルデータを作成
    pixels = Numo::UInt8.zeros(height, width, 4)
    pixels[true, true, 0] = int_r
    pixels[true, true, 1] = int_g
    pixels[true, true, 2] = int_b
    pixels[true, true, 3] = alpha
    
    # ピクセルデータをフラットな配列に変換
    pixels.flatten.to_a
end

3. Base64 変換を行い、表示できるようにする

def encode_base64(pixels, width, height)
    return nil if pixels.nil?
    
    rgba_stream = pixels.pack('C*')
    png = ChunkyPNG::Image.from_rgba_stream(width, height, rgba_stream)

    png_data = png.to_blob(:fast_rgba)
    base64_data = Base64.strict_encode64(png_data)
    
    "data:image/png;base64,#{base64_data}"
end

最後に View 側で表示するように実装すれば OK です。

<img src="<%= encode_base64(image_url) =>">

おわりに

今回は BlurHash の紹介と Rails の View で使用する方法について説明を行いました。意外と簡単に実装できますので、みなさんも導入してみてはいかがでしょうか。フロントエンドの体験がぐっと上がるはずです。

株式会社シロクでは一緒に働く仲間を募集しています

https://corp.sirok.jp/
株式会社シロクではビジネス×技術で事業を伸ばすことに興味のあるエンジニアを募集中です。
「お客様に最速で正しい価値を提供すること」を共通キーワードに、技術力 × ビジネス力でエンジニアとして事業を伸ばしてみませんか?
カジュアル面談からでもOKですので、ご気軽にお話できると幸いです!

脚注
  1. https://almanac.httparchive.org/en/2021/media#images ↩︎

  2. https://www.thinkwithgoogle.com/consumer-insights/consumer-trends/top-12-marketing-insights-2017-carry-you-2018/ ↩︎

  3. https://www.thinkwithgoogle.com/consumer-insights/consumer-trends/mobile-page-speed-new-industry-benchmarks/ ↩︎

  4. https://web.dev/articles/vitals?hl=ja ↩︎

  5. https://developer.chrome.com/docs/lighthouse/performance/performance-scoring?hl=ja ↩︎

  6. https://blurha.sh/ ↩︎

シロク エンジニアブログ

Discussion