夜空の壁紙を開いたとき、画面の上端にうっすらとした黒い帯が乗っているのが見えました。星が数個あるだけの、ほとんど真っ黒な絵です。白い時計は帯がなくても十分に読めます。それでも黒帯は律儀にかかっていました。
個人開発で壁紙アプリを運用しており、画像の上に時計とタイトルを重ねる画面があります。可読性を守るために、上端へ rgba(0,0,0,0.35) のグラデーションを一律で敷いていました。安全側に倒した実装です。
安全側であることと、正しいことは違います。夜空の絵は、その黒帯のぶんだけ暗く見えていました。逆に、淡いパステル調の絵では 0.35 では足りず、白文字が滲んで見える画面もありました。一律の値は、両側で外していたわけです。
では画像ごとに決めればよいはずです。問題は「何をもって読めると判定するか」で、ここが思っていたより深い穴でした。手元で 240 枚のコーパスを作り、判定指標を4つ順番に試した記録を残します。結論から言えば、最後に採用した方式では 240 枚中 189 枚でスクリムそのものが不要になりました。
判定を端末から取り込み時へ移す
最初に決めたのは、判定をどこで走らせるかです。
端末側で画像を解析する案は、すぐに落としました。壁紙は数千枚あり、スクロール中に次々と表示されます。表示のたびにピクセルを読むのは、描画予算を最も使いたくない場所で使う判断です。しかも同じ画像に対して端末ごとに違う答えが出る余地が残ります。
採ったのは、取り込み時に一度だけ計算し、結果を数値としてカタログへ書き込む形です。アプリ側は画像を一切解析しません。JSON に入っている style と scrim を読むだけになります。
| 判定の置き場所 | 表示時のコスト | 結果の一貫性 | やり直しの手間 |
| 端末(表示のたび) | 毎回かかる | 端末・OS で揺れる | 不要 |
| 端末(初回のみキャッシュ) | 初回のみ | キャッシュ破棄で揺れる | 不要 |
| 取り込み時(採用) | ゼロ | 完全に固定 | 再計算とデプロイが必要 |
やり直しの手間だけが増えます。判定ロジックを変えたら全アセットを再計算し、カタログを配り直すことになります。数千枚を一括処理できる速度を確保しておけば、この手間は許容範囲に収まると考えました。実際の処理時間は後半で測っています。
明るさの平均は、色で外れる
最初に書いたのは、思いつく限り最も素朴な判定でした。UI が乗る上端 12% の帯を切り出し、RGB の単純平均を取り、0.5 未満なら白文字にする。
これを 240 枚に流し、フル解像度で相対輝度の 95 パーセンタイルを取った結果と突き合わせました。一致は 120 枚でした。
240 枚中 120 枚。コインを投げるのと変わりません。
食い違いの内訳を見ると、原因ははっきりしていました。緑が支配的な葉の画像で 40 枚、夕景で 40 枚、中間調のテクスチャで 40 枚。いずれも (R+G+B)/3 が、人の目の感度と対応していない領域です。
WCAG が定める相対輝度は、sRGB 値を線形化したうえで緑に 0.7152、赤に 0.2126、青に 0.0722 の重みを掛けます。緑の寄与は青の 10 倍近くあります。単純平均はこの差を潰してしまいます。
import numpy as np
def relative_luminance(rgb: np.ndarray) -> np.ndarray:
"""sRGB(uint8 または float) -> WCAG 2.x 相対輝度。
単純平均ではなく、必ず線形化してから重み付けします。"""
c = rgb.astype(np.float64) / 255.0
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
return 0.2126 * lin[..., 0] + 0.7152 * lin[..., 1] + 0.0722 * lin[..., 2]
この関数に差し替えて、平均相対輝度で同じ判定をやり直しました。一致は 200 枚に上がりました。夕景と中間調テクスチャの誤りは、80 枚まるごと消えています。
残ったのは葉の画像 40 枚です。こちらは色の問題ではありませんでした。
分位点は、細かいテクスチャで外れる
葉の画像は、暗い緑を基調にしながら、木漏れ日にあたる明るい部分が散らばっている構成です。帯全体の平均は暗い。けれど局所的には白文字が埋もれる明るさに達している箇所があります。
平均という一つのスカラーに畳んだ時点で、この情報は消えます。そこで分位点に切り替えました。帯の相対輝度の 95 パーセンタイルを取り、その明るさに対して白文字が 4.5:1 を満たすかを見る方式です。
これで葉の画像の取りこぼしは解決しました。ただし今度は別の問題が出ました。
パイプラインを速くするため、判定を縮小画像で行う版を書いて比べたところ、フル解像度の分位点と食い違ったのです。幅 320px に縮小した場合で 40 枚、幅 64px でも同じく 40 枚。割合にして 16.7% です。
| 判定方法 | フル解像度 p95 との判定不一致 | 不一致の内訳 |
| 幅 320px に縮小して p95 | 40 / 240(16.7%) | すべて葉のテクスチャ |
| 幅 64px に縮小して p95 | 40 / 240(16.7%) | すべて葉のテクスチャ |
興味深いのは、320px と 64px で結果が変わらないことです。縮小率を上げても悪化していません。失われているのは解像度ではなく、高周波成分そのものでした。細かい粒状のテクスチャは、一度でも縮小すれば平均化されて消えます。
ここで手が止まりました。フル解像度の分位点と縮小版の分位点、どちらが正しいのか。判断する基準を持っていなかったからです。
文字が乗る窓で測る
考え直すきっかけになったのは、素朴な問いでした。1 ピクセルの輝点は、本当に文字を読みにくくするのでしょうか。
しません。時計の文字は、フル解像度でおよそ 40px × 120px の領域を占めます。読みにくさを決めるのは、その窓の中の平均的な明るさです。窓の中にたった 1 ピクセルの白点があっても、文字の輪郭は失われません。
つまり測るべきは、画像全体の分位点ではなく、文字が実際に乗る大きさの窓の平均輝度の最大値でした。すべての窓位置について平均を求め、その中で最悪のものを取ります。
素直に書くと窓の数だけループが回りますが、積分画像を使えば画像サイズに比例する計算で済みます。
def window_extremes(lum: np.ndarray, wh: int, ww: int) -> tuple:
"""積分画像で「wh x ww の窓の平均輝度」の最大・最小を O(HW) で求めます。
分位点ではなく窓平均を使うのが要点で、1px の輝点で判定が飛ぶのを防ぎます。"""
wh, ww = min(wh, lum.shape[0]), min(ww, lum.shape[1])
s = np.pad(lum, ((1, 0), (1, 0))).cumsum(0).cumsum(1)
h, w = lum.shape
area = (s[wh:, ww:] - s[:h - wh + 1, ww:]
- s[wh:, :w - ww + 1] + s[:h - wh + 1, :w - ww + 1]) / (wh * ww)
return float(area.max()), float(area.min())
最大値は白文字にとっての最悪ケース、最小値は黒文字にとっての最悪ケースになります。両方を一度に得られるので、後で文字色の切り替えを判断する際にそのまま使えます。
窓のサイズは、基準幅 1290px に対する比率で決めています。端末解像度や配信サイズが変わっても判定がぶれないようにするためです。
この方式をフル解像度に適用したところ、白文字で問題ないと判定されたのは 240 枚中 68 枚でした。フル解像度の分位点による判定(40 枚)とは 28 枚ずれています。ずれた 28 枚はすべて葉のテクスチャで、分位点が過剰に厳しく倒れていた側です。
縮小して判定するのは、手抜きではありませんでした
ここで、先ほど棚上げにしていた縮小版の結果を見直しました。
幅 320px に縮小して分位点を取る方式と、フル解像度で窓平均の最大を取る方式。両者の判定を突き合わせると、228 / 240 で一致していました。95.0% です。幅 64px でもまったく同じく 228 / 240 でした。
| 比較の組み合わせ | 一致 | 解釈 |
| 縮小 p95 ↔ フル解像度 p95 | 200 / 240(83.3%) | 16.7% で判定が反転する |
| 縮小 p95 ↔ フル解像度の窓平均最大 | 228 / 240(95.0%) | ほぼ同じことを測っている |
縮小は精度を落とす近似ではありませんでした。近傍のピクセルを平均するという操作は、窓平均そのものの粗い実装だったわけです。フル解像度で厳密に分位点を取るほど、実際の読みやすさから遠ざかっていたことになります。
事前の予想と逆でした。私自身、速度のために品質を諦める妥協だと思って書いていました。その縮小版のほうが、意図に近い指標を計算していたわけです。
処理コストも測っておきました。
| 処理 | 1 枚あたり | 備考 |
| フル解像度(1290×336)の相対輝度 + p95 | 26.5 ms | 最も高価で、最も的を外していた |
| 幅 320px へ縮小 + p95 | 3.2 ms | 窓平均とほぼ同じ判定 |
| フル解像度の窓平均(積分画像) | 4.8 ms | 意味が明確で、十分に安い |
私はこの二つのうち、意味の明確さを優先して窓平均を採りました。ただし縮小してから窓平均を取る形にしています。縮小と窓平均が同じ方向の操作である以上、両方を掛けても矛盾しません。窓サイズを縮小率に合わせて縮めれば、測っているものは変わらないためです。
二値ではなく、スクリムの量を配る
「白文字が読めるか」を真偽値で返すと、読めない画像に対して何をすべきかが決まりません。読めないと分かっただけでは、UI 側は結局どこかで固定値を選ぶことになります。
そこで出力を連続量に変えました。白文字が 4.5:1 に届くために必要な黒スクリムの最小 alpha を返します。
alpha が 0 なら下地は不要。0.2 なら控えめな帯で足りる。値そのものが指示になります。
計算は二分探索にしました。線形空間での近似式を使いたくなる場面ですが、スクリムの合成は sRGB 空間で起きます。線形の近似式を当てると、暗い側で目に見えるほど外れます。
TARGET_RATIO = 4.5
L_MAX_WHITE = 1.05 / TARGET_RATIO - 0.05 # 白文字が 4.5:1 を満たす背景輝度の上限
L_MIN_BLACK = 0.05 * TARGET_RATIO - 0.05 # 黒文字が 4.5:1 を満たす背景輝度の下限
def required_black_scrim(rgb: np.ndarray, wh: int, ww: int) -> float:
"""黒スクリムを sRGB 空間で合成したとき、白文字が 4.5:1 に届く最小 alpha。
合成が sRGB 空間で起きる以上、線形空間の近似式は外れます。"""
lo, hi = 0.0, 1.0
for _ in range(18):
mid = (lo + hi) / 2
dimmed = rgb.astype(np.float64) * (1 - mid)
if window_extremes(relative_luminance(dimmed), wh, ww)[0] <= L_MAX_WHITE:
hi = mid
else:
lo = mid
return hi
def required_white_scrim(rgb: np.ndarray, wh: int, ww: int) -> float:
"""白スクリム側です。黒文字が 4.5:1 に届く最小 alpha を同じ手順で求めます。"""
lo, hi = 0.0, 1.0
for _ in range(18):
mid = (lo + hi) / 2
lifted = rgb.astype(np.float64) * (1 - mid) + 255.0 * mid
if window_extremes(relative_luminance(lifted), wh, ww)[1] >= L_MIN_BLACK:
hi = mid
else:
lo = mid
return hi
18 回の反復で、alpha の分解能は 1/262144 になります。実際には後述の量子化で 0.05 刻みに丸めるため、10 回でも足ります。監査用に生の値を残したかったので、余裕を持たせています。
この段階で、白文字に固定したまま 240 枚の必要 alpha を測りました。スクリム不要は 68 枚。残る 172 枚の中央値は 0.31、90 パーセンタイルは 0.49、最大は 0.50 でした。
一律 0.35 という私の選択は、中央値としてはさほど外れていません。それでも 68 枚に対しては純粋に無駄で、上位 1 割に対しては足りていなかったことになります。
一律スクリムが失っていたもの
ここまでは白文字を前提にしていました。最後に、その前提を外しました。
帯が一様に明るい画像なら、白文字ではなく黒文字を置けばよいはずです。下地はいりません。窓平均の最小値が閾値以上であれば、黒文字が成立します。
判定を次の順序に組み直しました。
- 窓平均の最大が閾値以下 → 白文字、スクリムなし
- 窓平均の最小が閾値以上 → 黒文字、スクリムなし
- どちらでもない → 黒スクリムの必要量を計算し、上限内なら白文字 + スクリム
- 上限を超える → 黒文字 + 白スクリム
240 枚を流し直した結果です。
| 方式 | スクリム不要 | スクリムが要る枚数 | 必要 alpha 中央値 |
| 白文字に固定 | 68 枚(28.3%) | 172 枚(71.7%) | 0.31 |
| 文字色を画像ごとに切り替え | 189 枚(78.8%) | 51 枚(21.2%) | 0.30 |
スクリムが必要な枚数が、172 枚から 51 枚へ減りました。240 枚のうち 189 枚は、文字色を選び直すだけで下地なしに成立します。文字色の内訳は白文字 122 枚・黒文字 118 枚で、ほぼ半々でした。
一律スクリムという設計は、可読性の問題を解いていたのではなく、文字色を固定したせいで生じた問題を塗り潰していたことになります。
カテゴリごとの傾向も残しておきます。
| 画像の系統 | 白文字 | 黒文字 | スクリム最大 |
| 明るい空のグラデーション | 0 | 40 | 0.00 |
| 夜空(小さな高輝度あり) | 40 | 0 | 0.00 |
| 緑の葉のテクスチャ | 40 | 0 | 0.15 |
| 夕景 | 40 | 0 | 0.40 |
| 中間調テクスチャ | 2 | 38 | 0.20 |
| 淡いパステル | 0 | 40 | 0.00 |
夕景だけが一貫してスクリムを必要としています。暖色の高輝度と暗い影が同じ帯に同居し、白と黒のどちらの文字にとっても最悪ケースが厳しくなるためです。この系統に限っては、下地を敷く以外の解がありません。
なお、この 240 枚は自分のカタログの構成を模して手元で生成したコーパスです。実配信中の壁紙そのものではないため、割合は絵柄の分布に依存します。傾向の読み取りとしては使えますが、自分のカタログの数字は自分で測る必要があります。
メタデータの形と、アプリ側の受け取り方
出力は、アセットごとに 2 つの値だけを持つ形にしました。
{
"version": 1,
"target_ratio": 4.5,
"assets": [
{ "asset": "aurora-014.webp", "style": "light", "scrim": 0.0 },
{ "asset": "sunset-021.webp", "style": "light", "scrim": 0.4 },
{ "asset": "pastel-007.webp", "style": "dark", "scrim": 0.0 }
]
}
style は文字色、scrim は下地の alpha です。生の計算値は監査用に別ファイルへ落とし、配信物には含めていません。アプリに配る値と、判定を見直すための値を混ぜないためです。
受け取り側は、値を反映するだけの薄い層になります。
import { StatusBar } from "expo-status-bar";
import { Image } from "expo-image";
import { View, Text, StyleSheet } from "react-native";
type Overlay = { style: "light" | "dark"; scrim: number };
// カタログ JSON から引く。見つからない場合の既定値は安全側(黒スクリム 0.4)に倒す
export function WallpaperHeader({ uri, overlay, title }: {
uri: string; overlay: Overlay | undefined; title: string;
}) {
const resolved: Overlay = overlay ?? { style: "light", scrim: 0.4 };
const isLight = resolved.style === "light";
const scrimColor = isLight ? "0,0,0" : "255,255,255";
return (
<View style={styles.root}>
<Image source={{ uri }} style={StyleSheet.absoluteFill} contentFit="cover" />
{resolved.scrim > 0 && (
<View
pointerEvents="none"
style={[
styles.scrim,
{ backgroundColor: `rgba(${scrimColor},${resolved.scrim})` },
]}
/>
)}
{/* StatusBar の style は「文字の色」を指す。light = 白文字 */}
<StatusBar style={isLight ? "light" : "dark"} />
<Text style={[styles.title, { color: isLight ? "#FFFFFF" : "#000000" }]}>
{title}
</Text>
</View>
);
}
const styles = StyleSheet.create({
root: { height: 240 },
// 帯の高さは判定に使った BAND_BOTTOM(12%)と必ず揃える。ずれると測った意味がなくなる
scrim: { position: "absolute", top: 0, left: 0, right: 0, height: "12%" },
title: { position: "absolute", left: 20, bottom: 20, fontSize: 22, fontWeight: "600" },
});
expo-status-bar の style プロパティが「背景の明るさ」ではなく「文字の色」を指す点は、実装中に一度取り違えました。light を渡すと白い文字になります。名前から受ける印象と逆になる落とし穴です。判定側の style フィールドと同じ語彙に揃えておくと、マッピングを書かずに済みます。
もう一つ、スクリムの高さは判定で使った帯の高さと必ず一致させる必要があります。取り込み時に上端 12% を測ったのなら、描画側も 12% です。ここがずれると、測定した値は意味を失います。定数を 1 か所に置き、両側から参照する形にしました。
決定を安定させる
同じアセットを再処理したときに値が揺れると、カタログの差分が毎回発生します。生成物の再現性は別途詰めていたところで、ここでも同じ問題が顔を出しました。この観点は「生成した壁紙カタログのハッシュが毎回変わる — 非決定の出どころを5つ切り分ける」で扱っています。
対策は 2 つ入れています。
1 つ目は量子化です。alpha を 0.05 刻みに切り上げます。二分探索の結果は毎回わずかに揺れますが、丸めてしまえば同じ値に落ちます。切り捨てではなく切り上げにしているのは、丸めによって基準を下回らせないためです。
ALPHA_STEP = 0.05
quantized = float(np.ceil(alpha / ALPHA_STEP) * ALPHA_STEP)
2 つ目は再判定の条件を明示することです。アセットのバイト列が変わらない限り、既存の値をそのまま引き継ぎます。再計算するのは、アセットが差し替わったときと、判定ロジックのバージョンが上がったときだけです。JSON の version フィールドはこのために置いています。
再計算のコストも測っておきました。1/4 スケールで二分探索まで含めた完全な判定が、1 枚あたり 53 ms です。数千枚のカタログでも数分で一巡します。ロジックを見直すたびに全体を流し直せる範囲に収まりました。
効くところと、効かないところ
この設計が効くのは、上に乗る UI の位置と大きさが決まっている場合です。ステータスバー、固定ヘッダー、カード上の固定ラベル。窓のサイズを事前に決められるからこそ、窓平均という指標が成立します。
効かないのは次の場合です。
文字がスクロールして画像上を移動する画面では、帯だけを測っても足りません。移動範囲全体を対象にするか、そもそも文字の背後に不透明な面を敷く判断になります。
動的にテキスト長が変わる場合も、窓の幅が変わります。私は最長ケースの幅で測っていますが、極端に長い文言が入る画面では別途確認が要ります。
そして、Dynamic Type で文字が大きくなる場合。窓が大きくなれば平均も変わり、判定が緩む方向に動きます。この点はアクセシビリティ設定側と合わせて詰める必要があり、「Rork で VoiceOver と Dynamic Type を本番品質に整える実装メモ」で触れた領域と地続きになります。
最後に、4.5:1 という基準そのものについて。これは通常サイズの文字に対する WCAG AA の値です。大きな見出しに同じ基準を課すのは過剰で、この場合は 3:1 が基準になります。判定スクリプトの TARGET_RATIO を UI の役割ごとに分け、scrim_title と scrim_status を別々に出す形へ広げる余地があります。今は 1 つの値で運用していますが、見出しに過剰な下地がかかっている実感はあるため、次に手を入れるならここです。
一律の値を選ぶという判断は、測る手間を省くための妥協でした。測ってみると、妥協していたのは手間ではなく画像のほうでした。同じような固定値を UI のどこかに残している方がいらしたら、まずは 100 枚ほど手元で測ってみる価値があると思います。想像していたより偏っているはずです。
最後までお読みいただき、ありがとうございました。