Hockney / Picasso Joiner — kubistische Foto-Collage

Zerlegt das Foto in N zufällige Rechtecke, dreht jedes ein paar Grad, legt einen Schatten drunter und komponiert das Ganze in ungefähr-aber-nicht-genau der Original-Position zurück. Der klassische Hockney-"Joiner"-Look, den Picasso mit gemaltem Kubismus berühmt machte. Layout per SEED reproduzierbar — Slider schieben für Varianten, fixieren wenn du den richtigen Wurf hast.

INPUT
INPUT — Hockney / Picasso Joiner — kubistische Foto-Collage
Joiner collage
Joiner collage — Hockney / Picasso Joiner — kubistische Foto-Collage
JavaScript
// Hockney / Picasso "joiner" — fragment the photo into N random
// rectangles, rotate each a few degrees, drop a shadow under it,
// and composite the lot back at roughly-but-not-quite the original
// position. Result: the cubist photo-collage look that Hockney made
// famous with his polaroid joiners.
//
// demo_joiner_picasso.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: TILES:integer=22,min=4,max=80
//!PARAM: TILE_SIZE_PCT:number=0.32,min=0.1,max=0.6,step=0.01
//!PARAM: TILE_SIZE_JITTER:number=0.45,min=0,max=1,step=0.05
//!PARAM: ROTATION_DEG:number=14,min=0,max=45
//!PARAM: POSITION_JITTER:number=0.05,min=0,max=0.3,step=0.01
//!PARAM: SHADOW_BLUR:number=14,min=0,max=40
//!PARAM: SHADOW_STRENGTH:number=0.6,min=0,max=1,step=0.05
//!PARAM: BG_HINT:number=0.12,min=0,max=0.4,step=0.02
//!PARAM: BG_COLOR:string=#0a0a0a
//!PARAM: SEED:integer=42,min=1,max=99999

// ── 1. Setup ──────────────────────────────────────────────────────────
const src = Engine.loadImage(INPUT);
const W = src.width, H = src.height;
const PAD = Math.round(Math.min(W, H) * 0.08);   // extra room for rotated bits

// Seeded LCG so the same seed gives the same layout every run —
// makes the demo reproducible across sessions.
let _rng = SEED >>> 0;
function rand() {
  _rng = (_rng * 1103515245 + 12345) & 0x7fffffff;
  return _rng / 0x7fffffff;
}
const randRange = (lo, hi) => lo + rand() * (hi - lo);
const randInt   = (lo, hi) => Math.floor(randRange(lo, hi + 1));

// ── 2. Base canvas — solid colour + optional desaturated ghost ────────
// The ghost gives the eye a compositional anchor so chaotic tiles
// don't lose the photo's subject; BG_HINT=0 turns it off entirely.
const canvas = Engine.createImage(W + 2 * PAD, H + 2 * PAD);
const [br, bg, bb, ba] = (function () {
  const p = Pixel.fromHex(BG_COLOR);
  return [p.r, p.g, p.b, p.a];
})();
// Fill the canvas with the background colour. setPixel-per-pixel would
// be O(W·H); the canvas trick (fill + drawPath rect) is one Rust call.
const bgCanvas = Engine.createCanvas(W + 2 * PAD, H + 2 * PAD);
bgCanvas.fill([br, bg, bb, ba])
        .drawPath(Engine.createPath().rect(0, 0, W + 2 * PAD, H + 2 * PAD));
canvas.blendAt(bgCanvas.toImage(), px(0, 0), 1.0, Blend.Over);
bgCanvas.free();

if (BG_HINT > 0) {
  const hint = src.clone().grayscale().brightness(0.35);
  canvas.blendAt(hint, px(PAD, PAD), BG_HINT, Blend.Over);
  hint.free();
}

// ── 3. Drop N rotated fragments ───────────────────────────────────────
const baseTileW = Math.round(Math.min(W, H) * TILE_SIZE_PCT);
const minTile = Math.max(40, Math.round(baseTileW * 0.4));

for (let i = 0; i < TILES; i++) {
  // Tile dims with both scalar jitter and aspect-ratio jitter so the
  // fragments don't all look like the same square.
  const sizeJ = randRange(1 - TILE_SIZE_JITTER, 1 + TILE_SIZE_JITTER);
  const aspJ  = randRange(0.6, 1.6);
  let tileW = Math.round(baseTileW * sizeJ);
  let tileH = Math.round(tileW * aspJ);
  tileW = Math.max(minTile, Math.min(tileW, W));
  tileH = Math.max(minTile, Math.min(tileH, H));

  // Pick source rectangle (must fit inside the image).
  const sx = randInt(0, W - tileW);
  const sy = randInt(0, H - tileH);

  // Crop a fresh copy.
  const tile = src.clone().crop(sx, sy, tileW, tileH);

  // Random rotation, premultiplyAlpha keeps silhouette edges clean
  // through the bilinear interp inside rotateExpand.
  const angle = randRange(-ROTATION_DEG, ROTATION_DEG);
  tile.premultiplyAlpha().rotateExpand(angle);

  // Drop shadow gives the fragment its "polaroid lying on a table"
  // depth. Skipped when SHADOW_BLUR=0 for a flatter look.
  if (SHADOW_BLUR > 0) {
    const sd = Math.round(SHADOW_BLUR * 0.3);
    tile.dropShadow(sd, sd, SHADOW_BLUR, "#000000", SHADOW_STRENGTH);
  }

  // Anchor the rotated tile's CENTRE to the original crop's centre
  // (plus a small per-tile jitter so the joiner feels alive). After
  // rotateExpand the tile is bigger than tileW/tileH, so we centre
  // it via (tile.width - tileW) / 2.
  const jx = Math.round(randRange(-POSITION_JITTER, POSITION_JITTER) * W);
  const jy = Math.round(randRange(-POSITION_JITTER, POSITION_JITTER) * H);
  const targetX = PAD + sx + jx - Math.round((tile.width  - tileW) / 2);
  const targetY = PAD + sy + jy - Math.round((tile.height - tileH) / 2);

  canvas.blendAt(tile, px(targetX, targetY), 1.0, Blend.Over);
  tile.free();
}

// ── 4. Save ───────────────────────────────────────────────────────────
canvas.save(OUTPUT);
src.free();
canvas.free();

`${TILES} fragments composited (seed=${SEED})`;

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