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
INPUT — BlurHash — ein Bild in 28 Zeichen
Original neben BlurHash
Original neben BlurHash — BlurHash — ein Bild in 28 Zeichen
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();