Gesichtserkennung → Seitengalerie (Gesichts-Thumbs + Pfeile + Beschriftung)

Erkennt jedes Gesicht, erweitert dann die Leinwand nach rechts und platziert pro Gesicht ein kreisrundes Thumbnail untereinander. Jedes Gesicht wird im Original mit einem Kreis markiert; ein Pfeil verbindet es mit seinem Thumbnail rechts. Beschriftungen (standardmäßig 'Person 1', 'Person 2', …) stehen unter jedem Thumbnail. Die Thumb-Größe passt sich automatisch an die Bildhöhe an — 1, 5 oder 10 Gesichter bleiben gut lesbar. Komplett aus existierenden Engine-Bausteinen: Detect → crop → resize → applyMask + blendAt für die Thumbnails, Vektor-Canvas mit Kreis-/Linien-/Dreieck-Pfaden für die Marker, drawText für die Beschriftungen.

INPUT
INPUT — Gesichtserkennung → Seitengalerie (Gesichts-Thumbs + Pfeile + Beschriftung)
Original + per-face gallery
Original + per-face gallery — Gesichtserkennung → Seitengalerie (Gesichts-Thumbs + Pfeile + Beschriftung)
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