FX Module

A gallery of classical photographic effects wrapped in a shared live-preview dialog. Six effects at launch — Orton Glow, Soft Focus, Bloom / Star Glow, Vignette, Film Grain, Split Tone — each with its own parameter set and real-time preview. The dialog hot-swaps the controls panel when you pick a different effect from the dropdown. Downsampled preview buffer for fast interactive editing, full-resolution worker-thread apply, document mask integration, before/after toggle, and preset grip for every effect.

1. Workflow

  1. Open the dialog on an active image. The Effect dropdown at the top of the controls column defaults to Orton Glow.
  2. Pick an effect. The controls panel rebuilds for that effect's parameters, the description line below the dropdown updates, and a preview with the effect's defaults appears immediately.
  3. Adjust the parameters. The preview updates with a 150 ms debounce on a background QThread so dragging a slider doesn't stall the UI.
  4. Use the ⇆ Toggle Before / After button for instant A/B comparison — cached pixmaps on both sides, no re-computation.
  5. Apply runs the full-resolution computation on another worker thread (the dialog disables its controls during Apply), commits as an undoable edit, records the preset for Replay Last Action, and closes.

2. The six effects

2.1 Orton Glow

Michael Orton's 1980s darkroom technique, digitised: sandwich a sharp exposure with a soft-focus brighter one and blend them together. The result is a dreamy, hazy luminous look that works especially well on nebular imagery where you want to emphasise glowing structure without flattening the fine detail.

Parameters:

2.2 Soft Focus

The simplest of the six: Gaussian blur a copy, blend back at partial opacity. No brightening, no mode selection — just diffusion. Classic portrait / glamour softening, useful on astronomical imagery for taming overly crunchy sharpening results or giving a wide-field a dreamier atmosphere.

Parameters:

2.3 Bloom / Star Glow

Isolates just the bright highlights above a threshold, blurs only those, and screens them back onto the image. The result is a soft halo around every bright feature — stars, galaxy cores, planetary limbs, the bright edge of a lunar terminator — without touching the rest of the image. Essentially the engine HDR renderers use for their bloom pass.

Parameters:

2.4 Vignette

Radial darkening from the frame centre outward — the classical post-processing framing cue, drawing the eye toward the subject. Not just for portraits: works beautifully on widefield astroimages to concentrate attention on the central nebula or cluster.

Parameters:

2.5 Film Grain

Adds organic monochrome or colour Gaussian noise. The random seed is fixed at 42 so the grain pattern stays stable while you drag sliders — you're sculpting one specific grain field, not watching it re-randomise on every tick. (If you want a different pattern, Reset and reopen.)

Parameters:

2.6 Split Tone

Tints shadows one hue and highlights another. The classical example is cyan shadows / orange highlights (teal-and-orange, much maligned but genuinely useful on astronomical imagery for giving nebular glow a warm/cool contrast). Preserves luminance structure; only colour shifts.

Parameters:

3. Shared machinery

3.1 Before / after toggle

The ⇆ Toggle Before / After button is checkable; checked shows the original cached preview pixmap, unchecked shows the latest processed result. Both are cached, so switching is instant with no recomputation. Any slider or combo change automatically pops the button out of its “showing-before” state so you don't have to toggle back yourself.

3.2 Preview panel

Right side of the dialog. Supports:

Preview computation runs on a background QThread with cancellation: changing a slider mid-render cancels the in-flight preview and starts a new one after the 150 ms debounce fires, so no backlog of stale computations builds up during a slider drag.

3.3 Downsampled preview buffer

