Ein Bild entlang einer Kurve biegen — Bezier oder Acht

Das Porträt wird in einer langen Reihe wiederholt und entlang einer Kurve gebogen — wie eine Bilderkette, die einem Band folgt. Die Kurve ist entweder ein sanfter Bezier-Schwung oder eine Acht — mit CURVE_TYPE umschalten. Einstellbar: NUM_IMAGES (wie viele Kopien auf der Kurve), TILE_H (Bandhöhe), GRID_ROWS / GRID_COLS (Mesh-Auflösung — höher heißt glattere Ränder, aber langsamer), SHOW_CURVE (Hilfslinie ein-/ausblenden). Hintergrund: Hier läuft eine einzige `warpGrid`-Operation ohne Bildzerschneiden. Frühere Varianten haben das Bild in 450 kleine Vierecke geschnitten und einzeln perspektivisch verzerrt — sichtbar an den Säumen zwischen den Streifen. Die Gitter-Variante erzeugt ein durchgehend glattes Band in einem Durchgang.

INPUT
INPUT — Ein Bild entlang einer Kurve biegen — Bezier oder Acht
OUTPUT
OUTPUT — Ein Bild entlang einer Kurve biegen — Bezier oder Acht
JavaScript
// Place repeated images along an arbitrary curve using ONE grid warp
// demo_bezier_placer_3.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: CURVE_TYPE:enum(bezier|figure8)=figure8
//!PARAM: NUM_IMAGES:integer=30,min=2,max=200
//!PARAM: TILE_H:number=53,min=10,max=400
//!PARAM: GRID_ROWS:integer=80,min=10,max=400
//!PARAM: GRID_COLS:integer=200,min=10,max=800
//!PARAM: SHOW_CURVE:boolean=true

// Earlier versions of this demo cut every source image into 15 vertical
// strips and ran ONE perspective warp per strip × 30 placements = 450
// warps per render — ~edge-aligned but with visible seams between
// strips and per-image hops where the homography hand-off doesn't quite
// match. This version replaces that machinery with a SINGLE
// `warpGrid` call:
//
//   1. Build a "film strip" source: NUM_IMAGES copies of the portrait
//      laid horizontally, then scaled to fit a CANVAS_W × TILE_H band
//      at the top of a CANVAS_W × CANVAS_H source canvas.
//   2. Pre-compute a fine LUT mapping curve points to (x,y,arcDist) so
//      we can do constant-time closest-curve-point lookups per grid
//      cell.
//   3. For every grid cell on the output canvas, project the cell's
//      position onto the curve. Inside the strip half-width →
//      sample-UV maps to the right film-strip pixel. Outside →
//      out-of-[0,1] UV → border policy paints transparent.
//   4. One `warpGrid` call produces the bent-along-curve composition.
//
// The result has no strip seams (the mesh is C1-smooth between cells)
// and is cheaper than the per-strip perspective hop. The only
// behaviour difference vs the old script: at a self-intersection of
// the curve (the X in figure-8), the grid samples whichever branch's
// projection wins — there's no Z-sort. For a Bézier with no
// self-crossing this is moot; for figure-8 the result is acceptable.

const CANVAS_W = 1200;
const CANVAS_H = 600;
const BLEND_OVER = BlendMode.Over;

// ── Curves ────────────────────────────────────────────────────────────────

const figure8Curve = {
  scale: 300,
  centerX: CANVAS_W / 2,
  centerY: CANVAS_H / 2,
  point: function (t) {
    const angle = t * Math.PI * 2;
    return {
      x: this.centerX + Math.sin(angle) * this.scale,
      y: this.centerY + Math.sin(angle * 2) * (this.scale / 2.5),
    };
  },
  tangent: function (t) {
    const dt = 0.001;
    const p1 = this.point(t);
    const p2 = this.point(Math.min(0.999, t + dt));
    const dx = p2.x - p1.x, dy = p2.y - p1.y;
    const len = Math.sqrt(dx * dx + dy * dy);
    return len < 1e-6 ? { x: 1, y: 0 } : { x: dx / len, y: dy / len };
  },
  normal: function (t) {
    const tg = this.tangent(t);
    return { x: -tg.y, y: tg.x };
  },
};

