Phase 1 atomic ops — differenceMap + orientationField

Visualises the two atomic ops that anchor the Hertzmann painterly pipeline (and any other structure-aware effect). Four panels: 1. **original** — the input. 2. **gaussianBlur(σ)** — a softened reference. 3. **differenceMap(blur)** — per-pixel L2 RGB distance, rendered as a grayscale heatmap. Bright = changed, black = identical. The "where does the canvas disagree with the reference" target that drives stroke placement in Hertzmann. 4. **orientationField(σ)** — smoothed structure tensor on luminance, HSV-encoded. Hue = local edge direction, saturation = coherence (how strongly directional). Flat regions read as bright grayscale, oriented regions burst into colour. The colour wheel reveals which way painterly brush strokes will eventually flow. Both ops are independently useful far beyond painterly rendering: `differenceMap` for before/after diffs, motion gates, quality metrics; `orientationField` for any flow-based effect, edge-aware transforms, or analytical visualisations of image structure. Tweak `SMOOTH` to see how the field becomes blockier and more coherent at higher sigmas (good for global brush direction) but loses fine detail; lower sigmas track edges more precisely but look noisier.

INPUT
INPUT — Phase 1 atomic ops — differenceMap + orientationField
Phase 1 atomic ops visualisation
Phase 1 atomic ops visualisation — Phase 1 atomic ops — differenceMap + orientationField
JavaScript
// Phase 1 of the Hertzmann painterly pipeline — visualises the two
// new atomic ops that drive structure-aware effects.
// demo_orientation_field.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: SMOOTH:number=2.0,min=0.5,max=8.0
//!PARAM: DIFF_REF_BLUR:number=2.5,min=0.5,max=10.0
//!PARAM: MAX_W:integer=900,min=400,max=1800
//!PARAM: LABEL_SIZE:number=22,min=10,max=64

// Two new building blocks, both visualised side-by-side with the
// reference image:
//
//   ┌──────────────────────┬──────────────────────┐
//   │      original        │   gaussianBlur(σ)    │
//   ├──────────────────────┼──────────────────────┤
//   │  differenceMap(blur) │ orientationField(σ)  │
//   │  (where the blur     │  (HSV: hue=angle,    │
//   │   changed pixels)    │   sat=coherence)     │
//   └──────────────────────┴──────────────────────┘
//
// `differenceMap` is the per-pixel L2 RGB distance between two images,
// rendered as a grayscale heatmap — bright = changed, black =
// identical. Useful well beyond Hertzmann: before/after diffs,
// motion gates, quality heatmaps.
//
// `orientationField` is the smoothed structure tensor on luminance,
// HSV-encoded: hue = local edge direction, saturation = coherence
// (how strongly directional the structure is at that pixel). Flat
// regions read as bright grayscale; oriented regions burst into
// colour. The colour wheel reveals which way the painterly brush
// strokes will eventually flow when Phase 2 lands.
//
// Tweak `SMOOTH` to see how the orientation field becomes blockier
// and more coherent at higher sigmas, but loses fine detail.

const SRC0 = Engine.loadImage(INPUT);
{
  const long = Math.max(SRC0.width, SRC0.height);
  if (long > MAX_W) {
    const s = MAX_W / long;
    SRC0.resize(Math.round(SRC0.width * s),
                Math.round(SRC0.height * s),
                Interp.Bilinear);
  }
}
const W = SRC0.width;
const H = SRC0.height;

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 * 2, cellH * 2);

function panel(label, image, col, row) {
  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,0 — original
panel('1) original', SRC0.clone(), 0, 0);

// Panel 1,0 — blurred reference (the comparison target)
const blurred = SRC0.clone().gaussianBlur(DIFF_REF_BLUR);
panel(`2) gaussianBlur(σ=${DIFF_REF_BLUR})`, blurred.clone(), 1, 0);

// Panel 0,1 — differenceMap(original, blur)
{
  const diff = SRC0.clone().differenceMap(blurred);
  panel('3) differenceMap → grayscale', diff, 0, 1);
}

// Panel 1,1 — orientationField on the original
{
  const flow = SRC0.clone().orientationField(SMOOTH);
  panel(`4) orientationField(σ=${SMOOTH})`, flow, 1, 1);
}

SRC0.free();
blurred.free();
out.save(OUTPUT);
out.free();

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