Hockney / Picasso joiner — cubist photo collage
Fragments the photo into N random rectangles, rotates each a few degrees, drops a shadow under it, and composites the lot back at roughly-but-not-quite the original position. The classic Hockney "joiner" look that Picasso made famous with painted cubism. All placement is seeded so the same SEED + sliders always produces the same layout — slide the seed to explore variants, lock it in when you find the one.
INPUT
Joiner 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