3 Porträts entlang einer kubischen Bézier-Kurve platziert, geschnitten

Eine fortgeschrittene Version der Bézier-Platzierungs-Demo, bei der jedes Bild in vertikale Streifen geschnitten und diese einzeln verzerrt werden. Dies ermöglicht es den Bildern, sich flüssiger entlang des Kurvenpfades zu biegen.

INPUT
INPUT — 3 Porträts entlang einer kubischen Bézier-Kurve platziert, geschnitten
OUTPUT
OUTPUT — 3 Porträts entlang einer kubischen Bézier-Kurve platziert, geschnitten
JavaScript
// 3 portraits placed along a cubic Bézier curve, sliced
// demo_bezier_placer.js
//!OUTPUT: OUTPUT
// demo_bezier_placer_sliced_final.js
// Crops strips from the image, then warps each strip individually to follow the curve.

const CANVAS_W = 1200;
const CANVAS_H = 600;
const NUM_IMAGES = 3;  // How many complete images to place
const SLICES_PER_IMAGE = 15;  // Cut each image into this many strips
const TILE_W = 250; // Image width on canvas
const TILE_H = 166; // Image height on canvas
const BLEND_OVER = BlendMode.Over;

// Cubic Bézier control points
const P0 = { x: 80,   y: 300 };
const P1 = { x: 380,  y: 30  };
const P2 = { x: 820,  y: 570 };
const P3 = { x: 1120, y: 300 };

// ── Bézier math ──────────────────────────────────────────────────────────────

function bezierPoint(t) {
  const mt = 1 - t, mt2 = mt * mt, mt3 = mt2 * mt;
  const t2 = t * t,  t3  = t2 * t;
  return {
    x: mt3*P0.x + 3*mt2*t*P1.x + 3*mt*t2*P2.x + t3*P3.x,
    y: mt3*P0.y + 3*mt2*t*P1.y + 3*mt*t2*P2.y + t3*P3.y,
  };
}

function bezierTangent(t) {
  const mt = 1 - t;
  const dx = 3*mt*mt*(P1.x-P0.x) + 6*mt*t*(P2.x-P1.x) + 3*t*t*(P3.x-P2.x);
  const dy = 3*mt*mt*(P1.y-P0.y) + 6*mt*t*(P2.y-P1.y) + 3*t*t*(P3.y-P2.y);
  const len = Math.sqrt(dx*dx + dy*dy);
  return len < 1e-6 ? { x: 1, y: 0 } : { x: dx/len, y: dy/len };
}

// ── Arc-length LUT ───────────────────────────────────────────────────────────

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

function getT(d) {
  if (d <= 0) { return 0; }
  if (d >= totalLen) { return 1; }
  let lo = 0, hi = arcLUT.length - 1;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (arcLUT[mid].dist < d) { lo = mid; } else { hi = mid; }
  }
  const s = (d - arcLUT[lo].dist) / (arcLUT[hi].dist - arcLUT[lo].dist);
  return arcLUT[lo].t + s * (arcLUT[hi].t - arcLUT[lo].t);
}

// ── Homography math ──────────────────────────────────────────────────────────

function gaussElim(A, b) {
  const n = 8;
  const M = A.map((row, i) => [...row, b[i]]);
  for (let col = 0; col < n; col++) {
    let maxRow = col;
    for (let row = col + 1; row < n; row++) {
      if (Math.abs(M[row][col]) > Math.abs(M[maxRow][col])) { maxRow = row; }
    }
    const tmp = M[col]; M[col] = M[maxRow]; M[maxRow] = tmp;
    for (let row = 0; row < n; row++) {
      if (row === col) { continue; }
      const f = M[row][col] / M[col][col];
      for (let j = col; j <= n; j++) { M[row][j] -= f * M[col][j]; }
    }
  }
  return M.map((row, i) => row[n] / row[i]);
}

function mat3mul(A, B) {
  const [a0,a1,a2, a3,a4,a5, a6,a7,a8] = A;
  const [b0,b1,b2, b3,b4,b5, b6,b7,b8] = B;
  return [
    a0*b0+a1*b3+a2*b6,  a0*b1+a1*b4+a2*b7,  a0*b2+a1*b5+a2*b8,
    a3*b0+a4*b3+a5*b6,  a3*b1+a4*b4+a5*b7,  a3*b2+a4*b5+a5*b8,
    a6*b0+a7*b3+a8*b6,  a6*b1+a7*b4+a8*b7,  a6*b2+a7*b5+a8*b8,
  ];
}

function computeH_fwd(srcPts, dstPts, canvasW, canvasH) {
  const sw = srcPts[1].x;
  const sh = srcPts[2].y;
  const sn = srcPts.map(p => ({ x: p.x / sw, y: p.y / sh }));
  const dn = dstPts.map(p => ({ x: p.x / canvasW, y: p.y / canvasH }));

  const A = [], b = [];
  for (let i = 0; i < 4; i++) {
    const { x: xs, y: ys } = sn[i];
    const { x: xd, y: yd } = dn[i];
    A.push([xs, ys, 1, 0,  0,  0, -xd*xs, -xd*ys]); b.push(xd);
    A.push([0,  0,  0, xs, ys, 1, -yd*xs, -yd*ys]); b.push(yd);
  }
  const h = gaussElim(A, b);
  const H_n = [h[0],h[1],h[2], h[3],h[4],h[5], h[6],h[7], 1.0];
  const normSrc   = [1/sw, 0, 0, 0, 1/sh, 0, 0, 0, 1];
  const denormDst = [canvasW, 0, 0, 0, canvasH, 0, 0, 0, 1];
  return mat3mul(denormDst, mat3mul(H_n, normSrc));
}