const bezierCurve = {
  p0: { x: 80,   y: 300 },
  p1: { x: 380,  y: 30  },
  p2: { x: 820,  y: 570 },
  p3: { x: 1120, y: 300 },
  point: function (t) {
    const mt = 1 - t, mt2 = mt * mt, mt3 = mt2 * mt;
    const t2 = t * t, t3 = t2 * t;
    return {
      x: mt3 * this.p0.x + 3 * mt2 * t * this.p1.x + 3 * mt * t2 * this.p2.x + t3 * this.p3.x,
      y: mt3 * this.p0.y + 3 * mt2 * t * this.p1.y + 3 * mt * t2 * this.p2.y + t3 * this.p3.y,
    };
  },
  tangent: function (t) {
    const mt = 1 - t;
    const dx = 3 * mt * mt * (this.p1.x - this.p0.x) + 6 * mt * t * (this.p2.x - this.p1.x) + 3 * t * t * (this.p3.x - this.p2.x);
    const dy = 3 * mt * mt * (this.p1.y - this.p0.y) + 6 * mt * t * (this.p2.y - this.p1.y) + 3 * t * t * (this.p3.y - this.p2.y);
    const len = Math.sqrt(dx * dx + dy * dy);
    return len < 1e-6 ? { x: 1, y: 0 } : { x: dx / len, y: dy / len };
  },
  normal: function (t) {
    const tg = this.tangent(t);
    return { x: -tg.y, y: tg.x };
  },
};

const curve = CURVE_TYPE === "bezier" ? bezierCurve : figure8Curve;

// ── Curve LUT ──────────────────────────────────────────────────────────────
//
// `curveLUT[i]` carries (x, y, arcDist, t) at uniform t-step. Used to
// answer "what's the arc parameter of the curve point closest to
// (x,y)?" with one O(N) scan per grid cell.

const LUT_STEPS = 1500;
const curveLUT = [];
{
  let totalLen = 0;
  let prev = curve.point(0);
  curveLUT.push({ t: 0, x: prev.x, y: prev.y, dist: 0 });
  for (let i = 1; i <= LUT_STEPS; i++) {
    const t = i / LUT_STEPS;
    const pt = curve.point(t);
    const dx = pt.x - prev.x, dy = pt.y - prev.y;
    totalLen += Math.sqrt(dx * dx + dy * dy);
    curveLUT.push({ t, x: pt.x, y: pt.y, dist: totalLen });
    prev = pt;
  }
}
const totalLen = curveLUT[curveLUT.length - 1].dist;

// Trim 5% off each end so images don't run off the curve's tips.
const startDist = 0.05 * totalLen;
const endDist = 0.95 * totalLen;
const usedLen = endDist - startDist;

// ── Film-strip source ──────────────────────────────────────────────────────
//
// One canvas-sized image with a horizontal band of NUM_IMAGES copies
// of the portrait at the top. The band's full width matches CANVAS_W
// so its u ∈ [0,1] maps linearly to "fraction along the used curve
// segment".

const portrait = Engine.loadImage(INPUT);
const portraitW = portrait.width;
const portraitH = portrait.height;
// Fit one tile so its aspect matches the portrait at TILE_H pixels tall.
const tileW = Math.max(1, Math.round(TILE_H * (portraitW / portraitH)));
const stripW = NUM_IMAGES * tileW;

// Build the raw film strip first (stripW × TILE_H), then blit it into
// a canvas-sized source canvas after scaling to CANVAS_W wide.
const filmStrip = Engine.createImage(stripW, TILE_H);
for (let i = 0; i < NUM_IMAGES; i++) {
  const tile = portrait.clone().resize(tileW, TILE_H);
  filmStrip.blendAt(tile, px(i * tileW, 0), 1.0, BLEND_OVER);
  tile.free();
}
portrait.free();

// Squash the film strip horizontally so it occupies the full canvas
// width — this makes u_film == arc_progress trivially.
filmStrip.resize(CANVAS_W, TILE_H);

// Vertically-centred film placement — leaves transparent margins
// above and below so warpGrid's bilinear mesh interpolation can
// transition smoothly into "nothing" instead of hitting a hard
// in-strip / off-strip cutoff. Without this, cells whose corners
// straddle the strip boundary produce a scalloped silhouette
// because the binary border policy can't blend within a cell.
const filmTopY = Math.round((CANVAS_H - TILE_H) / 2);
const srcCanvas = Engine.createImage(CANVAS_W, CANVAS_H);
srcCanvas.blendAt(filmStrip, px(0, filmTopY), 1.0, BLEND_OVER);
filmStrip.free();

