Progressive multi-pass sharpening — every stage visible

Runs 4 sharpen+soften passes with increasing sharpness each round and shows the cumulative result after every pass in a 2×3 grid: - **original** — control panel (untouched input) - **pass 1…N** — `sharpen(SHARP_BASE + i*SHARP_STEP)` followed by `gaussianBlur(SOFTEN_SIGMA)` — the soften pass keeps the sharpening from snowballing into halos - **final** — the published look: cumulative passes plus `contrast`, `saturation`, `vignette` Watch how local micro-contrast builds up across the passes without blowing out highlights or shadows — loops + computed per-pass parameters drive the whole sequence.

INPUT
INPUT — Progressive multi-pass sharpening — every stage visible
Stage-by-stage sharpening
Stage-by-stage sharpening — Progressive multi-pass sharpening — every stage visible
JavaScript
// Iterative multi-pass sharpening — visualised stage by stage
// demo_progressive_enhance.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: PASSES:integer=4,min=2,max=6
//!PARAM: SHARP_BASE:number=0.20,min=0.05,max=0.5
//!PARAM: SHARP_STEP:number=0.08,min=0.0,max=0.2
//!PARAM: SOFTEN_SIGMA:number=0.25,min=0.0,max=1.0
//!PARAM: FINAL_CONTRAST:number=1.2,min=0.5,max=2.0
//!PARAM: FINAL_SATURATION:number=1.15,min=0.0,max=2.0
//!PARAM: FINAL_VIGNETTE:number=0.35,min=0.0,max=1.0
//!PARAM: TILE_W:integer=512,min=256,max=1024
//!PARAM: LABEL_SIZE:number=18,min=10,max=64

// Multi-pass sharpening, every stage visible in a 2×3 grid:
//
//   ┌──────────┬──────────┬──────────┐
//   │ original │ pass 1   │ pass 2   │
//   ├──────────┼──────────┼──────────┤
//   │ pass 3   │ pass 4   │ + final  │
//   └──────────┴──────────┴──────────┘
//
// Each pass = sharpen(amount) → gaussianBlur(sigma) where amount
// grows linearly with i (SHARP_BASE + i * SHARP_STEP). The last
// panel adds a contrast/saturation/vignette pass on top of the
// fully sharpened image — the published "final look".
//
// Watch how local micro-contrast builds up across the passes
// without blowing out highlights or shadows.

// Load and downscale: a 6-panel grid at full resolution becomes
// huge fast. Cap each tile at TILE_W on its long side.
const src = Engine.loadImage(INPUT);
{
  const ow = src.width, oh = src.height;
  const long = Math.max(ow, oh);
  if (long > TILE_W) {
    const s = TILE_W / long;
    src.resize(Math.round(ow * s), Math.round(oh * s), Interp.Bilinear);
  }
}
const W = src.width;
const H = src.height;

// Cap visible pass panels at 4 so the grid stays human-readable
// even when PASSES is cranked high. Anything past pass 4 still
// applies to the accumulator below — it just doesn't get its own
// snapshot panel.
const MAX_PASS_PANELS = 4;
const passesShown = Math.min(PASSES, MAX_PASS_PANELS);

// Grid sized to exactly the cells we'll actually render:
//   1 (original) + passesShown + 1 (final)
// Picking COLS dynamically avoids the black-panel gap that used
// to appear when PASSES < 4 with a fixed 3×2 layout. The COLS
// table keeps the grid as close to square as possible for the
// common cellsUsed values:
//   3 → 3×1   (PASSES=1)
//   4 → 2×2   (PASSES=2)         ← perfect, no empty cells
//   5 → 3×2   (PASSES=3)         ← one empty cell at bottom-right
//   6 → 3×2   (PASSES≥4, capped) ← perfect
const cellsUsed = 1 + passesShown + 1;
const COLS = cellsUsed <= 3 ? cellsUsed
           : cellsUsed === 4 ? 2
           : 3;
const ROWS = Math.ceil(cellsUsed / COLS);
const FINAL_SLOT = cellsUsed - 1;        // always the last filled cell

const bandH = LABEL_SIZE + 8;
const cellW = W;
const cellH = bandH + H;

const out = Engine.createImage(1, 1);
out.setPixel(px(0, 0), new Pixel(0.05, 0.05, 0.07, 1.0));
out.resize(cellW * COLS, cellH * ROWS);

function panel(label, image, idx) {
  const col = idx % COLS;
  const row = Math.floor(idx / COLS);
  const x0 = col * cellW;
  const y0 = row * cellH;
  out.drawText(label, x0 + 16, y0 + LABEL_SIZE - 2, {
    size: LABEL_SIZE, color: NamedColor.white,
  });
  out.blendAt(image, px(x0, y0 + bandH), 1.0, BlendMode.Over);
}

// Panel 0 — original (control)
panel('0) original', src.clone(), 0);

// Working buffer that accumulates the iterations. Snapshot
// after each pass so we can show the cumulative effect.
const acc = src.clone();
src.free();

// One progress bar per pass — useful when PASSES is high and the
// gaussianBlur sigma is wide enough to make each pass take real time.
const p = Engine.progress("Sharpening passes", { total: PASSES });
for (let i = 0; i < PASSES; i++) {
  const sharpAmount = SHARP_BASE + i * SHARP_STEP;
  acc.sharpen(sharpAmount).gaussianBlur(SOFTEN_SIGMA);

  if (i < passesShown) {
    const snap = acc.clone();
    panel(
      `${i + 1}) pass ${i + 1}  sharpen(${sharpAmount.toFixed(2)})`,
      snap, i + 1
    );
    snap.free();
  }
  p.step(1, `sharpen(${sharpAmount.toFixed(2)}) + blur(${SOFTEN_SIGMA})`);
}
p.done(`✓ ${PASSES} passes applied`);

// Last panel — apply the published "final look" on top of the
// fully sharpened image: contrast, saturation, vignette.
{
  const finalImg = acc.clone();
  finalImg
    .contrast(FINAL_CONTRAST)
    .saturation(FINAL_SATURATION)
    .vignette(FINAL_VIGNETTE);
  panel(
    `${FINAL_SLOT + 1}) final  c=${FINAL_CONTRAST.toFixed(2)} s=${FINAL_SATURATION.toFixed(2)} v=${FINAL_VIGNETTE.toFixed(2)}`,
    finalImg, FINAL_SLOT
  );
  finalImg.free();
}

acc.free();
out.save(OUTPUT);
out.free();

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