BlurHash — ein Bild in 28 Zeichen
Kodiert das Bild in eine BlurHash-Zeichenkette und wieder zurück, neben dem Original. Die Zeichenkette passt in eine Datenbankspalte: Der Platzhalter reist im JSON mit, statt als zweite Bildanfrage. Zeigt getPixel/setPixel um eine selbst geschriebene Kosinustransformation herum — und warum sie in linearem Licht rechnen muss.
INPUT
Original neben BlurHash
JavaScript
// BlurHash — ein Bild als ~30 Zeichen, und wieder zurück
// blurhash.js
//
// BlurHash von Dag Ågren / Wolt (MIT). Dieses Skript ist eine
// eigenstaendige Umsetzung in OpticScript: links das Original, rechts
// die Rekonstruktion allein aus der Zeichenkette darunter.
//
// Wofuer: ein Platzhalter, der in eine Datenbankspalte passt. Statt
// ein Vorschaubild zu speichern und auszuliefern, steht der Hash im
// JSON neben der Bild-URL; die Seite zeigt ihn, bis das echte Bild da
// ist.
//
// Zwei Dinge, die das Beispiel zeigt:
// - getPixel / setPixel mit einem eigenen Algorithmus dazwischen
// - warum in linearem Licht gerechnet werden muss: die Engine gibt
// sRGB-kodierte Werte zurueck, der Mittelwert zweier Farben ist
// darin nicht ihre Mischung.
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: COMP_X:number=4, COMP_Y:number=3, PUNCH:number=1
const DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
function encode83(n, length) {
let out = "";
for (let i = 1; i <= length; i++) {
const d = Math.floor(n / Math.pow(83, length - i)) % 83;
out += DIGITS[d];
}
return out;
}
function decode83(str) {
let v = 0;
for (const ch of str) v = v * 83 + DIGITS.indexOf(ch);
return v;
}
// sRGB <-> lineares Licht. Ohne diese beiden Funktionen wird jeder
// Mittelwert zu dunkel — genau der Fehler, den Bildbearbeitung seit
// Jahrzehnten begleitet.
function sRGBToLinear(v) {
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function linearToSRGB(v) {
const c = Math.max(0, Math.min(1, v));
const s = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.round(s * 255);
}
function signPow(v, e) {
return Math.sign(v) * Math.pow(Math.abs(v), e);
}
// ── Kodieren ───────────────────────────────────────────────────────
//
// Das Bild wird auf ein kleines Raster gerechnet (32x32 reicht: der
// Hash traegt ohnehin nur wenige Koeffizienten) und dann in eine
// Kosinus-Basis zerlegt — eine diskrete Kosinustransformation, wie
// bei JPEG, nur dass hier 12 statt 64 Koeffizienten ueberleben.
function encodeBlurhash(img, cx, cy) {
const N = 32;
const small = img.clone().resize(N, N);
const lin = new Float64Array(N * N * 3);
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const p = small.getPixel(px(x, y));
const i = (y * N + x) * 3;
lin[i] = sRGBToLinear(p.r);
lin[i + 1] = sRGBToLinear(p.g);
lin[i + 2] = sRGBToLinear(p.b);
}
}
small.free();
const factors = [];
for (let j = 0; j < cy; j++) {
for (let i = 0; i < cx; i++) {
const norm = i === 0 && j === 0 ? 1 : 2;
let r = 0, g = 0, b = 0;
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const basis = Math.cos((Math.PI * i * x) / N) * Math.cos((Math.PI * j * y) / N);
const k = (y * N + x) * 3;
r += basis * lin[k];
g += basis * lin[k + 1];
b += basis * lin[k + 2];
}
}
const scale = norm / (N * N);
factors.push([r * scale, g * scale, b * scale]);
}
}
const dc = factors[0];
const ac = factors.slice(1);
let maxAc = 0;
for (const f of ac) for (const v of f) maxAc = Math.max(maxAc, Math.abs(v));
const quantMax = ac.length > 0
? Math.max(0, Math.min(82, Math.floor(maxAc * 166 - 0.5)))
: 0;
const maxValue = ac.length > 0 ? (quantMax + 1) / 166 : 1;
let hash = encode83((cx - 1) + (cy - 1) * 9, 1) + encode83(quantMax, 1);
hash += encode83((linearToSRGB(dc[0]) << 16) + (linearToSRGB(dc[1]) << 8) + linearToSRGB(dc[2]), 4);
for (const f of ac) {
const q = f.map((v) =>
Math.max(0, Math.min(18, Math.floor(signPow(v / maxValue, 0.5) * 9 + 9.5))));
hash += encode83(q[0] * 19 * 19 + q[1] * 19 + q[2], 2);
}
return hash;
}
// ── Dekodieren ─────────────────────────────────────────────────────
//
// Klein rechnen, gross skalieren: die Rekonstruktion hat ohnehin
// keine Details, und 32x32 Pixel einzeln zu setzen kostet nichts.
function decodeBlurhash(hash, w, h, punch) {
const sizeFlag = decode83(hash[0]);
const cx = (sizeFlag % 9) + 1;
const cy = Math.floor(sizeFlag / 9) + 1;
const maxValue = (decode83(hash[1]) + 1) / 166;
const colours = [];
const dcVal = decode83(hash.substring(2, 6));
colours.push([
sRGBToLinear(((dcVal >> 16) & 255) / 255),
sRGBToLinear(((dcVal >> 8) & 255) / 255),
sRGBToLinear((dcVal & 255) / 255),
]);
for (let i = 1; i < cx * cy; i++) {
const v = decode83(hash.substring(4 + i * 2, 6 + i * 2));
const r = Math.floor(v / (19 * 19));
const g = Math.floor(v / 19) % 19;
const b = v % 19;
colours.push([
signPow((r - 9) / 9, 2) * maxValue * punch,
signPow((g - 9) / 9, 2) * maxValue * punch,
signPow((b - 9) / 9, 2) * maxValue * punch,
]);
}
const N = 32;
const out = Engine.createColoredImage(N, N, "#000000");
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
let r = 0, g = 0, b = 0;
for (let j = 0; j < cy; j++) {
for (let i = 0; i < cx; i++) {
const basis = Math.cos((Math.PI * x * i) / N) * Math.cos((Math.PI * y * j) / N);
const c = colours[i + j * cx];
r += c[0] * basis;
g += c[1] * basis;
b += c[2] * basis;
}
}
out.setPixel(px(x, y), new Pixel(
linearToSRGB(r) / 255,
linearToSRGB(g) / 255,
linearToSRGB(b) / 255,
1));
}
}
return out.resize(w, h);
}
// ── Gegenüberstellung ──────────────────────────────────────────────
const src = Engine.loadImage(INPUT);
const W = 520;
const H = Math.round((W * src.height) / src.width);
const t0 = Date.now();
const hash = encodeBlurhash(src, COMP_X, COMP_Y);
const encodeMs = Date.now() - t0;
console.log("BlurHash: " + hash + " (" + hash.length + " Zeichen, " + encodeMs + " ms)");
console.log("Original: " + src.width + "x" + src.height + " Pixel");
const left = src.clone().resize(W, H);
const right = decodeBlurhash(hash, W, H, PUNCH);
const PAD = 24, GAP = 16, LABEL = 64;
const canvasW = PAD * 2 + W * 2 + GAP;
const canvasH = PAD + H + LABEL;
const sheet = Engine.createColoredImage(canvasW, canvasH, "#12161d");
sheet.blendAt(left, px(PAD, PAD), 1.0, Blend.Over);
sheet.blendAt(right, px(PAD + W + GAP, PAD), 1.0, Blend.Over);
sheet.drawText("Original", PAD, PAD + H + 26,
{ font: "Inter", size: 14, color: "#8b949e" });
sheet.drawText("BlurHash · " + COMP_X + "x" + COMP_Y + " Komponenten", PAD + W + GAP, PAD + H + 26,
{ font: "Inter", size: 14, color: "#8b949e" });
sheet.drawText(hash, PAD + W + GAP, PAD + H + 48,
{ font: "JetBrains Mono", size: 13, color: "#3fb950" });
sheet.save(OUTPUT);
left.free();
right.free();
src.free();
sheet.free();