// Film occupies y ∈ [filmTopY, filmTopY + TILE_H] of a CANVAS_H-tall
// source. perp = 0 → v = 0.5; perp = ±TILE_H/2 → strip edges; beyond
// that we let v drift into the transparent margin and only fall
// back on border policy at v < 0 or v > 1.
const filmHalfH = TILE_H / 2;

// ── Grid construction ─────────────────────────────────────────────────────
//
// Output grid is GRID_ROWS × GRID_COLS uniform cells on the destination
// canvas. For each cell at output position (xOut, yOut) we run a
// closest-point search over `curveLUT` to find which curve sample is
// nearest. That sample's arc distance + signed perpendicular offset
// give the source UV; outside the strip half-width we push (-1, -1)
// to trip `border: WarpBorder.Transparent`.
//
// Cost: GRID_ROWS × GRID_COLS × LUT_STEPS = 80 × 200 × 1500 = 24M
// distance evaluations. ~2-3s in QuickJS — acceptable for a demo, and
// runs ONCE per render regardless of NUM_IMAGES.

const nodes = {};
for (let r = 0; r < GRID_ROWS; r++) {
  const row = [];
  const yOut = (r / (GRID_ROWS - 1)) * CANVAS_H;
  for (let c = 0; c < GRID_COLS; c++) {
    const xOut = (c / (GRID_COLS - 1)) * CANVAS_W;

    // 1. Closest point on curve.
    let bestIdx = 0;
    let bestDist2 = Infinity;
    for (let i = 0; i < curveLUT.length; i++) {
      const dx = curveLUT[i].x - xOut;
      const dy = curveLUT[i].y - yOut;
      const d2 = dx * dx + dy * dy;
      if (d2 < bestDist2) { bestDist2 = d2; bestIdx = i; }
    }
    const cp = curveLUT[bestIdx];

    // 2. Signed perpendicular distance from cell to curve.
    const norm = curve.normal(cp.t);
    const perp = (xOut - cp.x) * norm.x + (yOut - cp.y) * norm.y;

    // 3. Map to source-canvas UV linearly — no hard "in-strip /
    //    off-strip" branch. The film strip is centred in the source
    //    canvas; perp = 0 lands at v = 0.5, perp = ±TILE_H/2 at the
    //    strip edges, perp beyond that drifts into the transparent
    //    margins. Cells whose closest curve point sits outside
    //    [startDist, endDist] map to srcU outside [0,1] → border
    //    policy paints transparent.
    const arcU = (cp.dist - startDist) / usedLen;     // outside [0,1] for trim region
    const srcU = arcU;
    const srcV = 0.5 + perp / CANVAS_H;
    row.push(srcU, srcV);
  }
  nodes[String(r)] = row;
}

// ── Single warpGrid call ───────────────────────────────────────────────────

const warped = srcCanvas.warpGrid(
  { rows: GRID_ROWS, cols: GRID_COLS, nodes, border: WarpBorder.Transparent },
  Interp.Bilinear,
);

// ── Output composite ──────────────────────────────────────────────────────

const outImg = Engine.createImage(1, 1);
outImg.setPixel(px(0, 0), new Pixel(1, 1, 1, 0));
outImg.resize(CANVAS_W, CANVAS_H);
outImg.blendAt(warped, px(0, 0), 1.0, BLEND_OVER);
warped.free();

if (SHOW_CURVE) {
  const ov = Engine.createCanvas(CANVAS_W, CANVAS_H);
  let path = `M ${curve.point(0).x},${curve.point(0).y}`;
  const steps = 200;
  for (let i = 1; i <= steps; i++) {
    const t = i / steps;
    const pt = curve.point(t);
    path += ` L ${pt.x},${pt.y}`;
  }
  ov.pen(new Pixel(0.10, 0.10, 0.10, 0.85), 3.0);
  ov.drawPathStr(path, true);
  const ovImg = ov.toImage();
  outImg.blendAt(ovImg, px(0, 0), 1.0, BLEND_OVER);
  ovImg.free();
  ov.free();
}

outImg.save(OUTPUT);
outImg.free();

// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0