For images larger than 1200 px on the long side, the preview pipeline operates on a downsampled copy (OpenCV's INTER_AREA area averaging). Interactive preview on a 24 MP frame therefore runs just as fast as on a 2 MP crop — the slider-drag response is driven by the preview buffer size, not the full resolution. The final Apply always runs on the full resolution for the committed result, which is why that stage disables the controls and shows a “processing full-resolution image…” status line while it works.

3.4 Document mask integration

If the active document has a mask layer, the processed result is blended with the untouched original through the mask so only masked regions receive the effect. The mask is resampled with nearest-neighbour (OpenCV INTER_NEAREST) if its dimensions don't match. Both the live preview and the final Apply respect the mask identically.

3.5 Active-document tracking

The dialog listens for the main window's currentDocumentChanged signal and switches its preview target when you focus a different open image, so you can auditioning the same effect across several frames without reopening the dialog. The current effect selection and parameter state carry across the document change.

3.6 Preset grip

The handle at the bottom-left of the controls column carries the full state: the selected effect and that effect's current parameters. Drag it onto the canvas to make a preset tile that runs that specific effect with those settings headlessly, or drop it directly on an image for immediate apply. Double-clicking a shortcut tile reopens this dialog pre-seeded with the saved effect and parameters.

4. Technical details

All processing on float32 in [0, 1]. OpenCV provides the Gaussian blurs; everything else is numpy. Blend formulas follow the W3C Compositing Spec where they overlap with Photoshop.

4.1 Blend mode primitives

Three blend modes are available (Orton Glow); the others use Screen directly.

Screen — symmetric commutative blend, inverse of Multiply, always lightens:

S(a, b) \;=\; 1 - (1-a)(1-b) \;=\; a + b - ab

Lighten — per-pixel maximum, preserves whichever channel is brighter:

L(a, b) \;=\; \max(a, b)

Soft Light — the W3C Compositing Spec formulation that matches Photoshop's Soft Light. For base a and blend b, the formula is piecewise on b. When b ≤ 0.5:

d_{\mathrm{lo}} \;=\; a - (1 - 2b)\,a(1-a)\,,\qquad b \leq 0.5

and when b > 0.5 an auxiliary function g(a) is used that itself is piecewise on a:

g(a) = ((16a - 12)a + 4)a if a ≤ 0.25 √a if a > 0.25
d_{\mathrm{hi}} \;=\; a + (2b - 1)(g(a) - a)\,,\qquad b > 0.5

This gives a dodging/burning response symmetric around b = 0.5 that gently steepens contrast without the hard breakpoints of Overlay or Hard Light.

4.2 Luma recovery

Screen-blending two similar images runs away in brightness: for identical layers, S(x, x) = 2x − x², which is always brighter than x for 0 < x < 1 (maximum excess of 0.25 at x = 0.5). This matters a lot for Orton Glow and Bloom, where the blend layer is a brightened near-copy of the base. Luma recovery pulls the result back toward the average brightness of the two source layers.

For Rec. 709 luminance L = 0.2126R + 0.7152G + 0.0722B, the recovery target is the average of the two layers' luminance:

L_{\mathrm{target}} \;=\; \frac{L_a + L_b}{2}

and the blend is rescaled per-pixel by the ratio:

\mathrm{ratio} \;=\; \frac{L_{\mathrm{target}}}{L_{\mathrm{blended}} + \varepsilon}

with the final output being a dry/wet mix of the raw blend and the ratio-corrected version controlled by the Luma recovery slider. The scale is multiplicative on all channels so chroma is preserved exactly — only overall brightness is tamed.

4.3 Orton Glow composition

End-to-end, with blur radius σ, glow brightness β, blend mode ⊗, luma recovery ρ, highlight protect h, and opacity α:

boosted = clip(β · G_σ(img), 0, 1) blended = clip(img ⊗ boosted, 0, 1) blended' = LumaRecover(img, boosted, blended, ρ)

Highlight protection fades the effect in regions already near white:

\mathrm{protect} \;=\; 1 - h \cdot \mathrm{clip}\!\left(\frac{L - 0.7}{0.3},\;0,\;1\right)

which starts at 1.0 everywhere, begins falling at L = 0.7, and reaches 1 − h at L = 1. The final effective opacity is:

\alpha_{\mathrm{eff}} \;=\; \alpha \,\cdot\, \mathrm{protect}

and the composite:

\mathrm{out} \;=\; (1 - \alpha_{\mathrm{eff}})\,\mathrm{img} \;+\; \alpha_{\mathrm{eff}}\,\mathrm{blended}'

4.4 Bloom highlight mask

The threshold slider produces a soft highlight mask by linear ramping from zero at the threshold to one at pure white:

m(x) \;=\; \mathrm{clip}\!\left(\frac{L(x) - t}{1 - t},\;0,\;1\right)

The highlight-only image img · m is blurred, boosted by the brightness slider, and Screen-blended back with the same luma-recovery pass as Orton Glow. Because the mask smoothly ramps rather than hard-cutting at the threshold, the bloom halos fade naturally into non-highlight regions instead of showing a visible brightness-level contour where the mask edge would be.

4.5 Vignette geometry

The vignette uses a normalised radial distance in unit-centred coordinates where the frame edge midpoints sit at distance 1.0:

d(x, y) \;=\; \sqrt{\left(\frac{x - c_x}{c_x}\right)^2 \;+\; \left(\frac{y - c_y}{c_y}\right)^2}

Mapping that distance through the radius/softness interval [R − S, R + S] gives a smooth transition weight:

\tau(x, y) \;=\; \mathrm{clip}\!\left(\frac{d - (r - s)}{2s},\;0,\;1\right)

which is zero inside the bright disc, linearly rises through the transition band, and clips at 1 outside. The multiplicative darkening factor is then:

\mathrm{vig}(x, y) \;=\; 1 - A \,\cdot\, \tau(x, y)

reducing to 1 (no change) inside the clean centre and to 1 − A at the fully-vignetted edges. Applied by direct multiplication, broadcast to all three channels of RGB input.

4.6 Film grain

A unit-variance Gaussian noise field N(0, 1) of the same shape as the image (or per-channel for colour grain mode) is generated with a fixed RandomState seed of 42, so the pattern stays stable while you adjust sliders. If grain size σ > 0.01, it's Gaussian-blurred and renormalised to unit variance so the brightness-amplitude interpretation of Intensity stays consistent:

\eta \;=\; \frac{G_{\sigma} \ast N(0, 1)}{\mathrm{std}(G_{\sigma} \ast N(0, 1))}

Final composite is additive with Intensity (slider value I) and a global 0.15 amplitude multiplier so even at Intensity = 1 the effect stays in the visually sensible range:

\mathrm{out} \;=\; \mathrm{img} \;+\; 0.15 \,\cdot\, I \,\cdot\, \eta

Mono mode (default) shares the same noise field across all three channels so the grain is colourless — the pattern of brightness variation is identical in R, G, and B. Colour mode uses independent noise per channel, which produces visibly chromatic grain reminiscent of early colour film stocks.

4.7 Split tone

Hues are turned into pure HSV (h, 1, 1) RGB vectors:

\mathbf{c}_{\mathrm{shadow}}, \mathbf{c}_{\mathrm{highlight}} \;=\; \mathrm{hsv2rgb}(h/360,\;1,\;1)

The shadow/highlight split is weighted by luminance, with the Balance slider sliding the midpoint:

w_{\mathrm{hi}} \;=\; \mathrm{clip}(L + 0.5\,B,\;0,\;1)\,,\qquad w_{\mathrm{lo}} \;=\; 1 - w_{\mathrm{hi}}

so Balance = 0 puts the crossover at L = 0.5, positive Balance shifts it darker (more of the image reads as highlight → more warm tint), negative shifts it brighter (more cool-tint shadow). The per-pixel tint is a weighted mix of the two hue vectors:

\mathrm{tint}(x) \;=\; w_{\mathrm{lo}} \,\mathbf{c}_{\mathrm{shadow}} \;+\; w_{\mathrm{hi}} \,\mathbf{c}_{\mathrm{highlight}}

and the final result is a Strength-controlled mix between the untouched image and the tinted version (where the tint multiplies the image by 2·tint to approximately preserve luminance when the tint averages roughly 0.5):

\mathrm{out} \;=\; (1 - S)\,\mathrm{img} \;+\; S \,\cdot\, \mathrm{clip}(2\,\mathrm{img} \,\cdot\, \mathrm{tint},\;0,\;1)

4.8 Threading model

Two different workers handle computation:

4.9 Headless API

Three module-level entry points:

Credits. The Orton effect is Michael Orton's 1980s film darkroom technique (sandwiched transparencies, one sharp and one soft-focus overexposed) translated into digital compositing. The Screen, Soft Light, and Lighten blend formulas follow the W3C Compositing and Blending Level 1 specification, which Photoshop also follows. Rec. 709 luminance coefficients are from ITU-R Recommendation BT.709. Bloom is a longstanding technique in HDR rendering and cinematography (the modern HDR bloom pass in game engines; the earlier Harris Shutter and defocus-highlight film techniques). The Gaussian blur, resize, and bilateral kernels come from OpenCV. HSV ↔ RGB conversion uses Python's standard colorsys. The FX dialog's composition — the hot-swapping parameter panel driven by the ParamSpec registry, the downsampled preview buffer with full-resolution threaded Apply, the per-effect luma recovery pass for Screen-overshoot compensation, the highlight-protect roll-off, the smooth bloom mask ramp, the active-document tracking, and the preset grip system — is SASpro-exclusive.