// ── Render strips along curve ────────────────────────────────────────────────

const src = Engine.loadImage(INPUT);
const srcW = src.width;
const srcH = src.height;
const sliceWidthPx = srcW / SLICES_PER_IMAGE;  // Width of each strip in source
const sliceWidthCanvas = TILE_W / SLICES_PER_IMAGE;  // Width on canvas

// White canvas background
const canvas = Engine.createImage(1, 1);
canvas.setPixel(px(0, 0), new Pixel(1, 1, 1, 1));
canvas.resize(CANVAS_W, CANVAS_H);

// Curve range where we place images
const startDist = 0.10 * totalLen;
const endDist = 0.90 * totalLen;
const curveLengthPerImage = (endDist - startDist) / NUM_IMAGES;
const stepSize = curveLengthPerImage / SLICES_PER_IMAGE;  // Arc-length per strip

for (let imgIdx = 0; imgIdx < NUM_IMAGES; imgIdx++) {
  const imageStartDist = startDist + (imgIdx * curveLengthPerImage);

  for (let slice = 0; slice < SLICES_PER_IMAGE; slice++) {
    // Arc-length distance for this strip's position on the curve
    const sliceDist = imageStartDist + (slice * stepSize);

    // Get curve position and tangent at this point
    const t = getT(sliceDist);
    const pos = bezierPoint(t);
    const tang = bezierTangent(t);
    const N = { x: tang.y, y: -tang.x };  // Normal (perpendicular)

    // Crop the strip from the original image
    const stripX = slice * sliceWidthPx;
    const strip = src.clone().crop(stripX, 0, sliceWidthPx, srcH);

    // Source corners for this strip (0 to strip width, 0 to strip height)
    const stripW = strip.width;
    const stripH = strip.height;
    const srcPts = [
      { x: 0,        y: 0       },  // TL
      { x: stripW-1, y: 0       },  // TR
      { x: stripW-1, y: stripH-1},  // BR
      { x: 0,        y: stripH-1},  // BL
    ];

    // Destination quad on canvas for this strip
    // The strip is centered on the curve point
    const halfSliceWidth = sliceWidthCanvas / 2;
    const halfHeight = TILE_H / 2;

    const dstPts = [
      { x: pos.x - halfSliceWidth * tang.x + halfHeight * N.x,  // TL
        y: pos.y - halfSliceWidth * tang.y + halfHeight * N.y },
      { x: pos.x + halfSliceWidth * tang.x + halfHeight * N.x,  // TR
        y: pos.y + halfSliceWidth * tang.y + halfHeight * N.y },
      { x: pos.x + halfSliceWidth * tang.x - halfHeight * N.x,  // BR
        y: pos.y + halfSliceWidth * tang.y - halfHeight * N.y },
      { x: pos.x - halfSliceWidth * tang.x - halfHeight * N.x,  // BL
        y: pos.y - halfSliceWidth * tang.y - halfHeight * N.y },
    ];

    // Compute homography for this strip (strip source → canvas destination)
    const H_fwd = computeH_fwd(srcPts, dstPts, CANVAS_W, CANVAS_H);

    // Warp the strip onto the canvas
    const warpedStrip = strip.warpPerspective(CANVAS_W, CANVAS_H, H_fwd);
    canvas.blendAt(warpedStrip, px(0, 0), 1.0, BLEND_OVER);

    // Cleanup
    warpedStrip.free();
    strip.free();
  }
}

// ── Overlay: Bézier curve ────────────────────────────────────────────────────

const ov = Engine.createCanvas(CANVAS_W, CANVAS_H);

function circleMarker(cv, cx, cy, r, color, strokeW) {
  const p = Engine.createPath().circle(cx, cy, r);
  cv.fill(color).drawPath(p);
  if (strokeW > 0) { cv.pen("#ffffffff", strokeW).drawPath(p, true); }
  p.free();
}

// The curve
ov.pen(new Pixel(0.10, 0.10, 0.10, 0.85), 3.0);
ov.drawPathStr(`M ${P0.x},${P0.y} C ${P1.x},${P1.y} ${P2.x},${P2.y} ${P3.x},${P3.y}`, true);

// Control lines
ov.pen(new Pixel(0.92, 0.42, 0.12, 0.75), 1.5);
ov.drawPathStr(`M ${P0.x},${P0.y} L ${P1.x},${P1.y}`, true);
ov.drawPathStr(`M ${P3.x},${P3.y} L ${P2.x},${P2.y}`, true);

// Control points
for (const p of [P0, P3]) { circleMarker(ov, p.x, p.y, 7, new Pixel(0.18, 0.40, 0.92, 1.0), 1.5); }
for (const p of [P1, P2]) { circleMarker(ov, p.x, p.y, 5, new Pixel(0.92, 0.42, 0.12, 0.90), 1.5); }

const ovImg = ov.toImage();
canvas.blendAt(ovImg, px(0, 0), 1.0, BLEND_OVER);
ovImg.free();
ov.free();

canvas.save(OUTPUT);

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