Image along a curve — sliced and warped strip by strip
Cuts the image into strips and warps each one separately so it follows a cubic Bézier curve. The sliced variant keeps the perspective correct where a single warp would visibly shear.
INPUT
OUTPUT
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);