Bild entlang einer Kurve — in Streifen geschnitten und verzerrt

Schneidet das Bild in Streifen und verzerrt jeden einzeln, damit es einer kubischen Bézier-Kurve folgt. Die Streifen-Fassung hält die Perspektive dort richtig, wo eine einzelne Verzerrung sichtbar scheren würde.

INPUT
INPUT — Bild entlang einer Kurve — in Streifen geschnitten und verzerrt
OUTPUT
OUTPUT — Bild entlang einer Kurve — in Streifen geschnitten und verzerrt
JavaScript
//!INPUT: INPUT
//!OUTPUT: OUTPUT
// demo_bezier_placer_sliced_final.js
// Crops strips from the image, then warps each strip individually to follow the curve.
// demo_curve_placer_warp.js
// Korrekter Warp für jeden Streifen mit perspektivischer Verzerrung

const CANVAS_W = 1200;
const CANVAS_H = 600;
const NUM_IMAGES = 30;
const SLICES_PER_IMAGE = 15;
const TILE_W = 80;
const TILE_H = 53;
const BLEND_OVER = BlendMode.Over;

const CURVE_TYPE = "figure8";

// ============================================================================
// KURVEN-DEFINITIONEN
// ============================================================================

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)
    };
  },

  // Für die Normale brauchen wir die Tangentenrichtung an einem Punkt
  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;
    const dy = p2.y - p1.y;
    const len = Math.sqrt(dx*dx + dy*dy);
    if (len < 1e-6) { return { x: 1, y: 0 }; }
    return { x: dx/len, y: dy/len };
  },

  // Normale (senkrecht zur Tangente)
  normal: function(t) {
    const tang = this.tangent(t);
    return { x: -tang.y, y: tang.x };  // Nach oben zeigende Normale
  }
};

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 tang = this.tangent(t);
    return { x: -tang.y, y: tang.x };
  }
};

let curve;
switch(CURVE_TYPE) {
  case "bezier": curve = bezierCurve; break;
  case "figure8": curve = figure8Curve; break;
  default: curve = bezierCurve;
}

// ============================================================================
// BOGENLÄNGEN-TABELLE
// ============================================================================

const LUT_STEPS = 1000;
const arcLUT = [{ t: 0, dist: 0 }];
let totalLen = 0;

{
  let prev = curve.point(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;
    const 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 0.999; }

  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);
}

// ============================================================================
// HOMOGRAPHIE
// ============================================================================

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));
}

// ============================================================================
// RENDERING - JEDER STREIFTEN HAT EIGENE NORMALE LINKS UND RECHTS
// ============================================================================

const src = Engine.loadImage(INPUT);
const srcW = src.width;
const srcH = src.height;
const sliceWidthPx = srcW / SLICES_PER_IMAGE;
const sliceWidthCanvas = TILE_W / SLICES_PER_IMAGE;

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

const startDist = 0.05 * totalLen;
const endDist = 0.95 * totalLen;
const curveLengthPerImage = (endDist - startDist) / NUM_IMAGES;

let allStrips = [];

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

  for (let slice = 0; slice < SLICES_PER_IMAGE; slice++) {
    // Positionen auf der Kurve für linken und rechten Rand
    const sliceStartDist = imageStartDist + (slice * curveLengthPerImage / SLICES_PER_IMAGE);
    const sliceEndDist = sliceStartDist + (curveLengthPerImage / SLICES_PER_IMAGE);

    const tLeft = getT(sliceStartDist);
    const tRight = getT(sliceEndDist);

    // Punkte auf der Kurve
    const posLeft = curve.point(tLeft);
    const posRight = curve.point(tRight);

    // NORMALEN an der linken und rechten Kante (unterschiedlich für perspektivischen Effekt!)
    const normalLeft = curve.normal(tLeft);
    const normalRight = curve.normal(tRight);

    const halfHeight = TILE_H / 2;

    // Die 4 Ecken mit unterschiedlichen Normalen an jeder Kante
    // Das erzeugt den echten Warp-Effekt!
    const TL = {
      x: posLeft.x + halfHeight * normalLeft.x,
      y: posLeft.y + halfHeight * normalLeft.y
    };
    const TR = {
      x: posRight.x + halfHeight * normalRight.x,
      y: posRight.y + halfHeight * normalRight.y
    };
    const BR = {
      x: posRight.x - halfHeight * normalRight.x,
      y: posRight.y - halfHeight * normalRight.y
    };
    const BL = {
      x: posLeft.x - halfHeight * normalLeft.x,
      y: posLeft.y - halfHeight * normalLeft.y
    };

    // Optional: Visualisiere die 4 Punkte (zum Debuggen)
    // TL, TR, BR, BL

    // Slice aus Originalbild ausschneiden
    const stripX = slice * sliceWidthPx;
    const strip = src.clone().crop(stripX, 0, sliceWidthPx, srcH);

    const srcPts = [
      { x: 0,        y: 0       },  // TL
      { x: strip.width-1, y: 0  },  // TR
      { x: strip.width-1, y: strip.height-1 },  // BR
      { x: 0,        y: strip.height-1 },  // BL
    ];

    const dstPts = [TL, TR, BR, BL];
    const H_fwd = computeH_fwd(srcPts, dstPts, CANVAS_W, CANVAS_H);

    const centerY = (posLeft.y + posRight.y) / 2;

    allStrips.push({
      centerY: centerY,
      H_fwd: H_fwd,
      strip: strip
    });
  }
}

// Sortiere nach Y-Position
allStrips.sort((a, b) => b.centerY - a.centerY);

// Rendern
for (const stripData of allStrips) {
  const warpedStrip = stripData.strip.warpPerspective(CANVAS_W, CANVAS_H, stripData.H_fwd);
  canvas.blendAt(warpedStrip, px(0, 0), 1.0, BLEND_OVER);
  warpedStrip.free();
  stripData.strip.free();
}

// ============================================================================
// VISUALISIERUNG
// ============================================================================

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

function drawCurve() {
  const steps = 200;
  let path = `M ${curve.point(0).x},${curve.point(0).y}`;
  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);
}

drawCurve();

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

canvas.save(OUTPUT);