Face detect → side gallery (per-face thumbs + arrows + labels)
Detects every face, then extends the canvas to the right and pastes a circular thumb of each face stacked vertically. Each face is marked with a circle on the original; an arrow connects it to its thumb on the right. Labels (default 'Person 1', 'Person 2', …) sit below each thumb. Auto-fits thumb size to the original height so 1, 5 or 10 faces all stay readable. Built from existing engine ops only — detect → crop → resize → applyMask + blendAt for the thumbs, vector canvas with circle / line / triangle paths for the markers, and drawText for the labels.
INPUT
Original + per-face gallery
JavaScript
// Tool plugin demo — face detection + side gallery (one thumb per face)
// tool_facedetect_gallery.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: MIN_SIZE:number=60,min=10,max=400
//!PARAM: SCORE:number=30,min=5,max=200
//!PARAM: PROBE_CONTRAST:number=1.4,min=0.5,max=5.0
//!PARAM: PROBE_DENOISE:number=0.8,min=0.0,max=5.0
//!PARAM: AREA_SCALE:number=1.3,min=0.8,max=3.0
//!PARAM: THUMB_SIZE:number=200,min=80,max=600
//!PARAM: MARK_RING_PX:number=4,min=1,max=20
//!PARAM: MARK_COLOR:color=#ff3030ff
//!PARAM: SHOW_ARROWS:boolean=true
//!PARAM: LABEL_PREFIX:string=Person
//!PARAM: LABEL_SIZE:number=20,min=10,max=80
//!PARAM: BG_COLOR:color=#ffffffff
// Detects every face in the image, then builds a "contact sheet" by
// extending the original to the right and pasting one cropped thumb
// per face. Each face also gets a circular marker in the original,
// optional arrows pointing to its thumb, and a label below ("Person
// 1", "Person 2", …) that you can re-prefix or replace per script.
//
// Works as-is on group photos: 1, 2 or 8 faces all stack vertically
// on the right. THUMB_SIZE is treated as a maximum — when many faces
// would overflow the image height, the script auto-shrinks each
// thumb so the gallery still fits next to the original. For very
// many faces this gets tight; bump THUMB_SIZE down or expect smaller
// thumbs.
const img = Engine.loadImage(INPUT);
const ow = img.width;
const oh = img.height;
// 1) Detection probe (grayscale + contrast + mild denoise → easier
// signal for the cascade; original colour stays untouched).
const probe = img.clone().grayscale().contrast(PROBE_CONTRAST);
if (PROBE_DENOISE > 0) { probe.gaussianBlur(PROBE_DENOISE); }
const faces = Engine.detectFaces(probe, {
minSize: MIN_SIZE,
scoreThreshold: SCORE,
});
probe.free();
console.log(`detected ${faces.length} face(s)`);
for (let i = 0; i < faces.length; i++) {
const f = faces[i];
console.log(` ${LABEL_PREFIX} ${i + 1}: ${f.width}×${f.height} at (${f.x},${f.y}), Q=${f.confidence.toFixed(1)}`);
}
if (faces.length === 0) {
img.save(OUTPUT);
img.free();
} else {
// ──────── Layout ────────
const margin = 20;
const labelH = Math.round(LABEL_SIZE * 1.6);
const cellGap = 12;
// Stack vertically; auto-shrink thumbs so they fit next to
// the original at full height.
const cellH = Math.floor((oh - 2 * margin - (faces.length - 1) * cellGap) / faces.length);
const thumbSize = Math.max(40, Math.min(THUMB_SIZE, cellH - labelH));
const panelW = thumbSize + 2 * margin;
// 2) Extend the canvas to the right with the chosen background.
img.padRight(panelW, BG_COLOR);
// img is now (ow + panelW) × oh; original pixels still at (0..ow, 0..oh).
// 3) Compute each face's center / radius / thumb position so we
// can render the thumbs (raster) and the markers (vector overlay)
// in two clean passes.
const slots = [];
for (let i = 0; i < faces.length; i++) {
const f = faces[i];
const cx = f.x + f.width / 2;
const cy = f.y + f.height / 2;
const r = (Math.max(f.width, f.height) / 2) * AREA_SCALE;
const tx = ow + margin;
const ty = margin + i * (thumbSize + labelH + cellGap);
slots.push({ cx, cy, r, tx, ty });
}
// 4) Raster pass: crop each face from the original, resize, paste
// into its slot. Square crop so the thumb is square; circular
// mask gives a clean disc edge regardless of face aspect.
for (const s of slots) {
const sx = Math.max(0, Math.round(s.cx - s.r));
const sy = Math.max(0, Math.round(s.cy - s.r));
const sw = Math.min(ow - sx, Math.round(s.r * 2));
const sh = Math.min(oh - sy, Math.round(s.r * 2));
if (sw < 4 || sh < 4) { continue; }
const thumb = img.clone().crop(sx, sy, sw, sh).resize(thumbSize, thumbSize);
// Circular mask — disc-shaped thumb on the BG_COLOR panel.
const m = Engine.createCanvas(thumbSize, thumbSize);
m.fill('#ffffff').drawPath(Engine.createPath().circle(thumbSize / 2, thumbSize / 2, thumbSize / 2));
const mask = m.toImage();
thumb.applyMask(mask);
mask.free();
img.blendAt(thumb, new Px(s.tx, s.ty), 1.0, BlendMode.Over);
thumb.free();
}
// 5) Vector pass: circles around faces in the original, optional
// arrows from each face circle to its thumb, plus an outline
// around each thumb so it reads as a framed gallery item. All
// drawn on a single canvas the size of the padded image.
const overlay = Engine.createCanvas(img.width, img.height);
overlay.pen(MARK_COLOR, MARK_RING_PX);
overlay.fill(MARK_COLOR); // for filled arrowheads (drawn unstroked)
for (const s of slots) {
// a) circle around face in original
overlay.drawPath(Engine.createPath().circle(s.cx, s.cy, s.r), true);
// b) circle around thumb (matches the disc shape)
const tcx = s.tx + thumbSize / 2;
const tcy = s.ty + thumbSize / 2;
overlay.drawPath(Engine.createPath().circle(tcx, tcy, thumbSize / 2), true);
// c) arrow from face circle edge to thumb circle edge
if (SHOW_ARROWS) {
const dx = tcx - s.cx, dy = tcy - s.cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < s.r + thumbSize / 2 + 8) { continue; } // too close — skip arrow
const ux = dx / dist, uy = dy / dist;
const startX = s.cx + ux * s.r;
const startY = s.cy + uy * s.r;
const endX = tcx - ux * thumbSize / 2;
const endY = tcy - uy * thumbSize / 2;
// Shaft — drawn stroked.
// Stop the shaft a touch before the arrowhead tip so they
// join cleanly.
const ahLen = Math.max(8, MARK_RING_PX * 3);
const ahWidth = Math.max(5, MARK_RING_PX * 2);
const shaftEndX = endX - ux * ahLen * 0.6;
const shaftEndY = endY - uy * ahLen * 0.6;
overlay.drawPath(
Engine.createPath().moveTo(startX, startY).lineTo(shaftEndX, shaftEndY),
true,
);
// Arrowhead — filled triangle, perpendicular base ahWidth wide.
const baseX = endX - ux * ahLen;
const baseY = endY - uy * ahLen;
const perpX = -uy, perpY = ux;
const head = Engine.createPath()
.moveTo(endX, endY)
.lineTo(baseX + perpX * ahWidth, baseY + perpY * ahWidth)
.lineTo(baseX - perpX * ahWidth, baseY - perpY * ahWidth)
.close();
overlay.drawPath(head); // fill mode = MARK_COLOR
}
}
const overlayImg = overlay.toImage();
img.blendAt(overlayImg, new Px(0, 0), 1.0, BlendMode.Over);
overlayImg.free();
// 6) Labels — drawn directly on the padded image with the engine's
// built-in Inter font. Anchor "middle" centres the text under each
// thumb.
for (let i = 0; i < slots.length; i++) {
const s = slots[i];
const tcx = s.tx + thumbSize / 2;
const baseline = s.ty + thumbSize + Math.round(LABEL_SIZE * 1.1);
img.drawText(
`${LABEL_PREFIX} ${i + 1}`,
tcx,
baseline,
{ size: LABEL_SIZE, color: '#202020ff', anchor: 'middle' },
);
}
img.save(OUTPUT);
img.free();
}
// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0