BlurHash +α で実現する Progressive Image Loading 体験
どんな話?
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 は、スムーズな画像表示を提供するための手法の一つです。最初に大きな画像を一度に読み込むのではなく、徐々に高解像度のデータを取得して表示品質を向上させることで、ページの読み込み体験手法のことを指します。
この手法の利点は以下のとおりです:
- 読み込み待ちのストレスを軽減:読み込み中も画像の内容が雰囲気でわかるため、ストレス軽減につながる
- 帯域幅の最適化:必要に応じて高解像度のデータを取得するため、無駄なデータ転送を避けられます
具体的な実現方法としては、以下の方法が挙げられます。
- 低解像度の画像を設置してぼかしをかけたうえで、プレースホルダー画像として表示する
- その後に高解像度の画像を読み込み表示する
この方法により、画像の読み込みストレスを減らしつつ、目的の画像を表示することが可能となります。
BlurHash とは
BlurHash [6] は、Progressive Image Loading を実現するための機能を提供するライブラリです。フードデリバリーサービスを提供している Wolt が開発したアルゴリズムライブラリとなっており、ぼかしを加えた画像データを、軽量な文字列で表現できる利点を持っています。
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~
この文字列から Base83 文字列が生成される形となります。
生成された文字列を表示すると、以下のように ぼかしを加えた画像 を生み出すことが可能となります。
https://blurha.sh/ より引用
様々な言語に対応しており、柔軟に組み込むことが可能です。
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 で使用する方法について説明を行いました。意外と簡単に実装できますので、みなさんも導入してみてはいかがでしょうか。フロントエンドの体験がぐっと上がるはずです。
株式会社シロクでは一緒に働く仲間を募集しています
「お客様に最速で正しい価値を提供すること」を共通キーワードに、技術力 × ビジネス力でエンジニアとして事業を伸ばしてみませんか?
カジュアル面談からでもOKですので、ご気軽にお話できると幸いです!
-
https://www.thinkwithgoogle.com/consumer-insights/consumer-trends/top-12-marketing-insights-2017-carry-you-2018/ ↩︎
-
https://www.thinkwithgoogle.com/consumer-insights/consumer-trends/mobile-page-speed-new-industry-benchmarks/ ↩︎
-
https://developer.chrome.com/docs/lighthouse/performance/performance-scoring?hl=ja ↩︎
「N organic」、「FAS」等の化粧品ブランドを展開している株式会社シロクのエンジニアブログです。 ECサイトを中心とした自社サービスの開発・運用を行っています。 sirok.jp/norganic
Discussion